singleton: guard the state, not the program's name #157
Reference in New Issue
Block a user
Delete Branch "fix/singleton-guards-the-state"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The defect
el_singleton_acquire()guarded a filename, not a store.The lock was
$EL_SINGLETON_DIR|$TMPDIR|/tmp+/el-singleton-<program-name>.lock. It was keyed on the program's name and on a temp directory, and it never consulted the state it claimed to protect — while its own refusal message read:It had not looked at any state. Measured on the pre-fix build, it failed in both directions:
ENGRAM_DATA_DIRwas refused, naming an unrelated pid. Two instances against genuinely different stores could not coexist.TMPDIR=/tmp/otherlet a second instance start against the same data dir with no complaint. That is precisely the two-writer data-loss condition the guard exists to prevent, and the workaround was one environment variable.The principle
Both failure directions are one error, not two: the identity of the resource had been replaced by a label for it. This is the same class of mistake as reducing a structure to a location.
The design, and why
The runtime cannot know generically which environment variable holds an arbitrary program's state, so the
programblock has to say. Three options were weighed:programso the singleton names its state(b) was rejected because it does not actually fix the bug. Hashing the state into the name leaves
$EL_SINGLETON_DIR/$TMPDIRin the location, so the false negative survives: point the lock dir somewhere else and the second instance still starts. Closing that would mean hard-coding a lock directory and writing a canonicaliser whose string comparison has to be right — machinery that (a) gets from the filesystem for free.(a) is the whole fix, in one placement decision:
flockcontends. There is noTMPDIRin the key, so there is nothing left to change to get past it.$EL_SINGLETON_DIRis deleted, not deprecated.x/../x, a symlink — collapse to one inode in the kernel's own path walk, so they contend without this code comparing strings.realpath()is used only so the diagnostic names one directory in one spelling; the decision never depends on it and is correct if it fails.(c), minimally:
guards:is an expression, not a string. The engram's data dir is owned byengram_resolve_data_dir()— it owns both the$ENGRAM_DATA_DIRread and the$HOME/.neuron/engramfallback. Spec §18.4 already forbids giving that path a second owner. A string-valuedguards:would force the default to be written down twice, and a guard that resolved the path its own way could lock a directory the program never writes to. An expression lets the block point at the existing owner:guards:is mandatory.singleton:without it is now a compile error (#errorin the emitted C). Emitting a name-keyed lock instead would be emitting the defect — and it fails silently in the direction that loses data. Engram was the onlysingleton:in the tree, so nothing else moves.Kept
flock, still reports the holder's pid (added becausepkill -fsilently failed to match a stale process that went on answering probes).SIGKILL. A lock file left inside acp -Rc'd data dir is inert: it carries no lock, only a stale pid string the next holder overwrites. Demonstrated below.Changed
The FATAL message is now true. It says "the same state" only because the lock it failed to take is in that state, and it names the state it checked. A diagnostic asserting a check that did not happen is worse than none — it is what let the name-keyed version read as correct for as long as it did.
Measurement
CI is not the bar; the local build is. Built exactly as documented, with the rebuilt
dist/platform/elc(self-host fixpoint verified byte-identical):Emitted call:
Run on two APFS clones of the live store (
cp -Rc ~/.neuron/engram dataA/dataB, 34 948 nodes each) on ports 19001–19012. Production (:8742,~/.neuron/engram) was never bound, written, or signalled.Summary
TMPDIRTMPDIRTMPDIR..refused only becauseTMPDIRmatched; symlink + differentTMPDIRstarted ❌Case 4's "passes" before the fix were passing for the wrong reason — the name matched, not the store — which case 4c exposes by varying
TMPDIRat the same time.Case 1 — same data dir, same TMPDIR → REFUSED (still works)
Case 2 — same data dir, DIFFERENT TMPDIR → REFUSED (the bug)
Before (
ENGRAM_DATA_DIR=…/dataA TMPDIR=…/tmpB) — the second instance started:After — refused, and the message names the store, not the temp dir:
Case 3 — different data dirs, same TMPDIR → BOTH START
Before — the second was refused, naming an unrelated pid:
After — both alive, both serving, both answering from their own store:
Case 4 — equivalent-but-differently-spelled paths → REFUSED
Holder is on
…/lab/dataA. Note 4c also variesTMPDIR— before the fix it started (exit=124,[http] listening on [::]:19010); the two that did refuse before did so only because the name matched.All three spellings report the same canonical state and the same canonical lock path as the holder — the guard resolved them to one thing.
Two more, because they are properties this fix must not break
No unstick ritual. After
kill -9of the holder, the lock file is still on disk carrying the dead pid, and the next start just works:Unguardable state refuses instead of starting unguarded (read-only dir):
Compile-time refusal of a guardless singleton:
Toolchain
lang/dist/platform/elcis rebuilt, because the parser and codegen changed. Self-host fixpoint verified byte-identical (elc-newrecompilingelc-cli.elreproduces its own input.cexactly).tests/native/{test_core,test_compiler,test_string,test_json}.elall still compile under it.lang/AGENTS.md's compiler-rebuild recipe is corrected in passing: linkingel_runtime.calone no longer resolves (ldfails onengram_store/engram_vindex/eg_cosine_batch/el_seedsymbols).Files
lang/runtime/el_runtime.c/.h—el_singleton_acquire(id, state); lock moved inside the state;el_singleton_dir()and$EL_SINGLETON_DIRdeleted; messages made true.lang/el-compiler/src/parser.el—guards: <expr>in theprogramblock.lang/el-compiler/src/codegen.el— passes it as the second argument;#errorwhen absent.lang/spec/language.md— §18.1 grammar, §18.2 rewritten around the principle and the measured failure table, §18.4 cross-reference.engram/src/server.el—guards: engram_resolve_data_dir().lang/dist/platform/elc,lang/AGENTS.md.