ADR-039: The Lock Has One Shared Form and Four Doors, and Taking a Lock Inside a Lock Is Refused

Status: Superseded by ADR-281 — its D1 is D4 there, D2-D7 are D28-D33, D8 is D36-D37, D9 is D1 and D3, and D10 is D10, D20 and D25 Related: ADR-005 D6 (the sentence D2 displaces, and the Send check D6 extends), ADR-037 D3 (this answers its second half, which ADR-037 D7 left open), ADR-027 (the propagation D3 reuses), ADR-010 D1 (the polarity D5 turns around for one column), ADR-029 D3 (the bound D3 does not need, and why), ADR-033 D8 (the precedent for D8’s switch, and the switch its count left out), ADR-021 D5 (whose cache-key enumeration D8 adds a dimension to), ADR-033 D2 (the touch vocabulary D4 must not be mistaken for), ADR-038 D7 (the foreign-thread rule D6 extends), ADR-028 (whose division of labour decides where D3’s answers come from), ADR-006 D2 (whose “for free” composition D10 replaces with a stronger sentence). Evidence: docs/mutex-floor.md, docs/history/rc-or-arc.md.

1. Context

This record was opened to decide one thing — whether Locked[T] keeps two implementations, one per user_parallelism, or gets a single Mutex floor — and it closes as something else. It is a build order for a type no Nikaia program can write today. Saying that plainly is part of the record, because the question as posed assumed a type that exists.

The probe that settled it:

let kasse: Shared[Locked[i32]] = Shared::new(Locked::new(0))

Four errors — cannot find type 'Shared' in this scope, cannot find type 'Locked' in this scope, and twice use of undeclared type. nikaia-std carries three type entries in its ledger (cli::Args, fs::Mapped, io::Lines) and neither of these is among them; the emitter has no lowering for either; the checker does not know the names.

What is built is the reasoning above them. contracts::send lists Shared among the container names its crossing walk recognises, and contracts::sharing infers which count each Shared position gets (ADR-037 D7, D8). So the analysis of these types is built and the types are not — which is also why no measurement of a floor could say anything about behaviour: there was nothing to behave.

The measurement did settle three things, and none of them was the question. Blocking is not pausing, and the specification had already chosen: the panic hook must be sync and Part I 7.2 permits it to block, which makes a lock acquisition not a suspension point. access stays sync through ADR-029 D3’s from(f), which turns on one ledger line and never on the primitive. And the floor’s price is 11.3 ns an uncontended acquisition, no syscall, a smaller value than the alternative, with contention unreachable at user_parallelism = no.

2. Decisions

D1 — Two representations stay. What closes is the observability of the difference, at compile time.

The two implementations differ in two things a program can see: re-entering the same lock panics under one and hangs under the other, and a panic inside the guard poisons under one and not the other. That is the real objection to keeping both, and it is answered by making the programs that could observe it impossible (D2) rather than by buying the difference away for 11.3 ns on every acquisition in every program.

Checks belong in compile time. Where one does not fit in the compiler this record says so; it does not cover the gap with nanoseconds.

Two arguments that were made for the floor and do not hold:

Crossing — and this argument was dismissed on a false premise, corrected here. A floor was argued to make Shared[Locked[T]] movable across threads at all, and that was answered: at user_parallelism = yes the lock is already a real OS lock, so everything already travels there, leaving only the narrow case of handing a value to a foreign Rust library at no.

The answer was wrong, and so the dismissal was. The verdict does not look at the implementation this build uses. For a type whose implementation follows the switch it takes the worse of the two settings (ADR-037 D6) — so nothing travels at yes either, and a floor would in fact make the type crossable. What it buys is therefore not a narrow foreign-library case. It is Part II 12.2’s counter, at the setting that exists for it. §3 states that as the open half of this record rather than as a settled cost.

The 11.3 ns is still 11.3 ns, and it is still paid at the setting where it unlocks nothing. What changed is the other side of the comparison.

Poisoning. Making a panic poison at both settings was argued to follow from D1’s motive. It does not: at no a panic is a process abort (Part III, appendix A.2), so nobody survives for whom poisoning would be done. The difference in panic behaviour between the settings exists for a reason unrelated to locks, and no normative sentence changes for it.

Keeping both representations also keeps NK2501/NK2502 a candidate: the lock stays non-crossable at no, so the two codes keep the occupant they would otherwise never have had.

And that has a consequence this decision did not record, which §3 now states as an open question rather than leaving it to be discovered. A lock that may not cross cannot be handed to a task, so Part II 12.2’s counter — the program user_parallelism = yes exists to serve — cannot be written. D1 is not wrong about the 11.3 ns; it is incomplete about what the 11.3 ns was buying.

D2 — Acquiring a lock while a lock is held is refused, always.

Part II 12.3 already calls manual nesting an anti-pattern and “often a compile-time error”. “Often” becomes “always.” With that, no program exists in which the two representations of D1 can behave differently, because the one behaviour that distinguishes them cannot occur.

This displaces two sentences that say the opposite, and they are the reason this is a decision and not a sharpening:

D3 — A second derived property per function, “does it touch a lock”, propagated over the same call graph as sync and with the opposite lattice.

Inside any of D10’s blocking doors, calling anything whose property is set is refused. That catches chains and self-calls, which is what D2 needs to be complete rather than local.

It is one traversal and two lattices, not a second pass. The call graph contracts::sync builds already answers three questions it would be expensive to get right twice: a method call is resolved by the type checker and not by a second resolver in this file (ADR-028); a trailing lambda’s body belongs to the function that writes it, because it runs during the call; and spawn’s body does not, because it runs later and elsewhere (Part I 5.4).

The difference is the direction:

  starts from rule
sync everyone is sync greatest fixpoint — take the claim from whoever reaches a non-holder
touches a lock nobody touches one least fixpoint — give the property to whoever reaches a holder

That is why mutual recursion between pure functions keeps sync (ADR-027 D1) and correctly gets nothing here, for the mirrored reason. Fail-closed lands on the same side in both: an unresolvable call takes the sync claim away and sets this property, so one polarity decision serves both.

A scope’s tasks propagate upward; a spawned task does not. A scope waits for its tasks, so they run during the call and their bodies belong to the surrounding function, for the same reason a trailing lambda’s body does. The consequence is intended: opening a scope inside an open lock is refused, because the task waits for the lock and the holder waits for the end of the scope, and that is a real deadlock rather than a precaution. A task started with spawn runs later and elsewhere, so taking a lock in one is the ordinary case.

This column needs no from(f), and the reason bounds it. ADR-029 D3 gives sync a fourth state because a ledger entry for map must answer a question whose answer is in the caller’s lambda. Here the check runs on the caller’s own body, where the walk already sees that lambda — so what sync had to summarise in an entry, this reads directly. The premise is stated so that it is visible if it is wrong: it holds exactly while the lambda is immediate. A lambda the callee stores — so that it may run while a lock is open somewhere this walk cannot see — is D7’s case. A lambda the callee spawns is neither: it runs later and elsewhere, so the sentence above about spawn is the whole of it.

D4 — The property is coarse, and it must never be the thing that answers an ordering question.

It says “a lock”, not “which lock”. The precise version needs alias analysis, and the cost of that is not compile time: whether a program compiles would depend on whether two handles are provably distinct, so an unrelated line elsewhere could make unchanged code stop compiling. That is a worse failure than a refusal a programmer can read.

So this is a different column from ADR-033 D2’s touches, which names a lock per container because Part I 8.1.1 promises “which file, which socket, which lock”. touches answers whether two operations are ordered; this answers whether a call may appear inside an open lock. Part II 12.2’s rule that two accesses to the same lock keep their order rests on the first, and must never be re-derived from this one, which cannot tell two locks apart.

If the coarse rule hurts in real programs, touches is the home for the precise version. Not now.

D5 — For this column, an absent entry means “touches a lock”. The opposite of sync, written out where a reader would apply sync’s rule.

Part III 13.5 states, as a rule about that file, “An absent sync therefore means not sync” — safe there, because absent lands on the restrictive answer. For this column absent would land on permission, and an entry nobody wrote would be a hole through which every rule in this record falls. Reading the absence of an answer as a yes is the polarity ADR-010 D1 forbids.

This is a sentence of its own in 13.5, beside the general rule, not a footnote.

D6 — A call into foreign code is judged by what its arguments can reach, and the check is the crossing walk generalised — never copied.

Foreign code can only touch what it reaches, and this language has no global mutable data. So if no lock is reachable from the arguments — transitively, through structs — the call is allowed, silently. Otherwise it is refused. This extends ADR-038 D7’s first rule to locks and displaces nothing there.

Generalising rather than copying is the decision, not an implementation preference, and one case shows why. fs::Mapped has a ledger entry with an empty field list, because its fields are Rust. Read structurally that is “nothing reachable”, and “allowed silently” would have had a hole at exactly that shape. contracts::send’s walk already gets it right — an empty field list answers undecided, which is the fail-closed arm, because a type with no fields recorded is not a type with no fields. The walk carries three more such judgements: a depth limit against a type that holds itself through a container, a cycle rule, and the arm D7 is about. A copy re-derives four subtleties and one of them is re-derived wrongly.

D7 — A function handed to foreign code may not touch a lock, and that answer comes from its body, never from its type.

(Corrected on first use. This decision was first written as “a function handed outward”, which read as forbidding what D3 calls ordinary: a spawned lambda is handed outward and may take a lock. The rule exists to close the callback detour — foreign code, called from inside an open lock, calling back into Nikaia and taking a lock — so D6’s boundary is its scope. A spawned task is handed to our own runtime and runs later, so it cannot be inside an open lock, and D3’s sentence stands unchanged.)

The same property as D3, and it is worth being exact about where it comes from: contracts::send’s walk answers undecided for a function type, because a lambda is its captures and nothing writes those down. The walk says so honestly, which means the type can never answer this question. So this rides on D3’s body walk: a lookup for a named function, the same walk for a lambda.

D6 and D7 are the two halves of one boundary. D6 asks what a foreign call’s arguments can reach and has nothing to say about a function among them, because the walk honestly answers undecided there; D7 is that missing answer. Neither reaches past the foreign call, which is why D3’s spawn sentence is untouched by both.

Today this is narrow, and the reason is in the specification: Nikaia’s type grammar has no function type, so no source program declares a parameter of that shape (Part I 5.4, Part III 13.5). It covers a lambda written at a foreign call site and std’s own detached entries, enforced the way ADR-029 D4 is enforced — by walking the entries rather than trusting them. It widens the day the type grammar gains a function type.

D8 — The runtime re-entrancy check becomes a build switch, on by default, in the manifest.

Part I 1.2 says “What you choose when you build is how, never what, and there are exactly two things to choose.” The principle is untouched and is this switch’s own guarantee: for every program that satisfies D2, both builds behave identically. The switch decides only whether a violation is noticed.

(Corrected on first use. This decision was first written as a third switch, making the count “three”. That was wrong by its own argument: the same paragraph observed that ordering lives in [build] uncounted (ADR-033 D8), which makes the answer four and not three. So Part I 1.2 stops counting and names them instead — target, user_parallelism, ordering and this one — because a count that has been wrong twice reads as a promise nobody maintains. ordering gains the subsection it never had there. cleanup-deadline is not among them: the manifest still accepts the key and the compiler no longer reads it (ADR-038 D5).)

Under D2 the check cannot fire in a correct compiler, and that is its new role: self-control of D2’s rule, not error handling. No input can trigger it; if it fires, the compiler has a hole. Without it such a hole appears as a silent hang and, at no, as a stopped cleanup deadline (ADR-006 D5).

It is not named “debug” and is not a development aid to be removed later. It is a guarantee that can be declined, which is ADR-033 D8’s precedent: “A semantic default that cannot be switched off is a decision imposed rather than offered.” It lives in the manifest rather than on the command line only, because otherwise a shipped build is not reproducible, and it is a cache-key dimension like the other two (ADR-037 D4).

It belongs in the manifest’s [build] and not in the runtime settings file, and that needs saying because cleanup-deadline moved the other way. The difference: cleanup-deadline changes what a running program waits for and the compiler need not know it, while this switch changes what the compiler emits. A choice the compiler acts on is the compiler’s to read.

It also sits inside ADR-037 D7’s rule — what is observable is written, what is not is inferred, with safe as the floor. Under D2 a re-entrant acquisition is not observable in a correct program, so nothing observable is being switched.

D9 — The shared mutable type is named SharedMut[T], from the start. Locked[T] stays, with its own use.

Two wrappers are Rust’s shape and a wart there. The combination is the normal case, so the normal case gets the short name and the exception gets the long one. This is not a rename: nothing exists to rename, so there is no body of code to split in half, and SharedMut is simply what the type is called. The short form is a deliberate departure from this language’s habit of spelling words out — for its most common type, SharedMutable is too long.

Locked[T] stays because it has a second real use: individually locked fields inside a shared structure. What disappears is the hand-written nesting.

Part I 6.2 defines Locked[T] by that nesting — “Allows data inside a Shared container to be modified”. Since Locked keeps the second use, that definition is wrong as written rather than merely incomplete, and the rewrite belongs to this decision.

One thing this gets for free: Part III 13.5 records a parameter’s type in the ledger as the source writes it, so a ledger entry is user-visible. With one name from the start there is no internal spelling that could appear in a diagnostic, a printed type, or an entry.

D10 — Four doors, not one.

Today every access goes through the door that carries every rule. Three of the four need none of them.

form for block may wait
kasse.get() taking a copy out —
kasse.set(value) replacing, value computed outside yes, outside
kasse.update fn(mut v) { v += 100 } changing it, under the lock no
kasse.access fn(state) { … } in place, large values no

set needs no block because arguments are evaluated before the call: the lock is open for the duration of one store. update hands the block the value as mut v — a copy where the value is a word, the address otherwise, which is the compiler’s by type (ADR-110 D2) — and the block returns nothing. access stays for everything where copying is too expensive — a list of ten thousand entries is not copied to append one.

The derivation this breaks, and the stronger one that replaces it. Part II 12.2 reasons: the lambda is sync, therefore nothing can fall due while the lock is held — and ADR-006 D2 calls that composition free. set has no lambda. The replacement is not a patch but a stronger sentence: while the lock is open in get and set, no user code runs at all, so there is nothing that could fall due. One sentence instead of a derivation.

get is a read; set, update and access are writes. Part II 12.2 says two accesses to the same lock keep their order, and that sentence was written when access was the only door and therefore had to count as a write. Under ADR-033 D2 two reads of one resource are unordered, so two gets are unordered and the three writing forms stay ordered. A refinement to be written down, not a conflict.

Two refusals come with the doors: assigning to a SharedMut directly is refused and names set, and a set whose argument contains a get on the same container is refused and names update. The second reads the stamp a value carries out of a lock (ADR-111), so it catches the pair spread over two lines and over two functions as well as the one written inline.

3. Consequences

The open half: a lock cannot reach a task, so 12.2’s counter cannot be written

D1 keeps one implementation per setting. The crossing verdict may not consult the switch, so for a type whose implementation follows it the verdict takes the worse of the two settings (ADR-037 D6) — may not cross. A SharedMut[T] holds a lock, so it cannot be captured by a spawn, and the shared counter of Part II 12.2 has no second task to reach.

The sharpest form, and it is sharper than a cost. At user_parallelism = yes the cheap implementation is not built at all. What stands in the emitted program is a real operating-system lock, under which that counter would be entirely safe. It is refused on account of an implementation that does not occur in that program. With the switch turned on, the compiler prevents the one thing the setting exists for.

Two ways out. Neither is taken here.

A — the verdict takes the destination, and the portability rule costs nothing. What is judged stops being “may this type be on a foreign thread in the worst case” and becomes “may this value go to this destination“. Both answers are switch-independent:

destination answer why, at no and at yes
a task of our own may at yes the lock is an OS lock and holds; at no nothing crosses, because no code of the user’s runs concurrently (ADR-037 D2)
foreign code whose end this compiler cannot see may not a foreign runtime’s threads are not bound by a switch over our code (ADR-038 D7)

The verdict is therefore the same at both settings for each destination, which is the whole of what ADR-005 §1 Group B asks — that note already says “‘At every setting’ is a claim about the verdict, not about the severity.” The justification differs per setting; the answer does not. So A displaces no record.

A does not fail on the verdict machinery, and that is checked rather than assumed. The two destinations are already two call sites in check: crosses_into_a_task raises NK2501, crosses_into_an_unseen_call raises NK2502, their polarities are already recorded separately in Part III C.3, and both ask the same send::crossing(ty, own, library) and then only whether it refused. The destination is already known where the question is asked; what is shared is the one verdict function. A is that function learning the destination.

What A does cost, named so it is not discovered later:

B — one implementation after all, which is the Mutex floor under another name. The price is paid at no, the setting where it unlocks nothing, because there is no second thread there to be a destination. Uniformity instead of capability.

And the question that would have to be asked before B: does the cheap implementation need a full lock to be shareable, or would a flag of atomic instructions that refuses on conflict instead of waiting do? The measurement behind this record answers it without new work — an uncontended lock is one atomic and a branch, with zero futex calls in a million acquisitions. A conflict-refusing flag needs the same atomic, so B does not get cheaper by being weaker; and under D2 a conflict cannot arise at no at all, so its refusing branch would be dead code. B stays where the measurement put it.

What this displaces. Part II 12.3’s “often” (D2); Part II 12.2’s safety-net sentence (D2); ADR-005 D6’s last sentence (D2); Part I 1.2’s “exactly two” — which D8’s own correction replaces with a named list rather than a larger number; Part I 6.2’s definition of Locked[T] (D9); Part II 12.2’s sync-lambda derivation and ADR-006 D2’s “for free”, both replaced by a stronger sentence (D10).

What it does not displace. ADR-037 D3’s second half is answered, which D7 of that record explicitly left open. ADR-038 D7’s first rule is extended to locks. ADR-005 Group B needs nothing: a crossing into a task at user_parallelism = no is already reported as a lint rather than refused, so a spawned task taking a SharedMut is the ordinary case there too.

What it costs. The type does not exist. Beside the analysis in D3–D7 this needs SharedMut[T] and Locked[T] as types the checker knows and the emitter lowers, their four doors, and the std entries behind them. Roughly thirty normative sentences become incomplete, fourteen code and table sites and nine Status notes need rewriting, and the diagnostics appendix needs codes for D2, D10’s two refusals and D6’s refusal. That is follow-on work, not a second decision.

A retry instead of a lock is ADR-110 D3: an update block may run more than once, because sync says it does not wait, the touches column (ADR-067 D1) says it takes nothing else, and its captures are views — and the compiler uses that where the value is a word.

4. What is built

Nothing in this record is built. SharedMut does not exist, Locked does not exist, neither does any of the four doors, and no check refuses D2’s nesting. What exists is the probe in §1 and the measurement behind D1, and the machinery D3 and D6 are to be built on: contracts::sync’s call graph and fixpoint, and contracts::send’s structural walk.

This site is open source. Improve this page.