singleton: guard the state, not the program's name
El SDK CI - dev / build-and-test (pull_request) Failing after 4m6s
El SDK CI - dev / build-and-test (pull_request) Failing after 4m6s
The singleton lock protected a filename, not a store. It was keyed on $EL_SINGLETON_DIR|$TMPDIR|/tmp + /el-singleton-<program>.lock — the program's NAME and a temp directory — and never consulted the state it claimed to protect, while its own refusal message read "Refusing to start a second instance against the same state." Measured, it failed in both directions. A second engram against a DIFFERENT data dir was refused, naming the first's pid. And TMPDIR=/tmp/other let a second engram start against the SAME data dir with no complaint — the two-writer data-loss condition the guard exists to prevent, defeated by one environment variable. Both are one error: the identity of the resource had been replaced by a label for it. The lock now lives inside the state it guards — <state>/.el-singleton-<id>.lock — and the program block says what that state is. Same directory is the same file is the same inode, so it contends and there is no TMPDIR left in the key to change. Different directories are different files, so they don't. Different spellings of one directory (trailing slash, x/../x, symlink) collapse in the kernel's own path walk, so they contend without this code comparing strings; canonicalisation is for the message, never the decision. `guards:` is an expression so a program can point at the resolver that already owns its path — guards: engram_resolve_data_dir() — instead of restating that resolver's default, which is the two-owners defect spec 18.4 exists to prevent. A `singleton:` without `guards:` is now a compile error; emitting a name-keyed lock instead would be emitting the defect. Kept: the flock (the kernel drops it on crash and SIGKILL, so there is still no "delete the lock file to get unstuck" ritual — a stale file inside a copied data dir is inert), and the holder's pid in the message. Changed: the message is true. It says "the same state" because the lock it failed to take is in that state, and it names the state it checked. An unguardable state (missing, read-only) now refuses rather than starting unguarded. Also corrects lang/AGENTS.md's compiler rebuild line, which had gone stale: linking el_runtime.c alone no longer resolves.
This commit is contained in:
@@ -1976,6 +1976,18 @@ fn parse_stmt(tokens: [Any], pos: Int) -> Map<String, Any> {
|
||||
// singleton: "id" — process identity. The runtime takes an exclusive
|
||||
// lock at startup; a SECOND start is refused, loudly,
|
||||
// instead of two processes sharing one data dir.
|
||||
// guards: <expr> — WHAT that singleton protects: an expression yielding
|
||||
// the path of the guarded state directory, evaluated at
|
||||
// startup. MANDATORY with `singleton:`, because a lock
|
||||
// keyed on a program's NAME rather than on its STATE is
|
||||
// not a guard — measured 2026-08-16, the name-keyed
|
||||
// version refused unrelated instances (different data
|
||||
// dirs) AND permitted concurrent ones (same data dir,
|
||||
// different $TMPDIR). It is an expression and not a
|
||||
// string so a program can point at the resolver that
|
||||
// already OWNS the path (§18.4) instead of restating
|
||||
// its default here, which would give the path two
|
||||
// owners that can disagree.
|
||||
// env NAME: T = "d" — one configuration entry. Its type and its default
|
||||
// are declared ONCE, here, and resolved+validated
|
||||
// before main() body runs.
|
||||
@@ -1993,6 +2005,8 @@ fn parse_stmt(tokens: [Any], pos: Int) -> Map<String, Any> {
|
||||
let p = expect(tokens, p, "LBrace")
|
||||
let singleton = ""
|
||||
let has_singleton = false
|
||||
let guards_node = { "expr": "Str", "value": "" }
|
||||
let has_guards = false
|
||||
let entries = native_list_empty()
|
||||
// Entry-scratch declared at loop-body level (not inside the branch) so
|
||||
// that inner `let` forms compile to assignment rather than a C-scoped
|
||||
@@ -2048,13 +2062,26 @@ fn parse_stmt(tokens: [Any], pos: Int) -> Map<String, Any> {
|
||||
"required": erequired
|
||||
})
|
||||
} else {
|
||||
// scalar field: `name: "value"`
|
||||
let p = expect(tokens, p, "Colon")
|
||||
let fval = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
if str_eq(fname, "singleton") {
|
||||
let singleton = fval
|
||||
let has_singleton = true
|
||||
if str_eq(fname, "guards") {
|
||||
// guards: <expr> — the STATE the singleton protects.
|
||||
// Parsed as a full expression, not a string literal, so
|
||||
// it can name the resolver that owns the path
|
||||
// (`guards: engram_resolve_data_dir()`) rather than
|
||||
// duplicating that resolver's default here.
|
||||
let p = expect(tokens, p, "Colon")
|
||||
let g_r = parse_expr(tokens, p)
|
||||
let guards_node = g_r["node"]
|
||||
let p = g_r["pos"]
|
||||
let has_guards = true
|
||||
} else {
|
||||
// scalar field: `name: "value"`
|
||||
let p = expect(tokens, p, "Colon")
|
||||
let fval = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
if str_eq(fname, "singleton") {
|
||||
let singleton = fval
|
||||
let has_singleton = true
|
||||
}
|
||||
}
|
||||
}
|
||||
let k5 = tok_kind(tokens, p)
|
||||
@@ -2070,6 +2097,8 @@ fn parse_stmt(tokens: [Any], pos: Int) -> Map<String, Any> {
|
||||
"name": name,
|
||||
"singleton": singleton,
|
||||
"has_singleton": has_singleton,
|
||||
"guards": guards_node,
|
||||
"has_guards": has_guards,
|
||||
"entries": entries
|
||||
}, p)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user