E08253: same lock acquired across a thread boundary — inner acquire blocks waiting for outer holder; MutexLock reentrancy does not cross threads

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

What went wrong

The same MutexLock is acquired on two different threads while the outer thread still holds it. The inner thread's enter() will block waiting for the outer thread to release. If the outer thread is in any way waiting on the inner's completion (joining, reading a result, waiting for an async pipeline to drain, etc.), this is a deadlock.

Rationale

MutexLock supports reentrancy — the SAME THREAD can call enter() on the same lock multiple times without blocking. But reentrancy is a per-thread property. A different thread acquiring the same lock behaves exactly like any other contender: it waits.

This rule catches the family of patterns where a call inside one enter() body crosses a thread boundary AND the handler tries to acquire the same lock the outer body holds. Even though it looks like "the same lock so it's reentrant," it isn't — the outer holder is on a different thread.

Thread-boundary-creating constructs

EK9 creates a thread boundary at three syntactic points:

  1. TCP.accept(handler) — handler runs on a new connection thread.
  2. Signals.register(name, handler) — handler runs on the dispatch thread.
  3. Stream pipelines with `| async` — each element runs on a thread- pool worker.

Anywhere else (regular calls, the `| call` stream stage) stays on the same thread, where reentrancy is legal.

How to fix it

  1. Do the locked work BEFORE the thread boundary: enter the lock, copy what the workers need out of it inside the MutexKey (a copy, never the locked item itself), and pass that copy to the handler or async stage. Or let the handler take the lock itself, from its own thread, without the caller holding it (the [EXAMPLE] section below registers a Signals handler that enters the lock only when it runs).
  2. If you don't actually need a thread pool, change `| async` to `| call`. The pipeline then runs each element synchronously on the same thread, where same-lock reacquisition is reentrant and legal.
  3. If the handler genuinely needs to mutate the same shared state, the handler is itself a critical section — run it on the outer thread (inside the same enter() body) instead of dispatching it across a thread boundary.

Common causes

  • A service operation enters a lock then registers a Signals/TCP handler that also enters the same lock.
  • A pipeline runs `| async` over a worker that captures and re-enters a lock the outer pipeline body holds.
  • Deep refactoring — work pushed several call frames into a helper, then the helper later gets a `| async` stage to "speed it up" — the worker captures a lock held many frames above without that being obvious in the source.

Worked examples

Compilable examples demonstrating this error and the correct fixes:

  • ek9 -q 1299 (I got E08253 CROSS_THREAD_SAME_LOCK — what is the thread ...)
  • ek9 -q 158 (How do I protect shared data from concurrent access in EK9?)
  • ek9 -q 1300 (How do I use multiple MutexLocks correctly — coarse vs fine-grained?)

See also

  • E08252: nested enter() forming a lock-order cycle (related — same call-chain structure, different identity).
  • E08251: shared state mutation outside lock.

Examples

Every example is compiled by the EK9 compiler: the first fails exactly as captioned, the others fix it.

Fails with E08253: same lock acquired across a thread boundary — inner acquire blocks waiting for outer holder; MutexLock reentrancy does not cross threads

#!ek9
defines module error.e08253.bad

  defines function

    spawnHandlerOnSameLock()
      -> theLock as MutexLock of Integer

      handler <- (theLock) is SignalHandler as function
        result: 0
        innerKey <- () is MutexKey of Integer as function
          stdout <- Stdout()
          stdout.println(lockedItem)

        require theLock.enter(innerKey)

      signals <- Signals()
      signals.register("SIGHUP", handler)

    crossThreadCaller()
      -> theLock as MutexLock of Integer

      outerKey <- (theLock) is MutexKey of Integer as function
        spawnHandlerOnSameLock(theLock)

      require theLock.enter(outerKey)

Fixed: Do not hold the lock while starting work that takes it on another thread

#!ek9
defines module error.e08253.good

  defines function

    spawnHandlerOnSameLock()
      -> theLock as MutexLock of Integer
      handler <- (theLock) is SignalHandler as function
        result: 0
        innerKey <- () is MutexKey of Integer as function
          stdout <- Stdout()
          stdout.println(lockedItem)
        require theLock.enter(innerKey)
      signals <- Signals()
      signals.register("SIGHUP", handler)

    caller()
      -> theLock as MutexLock of Integer
      spawnHandlerOnSameLock(theLock)

Questions and answers

These Q&A entries show E08253 and its fix: