E08255: two locks of the same type are nested but no consistent acquisition order can be statically proven — under some interleaving this will deadlock; protect the state under ONE lock over an owning record (and if the state is durable or shared across instances, a mutex cannot protect it — that is a transaction concern)

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

What went wrong

An enter() call on one MutexLock was reached while another MutexLock of the SAME type was still held, and the two locks are not provably the same physical object. They sit on caller- or runtime-determined objects — two different parameters of the same type, the same field on two different instances, or a collection / dictionary element chosen at runtime. Because which object each lock belongs to is decided at call time, no single consistent acquisition order can be proven to hold for every execution. Under some interleaving two threads acquire the pair in opposite orders and deadlock.

Rationale

EK9's concurrency contract guarantees no execution can deadlock on a lock-acquisition cycle. For locks of DIFFERENT types, or distinct declared fields/allocations of the same object, a single global order can be proven and the nesting is allowed (E08252 only fires if a real cycle forms). But when the nested locks are the same type on objects the compiler cannot pin to a fixed order — f(p, q) called somewhere as f(p, q) and elsewhere as f(q, p); from.lock then to.lock; or accounts.get(i).lock then accounts.get(j).lock — the order is a property of the runtime arguments, not the source. EK9 refuses such code rather than detect a cycle after the fact, because the unprovable case is the classic dining-philosophers / bank-transfer deadlock and there is no sound static order to verify against.

This is the strict stance: if a lock-acquisition order cannot be reasoned about in 30 seconds by a reader, the compiler refuses it. There is no ordered-multi-acquire escape valve, no lock-ranking annotation, no --lenient flag — the language does not have them. The escape is to refactor to a shape with a provable order.

How to fix it

  1. Consolidate the protected state under a SINGLE MutexLock over an owning record. If two same-type resources must be updated atomically together, model them as one record guarded by one lock — there is no second lock, so no order question can arise. This is the canonical fix. The [EXAMPLE] section below shows same-type locks of two parameter objects nested (their order is whatever the caller passes, so it cannot be proven), then one MutexLock of an 'AccountPair' record that owns the whole transfer.
  2. If the data is DURABLE or SHARED ACROSS INSTANCES (rows in a database, records in another process, entries owned by other services), a mutex cannot protect it at all — in-memory locks do not span persistence or process boundaries. That is a transaction concern: use the database's transaction / optimistic-locking facilities, not a MutexLock.
  3. If the resources are genuinely independent and never need joint atomicity, acquire them sequentially — release the first lock before taking the second. With no overlap there is no nesting and no order to prove.

Common causes

  • Bank-transfer style: from.lock then to.lock, where from/to are the same type and callers can pass them in either order.
  • Two instances of the same lock-bearing class nested in one function.
  • Dining philosophers: a ring of same-type resources each "left then right".
  • Collection / dictionary element locks: accounts.get(i).lock then accounts.get(j).lock with i/j chosen at runtime.
  • A getter that returns one of several same-type locks depending on a runtime value, then both results nested.

Worked examples

Compilable examples demonstrating this error and the correct fixes:

  • ek9 -q 1300 (How do I use multiple MutexLocks correctly — coarse vs fine-grained?)
  • ek9 -q 158 (How do I protect shared data from concurrent access in EK9?)

See also

  • E08252: nested enter() on different lock identities that DO have a provable order and form a real cycle (lock-order cycle).
  • E08253: same lock acquired across a thread boundary.
  • 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 E08255: two locks of the same type are nested but no consistent acquisition order can be statically proven — under some interleaving this will deadlock; protect the state under ONE lock over an owning record (and if the state is durable or shared across instances, a mutex cannot protect it — that is a transaction concern)

#!ek9
defines module error.e08255.bad

  defines class

    Account
      balanceLock as MutexLock of Integer: MutexLock(Integer(0))

      getLock() as pure
        <- rtn as MutexLock of Integer: balanceLock

      default operator ?

  defines function

    transfer()
      ->
        from as Account
        to as Account

      innerKey <- () is MutexKey of Integer as function
        stdout <- Stdout()
        stdout.println(lockedItem)

      outerKey <- (to, innerKey) is MutexKey of Integer as function
        require to.getLock().enter(innerKey)

      require from.getLock().enter(outerKey)

Fixed: Keep both balances under one lock over an owning record

#!ek9
defines module error.e08255.good

  defines record

    AccountPair
      source as Integer: 0
      target as Integer: 0

      default operator ?

  defines class

    Ledger
      accountLock as MutexLock of AccountPair: MutexLock(AccountPair())

      transfer()
        -> amount as Integer
        key <- (amount) is MutexKey of AccountPair as function
          lockedItem.source: lockedItem.source - amount
          lockedItem.target: lockedItem.target + amount
        require accountLock.enter(key)

      default operator ?

Questions and answers

These Q&A entries show E08255 and its fix: