E08253: same lock acquired across a thread boundary — inner acquire blocks waiting for outer holder; MutexLock reentrancy does not cross threads
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:
- TCP.accept(handler) — handler runs on a new connection thread.
- Signals.register(name, handler) — handler runs on the dispatch thread.
- 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
- 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).
- 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.
- 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
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: