E08251: mutation of shared state must happen inside MutexLock.enter(...) — wrap the write in a MutexKey body

Category: E08xxx Pre-IR checks (flow analysis) · Reported in phase: PRE_IR_CHECKS · Classification: SHARED_STATE_MUTATION_OUTSIDE_LOCK · All error codes

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

  1. 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).
  2. 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.
  3. 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

  • E08250: this-escape from constructor (prevents partially- initialised state being observed concurrently)
  • E08256: MutexLock construction outside field initialiser
  • E08257: MutexLock argument must be a fresh value
  • E08258: MutexLock payload exposed

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: