E08251: mutation of shared state must happen inside MutexLock.enter(...) — wrap the write in a MutexKey body
What went wrong
A field on the enclosing aggregate was reassigned inside a method that is transitively reachable from a concurrent entry point (an HTTP service operation, a TCP/Signals handler, or an async stream-stage worker). Two concurrent invocations of the entry point will race on this write.
A local that refers to shared state (counter <- shared.total, or whole <- shared) is that state under another name: mutating it in place (counter += 1, counter :=: 5) or writing a field through it (whole.total: 7) is the same racy write. Re-pointing the local (counter := 5) changes only the local and is allowed.
Every in-place mutation counts, whatever statement applies it: counter++ and counter-- (Integer's ++ mutates the object, exactly as += does), += -= *= /= :=: :^: :~:, and streaming into a target (cat items > shared.list, >> shared.list, | tee in shared.list), which pipes each item in with the target's '|' operator. A copy (n <- Integer(shared.total)) is a new object and is not shared.
Stdout/Stderr may be shared across workers; each line is written whole, but the order of lines from concurrent workers is not guaranteed. So cat lines > stdout, | tee in stderr and stdout.println(...) are allowed. The exemption is by type: a variable declared 'as StringOutput' could hold any implementation and is still checked.
Rationale
EK9's concurrency contract: every mutation of in-process shared state happens inside MutexLock.enter(...) — guaranteed at compile time. HTTP service operations, TCP accept handlers, Signals handlers, and async workers execute on a thread pool. Any state they touch (via direct fields or through DI-injected components reached at runtime) is shared. Writes therefore must go through a MutexLock.
The constructor body is the only exception: the object hasn't escaped yet during construction, so its fields aren't yet shared. E08250 guarantees nothing leaks 'this' before construction completes.
How to fix it
- Wrap the shared field in a MutexLock and perform the write inside a MutexKey body (the [EXAMPLE] section below: a counter's field becomes a 'MutexLock of Integer', and 'bump()' increments it inside a MutexKey).
- If the field doesn't need to outlive a single invocation, declare it as a local variable inside the method body instead. Per-invocation locals are not shared.
- If the field is genuinely read-only after construction, the read is safe; only the unguarded write triggers this error. Initialise the field at declaration time or in the constructor (constructor writes are exempt) and treat it as immutable thereafter.
Common causes
- Porting a single-threaded handler/service that mutates instance state on each request — works in dev under one user, races under load.
- Treating DI-injected Component fields as ordinary instance state without recognising that the singleton is reached from many threads.
- Caching computed values into a field on the receiver without a lock guarding the write.
See also
Examples
Every example is compiled by the EK9 compiler: the first fails exactly as captioned, the others fix it.
Fails with E08251: mutation of shared state must happen inside MutexLock.enter(...) — wrap the write in a MutexKey body
#!ek9 defines module error.e08251.bad defines component Counter as abstract bump() as abstract default operator ? UnsafeCounter extends Counter count <- 0 override bump() count: count + 1 default operator ? defines service Counters for :/counters open countRequests() as GET for :/count <- response as HTTPResponse: () with trait of HTTPResponse override status() as pure <- rtn as Integer: 200 override content() <- rtn as Bytes: Bytes().withUtf8("ok") override contentType() as pure <- rtn as String: "text/plain" default operator ? counter as Counter! counter.bump() defines application CounterApp register UnsafeCounter() as Counter register Counters()
Fixed: Hold the shared state in a MutexLock and change it inside a MutexKey
#!ek9 defines module error.e08251.good defines component Counter as abstract bump() as abstract default operator ? SafeCounter extends Counter count as MutexLock of Integer: MutexLock(Integer(0)) override bump() bumper <- () is MutexKey of Integer as function lockedItem++ require count.enter(bumper) default operator ? defines service Counters for :/counters open countRequests() as GET for :/count <- response as HTTPResponse: () with trait of HTTPResponse override status() as pure <- rtn as Integer: 200 override content() <- rtn as Bytes: Bytes().withUtf8("ok") override contentType() as pure <- rtn as String: "text/plain" default operator ? counter as Counter! counter.bump() defines application CounterApp register SafeCounter() as Counter register Counters()
Questions and answers
These Q&A entries show E08251 and its fix: