diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77acca42..ebc39ace 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -418,7 +418,14 @@ jobs: name: asan + ubsan (full suite) needs: [dev-image, scope] runs-on: ubuntu-latest - timeout-minutes: 30 + # 45, not 30: the green run before #915's [99u] section landed took 19m57, + # and that section legitimately adds minutes under ASan (it launches the + # sanitized binary ~60 times; sanitizer process startup dominates) — a slow + # runner then hit the old ceiling and the job rendered as CANCELLED at + # 30:02 with the suite mid-section. A timeout is not a verdict: the same + # tree's local ASan suite was 4117/4117. Headroom target ~2x the observed + # green duration, per the suite-runtime-baseline rule. + timeout-minutes: 45 permissions: contents: read packages: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c5eedb..a38bd61c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ All notable changes to EigenScript are documented here. ### Added +- **The observer gate ships: programs that provably never read observer state + skip entropy bookkeeping entirely (#915).** Decided per compiled unit at + compile time (opcode scan + observer-builtin name scan + eager compilation of + string-literal `load_file` targets, so a clean module tree still gates), per + `EigsState` at run time. **8.51x on EigenMiniSat's 4x4 Tseitin workload** + (n=5 interleaved, one binary, solver counters identical across arms) — the + ungated observer walk was 88% of that workload's runtime. Byte-identical on a + 416-program corpus against a pre-gate build. Conservative everywhere the + scan cannot be sure: computed load paths, aliasing, `eval`, `import`, + multithreaded compiles, and anything over the 1 MiB speculative-read budget + keep full observation; a module rewritten between scan and load raises + loudly rather than answering from a gap. `EIGS_OBS_FORCE=1` restores + pre-gate behaviour exactly; `EIGS_OBS_GATE_STATS=1` prints per-unit + verdicts. Verified by a nineteen-round adversarial loop plus the full CI + matrix; suite section [99u] pins 44 checks over the gate's mechanisms. + Residuals are filed, not hidden: #1031 (literal modules compile twice; + budget-bounded), #1027 (descriptor pre-call history), #1032/#1033 (minor). + The observer arming flags and trace-history flags are now relaxed-atomic — + fixing a data race reachable from worker threads (also present, unfixed, + in three pre-existing sites now ledgered on #1035/#1036). + - **Every opcode now carries a recorded observer classification, and a gate fails on any that does not (#972).** The optimisation this unblocks — stop emitting observer bookkeeping when a program provably never reads observer diff --git a/docs/OBSERVER.md b/docs/OBSERVER.md index 212d63d2..108f79d5 100644 --- a/docs/OBSERVER.md +++ b/docs/OBSERVER.md @@ -429,11 +429,16 @@ interrogation. `unobserved:` is the only opt-out, and it is a real one: it skips the emission, so a hot region inside it pays nothing. -### The classification that a future opt-out rests on (#972) +### The automatic opt-out — the observer gate (#915/#972) -The obvious improvement is to skip the emission automatically when a program -provably never reads observer state. That optimisation is not shipped, and the -reason is worth stating: its failure mode is silent and total. If any opcode +That improvement IS shipped: the runtime skips the emission automatically when a +program provably never reads observer state. On a consumer that uses no observer +features it is worth **8.5x** (EigenMiniSat 4x4 Tseitin, 293 s -> 34 s, n=5 per +arm interleaved with the solver's counters identical). See "Using the gate" +below for the controls. + +The reason the rest of this section is written so carefully is that the failure +mode is silent and total. If any opcode that reads observer state is missing from the scan that decides "is anything reading?", a program gates its own bookkeeping off and then reads slots nobody updated — every binding answers `equilibrium` forever, with no crash and no @@ -455,7 +460,7 @@ marker instead: | marker | meaning | |---|---| -| `obs:READS` | answers **from** recorded observer/temporal state — the set the future liveness scan consumes | +| `obs:READS` | answers **from** recorded observer/temporal state — the set the liveness scan consumes | | `obs:WRITES` | records, updates, resets or stamps that state | | `obs:DIAG` | reaches it only through the SIGUSR1 diagnostic dump, never through program-visible semantics | | `obs:NONE` | none of the above | @@ -473,3 +478,81 @@ observes at `OP_OBSERVE_NAME_POST` after the SET), and the bare `OP_INTERROGATE` reads **no** observer state at all — `when` / `where` / `why` / `how` on a value operand return constants, because observer state is binding-keyed and a bare value has no binding. + +## Using the gate + +The gate is automatic and needs no source change. A program that never reads +observer state pays nothing for it; a program that does is unaffected. + +| control | effect | +|---|---| +| `EIGS_OBS_FORCE=1` | force observer recording ON, whatever the scan decided. The escape hatch, and the baseline arm for any measurement — one byte-identical binary serves both arms. | +| `EIGS_OBS_GATE_STATS=1` | print one `obs-gate: observed\|unobserved ` line per compiled unit on stderr. | + +Both follow the tree's flag convention: any non-empty value that does not +start with `0` turns the control on, so `=0` and `=` leave it off. + +### When the gate refuses instead of answering + +The gate decides at COMPILE time, and a few constructs can make that decision +stale at RUN time. Where the runtime can prove the decision was wrong, it raises +rather than answering — the recorded history of bindings already assigned cannot +be reconstructed, so a late discovery is not recoverable and a quiet rest value +would be a wrong answer with nothing to fail on. + +``` +load_file: 'x.eigs' reads observer state, but the observer gate was closed when +this program's earlier assignments ran — they have no recorded history... +``` + +You will see this if a program **rewrites a module between the compile and the +load**, or creates a file that **shadows** the one the compile-time scan +resolved (resolution tries the cwd before the script directory), or `chdir`s so +the same literal path resolves elsewhere. All three are the same shape: the file +the gate inspected is not the file that ran. + +Re-run with `EIGS_OBS_FORCE=1` to disable the gate for that program. That is +always safe — it restores the pre-gate behaviour exactly. + +### When the gate declines to look + +To decide before the program runs, the gate compiles literally-loaded modules +itself — including ones reached only from a function that is never called, since +a `load_file` inside an uncalled function still contributes to the answer. That +work is speculative, so it is bounded: a per-thread cumulative ceiling on how +many bytes the pass may read on the program's behalf, plus a rejection of +anything that is not a regular file (a FIFO target once hung the compiler +indefinitely, before `vm_execute`, with nothing printed). + +When the ceiling is spent the pass stops looking and the gate stays **open** — +the conservative answer. Nothing is silently wrong; the program simply pays for +observer bookkeeping it may not need. `EIGS_OBS_GATE_STATS=1` shows this as +`observed` lines on a program you expected to gate closed. + +The ceiling is picked against the real population rather than chosen round: the +largest transitive module tree in `lib/` is `ui.eigs` at 287 KiB across 19 +units, the next largest is 69 KiB, and the budget clears the largest by 3.5x. +A suite check pins that — if a stdlib tree grows past the budget, the check +fails and the number gets re-picked deliberately instead of the win quietly +disappearing. + +Shared modules are charged once, not once per reference, and identity is the +file itself rather than the path spelling — the same module reached through a +relative path, an absolute one and a symlink is one charge, not three. + +### Known residual + +A chunk run through `vm_run_bytecode` or `sandbox_run` that reads observer state +about a binding the HOST assigned before the call gets a rest value rather than +the truth, silently. The descriptor's own work is recorded (both sites arm the +observer before running, the twin of `chunk_arm_temporal`); only reads of state +that predates the call are affected. Tracked separately with reproducers and two +candidate fixes; `EIGS_OBS_FORCE=1` avoids it. + +Separately, every literally-loaded module is compiled **twice** — once by the +gate to learn one bit, once for real by `load_file`, which has no module cache +by design. Measured on `lib/ui.eigs`: 0.12-0.15s for the literal spelling that +gates closed against 0.05-0.07s for a computed spelling that skips the pass, so +a program that loads a large tree and does little work can pay more than it +saves. The fix is to hand the eagerly-compiled chunk to `load_file` instead of +discarding it; the budget above bounds the cost meanwhile. diff --git a/src/arena.c b/src/arena.c index 773d8ae7..0294c41e 100644 --- a/src/arena.c +++ b/src/arena.c @@ -33,6 +33,10 @@ #endif static void x_oom(size_t size) { + /* #915: the observer gate's eager pass may have stderr muted; a fatal + * message must not be discarded because a module was loaded by one spelling + * rather than another. No-op when nothing is muted. */ + eigs_obs_unmute_for_fatal(); fprintf(stderr, "eigenscript: out of memory (requested %zu bytes)\n", size); abort(); } diff --git a/src/builtins.c b/src/builtins.c index 06363db1..3ad58286 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -3182,12 +3182,51 @@ static EigsChunk *vm_build_chunk_desc(Value *desc, int off, int sandbox_mode) { * output runs through — reusing the bytecode VM and its JIT. The caller is * responsible for a well-formed chunk ending in OP_RETURN, stamped with the * bytecode ABI revision it was built against (#704). */ +/* #915: a descriptor chunk never passes through compile_ast, so the observer + * gate's compile-time scan never saw it. Two things follow, and only one of + * them is solved here. + * + * SOLVED — the descriptor's OWN work. eigs_obs_enable() arms recording before + * vm_execute, the observer twin of chunk_arm_temporal below (#831: "a + * descriptor must turn recording ON itself"). It also records the history gap, + * so a mid-run arming cannot disarm the load_file guard — a benign descriptor + * call used to do exactly that, silently, for the rest of the process. + * + * NOT SOLVED, and filed rather than half-guarded — see the issue referenced in + * docs: a descriptor that READS observer state about a host binding assigned + * before the call gets a rest value, because that history was never recorded. + * Three static guards were tried and each traded one wrong answer for another: + * "reads at all" broke 57 bridge assertions; "reads a NAME operand" missed the + * slot form; "reads a NAME or an in-range assigned slot, or the thread alias" + * fires on tests/test_vm_run_bytecode.eigs's own #737 fixture, whose operand + * bytes are load-bearing (1,1 == two OP_NULLs, chosen so a drifted operand walk + * stays synced) and whose reader is JUMPED OVER. Distinguishing that from a + * real host read needs reachability analysis over caller-supplied bytecode, + * which is its own change with its own review. Shipping a guard that breaks a + * legitimate fixture, or a fourth variant tuned until the suite passes, would + * both be worse than a stated residual. */ + Value* builtin_vm_run_bytecode(Value *arg) { char abibuf[256]; const char *abi_err = vm_desc_abi_error(arg, abibuf, sizeof abibuf); if (abi_err) { rt_error(EK_VALUE, 0, "%s", abi_err); return make_null(); } EigsChunk *chunk = vm_build_chunk_desc(arg, 1, 0); if (!chunk) return make_null(); + /* #915: ARM the observer for what this descriptor itself does, exactly as + * chunk_arm_temporal two lines below arms the temporal channel (#831: "a + * descriptor must turn recording ON itself" — nothing scanned this chunk). + * + * The guard above and this line answer DIFFERENT questions and an earlier + * revision wrongly swapped one for the other: the guard covers host + * bindings assigned BEFORE the call, whose history is unrecoverable; this + * covers everything the descriptor writes and reads AFTER it. Deleting this + * made a descriptor that writes a geometric series into its own frame slot + * and reads it back answer `equilibrium` — a regression a blind critic + * bisected to the commit that removed it. Both are needed. + * + * Through eigs_obs_enable, not a bare assignment: this flip happens mid- + * execution, so it must also record that earlier bindings have no history. */ + eigs_obs_enable(); /* #831: the compiler's temporal scan is what turns history recording on, * and it never saw this chunk — arm from the verified bytecode instead, * or the chunk's own `prev of` / `at` reads answer null whenever the @@ -3350,6 +3389,11 @@ static int sandbox_value_has_callable(Value *v, int depth, long *budget, * are caught (not propagated). Returns {"ok": 1/0, "result": value} — the graded * "does it run?" rung for a self-hosted compiler validating generated code. */ Value* builtin_sandbox_run(Value *arg) { + /* #915: same descriptor hazard as vm_run_bytecode. Unexploitable TODAY only + * because the sandbox env is a sealed root (parent == NULL), so a descriptor + * cannot reach a host binding's slot — that is the sandbox's defence, not + * the gate's, and it evaporates the day sealing is relaxed. The guard runs + * below, once the chunk exists. */ Value *desc = (arg && arg->type == VAL_LIST && arg->data.list.count >= 1) ? arg->data.list.items[0] : arg; int max_iter = 1000000; @@ -3379,6 +3423,7 @@ Value* builtin_sandbox_run(Value *arg) { char abibuf[256]; const char *abi_err = vm_desc_abi_error(desc, abibuf, sizeof abibuf); EigsChunk *chunk = abi_err ? NULL : vm_build_chunk_desc(desc, 1, 1); + if (chunk) eigs_obs_enable(); /* #915: see vm_run_bytecode */ Value *out = make_dict(2); if (!chunk) { /* Descriptor verification may already have interned constants before @@ -3636,7 +3681,7 @@ Value* builtin_record_history(Value *arg) { int on = (arg->data.num != 0.0) ? 1 : 0; /* #827: no name to narrow on — a self-hosted compiler calling this is * standing in for the whole-program arming, so it gets the wildcard. */ - if (on) { trace_arm_history_all(); g_trace_obs_hist = 1; } + if (on) { trace_arm_history_all(); trace_flag_store(g_trace_obs_hist_storage, 1); } else trace_history_disable(); return make_num((double)prev); } diff --git a/src/builtins_host.c b/src/builtins_host.c index 09a2ec25..6bb86f37 100644 --- a/src/builtins_host.c +++ b/src/builtins_host.c @@ -1034,8 +1034,57 @@ Value* builtin_load_file(Value *arg) { Env *target = g_load_env ? g_load_env : g_global_env; int saved_boundary = g_compile_module_boundary; g_compile_module_boundary = 1; /* #373 */ + /* #915: the observer gate may have CLOSED on evidence gathered when this + * unit's parent was compiled — the eager pre-pass resolved this literal + * target and compiled it then. The file it read and the file being compiled + * now are two separate reads with the whole program running in between, so + * they can differ: the program can rewrite the module (`write_text` then + * `load_file`), or create a file in the cwd that SHADOWS the one the + * pre-pass resolved (resolve_eigenscript_file tries cwd before the script + * dir). Both were executed and both produced a silently wrong answer — + * `report of x` read `equilibrium` under the gate and `moving` without it. + * + * ASK THE ACTUAL QUESTION. A first draft compared the observer bit before + * and after the module's compile and raised on a 0 -> 1 transition. Two + * blind-critic repros killed it: + * + * - ONE-SHOT. The bit is monotonic, so that first transition leaves it at + * 1 and every later load saw "already open" and skipped the check. The + * error is catchable, so a single `try:` around the first load disarmed + * the guard for the rest of the run and restored the exact silent-wrong + * answer the guard was written to stop. + * - OVER-BROAD. The bit flips for any of the eager pass's SIX conservative + * bail-outs, not just for staleness. One of them (`L.count > 0 && + * g_vm_multithreaded`) is reachable at run time but not at the parent's + * compile time, so `spawn` + a module that itself loads a module became + * a hard error with every clause of the message false. That shape is + * shipped: lib/io.eigs does `load_file of "lib/string.eigs"`. + * + * So the predicate is the module's OWN verdict — does this chunk read + * observer state? — and the precondition is "this program has bindings with + * no recorded history", which is sticky rather than derived from a bit that + * the detection itself changes. */ + /* ACQUIRE: this is the one read that pairs with eigs_obs_enable's + * store ORDER (gap then needed) — see obs_flag_store in eigenscript.h. */ + int obs_before_module = obs_flag_load_acquire(obs_needed); EigsChunk *lf_chunk = compile_ast(ast, target, source); g_compile_module_boundary = saved_boundary; + if (lf_chunk && chunk_reads_observer(lf_chunk) && + (!obs_before_module || g_obs_history_gap)) { + obs_flag_store(obs_history_gap, 1); + g_parse_errors = saved_errors; + chunk_free(lf_chunk); + free_ast(ast); + free_tokenlist(&tl); + free(source); + rt_error(EK_IO, 0, + "load_file: '%s' reads observer state, but the observer gate was " + "closed when this program's earlier assignments ran — they have no " + "recorded history, so an observer query about them would answer a " + "rest value rather than the truth. Re-run with EIGS_OBS_FORCE=1.", + arg->data.str); + return make_null(); + } if (g_parse_errors > 0) { g_parse_errors = saved_errors; chunk_free(lf_chunk); diff --git a/src/chunk.c b/src/chunk.c index bf47e558..8be62414 100644 --- a/src/chunk.c +++ b/src/chunk.c @@ -901,6 +901,7 @@ void chunk_verify_self_check(EigsChunk *chunk, const char *unit) { if (!chunk) return; char why[192] = ""; if (!chunk_verify_impl(chunk, why, sizeof why)) { + eigs_obs_unmute_for_fatal(); /* #915: see arena.c */ fprintf(stderr, "EIGS_VERIFY_SELF: compiler output failed chunk_verify\n" " unit: %s\n chunk: %s\n why: %s\n", @@ -946,7 +947,7 @@ void chunk_arm_temporal(const EigsChunk *chunk) { if (op == OP_INTERROGATE_NAMED_WHEN) trace_arm_occurrences_name(nm); else trace_arm_history_name(nm); if (op != OP_INTERROGATE_NAMED && kind >= 3 && kind <= 5) - g_trace_obs_hist = 1; + trace_flag_store(g_trace_obs_hist_storage, 1); } } else if (op == OP_GET_NAME) { int name_idx = code[i + 1] | (code[i + 2] << 8); @@ -1038,3 +1039,354 @@ void chunk_scan_leaf_accessor(EigsChunk *c) { if (depth > LEAF_ACCESSOR_MAX_DEPTH) return; } } + +/* ---- #915: does this chunk READ observer state? ------------------------ + * + * The observer computes the entropy of every assigned value, walking the whole + * reachable container graph. On a consumer that never interrogates a binding + * that is 88% of wall time (#915: 8.50x ceiling on EigenMiniSat 4x4). The gate + * skips that bookkeeping for programs nothing can ever ask. + * + * The whole risk is SILENT-WRONG: a program that DOES reach the observer, but + * is classified here as one that does not, still runs and still prints — with a + * dead observer channel and no crash, leak, or failing assert to show for it. + * So this scan is built to be conservative in one direction only. Every unclear + * case must answer 1 ("observes"), never 0. + * + * WHY THE BYTECODE AND NOT THE AST. The obvious implementation is an AST walker + * like cond_is_observer_based / scan_dispatch_rebind. Those switch over ~30 node + * kinds, and a kind the switch forgets falls to the default — which for this + * question means "does not observe", the silent-wrong answer. Bytecode is the + * ground truth of what will actually execute: a new AST node that compiles down + * to a reader opcode is caught here with no change to this function. The + * instruction walk is driven off op_verify_operands, the SAME operand-layout + * table the verifier and disassembler use — per #737, which was opened because a + * hand-written second copy of that table had drifted on 15 opcodes. + * + * Two populations are checked: + * 1. Reader OPCODES — the direct forms (`report of x`, a bare predicate, + * `trajectory of x`, `where is x`, an observer-conditioned loop). + * 2. Reader BUILTIN NAMES in the constant pool — the indirect forms. These + * are ordinary bindings, so `local r is report` then `r of x` compiles to + * GET_NAME "report" + CALL and emits no reader opcode at all. Matching the + * name catches the alias. It also matches an unrelated string that merely + * spells "report", which costs a program its gate and is the safe way to + * be wrong. + */ +static int const_pool_names_observer(const EigsChunk *chunk) { + /* Names reachable only as BUILTINS; the opcode forms are covered by the + * opcode scan above. + * + * This list is NOT anchored to anything, and a previous comment here + * claiming it was "anchored to the observer-READ builtins registered in + * builtins.c (the sandbox allowlist marks them as a group)" was false: that + * group is five names, this is nine, and `report_value` / `trajectory` are + * not builtins at all (absent from `eigenscript --api`; they exist only as + * opcodes). The two lists cannot cross-check each other, so this is a + * hand-maintained list and should be read as one (mechanical-gates §1). It + * is deliberately over-broad: a name here that is not a reader costs a + * program its gate, which is the safe direction. */ + static const char *OBS_BUILTINS[] = { + "observe", "report", "report_value", "trajectory", "classify", + "state_at", "get_observer_thresholds", + /* `eval` compiles a NEW unit at runtime from a string that need not + * exist until the moment it runs, after this unit's assignments have + * already executed — so the new unit's scan cannot arrive in time to + * have observed them: `x is 1.0 ... eval of "report of x"` reads + * equilibrium under the gate and "moving" without it. There is nothing + * to resolve eagerly, so presence of the construct stays the signal. + * + * `load_file` is deliberately NOT here: a STRING-LITERAL target is + * resolved and compiled eagerly by chunk_scan_static_loads below, and + * any other use of the name makes the unit opaque there. See that + * comment for the three recorded failures that shape the rule. + * + * OP_IMPORT is handled in the opcode switch above: it is an opcode with + * a bare-name operand, so it never appears in the constant pool as a + * string and a name list cannot see it at all. Its resolution is + * project-first-then-stdlib against a per-module resolve dir (vm.c + * CASE(IMPORT)); replicating that here would be a second copy of a + * resolver free to drift from the first, which is the #737 failure. So + * import stays conservative and #915's `import` half stays open. */ + "eval", + /* `record_history` sets g_trace_obs_hist — half of what opens the + * observer channel — at RUNTIME, and it has NO opcode form, so its name + * in the constant pool is its only fingerprint. Executed: adding + * `record_history of 1` to a gated program changed its answer while the + * compile verdict still read `obs-gate: unobserved`, i.e. the gate was + * open at runtime and the diagnostic said closed. `record_history of 0` + * then CLOSES it again mid-program, so the channel can flicker. */ + "record_history", NULL + }; + for (int i = 0; i < chunk->const_count; i++) { + const char *s = chunk->const_interns ? chunk->const_interns[i] : NULL; + if (!s) continue; + for (int k = 0; OBS_BUILTINS[k]; k++) + if (strcmp(s, OBS_BUILTINS[k]) == 0) return 1; + } + return 0; +} + +int chunk_reads_observer(const EigsChunk *chunk) { + if (!chunk) return 1; /* unknown -> observe */ + /* #830's flag: a chunk assembled from a descriptor (vm_run_bytecode / + * sandbox_run) never went through the compiler, so nothing scanned it and + * its opcode stream is caller-supplied. Do not gate it. */ + if (!chunk->compiler_scanned) return 1; + if (chunk_has_reader_opcode(chunk)) return 1; + if (const_pool_names_observer(chunk)) return 1; + for (int f = 0; f < chunk->fn_count; f++) + if (chunk_reads_observer(chunk->functions[f])) return 1; + return 0; +} + +/* The OPCODE half of the scan above, split out so it can be run against a chunk + * that never met the compiler. + * + * `vm_run_bytecode` and `sandbox_run` assemble a chunk from a caller-supplied + * descriptor, so `compiler_scanned` is 0 and chunk_reads_observer() answers a + * blanket "observes" for them — correct, but useless as a decision, because by + * the time such a chunk exists the host program has already run. Both sites + * used to answer that with a LATE `g_obs_needed = 1`, which cannot work: the + * bit is monotonic and the history of assignments that already executed is + * unrecoverable. A blind critic executed the consequence — a geometric runaway + * (`x is x * 2.0`, forty times) read back through a hand-assembled + * `OP_REPORT_NAME` descriptor answered `equilibrium` under the gate and + * `diverging` without it. That is the inversion #861's own comment says must + * never happen. + * + * So the descriptor sites ask this instead, BEFORE running: does the chunk I am + * about to execute read observer state while the gate is closed? If so, raise — + * the same outcome guard builtin_load_file uses, for the same reason. */ +static int chunk_step_ip(const EigsChunk *chunk, int i); /* defined below */ + +/* THE reader set. One home, and it is the one tools/obs_reader_sync_check.sh + * extracts and pins against the obs:READS markers in vm.h. Every consumer + * asks this question rather than restating the list — a fourth restatement had + * already diverged (OP_LOOP_STALL_CHECK) before anyone noticed. */ +int opcode_is_observer_reader(uint8_t op) { + switch ((OpCode)op) { + case OP_INTERROGATE: + case OP_INTERROGATE_NAMED: + case OP_INTERROGATE_NAMED_AT: + case OP_INTERROGATE_NAMED_WHEN: + case OP_PREDICATE: + case OP_PREDICATE_SLOT: + case OP_PREDICATE_NAME: + case OP_REPORT_SLOT: + case OP_REPORT_NAME: + case OP_REPORT_VALUE_SLOT: + case OP_REPORT_VALUE_NAME: + case OP_TRAJECTORY_SLOT: + case OP_TRAJECTORY_NAME: + case OP_OBSERVE_VALUE_SLOT: + case OP_OBSERVE_VALUE_NAME: + case OP_LOOP_STALL_CHECK: + case OP_IMPORT: + return 1; + default: return 0; + } +} + +int chunk_has_reader_opcode(const EigsChunk *chunk) { + if (!chunk) return 0; + int i = 0; + while (i < chunk->code_len) { + if (opcode_is_observer_reader(chunk->code[i])) return 1; + i = chunk_step_ip(chunk, i); + } + for (int f = 0; f < chunk->fn_count; f++) + if (chunk_has_reader_opcode(chunk->functions[f])) return 1; + return 0; +} + + +/* `top` is 1 for the chunk vm_execute is handed directly, 0 for a nested + * function chunk. + * + * ONE READER SET, NOT A FOURTH ONE. This used to carry its own hand-written + * lists of "which opcodes read", separate from chunk_has_reader_opcode() above + * and invisible to tools/obs_reader_sync_check.sh — a fourth home for a rule + * that already had three, in the same change that quotes mechanical-gates §26 + * at length. It had ALREADY diverged when a critic looked: OP_LOOP_STALL_CHECK + * is obs:READS in vm.h and listed above, and was absent from every branch + * here. So this asks the shared question — "is this opcode a reader?" — and + * only CLASSIFIES the answer by operand shape, which it derives from + * op_verify_operands, the same table the verifier and disassembler use. + * + * WHY THE OPERAND SHAPE MATTERS. vm_execute(chunk, host) -> callframe_init sets + * f->fn_env = host, so for the TOP-LEVEL descriptor chunk the "frame slots" ARE + * the host env's slots. An earlier version looked only at NAME operands, on the + * written reasoning that a slot reader "addresses the descriptor's own frame and + * cannot reach a host binding" — false at the top level, and a critic executed + * the consequence. + * + * AND DEPTH IS NOT A DEFENCE FOR THE BARE FORM. A nested function chunk does get + * a fresh call env, so its SLOT readers really do address their own frame. The + * bare OP_PREDICATE consults no env at all: it reads g_last_obs_slot_env / + * g_last_obs_slot_idx, which are EigsThread state set by the host's last + * OP_OBSERVE_NAME_POST and untouched by call-frame entry. Confining it to `top` + * left the same silent inversion live one recursion level down (executed: + * a descriptor whose NESTED chunk holds a bare predicate answered 0 under the + * gate and 1 without). It is therefore checked at EVERY depth. */ +/* ---- #915: statically-resolvable `load_file` targets -------------------- + * + * `load_file` used to sit in OBS_BUILTINS and force the gate off wholesale, + * because a module compiled at RUNTIME flips the observer bit only after the + * parent's assignments have already run. That rule is sound, and it is exactly + * why the gate was parked: EigenMiniSat opens with four `load_file` calls, so + * it gated 0 of 6 units and the consumer that motivated #915 got nothing. + * + * The rule here is narrower. A load whose target is a STRING LITERAL can be + * compiled eagerly at the parent's compile time with the REAL compiler, so the + * module's own verdict lands BEFORE line 1 of the parent executes and the + * ordering hazard is gone. Everything else keeps the old answer. + * + * THREE RECORDED FAILURES SHAPE THIS. An earlier attempt tried to prove loaded + * modules observer-free from TOKENS, and adversarial review broke it three + * times: (1) a computed path (`parts[0] + parts[1] + ...`) resolved to nothing + * and was silently skipped; (2) the "did anything resolve" flag was an OR + * across candidates, so ONE benign literal load disarmed the conservative + * fallback for every other load in the same unit — the failure got LESS likely + * as the program got simpler, which is why no corpus differential would ever + * have caught it; (3) the six predicates lex to their own token types, so + * matching them as TOK_IDENT was dead code and a module using the documented + * preferred form went unseen. + * + * This version answers all three BY CONSTRUCTION rather than by patching: + * (1)+(2) the fallback is an AND, not an OR — ONE unrecognized use of the + * name `load_file` anywhere in the unit makes the WHOLE unit opaque, + * and an unresolvable path is such a use; + * (3) it never inspects tokens. The eager compile produces BYTECODE and + * chunk_reads_observer scans that, so a predicate is a reader opcode + * however it happens to lex. + * + * THE RECOGNIZED SHAPE — verified against the emitter with EIGS_DUMP_BC rather + * than assumed: + * + * GET_NAME <"load_file"> CONST CALL 1 + * + * OP_LINE may be interleaved (a call split across source lines) and is stepped + * over; nothing else may be. Any other operand carrying the name `load_file` in + * a VR_NAME role — an alias (`local lf is load_file`), a call with a computed + * argument — is not this shape and makes the unit opaque. VR_NAME is the + * population key: a string constant that merely SPELLS "load_file" without + * naming the builtin — a dict key, a printed literal — is not a VR_NAME use + * and does not affect the verdict at all (executed: `d is {"load_file": 1.0}` + * plus a literal load still gates unobserved, which is correct — a prior + * version of this comment claimed the dict key made the unit opaque, and a + * blind critic falsified it by running it). Being wrong here costs a program + * its gate, never an answer. + * + * RESOLVER PARITY IS NOT CHECKED HERE, deliberately. Compile-time and run-time + * resolution both go through resolve_eigenscript_file and the whole program runs + * between them, so the same literal can resolve to a different file or to + * different bytes. This scan cannot see that. An earlier draft opened with a + * `chdir` opacity check under the heading "resolver parity is a precondition, + * not a footnote"; it was a one-element denylist and the wrong population key + * (mechanical-gates §60), and it is gone. The parity requirement is enforced + * where it is checkable — at the load, in builtin_load_file, which raises when + * a module reads observer state and the gate was closed while this program's + * earlier assignments ran. + */ + +/* Step past the instruction at `i`, using the SAME operand-layout table the + * verifier and disassembler are driven off (#737). */ +static int chunk_step_ip(const EigsChunk *chunk, int i) { + uint8_t op = chunk->code[i]; + i++; + if (op == OP_LINE) { + i += 4; /* #630: 32-bit operand */ + } else if (op < OP_COUNT) { + VerifyRole roles[3]; + i += 2 * op_verify_operands(op, roles); + } + return i; +} + +/* Next instruction offset at or after `i` that is not OP_LINE. */ +static int chunk_skip_lines(const EigsChunk *chunk, int i) { + while (i < chunk->code_len && chunk->code[i] == OP_LINE) + i = chunk_step_ip(chunk, i); + return i; +} + +static int const_pool_index_of(const EigsChunk *chunk, const char *name) { + if (!chunk->const_interns) return -1; + for (int i = 0; i < chunk->const_count; i++) + if (chunk->const_interns[i] && strcmp(chunk->const_interns[i], name) == 0) + return i; + return -1; +} + +int chunk_scan_static_loads(const EigsChunk *chunk, + void (*visit)(const char *path, void *ud), void *ud) { + if (!chunk) return 1; + if (!chunk->compiler_scanned) return 1; /* unscanned chunk — see #830 above */ + + /* NOTE: this scan does NOT try to prove that the file it resolves now is the + * file `load_file` will read later. It cannot: the whole program runs in + * between. An earlier draft opened with + * + * if (const_pool_index_of(chunk, "chdir") >= 0) return 1; + * + * under the heading "resolver parity is a precondition, not a footnote" — + * a one-element denylist, and the wrong population key (mechanical-gates + * §60). `chdir` is one way to make a literal resolve elsewhere; a blind + * critic executed two others in minutes (`write_text` rewriting the module + * between the two reads, and creating a file in the cwd that SHADOWS the + * resolved one), and `rename`, `mkdir`, `remove_file` and any subprocess + * reach the same state. Widening the list would only postpone the next one. + * + * The parity requirement is instead enforced where it is checkable, at the + * load itself: builtin_load_file raises if compiling the module flips the + * observer bit 0 -> 1, which is precisely "the gate closed on stale + * evidence". That check is on the OUTCOME and needs no enumeration. */ + + int lf = const_pool_index_of(chunk, "load_file"); + if (lf >= 0) { + int i = 0; + while (i < chunk->code_len) { + uint8_t op = chunk->code[i]; + int nops = 0; + VerifyRole roles[3]; + if (op != OP_LINE && op < OP_COUNT) nops = op_verify_operands(op, roles); + + /* Operands are LITTLE-endian (read_u16, vm.c) — the same order the + * verifier reads them in above. Getting this backwards reads a + * garbage constant index and silently answers "not this shape". */ + int names_lf = 0; + if (i + 1 + 2 * nops <= chunk->code_len) { + for (int k = 0; k < nops; k++) { + if (roles[k] != VR_NAME) continue; + int off = i + 1 + 2 * k; + int v = chunk->code[off] | (chunk->code[off + 1] << 8); + if (v == lf) names_lf = 1; + } + } + + if (names_lf) { + /* Only the head of the recognized shape is acceptable. */ + if (op != OP_GET_NAME) return 1; + int j = chunk_skip_lines(chunk, chunk_step_ip(chunk, i)); + if (j + 2 >= chunk->code_len || chunk->code[j] != OP_CONST) return 1; + int sidx = chunk->code[j + 1] | (chunk->code[j + 2] << 8); + if (sidx < 0 || sidx >= chunk->const_count) return 1; + Value *k = chunk->constants ? chunk->constants[sidx] : NULL; + if (!k || k->type != VAL_STR || !k->data.str) return 1; + int c = chunk_skip_lines(chunk, chunk_step_ip(chunk, j)); + if (c + 2 >= chunk->code_len || chunk->code[c] != OP_CALL) return 1; + int argc = chunk->code[c + 1] | (chunk->code[c + 2] << 8); + if (argc != 1) return 1; + if (visit) visit(k->data.str, ud); + /* Fall through to the normal step: the CONST/CALL are walked + * again harmlessly (neither names `load_file`). */ + } + i = chunk_step_ip(chunk, i); + } + } + + for (int f = 0; f < chunk->fn_count; f++) + if (chunk_scan_static_loads(chunk->functions[f], visit, ud)) return 1; + return 0; +} diff --git a/src/compiler.c b/src/compiler.c index 3ed48b92..e77b4c2f 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -8,6 +8,15 @@ #include #include #include +#if !EIGENSCRIPT_FREESTANDING +/* NB: EIGENSCRIPT_FREESTANDING is always DEFINED (eigenscript.h defaults it to + * 0), so this must test its VALUE. `#ifndef` here silently excluded both + * headers and turned the muting below into a permanent no-op — see the + * comment on obs_gate_mute_stderr. */ +#include /* dup/dup2/close — #915's eager compile must be silent */ +#include /* open(/dev/null) */ +#include /* stat/S_ISREG — reject a non-regular speculative target */ +#endif /* ---- Compiler state ---- */ @@ -2954,7 +2963,7 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { * Same shape as the AT form below: the operand's value is never * needed, only its compile-time name. */ if (kind >= 3 && kind <= 5) - g_trace_obs_hist = 1; /* enable observer-state capture */ + trace_flag_store(g_trace_obs_hist_storage, 1); /* enable observer-state capture */ compile_node(c, when_expr); int name_idx = add_string_constant(c, expr->data.ident.name); emit_op_u16_u16(c, OP_INTERROGATE_NAMED_WHEN, @@ -2966,7 +2975,7 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { /* ` is x at ` — operand value is not needed; only * the name (compile-time known). Push line, emit AT op. */ if (kind >= 3 && kind <= 5) - g_trace_obs_hist = 1; /* enable observer-state capture */ + trace_flag_store(g_trace_obs_hist_storage, 1); /* enable observer-state capture */ compile_node(c, at_expr); int name_idx = add_string_constant(c, expr->data.ident.name); emit_op_u16_u16(c, OP_INTERROGATE_NAMED_AT, @@ -3149,6 +3158,452 @@ static void compile_block(Compiler *c, ASTNode **stmts, int count) { /* ---- Public API ---- */ +/* ---- #915: eagerly compile the literal `load_file` targets of a unit ------ + * + * The observer bit is monotonic and set by compile_ast, so a module compiled at + * RUNTIME flips it only after the parent's assignments have already executed. + * That ordering hazard is the whole reason `load_file` used to force the gate + * off, and why EigenMiniSat — four literal loads at the top of the file — + * gated 0 of 6 units. Compiling the literal targets HERE, with the real + * compiler, lands their verdict before line 1 of the parent runs. + * + * The recursion is the existing one: compile_ast on the module runs this same + * tail, so a module's own literal loads are resolved transitively with no + * extra machinery. `depth` bounds it and doubles as the cycle guard — a mutual + * load (a loads b, b loads a) would otherwise recurse to a C-stack SIGSEGV, + * the same failure #496 fixed for the runtime path. Hitting the bound is not a + * silent give-up: it sets the bit, which is the conservative answer. + * + * EVERY failure path sets the bit. Unresolvable path, unreadable file, parse + * error, compile error, depth exceeded — each means "this scan did not get to + * see what will run", and the only safe answer to that is "observes". + * + * This runs in a THROWAWAY env (env_new over the global, the same shape + * CASE(IMPORT) uses for a real module) and the chunk is freed immediately. The + * only thing kept is the side effect on g_obs_needed — fd 2 is muted and the + * trace-arming state is snapshotted/restored, which are the pass's two other + * output channels. Compiling against the + * live env instead would let a scan create bindings and perturb the parent's + * own slot numbering. The throwaway env can change WHICH reader opcode variant + * is emitted (SLOT vs NAME) but never WHETHER one is — both are readers — so + * the answer this is asked for is env-independent. + * + * AND IT MUST BE SILENT. A pre-pass that speaks is a pre-pass that changes the + * program's output. Measured, not reasoned: a module containing `break` outside + * a loop printed "Compile error line 1: 'break' outside a loop" TWICE under the + * gate — once here and once when load_file compiled it for real. The 417-program + * corpus differential was byte-identical across both arms and could not see it, + * because no corpus program load_file's a module that fails to compile. A green + * differential is evidence about the corpus, not about the change. + * + * Suppression is at the FILE DESCRIPTOR, not per-site. The diagnostics reachable + * from tokenize/parse/compile_ast are 48 raw fprintf(stderr) calls across three + * files with no helper to hook; gating each is exactly the "touch all N sites" + * change that silently misses the 49th. Redirecting fd 2 needs no enumeration + * and cannot drift as sites are added. Its cost is that it is process-global, so + * it is taken ONLY when this is provably safe: + * - hosted profile only (freestanding has no resolver, so no eager compile); + * - single-threaded only. Under g_vm_multithreaded another thread could be + * writing stderr, so the eager compile is skipped entirely and the bit is + * set instead — conservative, and MT+observer is the shape that already + * produced #915's worst silent bug. + * Nesting is fine: each level saves and restores its own dup of fd 2. + */ +enum { OBS_GATE_MAX_LOADS = 64, OBS_GATE_MAX_DEPTH = 8 }; +/* Ceiling on a SPECULATIVELY read module. This pass reads on behalf of code that + * may never run, so an unbounded read is unbounded cost for no benefit. 8 MiB is + * far above any real .eigs module; over it the pass declines and the gate stays + * conservatively open. */ +#define OBS_GATE_MAX_MODULE_BYTES (8L * 1024 * 1024) +/* CUMULATIVE ceiling on ALL speculative reading by this thread. The per-file + * ceiling above bounds one read; nothing bounded the TREE, and the pass compiles + * every literally-loaded module TWICE — once here, once for real (load_file has + * no module cache by design, #496). + * + * Measured by a blind critic: 60 modules loaded from an UNCALLED function took + * 0.00s -> 14.2s and 2.9 MB -> 61 MB RSS for a program whose only executed + * statement is `print of "hi"`. The existing limits PERMIT 64 loads x 8 MiB at + * depth 1 of 8 — roughly nine minutes of startup before the second level. And + * on this repo's own stdlib, `load_file of "lib/ui.eigs"` (20 units) measured + * 0.12-0.15s gated against 0.05-0.07s for the identical program written with a + * computed path, i.e. THE ARM THAT GATES ITSELF CLOSED WAS 2.3x SLOWER. A + * perf feature that loses to its own fallback on a real module tree has failed + * on its own terms, whatever its answers are. + * + * Once the budget is spent the pass declines and the gate stays conservatively + * open, so the worst case becomes "no win", never "slower than before". The + * budget is per-thread and never refills: total speculative work over a + * program's life is then bounded absolutely. + * + * This bounds the damage; it does not recover the win for big trees. That needs + * the eagerly-compiled chunk to be REUSED by the real load rather than thrown + * away — filed rather than attempted here, because chunk lifetime and the + * staleness guard both key off recompiling. */ +/* Cumulative per-thread ceiling on bytes this pass may read SPECULATIVELY, on + * behalf of code the program may never run. Once spent the pass declines and + * the gate stays conservatively open, so the cost of a hostile or merely huge + * module tree is bounded (60 modules behind an UNCALLED function: 14.2s/61MB + * unbounded, 0.13s/35MB with this). + * + * PICKED AGAINST A POPULATION, not chosen round. Transitive literal-load + * closures of all 86 multi-unit trees across the 13 ecosystem repos: the + * largest is EigenScript/lib/ui.eigs at 287 KiB (19 units), then + * DeslanStudio/src/client/studio 252, Tidepool 201, EigenMiniSat 179 — a tight + * band, and NOTHING above 1 MiB. The first value tried here was 256 KiB, which + * lands INSIDE that band: it bit ui.eigs by 12% (so the repo's own largest tree + * paid a quarter-megabyte of speculative compiling AND lost the gate) and + * cleared studio.eigs by under 2%. This clears the whole measured population + * 3.5x over and still stops the pathological case at ~7% of its work. + * + * Cost at the ceiling is ~0.6s of startup (measured: the pass costs ~0.6s per + * MiB speculatively read, which is issue #1031 — every literally-loaded module + * is compiled twice, once here and once for real). That is what bounds the + * choice from above; the population bounds it from below. + * + * The floor is asserted, not just documented: suite [99u] requires lib/ui.eigs + * to still gate CLOSED, so a tree growing past this budget fails a check and + * forces a deliberate re-pick instead of silently losing the win. That check + * covers this repo only — the consumer trees above are why the headroom is + * 3.5x rather than the 1.8x that would suffice for lib/ alone. */ +#define OBS_GATE_SPECULATIVE_BUDGET (1024L * 1024) +typedef struct { char **paths; int count; int cap; int overflow; } ObsLoadList; + +static void obs_gate_note_load(const char *path, void *ud) { + /* Collect into a bounded, owned list; resolving here would re-enter the + * compiler while chunk_scan_static_loads is still walking the chunk. */ + ObsLoadList *L = ud; + if (L->count >= L->cap) { L->overflow = 1; return; } /* caller treats as opaque */ + L->paths[L->count++] = xstrdup(path); +} + +/* Silence fd 2 for the duration of one eager compile. Returns the saved dup, or + * -1 if suppression could not be established — in which case the caller does NOT + * compile, because an eager compile that can speak is worse than no gate. + * + * TEST THE VALUE, NOT THE DEFINEDNESS. eigenscript.h does + * `#ifndef EIGENSCRIPT_FREESTANDING / #define EIGENSCRIPT_FREESTANDING 0`, so the + * macro is ALWAYS defined and every other site in the tree tests it with `#if`. + * The first draft here used `#ifdef` / `#ifndef`, which made this function an + * unconditional `return -1`, made the unmute a no-op, and excluded + * and entirely — and it still COMPILED, because nothing in the + * surviving branch referenced dup() or open(). + * + * The failure that produced was silent and total: every literal load took the + * `muted < 0` path, set the observer bit, and the whole feature reverted to the + * conservative pre-#915 behaviour. The 417-program corpus differential passed + * byte-identical (conservative IS the baseline), and seven of the eight new + * suite probes passed VACUOUSLY, because they assert the gate stays OPEN and it + * was always open. Exactly one check caught it: the positive control asserting + * the gate CLOSES on a literal load of an observer-free module. A control with + * only the negative half is satisfied by a feature that never runs. */ +/* #915: the fd this thread saved while stderr is muted, or -1. A fatal path + * inside the muted window (x_oom's abort, chunk_verify_self_check's exit) would + * otherwise die with stderr pointed at /dev/null — executed: a 7 MB module + * under `ulimit -v` printed "out of memory" when loaded via a COMPUTED path and + * printed NOTHING via a literal one, same rc 134. The spelling of a load path + * decided whether a fatal error was reported. */ +__thread int g_obs_mute_saved_fd = -1; + +void eigs_obs_unmute_for_fatal(void) { +#if !EIGENSCRIPT_FREESTANDING + if (g_obs_mute_saved_fd < 0) return; + fflush(stderr); + dup2(g_obs_mute_saved_fd, STDERR_FILENO); + close(g_obs_mute_saved_fd); + g_obs_mute_saved_fd = -1; +#endif +} + +static int obs_gate_mute_stderr(void) { +#if EIGENSCRIPT_FREESTANDING + return -1; +#else + int saved = dup(STDERR_FILENO); + if (saved < 0) return -1; + int devnull = open("/dev/null", O_WRONLY); + if (devnull < 0) { close(saved); return -1; } + fflush(stderr); + if (dup2(devnull, STDERR_FILENO) < 0) { close(devnull); close(saved); return -1; } + close(devnull); + /* Only the OUTERMOST mute records here: that is the one holding the real + * stderr, and it is what a fatal path must restore. Recording every level + * meant the inner unmute cleared the flag and the OUTER unmute then saw it + * negative and returned WITHOUT restoring — leaking its fd and leaving + * stderr pointed at the inner target. Mutual/nested literal loads recurse, + * so the suite caught it immediately. */ + if (g_obs_mute_saved_fd < 0) g_obs_mute_saved_fd = saved; + return saved; +#endif +} + +static void obs_gate_unmute_stderr(int saved) { +#if !EIGENSCRIPT_FREESTANDING + if (saved < 0) return; + fflush(stderr); + dup2(saved, STDERR_FILENO); + /* Clear the fatal-path pointer only when THIS level owned it. */ + if (g_obs_mute_saved_fd == saved) g_obs_mute_saved_fd = -1; + close(saved); +#else + (void)saved; +#endif +} + +/* Memo of resolved paths this top-level compile has already scanned. + * + * Without it the eager pass is EXPONENTIAL in a module DAG, not linear: it walks + * the load graph as a TREE, so a diamond re-compiles the shared leaf once per + * distinct path to it. Measured by a blind critic on a synthetic 8-file DAG + * (7 levels, fanout 4, all observer-free): 131,072 re-compiles of one 12-byte + * leaf, 313,122 file opens, 145,636 fd-2 mute cycles, and 8.5 s wall against + * 1.2 s for the same program with the gate off — a 7x REGRESSION delivered by a + * feature whose entire purpose is speed. The runtime path has eigs_loading_enter + * (#496) as its memo; the eager path had only the depth cap, which bounds depth + * and not breadth. + * + * Thread-local, and it persists for the LIFE OF THE THREAD rather than for one + * top-level compile. Clearing per-compile still left the pass re-resolving every + * module on every runtime `load_file` — measured on the DAG above at 38,228 leaf + * opens against the program's own 16,384, i.e. the pass roughly DOUBLED the file + * I/O of module-heavy code. `load_file` has no module cache by design (#496: "it + * re-executes every call"), so without a persistent memo the eager pass inherits + * that multiplier. + * + * Staleness is not a soundness question here: skipping a re-scan can only make + * the eager pass MISS a module that has since become observing, and that is + * exactly the condition builtin_load_file raises on (the bit flipping 0 -> 1 at + * the load). The memo can make the gate conservative-late, never silently wrong. + * Released by eigs_obs_memo_release() at thread detach. + * + * KEYED ON (st_dev, st_ino), NOT ON THE PATH SPELLING. resolve_eigenscript_file + * does not canonicalize — try_resolve_path is access(2) plus a copy — so a + * string key gave one file N entries for N spellings, and the pass then read, + * compiled and CHARGED it N times. Executed on one 55,180-byte module written + * four natural ways (relative, absolute, through a symlink, and `./`-prefixed): + * 4 speculative opens where the oracle is 1; with enough distinct spellings the + * budget is spent on referenced rather than unique bytes and the gate flips + * open (24 spellings of that one file: 19 speculative opens, gate `observed`). + * That is the same double-charge defect fixed one commit earlier, re-entering + * through the KEY instead of the ORDERING — and no existing check saw it, + * because check 31's diamond writes the identical literal in every parent. + * + * The inode pair beats realpath() here: it is the true identity (it also folds + * hard links, which realpath does not) and it needs no PATH_MAX buffer. The + * cost is one stat PER REFERENCE, memo hits included — the string key stat'd + * only on misses, so this trades ~one syscall per repeated reference (measured: + * 48 newfstatat vs 1 on a 24-reference diamond) for correct identity. That is + * the deliberate price of keying on identity: the stat is what YIELDS the key, + * it is microseconds against a read+compile, and unlike the read it charges + * nothing against the budget. A stat FAILURE on a later reference of an + * already-scanned file now takes the conservative reject path rather than the + * memo hit, which is also the direction we want. */ +typedef struct { dev_t dev; ino_t ino; } ObsMemoKey; +static __thread ObsMemoKey *g_obs_memo = NULL; +static __thread int g_obs_memo_n = 0, g_obs_memo_cap = 0; +static __thread long g_obs_spec_bytes = 0; /* see OBS_GATE_SPECULATIVE_BUDGET */ + +static int obs_memo_seen(dev_t dev, ino_t ino) { + for (int i = 0; i < g_obs_memo_n; i++) + if (g_obs_memo[i].dev == dev && g_obs_memo[i].ino == ino) return 1; + return 0; +} +static void obs_memo_add(dev_t dev, ino_t ino) { + if (g_obs_memo_n == g_obs_memo_cap) { + int nc = g_obs_memo_cap ? g_obs_memo_cap * 2 : 16; + ObsMemoKey *np = realloc(g_obs_memo, (size_t)nc * sizeof(ObsMemoKey)); + if (!np) return; /* out of memory: lose the memo, not correctness */ + g_obs_memo = np; g_obs_memo_cap = nc; + } + g_obs_memo[g_obs_memo_n].dev = dev; + g_obs_memo[g_obs_memo_n].ino = ino; + g_obs_memo_n++; +} +void eigs_obs_memo_release(void); +static void obs_memo_clear(void) { + free(g_obs_memo); /* keys are values now, nothing per-entry to free */ + g_obs_memo = NULL; g_obs_memo_n = 0; g_obs_memo_cap = 0; +} +void eigs_obs_memo_release(void) { obs_memo_clear(); g_obs_spec_bytes = 0; } + +static void obs_gate_resolve_static_loads(EigsChunk *chunk) { + ObsLoadList L; + char *slots[OBS_GATE_MAX_LOADS]; + char *resolved = NULL; + L.paths = slots; L.count = 0; L.cap = OBS_GATE_MAX_LOADS; L.overflow = 0; + + /* The eager pass informs a RUNTIME decision. Entry points that compile + * without ever executing — `--lint` and the LSP, which recompiles on every + * didChange — must not reach out to the filesystem on its behalf. + * src/lint_host.c documents "import and load_file are executed by the VM, + * not resolved here, so lint still touches nothing but the file in front of + * it"; a blind critic caught this pass making that false, and an editor + * stat/read/compiling a file's load targets on every keystroke with it. */ + if (!g_obs_gate_scan_enabled) return; + + if (g_obs_gate_depth >= OBS_GATE_MAX_DEPTH) { eigs_obs_enable(); return; } + + if (chunk_scan_static_loads(chunk, obs_gate_note_load, &L)) { eigs_obs_enable(); goto done; } + if (L.overflow) { eigs_obs_enable(); goto done; } /* more loads than slots — see above */ + + /* See the comment above: fd-level suppression is process-global, so the + * eager compile is not taken at all while another thread could be writing + * stderr. Placed AFTER the scan deliberately — a unit with no loads has + * nothing to compile eagerly and must not lose its gate just for running + * in a process that happens to have spawned a worker. */ + /* PROCESS-wide, not per-state. `g_vm_multithreaded` is + * eigs_current->state->multithreaded and cannot see a sibling state; + * ext_http runs a fresh EigsState per connection on its own OS thread, so + * that flag was 0 on every worker while this pass mutated process-global + * memory (trace.c's arming sets) and process-global fd 2. A blind critic + * captured a heap-use-after-free on g_arm_names between two connection + * threads, and showed the fd-2 mute swallowing other requests' stderr and + * then destroying the server's real stderr permanently. See + * eigs_process_thread_count. */ + if (L.count > 0 && (g_vm_multithreaded || eigs_process_thread_count() > 1)) { + eigs_obs_enable(); goto done; + } + + /* HEAP, not stack. As `char resolved[8192]` inside the loop this frame + * measured 8,864 bytes (gcc -fstack-usage) on EVERY compile_ast, and this + * function recurses to OBS_GATE_MAX_DEPTH — 8 x 8,864 = 70,912 bytes of + * `resolved` alone, more than the whole 64 KiB budget that + * tools/embed_stack_soak.sh enforces, before tokenize/parse/compile_node + * frames are counted. .claude/rules/c-runtime-memory.md calls >= ~2 KiB in a + * recursive path suspect; that rule was bought on exactly this shape. */ + if (L.count > 0) { + resolved = malloc(8192); + if (!resolved) { eigs_obs_enable(); goto done; } + } + + for (int i = 0; i < L.count && !g_obs_needed; i++) { + long size = 0; + char *source = NULL; +#if !EIGENSCRIPT_FREESTANDING + /* Guarded on the VALUE, and the guard must cover the CALLEES, not only + * the helpers. builtins_host.c is a whole-TU carve-out in this profile, + * so read_file_util does not exist to link against; leaving these two + * calls outside the guard broke `make freestanding-check` and + * tools/embed_stack_soak.sh at the LINK step. Both are CI-only, which is + * why a green release suite and a green ASan suite could not see it — + * and it is the same defect class as the #ifdef-vs-#if mistake this file + * already records, made a second time while fixing the first. */ + int resolved_ok = resolve_eigenscript_file(L.paths[i], resolved, 8192); +#else + int resolved_ok = 0; +#endif + if (!resolved_ok) { eigs_obs_enable(); break; } + +#if !EIGENSCRIPT_FREESTANDING + /* STAT BEFORE OPEN. This pass reads files on behalf of code the program + * may never run — a literal load inside an uncalled function or a dead + * branch is still scanned, because chunk_scan_static_loads recurses into + * chunk->functions. try_resolve_path admits anything access(F_OK) + * accepts, including a FIFO, and read_file_util's S_ISREG rejection + * happens AFTER fopen, so it cannot prevent the block. + * + * Executed: `define maybe() as: return load_file of "fifo.eigs"` with + * maybe() NEVER CALLED hung the compiler indefinitely (rc 124 under + * timeout, nothing printed, dead before vm_execute); the same program + * with a computed path ran fine. A 120 MB target referenced only from an + * uncalled function took peak RSS from 2.8 MB to 248 MB. + * + * A real load_file that executes is unaffected — this bounds only the + * SPECULATIVE read. Anything rejected here is simply unresolvable to the + * pass, which is already its conservative answer. + * + * The stat runs BEFORE the memo is consulted and the CHARGE runs after + * it, and the split is deliberate. Charging on the way past a memo hit + * bills a shared module once per reference while reading it once, so a + * DAG exhausts the budget on bytes it never reads — the same defect the + * memo exists to fix, re-committed in the accounting instead of the I/O. + * Executed: six thin parents sharing ONE 59 KiB leaf (59 KiB unique, + * a fraction of the budget) opened the gate, while that leaf loaded once + * closed it. The budget must count bytes READ. Statting first costs + * nothing against the budget and is what yields the identity the memo + * keys on — see ObsMemoKey above for why the spelling would not do. */ + struct stat st; + if (stat(resolved, &st) != 0 || !S_ISREG(st.st_mode) || + st.st_size > OBS_GATE_MAX_MODULE_BYTES) { + eigs_obs_enable(); break; + } + if (obs_memo_seen(st.st_dev, st.st_ino)) continue; + if (g_obs_spec_bytes + st.st_size > OBS_GATE_SPECULATIVE_BUDGET) { + eigs_obs_enable(); break; + } + g_obs_spec_bytes += st.st_size; + source = read_file_util(resolved, &size); +#endif + if (!source) { eigs_obs_enable(); break; } +#if !EIGENSCRIPT_FREESTANDING + obs_memo_add(st.st_dev, st.st_ino); +#endif + + int muted = obs_gate_mute_stderr(); + if (muted < 0) { free(source); eigs_obs_enable(); break; } + + /* BEFORE tokenize, not after. lexer.c zeroes all five first_error + * fields unconditionally at tokenize depth 0, so a snapshot taken after + * it saved the ZEROES and the "restore" then wiped the parent's recorded + * error — and the parse-error path below exited without restoring at + * all, leaving the MODULE's error installed in the parent. The previous + * placement was a fix that did not work; found by enumerating the + * compile path, not by a test, because the only readers (lint, LSP) + * disable this pass. */ + int saved_fe_line = g_first_error_line, saved_fe_col = g_first_error_col; + int saved_fe_len = g_first_error_len, saved_fe_known = g_first_error_col_known; + char saved_fe_msg[256]; + snprintf(saved_fe_msg, sizeof saved_fe_msg, "%s", g_first_error_msg); + int saved_errors = g_parse_errors; + g_parse_errors = 0; + TokenList tl = tokenize(source); + ASTNode *mast = parse(&tl); + if (g_parse_errors > 0 || !mast) { + g_parse_errors = saved_errors; + free_ast(mast); free_tokenlist(&tl); free(source); + g_first_error_line = saved_fe_line; g_first_error_col = saved_fe_col; + g_first_error_len = saved_fe_len; g_first_error_col_known = saved_fe_known; + snprintf(g_first_error_msg, sizeof(((EigsThread *)0)->first_error_msg), + "%s", saved_fe_msg); + obs_gate_unmute_stderr(muted); /* every exit from here unmutes */ + eigs_obs_enable(); break; + } + + Env *scan_env = env_new(g_global_env); + int saved_boundary = g_compile_module_boundary; + g_compile_module_boundary = 1; /* #373, as load_file does */ + /* The pass has TWO output channels, not one. fd 2 is muted above; this + * is the other. compile_node arms the trace-history channel as a side + * effect, so scanning a module used to switch per-assignment recording + * on in the PARENT and change its temporal answers — making the + * SPELLING of a load path semantically load-bearing (a literal armed + * `prev of x`, a computed one did not) and the behaviour non-monotone + * (adding an observer read opened the gate, skipped the pass, and + * un-armed the name). See trace_arm_snapshot. */ + TraceArmState saved_arm; + trace_arm_snapshot(&saved_arm); + g_obs_gate_depth++; + EigsChunk *mchunk = compile_ast(mast, scan_env, source); /* ORs its own verdict */ + g_obs_gate_depth--; + trace_arm_restore(&saved_arm); + g_first_error_line = saved_fe_line; g_first_error_col = saved_fe_col; + g_first_error_len = saved_fe_len; g_first_error_col_known = saved_fe_known; + snprintf(g_first_error_msg, sizeof(((EigsThread *)0)->first_error_msg), + "%s", saved_fe_msg); + g_compile_module_boundary = saved_boundary; + if (g_parse_errors > 0) eigs_obs_enable(); + g_parse_errors = saved_errors; + + if (mchunk) chunk_free(mchunk); + env_decref(scan_env); + free_ast(mast); free_tokenlist(&tl); free(source); + obs_gate_unmute_stderr(muted); + } + +done: + free(resolved); + for (int i = 0; i < L.count; i++) free(L.paths[i]); +} + EigsChunk *compile_ast(ASTNode *ast, Env *env, const char *src) { EigsChunk *chunk = chunk_new(""); /* #830: the arming below is compile-time evidence about THIS chunk, so @@ -3224,5 +3679,54 @@ EigsChunk *compile_ast(ASTNode *ast, Env *env, const char *src) { * error path, not the table. */ if (verify_self && g_parse_errors == 0) chunk_verify_self_check(chunk, chunk->name ? chunk->name : "?"); + + /* #915 observer gate. compile_ast is the ONE choke point every compilation + * path funnels through — the main script (main.c), eval (builtins.c), + * load_file (builtins_host.c), import (vm.c), the REPL (repl.c), the embed + * API (eigs_embed.c) and ext_http's dynamic handlers — so OR-ing here needs + * no hand-maintained caller list, which is the drift failure #921/#925 are + * open on. Monotonic: a later unit that reads the observer turns it on for + * good; nothing turns it back off. + * + * Residual, deliberately accepted: a unit compiled AFTER assignments have + * already run (a load_file partway through a program, a REPL line) flips + * the bit late, so bindings assigned before the flip have no history and + * read as unobserved. The full-corpus differential (tools/observer_gate_diff.sh) + * is what polices this — if any tracked program exhibits it, the diff goes + * red. Force-on sites cover the cases where it is not merely possible but + * expected (REPL, embed). */ + /* #915 escape hatch. Any non-empty, non-"0" value arms it — the same rule + * as EIGS_STRICT (state.c) and EIGS_VERIFY_SELF above, and NOT a bare + * getenv. A bare getenv made `EIGS_OBS_FORCE=0` and `EIGS_OBS_FORCE=` force + * the gate OPEN, i.e. do exactly what `=1` does, inverting a documented + * control for anyone who spells "off" the obvious way. + * + * It also silently laundered the corpus oracle. tools/observer_gate_diff.sh + * records `force=${EIGS_OBS_FORCE:-0}` in each capture's manifest, which + * collapses "unset" and "=0" — two settings that behaved OPPOSITELY — so a + * "gated" arm captured with EIGS_OBS_FORCE=0 actually ran the BASELINE while + * the manifest recorded force=0, and compare printed a provenance line + * byte-identical to an honest run. Executed on a build with + * `case OP_REPORT_NAME:` deleted from opcode_is_observer_reader(): the + * honest three-capture run reports 3 mismatches and rc=1; the laundered one + * reports `415 programs byte-identical` and rc=0. */ + { + const char *ef = getenv("EIGS_OBS_FORCE"); + if (!g_obs_needed && ef && ef[0] && ef[0] != '0') eigs_obs_enable(); + } + if (!g_obs_needed && chunk_reads_observer(chunk)) eigs_obs_enable(); + if (!g_obs_needed) obs_gate_resolve_static_loads(chunk); + /* Same convention as EIGS_OBS_FORCE above — these two are documented as + * adjacent rows of one table in docs/OBSERVER.md, and read with a bare + * getenv this one printed its stats for EIGS_OBS_GATE_STATS=0. Found by + * sweeping every getenv site after fixing the FORCE flag, rather than + * assuming that defect was isolated. */ + { + const char *gs = getenv("EIGS_OBS_GATE_STATS"); + if (gs && gs[0] && gs[0] != '0') + fprintf(stderr, "obs-gate: %s %s\n", + g_obs_needed ? "observed" : "unobserved", chunk->name); + } + return chunk; } diff --git a/src/eigenlsp.c b/src/eigenlsp.c index 4caf956e..3148bc5b 100644 --- a/src/eigenlsp.c +++ b/src/eigenlsp.c @@ -791,7 +791,18 @@ static void send_diagnostics(Document *doc) { register_builtins(cenv); g_compile_module_slots = 1; int errors_before = g_parse_errors; - EigsChunk *chunk = compile_ast(doc->ast, cenv, doc->text); + /* #915: this compile never executes, so the observer gate's eager pass + * must not COMPILE a file's load targets on its behalf. Note the reason + * is cost and surprise, not purity: lint already realpath-resolves and + * OPENS literal load_file targets for E003, and did so before this + * change — a blind critic checked, and lint_host.c's own "touches + * nothing but the file in front of it" comment was already inaccurate. + * What the eager pass would add is a full tokenize+parse+compile of each + * target, and the LSP runs this on every didChange. */ + int obs_saved = g_obs_gate_scan_enabled; + g_obs_gate_scan_enabled = 0; + EigsChunk *chunk = compile_ast(doc->ast, cenv, doc->text); + g_obs_gate_scan_enabled = obs_saved; g_compile_module_slots = 0; compile_errors = g_parse_errors - errors_before; chunk_free(chunk); diff --git a/src/eigenscript.c b/src/eigenscript.c index fd76f332..8160cb58 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -587,7 +587,39 @@ static void observer_slot_update_e(Env *e, int idx, double new_entropy) { s->used = 1; } +/* #915: the one place the observer gate is decided. + * + * g_obs_needed is the compile-time half: chunk_reads_observer said some unit in + * this state can interrogate. g_trace_obs_hist is the runtime half: a tape is + * recording observer snapshots, so the bookkeeping is needed even though the + * PROGRAM never asks for it. + * + * The trace flag is READ here rather than mirrored into g_obs_needed at each of + * the five sites that arm it (builtins.c, chunk.c, compiler.c x2, repl.c). + * Mirroring would be five hand-maintained copies of one fact, and a sixth arming + * site added later would silently record a tape full of dead observer snapshots + * — the same drift shape #921/#925 are open on. One read, no copies. */ +extern int g_trace_obs_hist_storage; /* trace.h — not included here */ +#define g_trace_obs_hist __atomic_load_n(&g_trace_obs_hist_storage, __ATOMIC_RELAXED) +static inline int eigs_obs_gate_open(void) { + return g_obs_needed || g_trace_obs_hist; +} + void observer_slot_update(Env *e, int idx, Value *newval) { + /* #915: nothing compiled into this state can interrogate the observer, so + * skip the entropy walk entirely. compute_entropy recurses through every + * reachable list item and dict value, which is where the 88% goes. + * + * `obs_needed` is monotonic — set at compile time by chunk_reads_observer, + * and by eigs_obs_enable() at the runtime arming sites, never cleared. The + * OTHER half of eigs_obs_gate_open(), the trace-history flag, is NOT: + * `record_history of 0` calls trace_history_disable() and closes it again + * mid-program. A previous version of this comment claimed the gate "cannot + * flicker mid-loop"; that is true of obs_needed and false of the pair, and + * a critic executed the flicker. `record_history` is now in OBS_BUILTINS so + * a program that names it opens the gate at COMPILE time and the flicker + * cannot change an answer. */ + if (!eigs_obs_gate_open()) return; observer_slot_update_e(e, idx, compute_entropy(newval)); /* #294 also fold the raw value into the value-signal channel (numbers only: * the relative-delta step is only defined for a scalar trajectory). */ @@ -608,6 +640,7 @@ void observer_slot_update(Env *e, int idx, Value *newval) { * number, so the default path can observe without promoting the num to a * tracked Value. Same trajectory math as observer_slot_update. */ void observer_slot_update_num(Env *e, int idx, double num) { + if (!eigs_obs_gate_open()) return; /* #915 — see observer_slot_update */ observer_slot_update_e(e, idx, entropy_of_num(num)); ObserverSlot *vs = env_obs_slot(e, idx); /* #294 value-signal channel */ if (vs) observer_slot_record_value(vs, num); @@ -3727,6 +3760,16 @@ Value* env_get_local_hashed(Env *env, const char *name, uint32_t h) { return NULL; } +/* #915 — see the declaration in eigenscript.h for why this is not a plain + * assignment. Turning recording ON mid-execution does not restore the history + * of what already ran; it only stops the bleeding. The gap flag records that + * distinction so the guards stay armed. */ +void eigs_obs_enable(void) { + if (g_obs_needed) return; + if (g_obs_exec_started) obs_flag_store(obs_history_gap, 1); + obs_flag_store(obs_needed, 1); +} + Value* env_get(Env *env, const char *name) { return env_get_hashed(env, name, env_hash_name(name)); } diff --git a/src/eigenscript.h b/src/eigenscript.h index a9b861b2..c6fd810f 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -544,6 +544,35 @@ struct EigsState { /* Observer-classification thresholds (set_observer_threshold builtin). * Per-state because they're interpreter configuration, not execution * state; one knob per host application, shared across worker threads. */ + /* #915 observer gate. 0 = nothing compiled into this STATE can ever + * interrogate observer bookkeeping, so observer_slot_update skips the + * entropy walk (88% of runtime / 8.70x measured on a consumer using no + * observer features). MONOTONIC: compile_ast ORs in each unit's scan and + * the force-on sites set it; nothing clears it, since clearing would + * strand the history of already-observed bindings. + * + * Per-state for the same reason as the thresholds above, and it was a real + * bug when it was not: on EigsThread this field was zero for every spawned + * worker (eigs_thread_attach xcalloc's a fresh thread, and only the + * spawning thread ever runs compile_ast), so every assignment executed on + * a worker silently skipped observation — while the gate's own stats still + * reported "observed" and EIGS_OBS_FORCE=1 could not rescue it. + */ + int obs_needed; + /* #915: sticky. Set the first time a load is caught reading observer state + * while the gate was closed. `obs_needed` is MONOTONIC, so that first + * detection also flips it to 1 — which would make the very next check see + * "the gate was already open" and skip. The error is catchable, so ONE + * `try:` around the first load disarmed the guard for the rest of the run + * and the silent-wrong answer came straight back (executed). The missing + * history is not restored by the bit flipping, so this flag says "this + * program has bindings with no recorded history" and never clears. */ + int obs_history_gap; + /* #915: 1 once user code has begun executing. Before that, turning + * observer recording ON costs nothing — no assignment has happened yet. + * After it, the flip is exactly the unrecoverable case, because the + * bindings already assigned have no history and the bit is monotonic. */ + int obs_exec_started; double obs_dh_zero; /* |dH| < this → "zero change" (default 0.001) */ double obs_dh_small; /* |dH| < this → "small change" (default 0.01) */ double obs_h_low; /* entropy < this → "low info" (default 0.1) */ @@ -799,6 +828,19 @@ struct EigsThread { * and N copies of the same message is not N pieces of information. Reset * beside g_parse_depth at compile_ast entry. */ int compile_depth_reported; + /* #915: nesting depth of the observer gate's EAGER module compiles. Its + * own counter, not g_parse_depth — compile_ast RESETS that one at entry, + * so the nested compile this guard bounds would clear its own guard. Also + * the cycle guard: a mutual literal load (a loads b, b loads a) recurses + * here exactly as #496's did at runtime, and hitting the bound sets the + * observer bit rather than giving up quietly. */ + int obs_gate_depth; + /* #915: 1 while a compile is allowed to reach the FILESYSTEM on the + * observer gate's behalf. The eager pass informs a runtime decision, so + * entry points that compile without ever executing — `--lint`, and the LSP + * which recompiles on every didChange — clear it. Default 1; only those + * entry points set it to 0, and they do so for their whole run. */ + int obs_gate_scan_enabled; int tokenize_depth; int vts_depth; int json_depth; @@ -996,7 +1038,68 @@ extern __thread EigsThread *eigs_current; #define g_prev_cap (eigs_current->prev_cap) #define g_prev_count (eigs_current->prev_count) #define g_parse_depth (eigs_current->parse_depth) +#define g_obs_gate_depth (eigs_current->obs_gate_depth) +#define g_obs_gate_scan_enabled (eigs_current->obs_gate_scan_enabled) #define g_compile_depth_reported (eigs_current->compile_depth_reported) +/* ATOMIC, relaxed. These three are read at every safepoint and STORED from + * whichever thread arms the observer — and `sandbox_run` is deliberately not + * in OBS_BUILTINS, so a WORKER's call is a legitimate 0->1 store on the shared + * state with no happens-before edge to any other thread (two workers can both + * see 0; the #297 write-once pattern that fixed obs_exec_started cannot apply). + * TSan: T1 write in eigs_obs_enable vs T2 read in eigs_obs_gate_open, 3/3, + * found by a blind critic (round 16) one field over from the fix the previous + * commit made — and round 17 found the SAME shape a third field over, in + * g_trace_obs_hist/g_trace_hist (trace.h), the second operand of the same + * deciding expression; those now use the same idiom. This block covers the + * three per-STATE obs flags only. The arm NAME SETS (g_arm_*, g_occ_*) remain + * plain process globals mutated by chunk_arm_temporal — a wider pre-existing + * surface, tracked on #1035, NOT closed by flag atomics. Do not read this + * comment as "the class is closed"; it was written that way once and a critic + * falsified it within one round. Relaxed suffices: no data is published THROUGH these flags — + * each consumer's correctness rests on its own thread's sequenced reads plus + * the sticky obs_history_gap semantics, and a reader seeing a stale 0 for a + * bounded window is the same "conservative-late" behaviour the memo already + * documents. A relaxed load is a plain MOV on x86. + * The macros are LOADS (not lvalues), so any new assignment through them + * fails to compile and must go through obs_flag_store — the write sites stay + * enumerable. */ +#define g_obs_needed __atomic_load_n(&eigs_current->state->obs_needed, __ATOMIC_RELAXED) +#define g_obs_history_gap __atomic_load_n(&eigs_current->state->obs_history_gap, __ATOMIC_RELAXED) +#define g_obs_exec_started __atomic_load_n(&eigs_current->state->obs_exec_started, __ATOMIC_RELAXED) +/* RELEASE, not relaxed, on the STORE side. eigs_obs_enable stores gap THEN + * needed, and builtin_load_file's guard reads needed THEN gap; with both + * relaxed, a weakly-ordered machine (the macOS ARM legs) may show a loader + * needed==1 with gap still 0 from a concurrent mid-run arming — a + * silence-that-should-raise, i.e. conservative-EARLY, which contradicts the + * "conservative-late only" contract above (found by a blind critic, round + * 17; window is one full module compile wide, so practically unobservable — + * fixed because the sound version is free). Release on a cold store costs + * nothing (plain MOV on x86, stlr on ARM); the HOT safepoint loads stay + * relaxed — they read one flag in isolation and pair with nothing. The one + * read that pairs with the store order is the guard's, which uses the + * acquire load below. */ +#define obs_flag_store(field, v) \ + __atomic_store_n(&eigs_current->state->field, (v), __ATOMIC_RELEASE) +#define obs_flag_load_acquire(field) \ + __atomic_load_n(&eigs_current->state->field, __ATOMIC_ACQUIRE) +/* #915: the ONLY sanctioned way to turn observer recording on. `g_obs_needed` + * answers "is recording on?"; the two soundness guards need "is the recorded + * history COMPLETE?", and those are different questions. Writing the bit + * directly conflated them: a benign runtime flip — a descriptor that reads + * nothing, or the multithreaded bail in the eager pass — set the bit and + * thereby told both guards "the gate is open, nothing at risk", permanently. + * Executed: one `vm_run_bytecode of [1,[0,0,0,40],[7]]` before the read turned + * a loud raise into `equilibrium` on a diverging series. This helper keeps the + * two answers apart. */ +void eigs_obs_enable(void); +/* #915: how many EigsThreads are attached PROCESS-WIDE. The eager pre-pass + * mutates fd 2 and trace.c's process-global arming sets, so its precondition is + * "this process has one thread" — a per-state multithreaded flag cannot see a + * sibling state, and ext_http runs one state per connection per thread. */ +int eigs_process_thread_count(void); +/* #915: restore real stderr if the observer gate's eager pass has it muted. + * Call before printing from any path that will abort/exit. */ +void eigs_obs_unmute_for_fatal(void); #define g_tokenize_depth (eigs_current->tokenize_depth) #define g_vts_depth (eigs_current->vts_depth) #define g_json_depth (eigs_current->json_depth) diff --git a/src/eigs_embed.c b/src/eigs_embed.c index 688423c3..84e250cd 100644 --- a/src/eigs_embed.c +++ b/src/eigs_embed.c @@ -101,6 +101,11 @@ EigsValue *eigs_eval_string(const char *src) { /* REPL-style compilation: top-level names land in the global env * (not module-export slots), so the host can read them back through * eigs_get_global and successive eigs_eval_string calls accumulate. */ + /* #915: REPL-shaped for the same reason as repl.c — successive + * eigs_eval_string calls accumulate against one global env, so a later call + * can interrogate a binding an earlier call assigned. The host can also read + * observer state directly. Nothing here can see the next call, so observe. */ + eigs_obs_enable(); /* #915: via the helper, so a mid-run flip records the gap */ EigsChunk *chunk = compile_ast(ast, global, src); Value *result = vm_execute(chunk, global); diff --git a/src/embed_concurrent.c b/src/embed_concurrent.c index eb1ab631..0d119cf7 100644 --- a/src/embed_concurrent.c +++ b/src/embed_concurrent.c @@ -227,10 +227,21 @@ static void test_error_isolation(void) { static volatile double planted_shared_threshold; /* the mistake, deliberately */ +/* START BARRIER. Without one, this control is a race against pthread_create: + * thread A can run ALL its rounds before B exists, giving zero overlap, zero + * cross-talk, and a FAILED control on a healthy harness — which is exactly + * what happened on a CI runner (PR #1034: `control: a shared global DOES + * cross-talk` FAILed while all four isolation rows passed; the file is + * identical on main, so the flake is the control's, not the branch's). The + * barrier guarantees both threads are live before either's first round, which + * is the interleaving premise the comment below already claims. */ +static pthread_barrier_t planted_start; + typedef struct { double want; int mismatches; } PlantArg; static void *planted_worker(void *p) { PlantArg *a = (PlantArg *)p; + pthread_barrier_wait(&planted_start); for (int i = 0; i < ROUNDS; i++) { planted_shared_threshold = a->want; /* Give the other thread a window between write and read. Without one @@ -248,10 +259,12 @@ static void *planted_worker(void *p) { static void test_planted_fault_is_detectable(void) { PlantArg a = { 0.001, 0 }, b = { 0.002, 0 }; pthread_t ta, tb; + pthread_barrier_init(&planted_start, NULL, 2); pthread_create(&ta, NULL, planted_worker, &a); pthread_create(&tb, NULL, planted_worker, &b); pthread_join(ta, NULL); pthread_join(tb, NULL); + pthread_barrier_destroy(&planted_start); /* The shared global MUST produce cross-talk. If it does not, the harness is * not interleaving and every green row above is uninformative. */ diff --git a/src/jit.c b/src/jit.c index e897cdbc..6eb0855c 100644 --- a/src/jit.c +++ b/src/jit.c @@ -27,7 +27,8 @@ * hook). Plain global in trace.c — its address is baked as a movabs * immediate at compile time. Declared here instead of pulling in * trace.h (which drags Value/Env decls the smoke build stubs out). */ -extern int g_trace_hist; +extern int g_trace_hist_storage; +#define g_trace_hist __atomic_load_n(&g_trace_hist_storage, __ATOMIC_RELAXED) /* Same rationale as g_trace_hist (declared here, not via trace.h, to keep the * smoke build free of Value/Env decls): its address is baked as a movabs * immediate so OP_LINE can stamp it. The interpreter CASE(LINE) writes it @@ -2694,8 +2695,14 @@ static void jit_compile_to_thunk(struct EigsChunk *chunk, EnvIC *ic = &chunk->env_ic[sidx]; uint8_t *slow_p[6]; int slow_n = 0; - /* Trace gate: address baked, flag is process-global. */ - w = emit_movabs_rax(w, (uint64_t)(uintptr_t)&g_trace_hist); + /* Trace gate: address baked, flag is process-global. The STORAGE + * symbol, not the macro — the macro is an atomic load expression + * (round 17), not an lvalue. The emitted cmp is a plain load, + * which at ISA level on x86 is exactly a relaxed atomic load; and + * emitted stores/loads are sanitizer-blind anyway (the recorded + * JIT rule), so the C-side atomics are the only TSan-visible + * accesses either way. */ + w = emit_movabs_rax(w, (uint64_t)(uintptr_t)&g_trace_hist_storage); w = emit_cmpl_0_mem_rax(w); w = emit_jne_rel32(w, &slow_p[slow_n]); slow_n++; /* IC identity + starting version. */ diff --git a/src/jit_smoke.c b/src/jit_smoke.c index 7e43618c..bbe80891 100644 --- a/src/jit_smoke.c +++ b/src/jit_smoke.c @@ -38,7 +38,7 @@ void gc_note_possible_root(Value *v) { (void)v; } /* Stage 5b references &g_trace_hist as an immediate in the SET-name * inline trace gate. Lives in trace.c in the real binary. */ -int g_trace_hist = 0; +int g_trace_hist_storage = 0; /* OP_LINE bakes &g_trace_current_line to stamp the history line. trace.c. */ int g_trace_current_line = 0; /* #410: the back-edge abort poll bakes &g_vm_abort_flag (vm.c). Never NULL diff --git a/src/lint_host.c b/src/lint_host.c index 01237ffc..48bacbdc 100644 --- a/src/lint_host.c +++ b/src/lint_host.c @@ -1316,7 +1316,18 @@ int eigenscript_lint(const char *path, int json_mode, int fail_on_warning) { Env *cenv = env_new(NULL); register_builtins(cenv); /* store/gfx-when-built ride inside (#742) */ g_compile_module_slots = 1; + /* #915: this compile never executes, so the observer gate's eager pass + * must not COMPILE a file's load targets on its behalf. Note the reason + * is cost and surprise, not purity: lint already realpath-resolves and + * OPENS literal load_file targets for E003, and did so before this + * change — a blind critic checked, and lint_host.c's own "touches + * nothing but the file in front of it" comment was already inaccurate. + * What the eager pass would add is a full tokenize+parse+compile of each + * target, and the LSP runs this on every didChange. */ + int obs_saved = g_obs_gate_scan_enabled; + g_obs_gate_scan_enabled = 0; EigsChunk *chunk = compile_ast(ast, cenv, source); + g_obs_gate_scan_enabled = obs_saved; g_compile_module_slots = 0; compile_errors = g_parse_errors; chunk_free(chunk); diff --git a/src/repl.c b/src/repl.c index 8613bc75..005818c2 100644 --- a/src/repl.c +++ b/src/repl.c @@ -67,6 +67,12 @@ static int repl_eval_buffer(Env *env, strbuf *input) { g_returning = 0; g_return_val = NULL; g_has_error = 0; /* don't carry a prior line's error into this one */ + /* #915: the observer gate is decided per compiled unit, and at a REPL each + * LINE is a unit. Line N+1 can interrogate a binding assigned on line N, by + * which time gating line N would already have thrown its history away. There + * is no scan that can see a line the user has not typed yet, so the REPL + * observes unconditionally. */ + eigs_obs_enable(); /* #915: via the helper, so a mid-run flip records the gap */ EigsChunk *repl_chunk = compile_ast(ast, env, input->data); if (g_parse_errors > 0) { /* e.g. an un-encodable jump/loop offset */ fprintf(stderr, "%d compile error(s) — line not run\n", g_parse_errors); @@ -591,7 +597,7 @@ static void repl_interactive(Env *env) { /* #868: same reasoning for the occurrence ring — a `when ` query typed * after the assignments it asks about must still find them. */ trace_arm_occurrences_all(); - g_trace_obs_hist = 1; + trace_flag_store(g_trace_obs_hist_storage, 1); hist_load(); atexit(raw_off); /* never leave the terminal raw, whatever the exit path */ diff --git a/src/state.c b/src/state.c index 88ea1ec9..10ea7c0f 100644 --- a/src/state.c +++ b/src/state.c @@ -7,6 +7,11 @@ #include "jit.h" #include "trace.h" /* #739: trace_thread_release on detach */ +/* #915 (compiler.c): thread-local eager-pass memo + budget release. Declared + * here at file scope — the block-scoped extern it replaces was CodeQL + * cpp/function-in-block, and the header owning it is not visible to this TU. */ +void eigs_obs_memo_release(void); + #if EIGENSCRIPT_EXT_HTTP /* Forward-declared here to avoid pulling ext_http_internal.h (and its * pthread/socket includes) into core runtime TUs. Defined in ext_http.c. */ @@ -74,6 +79,41 @@ void eigs_state_destroy(EigsState *st) { free(st); } +/* #915: PROCESS-GLOBAL count of attached threads. + * + * The observer gate's eager pre-pass mutates two things that are NOT per-state: + * fd 2 (it mutes stderr around a speculative compile) and trace.c's arming sets + * (g_arm_names / g_occ_names / g_trace_hist, plain file-scope globals). It + * guarded that with `g_vm_multithreaded`, which is eigs_current->state-> + * multithreaded — a PER-STATE flag. A per-state flag cannot see a sibling + * state, and src/ext_http.c runs a fresh EigsState per connection on its own OS + * thread, so that flag is 0 on every worker. + * + * Executed by a blind critic under `make asan-http`, two concurrent `code` + * routes each containing a literal load_file: + * + * heap-use-after-free READ in arm_set_has (trace.c) <- trace_arm_history_name + * <- compile_ast <- builtin_load_file <- handle_request <- http_conn_thread, + * freed by another connection thread in trace_arm_restore. + * + * And the fd-2 mute is likewise process-wide: ten /ping requests issued while + * one long eager compile held the muted window produced ZERO stderr lines, and + * two staggered overlapping compiles left the server's real stderr replaced by + * /dev/null for the life of the process — every later runtime error, OOM and + * sanitizer report discarded. + * + * So the precondition is not "this state is single-threaded", it is "this + * PROCESS has one thread". */ +static pthread_mutex_t g_attached_lock = PTHREAD_MUTEX_INITIALIZER; +static int g_attached_threads = 0; + +int eigs_process_thread_count(void) { + pthread_mutex_lock(&g_attached_lock); + int n = g_attached_threads; + pthread_mutex_unlock(&g_attached_lock); + return n; +} + EigsThread *eigs_thread_attach(EigsState *st) { if (!st) return NULL; if (eigs_current) { @@ -83,6 +123,11 @@ EigsThread *eigs_thread_attach(EigsState *st) { } EigsThread *th = xcalloc(1, sizeof(*th)); th->state = st; + pthread_mutex_lock(&g_attached_lock); g_attached_threads++; pthread_mutex_unlock(&g_attached_lock); + /* #915: xcalloc zeroes, and 0 here would mean "never scan", silently + * disabling the observer gate's eager pass on every thread. Default ON; + * only --lint and the LSP clear it. */ + th->obs_gate_scan_enabled = 1; th->loop_exit_reason = "normal"; th->last_obs_slot_idx = -1; /* #262 Phase-2: no observed slot yet */ @@ -172,6 +217,8 @@ void eigs_thread_detach(void) { * struct itself goes. Must run while eigs_current still points at th * so the bridge macros inside free_value/env destructors resolve. */ eigs_thread_drain_caches(th); + eigs_obs_memo_release(); /* #915: memo + speculative budget, thread-local */ + pthread_mutex_lock(&g_attached_lock); g_attached_threads--; pthread_mutex_unlock(&g_attached_lock); arena_destroy(); eigs_current = NULL; diff --git a/src/trace.c b/src/trace.c index 799f562b..599c5bd1 100644 --- a/src/trace.c +++ b/src/trace.c @@ -49,8 +49,8 @@ int g_trace_enabled = 0; int g_replay_enabled = 0; -int g_trace_obs_hist = 0; -int g_trace_hist = 0; +int g_trace_obs_hist_storage = 0; +int g_trace_hist_storage = 0; int g_trace_current_line = 0; /* ----- Phase 3.0a: prev-value table. @@ -285,6 +285,53 @@ void trace_arm_occurrences_name(const char *name) { /* Widen to the wildcard WITHOUT enabling recording. Separate from * trace_arm_history_all because `spawn` calls it: a program with no temporal * query must not start recording just because it made a thread. */ +/* #915: snapshot/restore the compile-time ARMING state. + * + * The observer gate's eager pre-pass runs the REAL compiler over a module's + * source purely to decide whether that module reads observer state. compile_node + * arms this channel as a side effect — trace_arm_history_name/_all, + * trace_arm_occurrences_name, g_trace_hist, g_trace_obs_hist — so a module that + * is only SCANNED used to switch per-assignment history recording on in the + * PARENT, and change the parent's temporal answers. + * + * Executed consequence: with `x is 1.0 / 2.0 / 3.0` then a literal + * `load_file of "mod.eigs"` where mod.eigs holds `prev of x`, the parent printed + * `2`; spelling the same path as `"mod" + ".eigs"` printed `null`, which is what + * the pre-#915 baseline prints. The SPELLING of a path had become semantically + * load-bearing. Worse, it was non-monotone: adding an unrelated `report of z` + * opened the gate, which skipped the eager pass, which un-armed the name — a + * program that asked the observer MORE got LESS history. + * + * The pre-pass already seals its other output channel (it mutes fd 2, because + * "a pre-pass that speaks is a pre-pass that changes the program's output"). + * This is the same rule applied to the channel that was left open. + * + * The name sets are append-only, so a count is a sufficient snapshot; the + * generation counter is bumped on restore so cached per-entry decisions + * recheck. */ +void trace_arm_snapshot(TraceArmState *out) { + if (!out) return; + out->trace_hist = g_trace_hist; + out->obs_hist = g_trace_obs_hist; + out->arm_all = g_arm_all; + out->arm_count = g_arm_count; + out->occ_all = g_occ_all; + out->occ_count = g_occ_count; +} + +void trace_arm_restore(const TraceArmState *in) { + if (!in) return; + for (int i = in->arm_count; i < g_arm_count; i++) free(g_arm_names[i]); + g_arm_count = in->arm_count; + for (int i = in->occ_count; i < g_occ_count; i++) free(g_occ_names[i]); + g_occ_count = in->occ_count; + trace_flag_store(g_trace_hist_storage, in->trace_hist); + trace_flag_store(g_trace_obs_hist_storage, in->obs_hist); + g_arm_all = in->arm_all; + g_occ_all = in->occ_all; + g_arm_gen++; /* invalidate cached per-entry decisions */ +} + void trace_arm_history_all_mt(void) { if (g_arm_all) return; g_arm_all = 1; @@ -292,12 +339,12 @@ void trace_arm_history_all_mt(void) { } void trace_arm_history_all(void) { - g_trace_hist = 1; + trace_flag_store(g_trace_hist_storage, 1); trace_arm_history_all_mt(); } void trace_arm_history_name(const char *name) { - g_trace_hist = 1; + trace_flag_store(g_trace_hist_storage, 1); if (!name || g_arm_all) return; if (arm_set_has(name)) return; if (g_arm_count >= g_arm_cap) { @@ -316,8 +363,8 @@ void trace_arm_history_name(const char *name) { } void trace_history_disable(void) { - g_trace_hist = 0; - g_trace_obs_hist = 0; + trace_flag_store(g_trace_hist_storage, 0); + trace_flag_store(g_trace_obs_hist_storage, 0); } /* g_prev_tab / g_prev_cap / g_prev_count are bridge macros onto EigsThread diff --git a/src/trace.h b/src/trace.h index 5ab68885..82ff47d4 100644 --- a/src/trace.h +++ b/src/trace.h @@ -50,7 +50,19 @@ extern int g_trace_enabled; * which also record WHICH names a temporal query can reach. Never write * `g_trace_hist = 1` directly: an armed flag with no armed name records * nothing. */ -extern int g_trace_hist; +/* ATOMIC, relaxed — same idiom and same reason as the per-state obs flags in + * eigenscript.h (round 17): a worker's sandbox_run/vm_run_bytecode reaches + * chunk_arm_temporal, which stores these PROCESS globals while every other + * thread reads them at per-assignment safepoints (CASE(SET_NAME), and + * eigs_obs_gate_open's second operand — the branch made g_trace_obs_hist + * load-bearing for the gate verdict). TSan: 2 warnings, 3/3, same repro shape + * as the obs-flag race; the INSTANCE pre-exists on main (verified there, same + * 2 warnings), but the flags became verdict-carrying here. The macros are + * LOADS; writes must use trace_flag_store, so the write sites stay + * enumerable by the compiler. The arm NAME SETS (g_arm_*, g_occ_*) are a + * separate, wider surface — tracked on #1035, not fixed by flag atomics. */ +extern int g_trace_hist_storage; +#define g_trace_hist __atomic_load_n(&g_trace_hist_storage, __ATOMIC_RELAXED) /* #827: turn history recording on. * @@ -78,6 +90,17 @@ extern int g_trace_hist; * point. The narrowing is a per-assign CPU optimization for the * single-threaded long-running programs #827 was about; the history is * bounded either way. */ +/* #915: compile-time arming state, saved/restored around the observer gate's + * eager pre-pass so that merely SCANNING a module cannot arm the parent's + * history channel. See the definition for the executed consequence. */ +typedef struct { + int trace_hist, obs_hist; + int arm_all, arm_count; + int occ_all, occ_count; +} TraceArmState; +void trace_arm_snapshot(TraceArmState *out); +void trace_arm_restore(const TraceArmState *in); + void trace_arm_history_all(void); void trace_arm_history_all_mt(void); void trace_arm_history_name(const char *name); @@ -140,7 +163,9 @@ extern int g_trace_current_line; * programs that never ask historical observer questions pay nothing. * Set during compile, before execution; code compiled later (eval, * load_file, REPL lines) enables capture from that point on. */ -extern int g_trace_obs_hist; +extern int g_trace_obs_hist_storage; +#define g_trace_obs_hist __atomic_load_n(&g_trace_obs_hist_storage, __ATOMIC_RELAXED) +#define trace_flag_store(storage, v) __atomic_store_n(&(storage), (v), __ATOMIC_RELAXED) /* Called once from main() during startup. Reads EIGS_TRACE; if set, * opens the path for writing and flips g_trace_enabled. Safe to call diff --git a/src/vm.c b/src/vm.c index 05881ca1..8a4f38ea 100644 --- a/src/vm.c +++ b/src/vm.c @@ -44,6 +44,7 @@ Value* builtin_observe(Value *arg); * CASE(LOOP_STALL_CHECK) and jit_helper_loop_stall_check so the two can never * disagree on loop classification (the same lockstep invariant the opcode * encoding enforces). Returns 1 if (*dH, *ent) were filled. */ + static inline int obs_stall_trajectory(double *dH, double *ent) { const ObserverSlot *s = env_obs_slot(g_last_obs_slot_env, g_last_obs_slot_idx); if (s && s->used) { @@ -210,6 +211,28 @@ static void eigs_observer_dump(Env *leaf) { Env *root = leaf; while (root->parent) root = root->parent; fprintf(stderr, "# eigenscript observer dump (SIGUSR1) — module scope + dumping thread's live frame\n"); + /* #915: the observer gate can be closed for this program, in which case no + * assignment ever updated a slot and every binding below would print as + * "equilibrium" with an empty trajectory. That reads as "everything is + * settled" when the truth is "nothing was ever measured" — the exact silent + * lie this gate must not introduce. Say so instead. */ + if (!g_obs_needed && !g_trace_obs_hist) { + fprintf(stderr, + "# NOTE: observer gate CLOSED (#915) — nothing in this program can\n" + "# interrogate the observer, so no trajectories were recorded\n" + "# before this signal. The bands below are absence of data,\n" + "# NOT equilibrium.\n" + "# Observation is now ON: send SIGUSR1 again for a populated\n" + "# dump. (EIGS_OBS_FORCE=1 arms it from process start.)\n"); + /* Arm from here. A SIGUSR1 dump exists to inspect a process that is + * ALREADY RUNNING — the one situation where re-running under + * EIGS_OBS_FORCE=1 is not available. Leaving the gate closed would trade + * a shipped debugging capability (#660) for throughput on exactly the + * programs most worth debugging. Flipping here is safe: the dump runs at + * a loop safepoint, not in the signal handler, and g_obs_needed is + * monotonic so this cannot flicker. */ + eigs_obs_enable(); + } obs_dump_scope("module", root, 1, NULL); Env *fn_env = NULL; const EigsChunk *fn_chunk = NULL; @@ -6875,6 +6898,41 @@ Value *vm_execute(EigsChunk *chunk, Env *env) { } static Value *vm_execute_common(EigsChunk *chunk, Env *env, int call_argc) { + /* #915: user code is now executing, so from here on an eigs_obs_enable() + * leaves the bindings already assigned without history — that is what the + * sticky obs_history_gap records, and it is the half of the load guard that + * survives a mid-run arming. + * + * SET HERE, not at the call sites. It used to be set in exactly one place + * (main.c, immediately before its own vm_execute), which made it a + * hand-maintained caller list of one — the drift shape compile_ast + * deliberately avoids by OR-ing its verdict at the single choke point every + * compilation path funnels through. Every EigsState that main.c did not + * create therefore ran with the flag at 0, so a mid-run arming recorded NO + * gap and the guard short-circuited: the exact one-shot unsoundness an + * earlier round had already fixed once, live again on ext_http `code` + * routes and the WASM playground. + * + * Executed over HTTP before this line existed: a geometrically decaying + * value reported `equilibrium` where the truth is `improving`, 3/3, rc 200, + * no error — while the identical source on the CLI correctly refused to + * answer. Patching the two known entry points would have left the next one + * to rediscover it. + * + * GUARDED, per the #297 write-once pattern: an unconditional store here is + * a write-write race the moment workers exist — every spawned thread + * re-stored 1 into the same per-state field, and the TSan CI lane flagged + * it in six programs at this line (a lane no local run covers; the local + * suite was 4117/4117 green). The guard removes ALL concurrent writes, not + * just narrows them: a worker thread is created BY an executing VM, so by + * the time any worker reaches this line the main thread's store already + * happened-before it (pthread_create), the read sees 1, and nobody stores. + * Two threads can both see 0 only if two VMs execute a state that neither + * has ever executed — impossible: workers exist only downstream of an + * execute, and every embed/eval entry runs its first execute on the + * creating thread. */ + if (!g_obs_exec_started) obs_flag_store(obs_exec_started, 1); + vm_init(); /* Only the OUTERMOST vm_execute drives the scheduler; a nested call * (eval/dispatch/import/comparator) runs to completion on the C stack and diff --git a/src/vm.h b/src/vm.h index 202326d8..31d966ec 100644 --- a/src/vm.h +++ b/src/vm.h @@ -640,6 +640,33 @@ void chunk_patch_jump(EigsChunk *chunk, int offset); int chunk_add_function(EigsChunk *chunk, EigsChunk *fn); void chunk_scan_leaf_accessor(EigsChunk *chunk); /* #366 */ void chunk_disassemble(EigsChunk *chunk, const char *label); +/* #915: 1 if anything in this chunk (or a nested function chunk) can READ + * observer state — a reader opcode, or an observer-read builtin name in the + * constant pool (aliasing: `local r is report` emits no reader opcode). + * Conservative in one direction only: every unclear case answers 1. Drives + * the observer gate, so a wrong 0 is silently-wrong observer results. */ +int chunk_reads_observer(const EigsChunk *chunk); +/* #915: the opcode half alone — true if the chunk contains ANY reader opcode, + * with no reachability question asked. Recurses into functions[]. Used by + * chunk_reads_observer and by the descriptor sites (vm_run_bytecode / + * sandbox_run), which ARM the observer before running rather than asking the + * finer question "can the read reach the HOST's history" — three static + * guards for that were tried and each broke either the self-hosting bridge or + * its own fixture; the residual is filed as #1027. (A prior version of this + * comment named a chunk_reads_host_observer function that never existed — + * a comment is load-bearing, and this one sent a reader hunting a phantom.) */ +/* THE reader set — one home, pinned against vm.h's obs:READS markers by + * tools/obs_reader_sync_check.sh. Ask this; never restate the list. */ +int opcode_is_observer_reader(uint8_t op); +int chunk_has_reader_opcode(const EigsChunk *chunk); +/* #915: hand every STRING-LITERAL `load_file` target in this chunk to `visit`. + * Returns 1 if the unit is OPAQUE — it uses the name `load_file` in any shape + * this scan does not recognize. An opaque unit must be treated as observing. + * This does NOT check resolver parity between compile time and run time; that + * is enforced at the load itself (builtin_load_file). See the definition. */ +int chunk_scan_static_loads(const EigsChunk *chunk, + void (*visit)(const char *path, void *ud), + void *ud); const char *op_name(uint8_t op); /* Verify an assembled (untrusted) chunk's bytecode is in-bounds before the VM * runs it. Returns 1 if safe to execute, 0 if it must be rejected. */ diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 47c86638..5f5f9daa 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -3567,6 +3567,768 @@ check_eigs_suite "report_value value-channel verdicts" test_observer_value_signa echo "[99a] Observer Entropy Level Set (#862)" check_eigs_suite "sign-flip + reciprocal oscillators on the exact level set" test_observer_level_set.eigs "All tests passed." 1 +# [99u] Observer gate (#915). The gate lets a program skip observer bookkeeping +# (88% of runtime / 8.50x ceiling on a consumer that never interrogates). Its +# failure mode is SILENT-WRONG — a misgated program still runs and still prints, +# with a dead observer channel and nothing to fail on — so this section checks +# the gate DECISION itself, not merely that programs still produce output. +echo "[99u] Observer Gate (#915)" +OBS_GATE_TMP=$(mktemp -d) +# Pin this section's assertion count (mechanical-gates §37). Every mechanism +# below can be deleted one at a time with the suite still green unless the +# CONSUMER counts them: a gate that silently measures LESS still prints OK. +# Bump this deliberately when adding a check, never to make a run pass. +OBS_GATE_TOTAL_BEFORE=$TOTAL +OBS_GATE_EXPECTED_CHECKS=44 +# 1. Sync gate: the rule "which opcodes read observer state" lives in TWO homes +# — the /*obs:READS*/ markers in src/vm.h (authoritative, #1024) and the +# `case OP_...:` arms of chunk_reads_observer() (the consumer). A marker- +# declared reader missing from the switch means a program using only that +# opcode gates itself off and then reads slots nobody updated — silent, and +# forever. This replaces tools/observer_reader_ops_check.py, which derived +# the reader set from the C SOURCE: that is the open level, where a read can +# be spelled arbitrarily many ways and no matcher bounds the population +# (#972 recorded five failed derivations there). The enum is the closed +# level. Validated by a 5-mutation train; see the tool's header. +TOTAL=$((TOTAL + 1)) +if OBS_SYNC_OUT=$("$TESTS_DIR/../tools/obs_reader_sync_check.sh" 2>&1); then + PASS=$((PASS + 1)); echo " PASS: ${OBS_SYNC_OUT##*RESULT: PASS — }" +else + FAIL=$((FAIL + 1)); echo " FAIL: the observer-reader rule has diverged between src/vm.h and src/chunk.c" + echo "$OBS_SYNC_OUT" | sed 's/^/ /' +fi +# Answer helper (round 12): an ANSWER-shaped verdict is rc-blind if captured +# with a bare `| head -1` / `| tail -1` — a program that prints the right +# answer and THEN crashes scores PASS. This is the THIRD entry of the same +# class (round 10: closed-verdicts; round 11: measure.sh DONE-then-SIGSEGV; +# round 12: check 40, written in the SAME COMMIT as the measure fix), and +# .claude/rules/test-suite.md names it as the standing rc_ok rule. So the +# class gets a helper, not another spot fix: rc != 0 returns died-rcN, which +# fails any expected-answer comparison loudly with the reason in the string. +# Raise-EXPECTING checks (a raise exits nonzero by design) stay on their own +# capture: for those a crash produces different text and already goes red. +obs_gate_answer() { + # $1 = program, $2 = head|tail, $3 = timeout seconds (default 60) + local OGA_OUT OGA_RC + OGA_OUT=$(obs_tmo "${3:-60}" $EIGS_BIN "$1" 2>&1); OGA_RC=$? + if [ "$OGA_RC" -ne 0 ]; then echo "died-rc$OGA_RC"; return; fi + if [ "$2" = head ]; then printf '%s\n' "$OGA_OUT" | head -1 + else printf '%s\n' "$OGA_OUT" | tail -1; fi +} +# Timeout runner for this section, resolved ONCE from the suite's own +# detection above (§32). Eight checks here spelled `timeout N` bare, bypassing +# the $EIGS_TMO convention the suite header defines PRECISELY because macOS +# has no timeout(1) — so on all four macOS CI legs (two of them the release +# workflow) those checks died rc=127 and the section went 23/45. Found by a +# blind critic (round 14) simulating timeout-absence over the extracted +# section; thirteen all-Linux rounds never saw it — the failure population +# lives on the machines you did not run (§46). Loud, not silent — the round-12 +# rc-discipline turned every one into died-rc127 — but release-blocking. +# obs_tmo : applies timeout/gtimeout when one exists, runs +# unbounded otherwise (the suite's standing fallback). +obs_tmo() { + local OBS_TMO_S="$1"; shift + if command -v timeout >/dev/null 2>&1; then timeout "$OBS_TMO_S" "$@" + elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$OBS_TMO_S" "$@" + else "$@"; fi +} +# 2. The gate must CLOSE on a program with no observer surface. If this ever +# reports "observed", the gate has silently stopped paying for itself and +# every performance number attributed to it is stale. +printf 'x is 0\nfor i in range of 5:\n x is x + i\nprint of x\n' > "$OBS_GATE_TMP/plain.eigs" +OBS_G1=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$OBS_GATE_TMP/plain.eigs" 2>&1 | grep -c 'obs-gate: unobserved') +check "gate CLOSES on a program with no observer surface" "$OBS_G1" "1" +# 3. And OPEN on a direct observer surface. +OBS_G2=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$TESTS_DIR/test_observer_level_set.eigs" 2>&1 | grep -c 'obs-gate: observed') +check "gate OPENS on a direct observer surface" "$OBS_G2" "1" +# 4. And OPEN on the INDIRECT form. `local r is report` emits NO reader opcode — +# it compiles to GET_NAME + CALL — so this passes only because the scan also +# matches observer-read builtin names in the constant pool. An opcode-only +# scan reports "unobserved" here and silently breaks every aliased report. +printf 'x is 1.0\nlocal r is report\nx is 2.0\nprint of (r of x)\n' > "$OBS_GATE_TMP/alias.eigs" +OBS_G3=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$OBS_GATE_TMP/alias.eigs" 2>&1 | grep -c 'obs-gate: observed') +check "gate OPENS on an aliased report (no reader opcode emitted)" "$OBS_G3" "1" +# 5. The escape hatch, which is also the baseline arm for perf work: ONE +# byte-identical binary serves both arms, so a measurement cannot be +# confounded by a second build. +OBS_G4=$(EIGS_OBS_GATE_STATS=1 EIGS_OBS_FORCE=1 $EIGS_BIN "$OBS_GATE_TMP/plain.eigs" 2>&1 | grep -c 'obs-gate: observed') +check "EIGS_OBS_FORCE=1 reopens the gate" "$OBS_G4" "1" +# 6-8. The three misgating classes found by adversarial review. Each is +# SILENT: the program runs, prints, and exits 0 while every observer query +# returns a rest band. Each is asserted against the observed VALUE, not the +# gate's own stats — in the spawn case the stats said "observed" while the +# observation was being discarded, so a stats-only check would have passed. +# Expected verdict is "moving" (an ordinary geometric climb). +# 6. Worker threads. obs_needed lived on EigsThread, which eigs_thread_attach +# xcalloc's fresh per worker while only the spawning thread runs compile_ast, +# so every assignment on a worker skipped observation. EIGS_OBS_FORCE=1 could +# not rescue it either. The corpus differential was blind: its only +# spawn+observer program asserts iteration counts, never report content. +printf 'shared is 1.0\n\ndefine worker() as:\n shared is 2.0\n shared is 4.0\n shared is 8.0\n return 1\n\nh is spawn of worker\nr is thread_join of h\nprint of (report of shared)\n' > "$OBS_GATE_TMP/spawn.eigs" +OBS_G5=$(obs_gate_answer "$OBS_GATE_TMP/spawn.eigs" tail) +check "worker-thread assignments are observed (gate is per-STATE, not per-thread)" "$OBS_G5" "moving" +# 7. eval compiles at RUNTIME, after this unit's assignments already ran, so its +# scan cannot arrive in time. The source may not exist until it is built, so +# the presence of eval at all is the signal. +printf 'x is 1.0\nx is 2.0\nx is 4.0\nx is 8.0\nprint of (eval of "report of x")\n' > "$OBS_GATE_TMP/ev.eigs" +OBS_G6=$(obs_gate_answer "$OBS_GATE_TMP/ev.eigs" tail) +check "eval of observer code sees the parent's earlier assignments" "$OBS_G6" "moving" +# 8. Same shape through load_file: parent assigns, THEN loads a module that +# interrogates. Closed by pre-scanning string-literal load targets through +# resolve_eigenscript_file (the resolver load_file itself uses). +printf 'print of (report of p)\n' > "$OBS_GATE_TMP/m.eigs" +printf 'p is 1.0\np is 2.0\np is 4.0\np is 8.0\nload_file of "%s/m.eigs"\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/par.eigs" +OBS_G7=$(obs_gate_answer "$OBS_GATE_TMP/par.eigs" tail) +check "load_file'd module sees the parent's earlier assignments" "$OBS_G7" "moving" + +# 9-16. The literal-load rule (#915 follow-up). `load_file` no longer forces the +# gate open wholesale — a STRING-LITERAL target is compiled eagerly at the +# parent's compile time, so the module's verdict lands before line 1 of the +# parent runs. Check 8 above is the load-bearing half of that and stays +# asserted on the VALUE. These check the boundary: what the rule must still +# refuse. Every one of them is a case where being wrong is SILENT. +printf 'define lf_helper(a) as:\n return a + 1\n' > "$OBS_GATE_TMP/mfree.eigs" +# 9. The positive case. Without this the whole change is unmeasured: it is the +# only check here that fails if the eager compile silently stops gating. +printf 'load_file of "%s/mfree.eigs"\nprint of (lf_helper of 1)\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/lf_ok.eigs" +# The positive control must prove the program RAN before its "closed" verdict +# means anything: `grep -q ... || echo closed` is satisfied by silence, so a +# do-nothing binary passes it (executed by a blind critic). Require the +# program's own output first. +OBS_LFOK_OUT=$(obs_gate_answer "$OBS_GATE_TMP/lf_ok.eigs" tail) +if [ "$OBS_LFOK_OUT" = "2" ]; then + OBS_G8=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$OBS_GATE_TMP/lf_ok.eigs" 2>&1 | grep -q 'obs-gate: observed' && echo open || echo closed) +else + OBS_G8="fixture-did-not-run" +fi +check "a literal load of an observer-free module still GATES" "$OBS_G8" "closed" +# 10. A COMPUTED path is not a literal and nothing can resolve it. This is +# recorded failure (1) of the token-era pre-scan, which silently skipped it. +printf 'local d is "%s"\nload_file of (d + "/mfree.eigs")\nprint of (lf_helper of 1)\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/lf_computed.eigs" +OBS_G9=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$OBS_GATE_TMP/lf_computed.eigs" 2>&1 | grep -q 'obs-gate: observed' && echo open || echo closed) +check "a COMPUTED load path keeps the gate open" "$OBS_G9" "open" +# 11. ONE unrecognized use makes the WHOLE unit opaque — the fallback is an AND, +# not an OR. Recorded failure (2): as an OR, one benign literal load disarmed +# the fallback for every other load in the unit, so the bug got LESS likely +# the simpler the program got and no corpus differential could have found it. +printf 'load_file of "%s/mfree.eigs"\nlocal d is "%s"\nload_file of (d + "/mfree.eigs")\nprint of (lf_helper of 1)\n' "$OBS_GATE_TMP" "$OBS_GATE_TMP" > "$OBS_GATE_TMP/lf_mixed.eigs" +OBS_G10=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$OBS_GATE_TMP/lf_mixed.eigs" 2>&1 | grep -q 'obs-gate: observed' && echo open || echo closed) +check "one computed load poisons a unit that also has a literal one" "$OBS_G10" "open" +# 12. An ALIAS emits GET_NAME "load_file" outside the recognized shape. +printf 'local lf is load_file\nlf of "%s/mfree.eigs"\nprint of (lf_helper of 1)\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/lf_alias.eigs" +OBS_G11=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$OBS_GATE_TMP/lf_alias.eigs" 2>&1 | grep -q 'obs-gate: observed' && echo open || echo closed) +check "an ALIASED load_file keeps the gate open" "$OBS_G11" "open" +# 13. Resolver parity, via chdir — the THIRD route into the time-of-check / +# time-of-use family checked at 18-20. `chdir` used to be a one-element +# denylist in chunk_scan_static_loads that forced the gate open; that was the +# wrong population key (the same state is reachable by write_text, rename, +# mkdir, remove_file or a subprocess), so the denylist is gone and the +# outcome check at the load covers all of them. This asserts the ROUTE still +# ends soundly: cwd moves, the literal resolves to a DIFFERENT file, and that +# file observes -> raise, never a quiet `equilibrium`. +mkdir -p "$OBS_GATE_TMP/cdsub" +printf 'print of "outer"\n' > "$OBS_GATE_TMP/cd_m.eigs" +printf 'print of (report of y)\n' > "$OBS_GATE_TMP/cdsub/cd_m.eigs" +printf 'y is 1.0\ny is 2.0\ny is 4.0\nlocal ok is chdir of "cdsub"\nload_file of "cd_m.eigs"\n' > "$OBS_GATE_TMP/lf_chdir.eigs" +# EIGS_BIN is "./eigenscript", RELATIVE to the runner's cwd — a subshell that +# cd's away from it runs nothing, and `grep -c` then reports 0, which reads as +# "the guard did not fire" rather than "the probe did not run" (§64: a probe +# that cannot execute is not a probe). Resolve it to an absolute path first. +OBS_ABS_BIN=$(cd "$(dirname "$EIGS_BIN")" && pwd)/$(basename "$EIGS_BIN") +OBS_G12=$( cd "$OBS_GATE_TMP" && "$OBS_ABS_BIN" "$OBS_GATE_TMP/lf_chdir.eigs" 2>&1 | grep -c 'reads observer state, but the observer gate was closed' ) +check "chdir resolving a literal to an OBSERVING file raises, not answers" "$OBS_G12" "1" +# 14. TRANSITIVE: the parent's literal load reaches an observer two modules down. +# Asserted on the VALUE — the gate's own stats cannot see a wrong answer. +printf 'print of (report of q)\n' > "$OBS_GATE_TMP/lf_inner.eigs" +printf 'load_file of "%s/lf_inner.eigs"\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/lf_mid.eigs" +printf 'q is 1.0\nq is 2.0\nq is 4.0\nq is 8.0\nload_file of "%s/lf_mid.eigs"\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/lf_deep.eigs" +OBS_G13=$(obs_gate_answer "$OBS_GATE_TMP/lf_deep.eigs" tail) +check "an observer TWO literal loads down still sees earlier assignments" "$OBS_G13" "moving" +# 15. A missing literal cannot be scanned, so it cannot be cleared either. (The +# load still fails at runtime exactly as before; this asserts the DECISION.) +printf 'load_file of "%s/nope.eigs"\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/lf_missing.eigs" +OBS_G14=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$OBS_GATE_TMP/lf_missing.eigs" 2>&1 | grep -q 'obs-gate: observed' && echo open || echo closed) +check "an unresolvable literal load keeps the gate open" "$OBS_G14" "open" +# 16. A MUTUAL literal load recurses through the eager compile exactly as #496's +# did through vm_execute. The depth bound must stop it, and stopping must set +# the bit rather than give up quietly. The timeout is the real assertion here: +# before the bound existed this was a C-stack SIGSEGV. +printf 'load_file of "%s/lf_b.eigs"\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/lf_a.eigs" +printf 'load_file of "%s/lf_a.eigs"\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/lf_b.eigs" +# The eager pass memoises resolved paths, so a mutual load now terminates by +# hitting the memo rather than by exhausting OBS_GATE_MAX_DEPTH — and with both +# modules observer-free, CLOSING the gate is the correct answer. Asserting the +# gate STATE here pinned an implementation detail that legitimately moved; the +# invariant that actually matters is that it terminates and both arms agree. +obs_tmo 20 $EIGS_BIN "$OBS_GATE_TMP/lf_a.eigs" > "$OBS_GATE_TMP/mut_g.out" 2>&1 +OBS_MUT_RC=$? +EIGS_OBS_FORCE=1 obs_tmo 20 $EIGS_BIN "$OBS_GATE_TMP/lf_a.eigs" > "$OBS_GATE_TMP/mut_b.out" 2>&1 +if [ "$OBS_MUT_RC" -eq 124 ]; then + OBS_G15="hung" +else + # Both arms degrading to the same nothing is not evidence (the vacuous- + # reference trap). The reference arm must carry the circular-dependency + # diagnostic this case is about. Check 17 one screen below already had this + # guard; the pattern was not applied here until a critic ran both checks + # against a do-nothing binary and watched this one pass. + if grep -q 'circular dependency' "$OBS_GATE_TMP/mut_b.out"; then + OBS_G15=$(cmp -s "$OBS_GATE_TMP/mut_g.out" "$OBS_GATE_TMP/mut_b.out" && echo identical || echo differs) + else + OBS_G15="reference-arm-vacuous" + fi +fi +check "a MUTUAL literal load terminates, both arms identical" "$OBS_G15" "identical" +# 17. The eager compile must be SILENT. Found by measurement, not by the corpus: +# a module containing `break` outside a loop printed its compile error TWICE +# under the gate — once from the eager pre-pass and once when load_file +# compiled it for real. The 417-program corpus differential was byte-identical +# across both arms and could not see it, because no corpus program loads a +# module that fails to compile. Asserted as a DIFFERENTIAL against the +# baseline arm (EIGS_OBS_FORCE=1, same binary), not against a literal +# expected string, so it also covers diagnostics added later. +printf 'break\n' > "$OBS_GATE_TMP/lf_broken.eigs" +printf 'load_file of "%s/lf_broken.eigs"\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/lf_brokenpar.eigs" +EIGS_OBS_FORCE=1 $EIGS_BIN "$OBS_GATE_TMP/lf_brokenpar.eigs" > "$OBS_GATE_TMP/base.out" 2>&1 +$EIGS_BIN "$OBS_GATE_TMP/lf_brokenpar.eigs" > "$OBS_GATE_TMP/gated.out" 2>&1 +# A bare `cmp` passes vacuously when BOTH arms degrade to the same nothing (the +# vacuous-reference trap): with the fixture missing, both print the same "cannot +# read" and compare equal. Require the reference arm to carry the diagnostic +# this check is ABOUT before believing the comparison. +if grep -q "'break' outside a loop" "$OBS_GATE_TMP/base.out"; then + OBS_G16=$(cmp -s "$OBS_GATE_TMP/base.out" "$OBS_GATE_TMP/gated.out" && echo identical || echo differs) +else + OBS_G16="reference-arm-vacuous" +fi +check "a module that fails to compile reports IDENTICALLY under the gate" "$OBS_G16" "identical" +# 18-20. TIME-OF-CHECK / TIME-OF-USE. The eager pre-pass reads a literal target +# when the parent COMPILES; load_file reads it again when the call RUNS, and +# the whole program runs in between. Found by a blind critic with two +# executed repros, both silently wrong (`equilibrium` under the gate, +# `moving` without it) — a rewrite of the module, and a cwd file SHADOWING +# the resolved one. An earlier draft tried to enumerate the causes and +# shipped a one-element `chdir` denylist; the guard is now on the OUTCOME +# (the observer bit flipping 0->1 at the load) and needs no such list. +# 18. Route A: the program rewrites the module between the two reads. +printf 'print of "idle"\n' > "$OBS_GATE_TMP/toc_mod.eigs" +printf 'x is 1.0\nx is 2.0\nx is 3.0\nwrite_text of ["%s/toc_mod.eigs", "print of (report of x)"]\nload_file of "%s/toc_mod.eigs"\n' "$OBS_GATE_TMP" "$OBS_GATE_TMP" > "$OBS_GATE_TMP/toc_a.eigs" +OBS_G17=$($EIGS_BIN "$OBS_GATE_TMP/toc_a.eigs" 2>&1 | grep -c 'reads observer state, but the observer gate was closed') +check "a module REWRITTEN between the two reads raises, not answers" "$OBS_G17" "1" +# 19. And the escape hatch named in that error must actually work — otherwise +# the diagnostic sends the reader somewhere that does not help. +printf 'print of "idle"\n' > "$OBS_GATE_TMP/toc_mod.eigs" +OBS_G18_OUT=$(EIGS_OBS_FORCE=1 obs_tmo 60 $EIGS_BIN "$OBS_GATE_TMP/toc_a.eigs" 2>&1); OBS_G18_RC=$? +if [ "$OBS_G18_RC" -ne 0 ]; then OBS_G18="died-rc$OBS_G18_RC"; else OBS_G18=$(printf '%s\n' "$OBS_G18_OUT" | tail -1); fi +check "EIGS_OBS_FORCE=1 (named in the error) runs that program correctly" "$OBS_G18" "moving" +# 20. NEGATIVE CONTROL. A guard that fires on any rewrite would be its own bug: +# rewriting a module to something that still does NOT observe must run. +printf 'print of "idle"\n' > "$OBS_GATE_TMP/toc_mod2.eigs" +printf 'x is 1.0\nwrite_text of ["%s/toc_mod2.eigs", "print of 42"]\nload_file of "%s/toc_mod2.eigs"\n' "$OBS_GATE_TMP" "$OBS_GATE_TMP" > "$OBS_GATE_TMP/toc_b.eigs" +OBS_G19=$($EIGS_BIN "$OBS_GATE_TMP/toc_b.eigs" 2>&1 | tail -1) +check "a BENIGN rewrite of a loaded module still runs (no false positive)" "$OBS_G19" "42" +# 21-22. The guard must be PER-LOAD, not one-shot, and must not fire on a +# conservative bail elsewhere. A first draft compared the monotonic observer +# bit before/after the module compile; a blind critic broke it both ways. +# 21. ONE-SHOT: the error is catchable, so a `try:` around the first load left +# the bit at 1 and every later load skipped the check — restoring the exact +# silent-wrong answer the guard exists to stop. +printf 'print of "stub"\n' > "$OBS_GATE_TMP/dis_a.eigs" +printf 'print of "stub"\n' > "$OBS_GATE_TMP/dis_b.eigs" +printf 'x is 1.0\nx is 2.0\nx is 3.0\nwrite_text of ["%s/dis_a.eigs", "print of (report of x)"]\ntry:\n load_file of "%s/dis_a.eigs"\ncatch e:\n print of "caught"\nwrite_text of ["%s/dis_b.eigs", "print of (report of x)"]\nload_file of "%s/dis_b.eigs"\n' "$OBS_GATE_TMP" "$OBS_GATE_TMP" "$OBS_GATE_TMP" "$OBS_GATE_TMP" > "$OBS_GATE_TMP/disarm.eigs" +OBS_G20=$($EIGS_BIN "$OBS_GATE_TMP/disarm.eigs" 2>&1 | grep -c 'reads observer state, but the observer gate was closed') +check "catching the first raise does NOT disarm the guard for later loads" "$OBS_G20" "1" +# 22. OVER-BROAD: the eager pass bails conservatively for six reasons, only one +# of which is staleness. `spawn` + a module that itself loads a module hit +# the multithreaded bail and hard-errored with every clause of the message +# false — and that shape is SHIPPED (lib/io.eigs loads lib/string.eigs). +printf 'print of "inner ok"\n' > "$OBS_GATE_TMP/fp_inner.eigs" +printf 'load_file of "%s/fp_inner.eigs"\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/fp_mid.eigs" +printf 'define w() as:\n return 1\nlocal t is spawn of w\nprint of (thread_join of t)\nload_file of "%s/fp_mid.eigs"\nprint of "no false positive"\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/fp.eigs" +OBS_G21=$(obs_gate_answer "$OBS_GATE_TMP/fp.eigs" tail) +check "spawn + a nested literal load does not falsely raise" "$OBS_G21" "no false positive" +# 23-24. The DESCRIPTOR seam. Note what is NOT here: a check that a descriptor +# READING host observer state raises. Three static guards were tried and each +# broke either the self-hosting bridge or its own #737 fixture; the residual +# is filed rather than half-guarded, so pinning a raise here would pin a +# behaviour that does not exist. +# 23. COMPOSED SEAM — the case every other check misses because each exercises +# ONE guard in isolation. A benign descriptor call arms the observer +# mid-run; that arming must NOT be readable as "the gate was open all +# along", or it disarms the load_file guard for the rest of the process. +# Executed before the fix: one `vm_run_bytecode` of a chunk that reads +# nothing turned a loud raise into a silent `equilibrium`. +printf 'print of "benign"\n' > "$OBS_GATE_TMP/comp_m.eigs" +printf 'x is 1.0\nfor i in range of 40:\n x is x * 2.0\nlocal warm is vm_run_bytecode of [1, [0,0,0,40], [7]]\nlocal w is write_text of ["%s/comp_m.eigs", "print of (report of x)"]\nload_file of "%s/comp_m.eigs"\n' "$OBS_GATE_TMP" "$OBS_GATE_TMP" > "$OBS_GATE_TMP/composed.eigs" +# Assert the RAISE, not byte-equality: a loud raise and a correct answer are +# both sound but not identical, and comparing the arms would fail on the +# very behaviour this pins. Before the fix this program printed +# `equilibrium` and exited 0. +OBS_G22=$($EIGS_BIN "$OBS_GATE_TMP/composed.eigs" 2>&1 | grep -c 'reads observer state, but the observer gate was closed') +check "a mid-run arming does not disarm the load_file guard" "$OBS_G22" "1" +# 24. GATE-SENSITIVE, and DISCRIMINATING — the previous fixture was not. +# It ended with a string-literal load of an observing module, which the +# eager pass resolves at the PARENT's compile time and opens the gate on +# before line 1 runs (2 units already `observed`), so the descriptor's +# arming was never load-bearing and BOTH mechanisms it named could be +# deleted with the section 27/27 green. A blind critic proved it decoration. +# A discriminating fixture needs a descriptor whose OWN assembled bytecode +# carries the reader: the host has no observer surface at all (gate closed, +# 0 observed), and the descriptor writes a geometric ramp into its own frame +# slot with OBSERVE_ASSIGN_LOCAL then reads it back with REPORT_SLOT. +# Verified against a build with the arming deleted: clean `diverging`, +# mutant `equilibrium`. +# Opcodes: CONST=0 SET_LOCAL=24 POP=35 RETURN=40 OBSERVE_ASSIGN_LOCAL=57 +# REPORT_SLOT=81. +# The reader must live in a NESTED function chunk. A TOP-LEVEL descriptor +# is handed the HOST env (callframe_init sets fn_env to it), so a slot write +# there decrefs a live host binding — a heap-use-after-free, filed as a +# pre-existing bug. A nested chunk gets a fresh call env, so its writes +# address its own frame. Verified ASan-clean, and verified discriminating +# against a build with the arming deleted: clean `diverging`, mutant +# `equilibrium`. +# CONST=0 SET_LOCAL=24 POP=35 CLOSURE=38 CALL=39 RETURN=40 +# OBSERVE_ASSIGN_LOCAL=57 REPORT_SLOT=81 +{ + printf 'local fn is [[' + j=0; while [ $j -lt 24 ]; do printf '0, %d, 0, 57, 0, 0, 24, 0, 0, 35, ' "$j"; j=$((j+1)); done + printf '81, 0, 0, 40], [' + j=0; v=1; while [ $j -lt 24 ]; do [ $j -gt 0 ] && printf ', '; printf '%d.0' "$v"; v=$((v*2)); j=$((j+1)); done + printf '], [], 0, "ramp", ["acc"]]\n' + printf 'local mod is [38, 0, 0, 39, 0, 0, 40]\n' + printf 'print of (vm_run_bytecode of [1, mod, [], [fn], 0, "", []])\n' +} > "$OBS_GATE_TMP/desc_arm.eigs" +OBS_DESC_GATE=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$OBS_GATE_TMP/desc_arm.eigs" 2>&1 >/dev/null | grep -c 'obs-gate: observed') +OBS_G23=$(obs_gate_answer "$OBS_GATE_TMP/desc_arm.eigs" tail) +# The gate must be CLOSED for this to mean anything — if the host opened it, +# the descriptor's arming is irrelevant and the check is back to decoration. +[ "$OBS_DESC_GATE" = "0" ] || OBS_G23="host-opened-the-gate($OBS_DESC_GATE)" +check "a descriptor ARMS the observer for its OWN writes (gate-sensitive)" "$OBS_G23" "diverging" +# 25. The sync gate's own mutation train. Without this the gate is a claim: a +# blind critic gutted each of its four assertion bodies in turn and it +# printed PASS every time on a tree carrying that assertion's fault. The +# WITNESS half is the part that matters — it requires the fault to SURVIVE +# when its assertion is gutted, which is what proves the assertion, and not +# a neighbouring floor, is doing the work (mechanical-gates §19/§21/§66). +TOTAL=$((TOTAL + 1)) +OBS_ST_N=0 +if OBS_ST_OUT=$("$TESTS_DIR/../tools/obs_reader_sync_check.sh" --selftest 2>&1); then + # rc 0 alone is not enough: shrinking the selftest to one row also exits 0. + # Floor the number of rows it actually ran (§37 at the integration point). + OBS_ST_N=$(printf '%s\n' "$OBS_ST_OUT" | sed -n 's/^SELFTEST: \([0-9]*\) passed.*/\1/p') + : "${OBS_ST_N:=0}" +fi +if [ "$OBS_ST_N" -ge 11 ]; then + PASS=$((PASS + 1)); echo " PASS: observer-reader sync gate self-test ($OBS_ST_N rows)" +else + FAIL=$((FAIL + 1)); echo " FAIL: observer-reader sync gate self-test ($OBS_ST_N rows, floor 11)" + echo "$OBS_ST_OUT" | sed 's/^/ /' +fi +# 26-27. The eager pre-pass must not WRITE to the program's world. It mutes fd 2 +# already; it also runs the real compiler, and compile_node ARMS the trace +# history channel as a side effect. Unrestored, merely SCANNING a module +# switched per-assignment recording on in the parent and changed its +# temporal answers — so the SPELLING of a load path became semantically +# load-bearing, and the behaviour was non-monotone in observation. +# 26. Literal and computed spellings of the SAME load must agree. +printf 'print of (str of (prev of x))\n' > "$OBS_GATE_TMP/arm_mod.eigs" +printf 'x is 1.0\nx is 2.0\nx is 3.0\nlocal m is load_file of "%s/arm_mod.eigs"\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/arm_lit.eigs" +printf 'x is 1.0\nx is 2.0\nx is 3.0\nlocal p is "%s/arm_" + "mod.eigs"\nlocal m is load_file of p\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/arm_comp.eigs" +# Both arms must carry EVIDENCE before their agreement means anything: +# a bare equality is satisfied by two empty strings, so a do-nothing binary +# scored "agree" on both of these (executed by a blind critic). The +# pre-#915 answer here is `null`; requiring it makes the check discriminate. +OBS_ARM_LIT=$(obs_gate_answer "$OBS_GATE_TMP/arm_lit.eigs" head) +OBS_ARM_COMP=$(obs_gate_answer "$OBS_GATE_TMP/arm_comp.eigs" head) +if [ "$OBS_ARM_LIT" = "null" ] && [ "$OBS_ARM_COMP" = "null" ]; then + OBS_G24="agree" +elif [ -z "$OBS_ARM_LIT" ] || [ -z "$OBS_ARM_COMP" ]; then + OBS_G24="arm-produced-nothing" +else + OBS_G24="differs($OBS_ARM_LIT/$OBS_ARM_COMP)" +fi +check "a literal and a computed load path give the same temporal answer" "$OBS_G24" "agree" +# 27. NON-MONOTONE control: adding an observer read must not REMOVE history. +# The extra read opens the gate, which skips the eager pass — which used to +# un-arm the name the pass had armed. +printf 'z is 7.0\nlocal r is report of z\nx is 1.0\nx is 2.0\nx is 3.0\nlocal m is load_file of "%s/arm_mod.eigs"\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/arm_more.eigs" +OBS_ARM_MORE=$(obs_gate_answer "$OBS_GATE_TMP/arm_more.eigs" head) +if [ "$OBS_ARM_LIT" = "null" ] && [ "$OBS_ARM_MORE" = "null" ]; then + OBS_G25="agree" +elif [ -z "$OBS_ARM_LIT" ] || [ -z "$OBS_ARM_MORE" ]; then + OBS_G25="arm-produced-nothing" +else + OBS_G25="differs($OBS_ARM_LIT/$OBS_ARM_MORE)" +fi +check "asking the observer MORE does not yield LESS history" "$OBS_G25" "agree" +# 28-29. Two mechanisms that had NO check at all until a blind critic mutated +# them away with the section still 27/27 green. (The third, the memo that +# fixed a measured 7x DAG regression, is covered by tools/observer_gate_measure.sh +# rather than here: distinguishing it needs a timing ratio, and a timing +# assertion in this suite would be flaky on a loaded 2-core box. Recorded +# rather than faked.) +# 28. The speculative read is BOUNDED. Without the stat/S_ISREG guard a literal +# load in an UNCALLED function blocks forever on a FIFO — the compiler dies +# before vm_execute with nothing printed. +OBS_FIFO_DIR="$OBS_GATE_TMP/fifo" +mkdir -p "$OBS_FIFO_DIR" +if mkfifo "$OBS_FIFO_DIR/lazy.eigs" 2>/dev/null; then + printf 'define never_called() as:\n return load_file of "%s/lazy.eigs"\nprint of "alive"\n' "$OBS_FIFO_DIR" > "$OBS_GATE_TMP/fifo_par.eigs" + OBS_G26=$(obs_gate_answer "$OBS_GATE_TMP/fifo_par.eigs" tail 10) +else + OBS_G26="alive" # no mkfifo on this platform; not a failure of the runtime +fi +check "a speculative load of a FIFO does not block the compiler" "$OBS_G26" "alive" +# 29. A scanned module's compile error must not clobber the parent's recorded +# first error. Observable through --lint, which reads those fields: linting +# a file whose literal load target is itself broken must still report the +# PARENT's own diagnostic, not the module's. +printf 'break\n' > "$OBS_GATE_TMP/fe_mod.eigs" +printf 'load_file of "%s/fe_mod.eigs"\nx is\n' "$OBS_GATE_TMP" > "$OBS_GATE_TMP/fe_par.eigs" +# EVIDENCE REQUIRED, like its neighbours 16/17/26/27. `grep -c fe_mod = 0` +# is an absence assertion with nothing behind it: a --lint that prints +# NOTHING AT ALL scores 0 and passes, so the check could not tell "the +# module's error was suppressed" from "no diagnostic was produced". Found +# by a blind critic. The parent's own error is `Parse error line 2:` on +# fe_par, and requiring it is what makes the absence mean something. +OBS_FE_OUT=$($EIGS_BIN --lint "$OBS_GATE_TMP/fe_par.eigs" 2>&1) +if echo "$OBS_FE_OUT" | grep -q "fe_mod"; then + OBS_G27="module-error-leaked" +elif [ -z "$OBS_FE_OUT" ]; then + OBS_G27="lint-produced-nothing" +elif ! echo "$OBS_FE_OUT" | grep -q "fe_par"; then + OBS_G27="no-parent-diagnostic" +elif ! echo "$OBS_FE_OUT" | grep -q "line 2"; then + OBS_G27="parent-error-not-at-its-own-line" +else + OBS_G27="parent-only" +fi +check "a scanned module's error does not leak into the parent's diagnostics" "$OBS_G27" "parent-only" +# 30. The SPECULATIVE BUDGET is live. The eager pass compiles every literal +# module twice (once here, once for real), and its per-file ceiling bounded +# one read but not the TREE: 60 modules loaded from an UNCALLED function +# took 14.2s and 61 MB for a program whose only executed statement is a +# print. A cumulative per-thread budget caps that; once spent the pass +# declines and the gate stays conservatively OPEN. +# Asserted deterministically on the gate DECISION, not on wall time — a +# timing assertion here would be flaky on a loaded 2-core box. +OBS_BUD_DIR="$OBS_GATE_TMP/budget" +mkdir -p "$OBS_BUD_DIR" +i=0 +while [ $i -lt 24 ]; do + # ~59 KiB apiece: twenty-four of them (1.4 MiB) exceed the 1 MiB budget. + # Sized WITH the budget — see check 32 for why the budget is 1 MiB. + awk -v n=$i 'BEGIN{for(j=0;j<1400;j++) printf "define pad_%d_%d(a) as:\n return a + %d\n", n, j, j}' > "$OBS_BUD_DIR/m$i.eigs" + i=$((i+1)) +done +{ i=0; while [ $i -lt 24 ]; do printf 'load_file of "%s/m%d.eigs"\n' "$OBS_BUD_DIR" "$i"; i=$((i+1)); done; printf 'print of "done"\n'; } > "$OBS_GATE_TMP/budget.eigs" +printf 'load_file of "%s/m0.eigs"\nprint of "done"\n' "$OBS_BUD_DIR" > "$OBS_GATE_TMP/budget_ctl.eigs" +OBS_G28=$(EIGS_OBS_GATE_STATS=1 obs_tmo 60 $EIGS_BIN "$OBS_GATE_TMP/budget.eigs" 2>&1 >/dev/null | grep -q 'obs-gate: observed' && echo open || echo closed) +check "a literal-load tree past the speculative budget leaves the gate open" "$OBS_G28" "open" +# Verdict helper for the closed-expectation checks below (round 10). "closed" +# must be PROVEN, never inferred from silence: a planted abort() at 20 memo +# entries SIGABRT'd (rc=134, core dumped, nothing printed) on check 31's own +# fixture and the section ran 39/39 GREEN, because `grep -q observed || echo +# closed` scores any silent death as "closed" — stdout discarded, rc never +# read (mechanical-gates SS18: a crash rendered as silence). And memo +# populations >=17 entries exist ONLY in these fixtures, so a crash-at-scale +# bug in the memo cluster was invisible to the entire bar, ASan lane included +# (a sanitizer report contains no "observed" line either). Found by a blind +# critic, executed. "closed" now requires rc=0 AND the program's own marker on +# stdout AND >=1 `unobserved` line; every other outcome is its own verdict and +# fails the comparison loudly with the reason in the string. +obs_gate_closed_verdict() { + # $1 = program, $2 = required stdout marker, $3 = timeout seconds + local OGV_ERR OGV_OUT OGV_RC + OGV_ERR=$(mktemp) + OGV_OUT=$(EIGS_OBS_GATE_STATS=1 obs_tmo "${3:-60}" $EIGS_BIN "$1" 2>"$OGV_ERR"); OGV_RC=$? + if grep -q 'obs-gate: observed' "$OGV_ERR"; then rm -f "$OGV_ERR"; echo open; return; fi + if [ "$OGV_RC" -ne 0 ]; then rm -f "$OGV_ERR"; echo "died-rc$OGV_RC"; return; fi + if ! printf '%s' "$OGV_OUT" | grep -q "$2"; then rm -f "$OGV_ERR"; echo no-output; return; fi + if ! grep -q 'obs-gate: unobserved' "$OGV_ERR"; then rm -f "$OGV_ERR"; echo no-evidence; return; fi + rm -f "$OGV_ERR"; echo closed +} +# 31. The budget counts bytes READ, not bytes REFERENCED. Charging on the way +# past a memo HIT bills a shared module once per reference, so a DAG +# exhausts the budget on files it never opens and the gate opens spuriously +# — the defect the memo itself exists to prevent, re-made in the accounting. +# Fixture is a DIAMOND: many thin parents sharing ONE ~59 KiB leaf. Unique +# bytes stay far under the budget while referenced bytes run well over it, +# so the two accountings give OPPOSITE verdicts and this discriminates. +# Paired with its own control (the same leaf loaded once) so it cannot be +# satisfied by a build that closes the gate unconditionally (§15). +OBS_DAG_DIR="$OBS_GATE_TMP/diamond" +mkdir -p "$OBS_DAG_DIR" +awk 'BEGIN{for(j=0;j<1400;j++) printf "define leaf_%d(a) as:\n return a + %d\n", j, j}' > "$OBS_DAG_DIR/leaf.eigs" +i=0 +while [ $i -lt 24 ]; do + printf 'load_file of "%s/leaf.eigs"\ndefine p%s(a) as:\n return a\n' "$OBS_DAG_DIR" "$i" > "$OBS_DAG_DIR/m$i.eigs" + i=$((i+1)) +done +{ i=0; while [ $i -lt 24 ]; do printf 'load_file of "%s/m%d.eigs"\n' "$OBS_DAG_DIR" "$i"; i=$((i+1)); done; printf 'print of "ok"\n'; } > "$OBS_GATE_TMP/diamond.eigs" +printf 'load_file of "%s/m0.eigs"\nprint of "ok"\n' "$OBS_DAG_DIR" > "$OBS_GATE_TMP/diamond_one.eigs" +OBS_G31=$(obs_gate_closed_verdict "$OBS_GATE_TMP/diamond.eigs" ok 60) +OBS_G31C=$(obs_gate_closed_verdict "$OBS_GATE_TMP/diamond_one.eigs" ok 60) +check "a shared module is charged once, not once per reference" "$OBS_G31" "closed" +check "control: that leaf loaded once also closes" "$OBS_G31C" "closed" + +# 32. The speculative budget CLEARS THE REAL POPULATION. The budget is a +# magic number, and the first value tried (256 KiB) landed INSIDE the +# population it was supposed to sit above: lib/ui.eigs's transitive +# literal-load closure is 287 KiB across 19 units, so the repo's own +# largest module tree paid a quarter-megabyte of speculative compiling and +# then lost the gate anyway. This pins the budget to the population rather +# than to the number (§60) — if lib/ui grows past it, or someone lowers the +# budget, this fails and the value gets re-picked deliberately. +# (cwd here is src/, so the stdlib tree is ../lib — ui.eigs's own internal +# loads resolve through the exe-relative stdlib mechanism.) +printf 'load_file of "../lib/ui.eigs" +print of "ok" +' > "$OBS_GATE_TMP/uitree.eigs" +OBS_G32=$(obs_gate_closed_verdict "$OBS_GATE_TMP/uitree.eigs" ok 120) +check "the largest real module tree (lib/ui) still gates closed" "$OBS_G32" "closed" +# 34. EIGS_OBS_FORCE follows the tree's flag convention: non-empty and not +# starting "0" arms it. Read with a BARE getenv, `EIGS_OBS_FORCE=0` and +# `EIGS_OBS_FORCE=` forced the gate OPEN — a documented control doing +# exactly the opposite of what it says for anyone who spells "off" the +# obvious way, while EIGS_STRICT and EIGS_VERIFY_SELF both got it right. +# It also laundered the corpus oracle: tools/observer_gate_diff.sh recorded +# force=${EIGS_OBS_FORCE:-0}, collapsing "unset" and "=0", so a "gated" arm +# captured with EIGS_OBS_FORCE=0 ran the BASELINE and printed a provenance +# line byte-identical to an honest run (found by a blind critic, executed +# against a build with case OP_REPORT_NAME: deleted: honest 3 mismatches +# rc=1, laundered "415 byte-identical" rc=0). +# All four spellings asserted in ONE verdict so a half-fix cannot pass. +OBS_FORCE_V="" +for OBS_FV in unset 0 EMPTY 1; do + case "$OBS_FV" in + unset) OBS_FR=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$OBS_GATE_TMP/plain.eigs" 2>&1 >/dev/null | head -1) ;; + EMPTY) OBS_FR=$(EIGS_OBS_GATE_STATS=1 EIGS_OBS_FORCE= $EIGS_BIN "$OBS_GATE_TMP/plain.eigs" 2>&1 >/dev/null | head -1) ;; + *) OBS_FR=$(EIGS_OBS_GATE_STATS=1 EIGS_OBS_FORCE="$OBS_FV" $EIGS_BIN "$OBS_GATE_TMP/plain.eigs" 2>&1 >/dev/null | head -1) ;; + esac + case "$OBS_FR" in + *unobserved*) OBS_FORCE_V="$OBS_FORCE_V$OBS_FV=off " ;; + *observed*) OBS_FORCE_V="$OBS_FORCE_V$OBS_FV=on " ;; + *) OBS_FORCE_V="$OBS_FORCE_V$OBS_FV=? " ;; + esac +done +check "EIGS_OBS_FORCE: only a non-empty non-0 value arms it" "$OBS_FORCE_V" "unset=off 0=off EMPTY=off 1=on " + +# 35. WITNESS for the multithreaded precondition. The pass declines when the +# process may be running more than one thread, because it walks and mutates +# process-global compiler state (round 7: a heap-use-after-free in +# arm_set_has under concurrent ext_http routes). That whole mechanism could +# be DELETED with this section green — a blind critic mutated it away and +# scored 30/30 — so per §37/§64 it was a claim, not a guard. +# The observable is the gate DECISION, reachable without a sanitizer: a +# module loaded AFTER a spawn is compiled while multithreaded, so scanning +# its own literal load must bail and leave the unit `observed`. Verified +# discriminating against the critic's mutant: clean 2, mutant 0. +# Paired with a no-spawn control that must be 0, so a build that opens the +# gate unconditionally fails instead of passing (§15). +# RESIDUAL, stated exactly: this pins that the bail FIRES, not that the race +# it prevents is absent. The latter needs make asan-http plus two concurrent +# literal-load routes, which nothing in-tree runs. +OBS_MT_DIR="$OBS_GATE_TMP/mt" +mkdir -p "$OBS_MT_DIR" +printf 'print of "inner ok"\n' > "$OBS_MT_DIR/inner.eigs" +printf 'load_file of "%s/inner.eigs"\n' "$OBS_MT_DIR" > "$OBS_MT_DIR/mid.eigs" +printf 'define w() as:\n return 1\nlocal t is spawn of w\nlocal j is thread_join of t\nlocal m is load_file of "%s/mid.eigs"\nprint of "done"\n' "$OBS_MT_DIR" > "$OBS_GATE_TMP/mt.eigs" +printf 'local m is load_file of "%s/mid.eigs"\nprint of "done"\n' "$OBS_MT_DIR" > "$OBS_GATE_TMP/mt_ctl.eigs" +OBS_G35=$(EIGS_OBS_GATE_STATS=1 obs_tmo 60 $EIGS_BIN "$OBS_GATE_TMP/mt.eigs" 2>&1 >/dev/null | grep -c 'obs-gate: observed') +OBS_G35C=$(EIGS_OBS_GATE_STATS=1 obs_tmo 60 $EIGS_BIN "$OBS_GATE_TMP/mt_ctl.eigs" 2>&1 >/dev/null | grep -c 'obs-gate: observed') +check "a literal load after spawn hits the multithreaded bail" "$OBS_G35" "2" +check "control: the same load with no spawn does not" "$OBS_G35C" "0" +# 36. The SAME convention for EIGS_OBS_GATE_STATS. Found by sweeping every +# getenv site in src/ after fixing EIGS_OBS_FORCE, rather than assuming +# that defect was isolated: these two are documented as adjacent rows of +# one table in docs/OBSERVER.md and behaved DIFFERENTLY for "=0" — the +# stats flag printed its output. (The sweep also found the counter-example +# that stops this becoming a blanket rule: EIGS_TRACE=0 is not "tracing +# off", it is a tape written to a file named `0`. Presence-only is correct +# for value-carrying variables; it is wrong only for booleans.) +OBS_STATS_V="" +for OBS_SV in unset 0 EMPTY 1; do + case "$OBS_SV" in + unset) OBS_SR=$($EIGS_BIN "$OBS_GATE_TMP/plain.eigs" 2>&1 >/dev/null | grep -c 'obs-gate:') ;; + EMPTY) OBS_SR=$(EIGS_OBS_GATE_STATS= $EIGS_BIN "$OBS_GATE_TMP/plain.eigs" 2>&1 >/dev/null | grep -c 'obs-gate:') ;; + *) OBS_SR=$(EIGS_OBS_GATE_STATS="$OBS_SV" $EIGS_BIN "$OBS_GATE_TMP/plain.eigs" 2>&1 >/dev/null | grep -c 'obs-gate:') ;; + esac + if [ "$OBS_SR" = "0" ]; then OBS_STATS_V="$OBS_STATS_V$OBS_SV=quiet "; else OBS_STATS_V="$OBS_STATS_V$OBS_SV=prints "; fi +done +check "EIGS_OBS_GATE_STATS: only a non-empty non-0 value prints" "$OBS_STATS_V" "unset=quiet 0=quiet EMPTY=quiet 1=prints " +# 37. The memo keys on FILE IDENTITY, not on the path SPELLING. +# resolve_eigenscript_file does not canonicalize (try_resolve_path is +# access(2) plus a copy), so a string key gave ONE file N entries for N +# spellings and the pass read, compiled and CHARGED it N times. Executed on +# one 55,180-byte module written four NATURAL ways (relative, absolute, via +# a symlink, ./-prefixed): 4 speculative opens where the oracle is 1; with +# enough spellings the budget is spent on referenced rather than unique +# bytes and the gate flips open. Same double-charge defect as check 31, +# re-entering through the KEY instead of the ORDERING — and check 31 could +# not see it, because its diamond writes the identical literal in every +# parent. Found by a blind critic. Fixed by keying on (st_dev, st_ino). +# RESIDUAL: the st_dev half of the key is UNTESTED here — dropping the dev +# comparison survives this section on a single-filesystem box. Its failure +# direction is conservative only (a cross-device false HIT skips a scan, +# and a skipped scan's net is the load-time raise), so noted, not fixtured. +# This is the EXACT COMPLEMENT of check 30: same statement count, same +# referenced bytes, opposite verdict — 30 is N distinct FILES (must open), +# 37 is N distinct SPELLINGS of ONE file (must close). The pair +# discriminates identity from arithmetic. +OBS_SPELL_DIR="$OBS_GATE_TMP/spell" +mkdir -p "$OBS_SPELL_DIR" +awk 'BEGIN{for(j=0;j<1400;j++) printf "define sp_%d(a) as:\n return a + %d\n", j, j}' > "$OBS_SPELL_DIR/leaf.eigs" +{ i=0; while [ $i -lt 24 ]; do + OBS_PAD=""; k=0; while [ $k -lt $i ]; do OBS_PAD="$OBS_PAD./"; k=$((k+1)); done + printf 'load_file of "%s/%sleaf.eigs"\n' "$OBS_SPELL_DIR" "$OBS_PAD" + i=$((i+1)); done + printf 'print of "ok"\n'; } > "$OBS_GATE_TMP/spell.eigs" +OBS_G37=$(obs_gate_closed_verdict "$OBS_GATE_TMP/spell.eigs" ok 60) +check "24 spellings of ONE file are charged once (identity, not spelling)" "$OBS_G37" "closed" +# 38-39. The verdict helper's OWN controls (§64: a checker never shown to fail +# is decoration). obs_gate_closed_verdict is now the sole judge for four +# checks; gutted to `echo closed` it would pass all four and nothing above +# catches a gutted helper — the count pin sees deleted checks, not blind +# ones. Two planted inputs it MUST refuse to call closed: +# 38. A program that dies (here: raises, rc nonzero) is died-*, never closed. +printf 'raise of "boom"\n' > "$OBS_GATE_TMP/vh_die.eigs" +OBS_VH1=$(obs_gate_closed_verdict "$OBS_GATE_TMP/vh_die.eigs" ok 30) +case "$OBS_VH1" in died-*) OBS_VH1=died ;; esac +check "verdict helper: a dying program is never 'closed'" "$OBS_VH1" "died" +# 39. A program that exits 0 but never prints the required marker is +# no-output, never closed — the compiler finishing is not the program +# running. +printf 'x is 1\n' > "$OBS_GATE_TMP/vh_quiet.eigs" +OBS_VH2=$(obs_gate_closed_verdict "$OBS_GATE_TMP/vh_quiet.eigs" ok 30) +check "verdict helper: exit-0 without the marker is never 'closed'" "$OBS_VH2" "no-output" +# 40. IMPORT gating has a BEHAVIORAL witness. OP_IMPORT's reader-set membership +# had none anywhere in the bar: a one-line DEMOTION (case OP_IMPORT: moved +# to the return-0 group) was silent-wrong on a five-line program — the +# forced arm answers `diverging`, the mutant answered `equilibrium`, rc=0 — +# and passed [99u], the 416-program differential (no corpus program +# interrogates a binding assigned before its first import), AND the sync +# gate (whose walker then counted labels without binding them to their +# return group; fixed, with a demotion selftest row). Found by a blind +# critic, round 11. This is the #861 inversion the gate's header forbids, +# on the exact seam the code marks as "the expected next change" — import +# staying conservative is a CLAIM until something holds the line, and this +# check is that line: whoever narrows import's rule must arrive with +# machinery that keeps this answer right. +OBS_IMP_DIR="$OBS_GATE_TMP/imp" +mkdir -p "$OBS_IMP_DIR/lib" +printf 'print of (report of x)\nverdict is 1.0\n' > "$OBS_IMP_DIR/lib/probe.eigs" +printf 'x is 1.0\nfor i in range of 40:\n x is x * 2.0\nimport probe\n' > "$OBS_IMP_DIR/host.eigs" +# $EIGS_BIN is RELATIVE to the runner's cwd (src/), so it must be +# absolutized before the cd — a relative binary under cd was already a +# recorded probe trap this session, and it bit again right here on the +# check's first run. +OBS_EIGS_ABS=$(cd "$(dirname "$EIGS_BIN")" && pwd)/$(basename "$EIGS_BIN") +OBS_G40_OUT=$(cd "$OBS_IMP_DIR" && obs_tmo 60 "$OBS_EIGS_ABS" host.eigs 2>&1); OBS_G40_RC=$? +if [ "$OBS_G40_RC" -ne 0 ]; then OBS_G40="died-rc$OBS_G40_RC"; else OBS_G40=$(printf '%s\n' "$OBS_G40_OUT" | head -1); fi +check "an imported module sees the host's pre-import history (diverging)" "$OBS_G40" "diverging" +# 41. Check 30's own control (its open-expectation was the vacuity sibling of +# the round-10 hole): the gate opened on a PARSE ERROR in a rotted fixture +# exactly as it opens on a genuine budget exhaustion, so garbage awk +# modules kept check 30 green while its fixture population tested nothing. +# One module from the SAME population loaded alone is under budget and must +# PROVE closed — if the generator rots, this goes red first. +OBS_G41=$(obs_gate_closed_verdict "$OBS_GATE_TMP/budget_ctl.eigs" done 60) +check "control: one budget-fixture module alone proves closed" "$OBS_G41" "closed" +# 42m. META: no NEW rc-blind answer capture may enter this section. The class +# "prints the right answer, then crashes, scores PASS" bit three rounds +# RUNNING (round 10 closed-verdicts, round 11 measure.sh, round 12 check +# 40 — written in the same commit as the round-11 fix). Prose did not stop +# it; per the standing hooks-beat-advice rule a thrice-bitten mistake gets +# a write-site gate. This greps THIS FILE's [99u] region for bare +# `$EIGS_BIN ... | head/tail -1)` captures; each existing one is WAIVED by +# count with its reason, and the count is pinned so a new unrouted capture +# — or a silently vanished waived one — both go red. Waived (4): +# 1x OBS_G19 — module rewritten between reads RAISES: nonzero rc and +# the raise text ARE the expectation; a crash reads red. +# 3x OBS_FR — check 34 captures the stderr STATS line, not a program +# answer; a crash yields no stats line, an empty verdict, +# and the composite comparison goes red on its own. +# Everything else must route through obs_gate_answer / the inline rc +# pattern / obs_gate_closed_verdict. +# ANCHORED ON THE CAPTURE SHAPE, NOT THE BINARY'S NAME. The first version +# grepped for `$EIGS_BIN ... | head -1)` — and the round-12 bug that +# motivated this gate was spelled `"$OBS_EIGS_ABS" ... | head -1)`, so the +# gate could not catch the very defect it was built for, and the natural +# next accidental spelling (copying check 40's cd scaffolding, or a quoted +# "$EIGS_BIN", or `head -n1`) evaded identically. Found by a blind critic +# (round 13), plant-verified in both directions. The shape that matters is +# "merged-stderr program output piped straight into a first/last-line +# pick": that is what makes a capture rc-blind, whatever the binary +# variable is called. +# RESIDUAL, stated exactly (§45 — full closure is impossible): `2>&1 | +# sed -n 1p`, `| awk NR==1`, `|& head -1`, and a pipeline with a second +# filter stage all evade this regex. The gate targets the two ACCIDENTAL +# spellings that have actually occurred (2>&1 and 2>/dev/null into a +# head/tail first/last-line pick); an author actively dodging it is out +# of scope — review is the layer for that. +OBS_META_N=$(sed -n '/^# \[99u\]/,/^OBS_GATE_RAN=/p' "$TESTS_DIR/run_all_tests.sh" \ + | grep -cE '(2>&1|2>/dev/null) *(>/dev/null *)?\| *(head|tail) +(-n *)?-?1\)') +check "no new rc-blind answer capture in [99u] (4 pinned waivers)" "$OBS_META_N" "4" +# 43. A FATAL ERROR inside the muted window must still reach stderr. The +# eager pass mutes fd 2 around its speculative compile; x_oom and +# chunk_verify_self_check call eigs_obs_unmute_for_fatal() before their +# dying message so an OOM mid-scan is not a SILENT death. That mechanism +# had ZERO witnesses — with the unmute deleted, an OOM inside the window +# died rc=134 with 0 bytes on stderr and nothing in the suite went red +# (found by a blind critic, round 13; plant-verified both directions: +# HEAD prints `out of memory`, the mutant prints nothing and the only +# stderr is timeout(1)'s own core-dump line — which is why the assertion +# is the MESSAGE, not stderr non-emptiness). +# The fixture is ~518 KB (under the 1 MiB budget, so the eager pass DOES +# read it) behind a literal load, run under ulimit -v 60000. +# SKIPS on sanitizer builds: ASan's allocator aborts inside the window on +# BOTH arms (its report goes to the muted fd), so the probe cannot +# discriminate there; the release lane carries this witness. +if ASAN_OPTIONS=help=1 $EIGS_BIN --version 2>&1 | grep -q 'AddressSanitizer'; then + OBS_G43="oom-message-reaches-stderr" # sanitizer build: witness carried by the release lane + OBS_G43_NOTE=" (SKIP: sanitizer build)" +elif ! ( ulimit -v 60000 2>/dev/null; printf 's is "xxxxxxxxxxxxxxxx"\nfor i in range of 23:\n s is s + s\nprint of "grew"\n' > "$OBS_GATE_TMP/rl_probe.eigs"; obs_tmo 30 $EIGS_BIN "$OBS_GATE_TMP/rl_probe.eigs" >/dev/null 2>&1 ); then + # rlimit BITES here (the 128 MB doubling probe died under it): the real + # arm below is meaningful. Fall through by doing nothing in this branch — + # bash needs a statement, so: + OBS_G43_RLIMIT=bites + OBS_G43_NOTE="" + OBS_OOM_DIR="$OBS_GATE_TMP/oom" + mkdir -p "$OBS_OOM_DIR" + awk 'BEGIN{for(j=0;j<12000;j++) printf "define oom_%d(a) as:\n return a + %d\n", j, j}' > "$OBS_OOM_DIR/big.eigs" + printf 'load_file of "%s/big.eigs"\nprint of "ok"\n' "$OBS_OOM_DIR" > "$OBS_GATE_TMP/oom_par.eigs" + OBS_OOM_ERR=$( (ulimit -v 60000; obs_tmo 30 $EIGS_BIN "$OBS_GATE_TMP/oom_par.eigs") 2>&1 >/dev/null ); OBS_OOM_RC=$? + if [ "$OBS_OOM_RC" -eq 0 ]; then + OBS_G43="ran-clean-probe-vacuous" # rlimit PROVABLY bites here, so a clean run means the fixture rotted — loud fail is right + elif printf '%s' "$OBS_OOM_ERR" | grep -q 'out of memory'; then + OBS_G43="oom-message-reaches-stderr" + else + OBS_G43="died-silently-rc$OBS_OOM_RC" + fi +else + # ulimit -v (RLIMIT_AS) is not enforced on this platform — macOS most + # prominently — so the witness CANNOT discriminate here and a permanent + # red would train people to ignore the section (§13). A visible SKIP, + # exactly like the sanitizer arm above; the Linux release lane carries + # this witness. Found by a blind critic (round 14): thirteen all-Linux + # rounds never ran the four macOS CI legs, two of which are the release + # workflow (§46). + OBS_G43="oom-message-reaches-stderr" + OBS_G43_NOTE=" (SKIP: rlimit not enforced on this platform)" +fi +check "a fatal OOM inside the muted window still reaches stderr$OBS_G43_NOTE" "$OBS_G43" "oom-message-reaches-stderr" +# The count pin itself (§37). Also the vacuity floor: a section that ran zero +# checks is not a section that passed. +TOTAL=$((TOTAL + 1)) +OBS_GATE_RAN=$((TOTAL - 1 - OBS_GATE_TOTAL_BEFORE)) +if [ "$OBS_GATE_RAN" -eq "$OBS_GATE_EXPECTED_CHECKS" ]; then + PASS=$((PASS + 1)); echo " PASS: section [99u] ran all $OBS_GATE_EXPECTED_CHECKS pinned checks" +else + FAIL=$((FAIL + 1)) + echo " FAIL: section [99u] ran $OBS_GATE_RAN checks, expected $OBS_GATE_EXPECTED_CHECKS (a check was added or deleted)" +fi +rm -rf "$OBS_GATE_TMP" +echo "" + # [100] Worker-thread JIT lifetime (#296). A shared chunk that gets hot and # JIT-compiles ON a worker must not leave chunk->jit_code dangling when that # worker exits (its per-thread JIT code arena is munmap'd at detach). Crashed diff --git a/tests/test_sigusr1_dump.sh b/tests/test_sigusr1_dump.sh index da600db0..1a395507 100644 --- a/tests/test_sigusr1_dump.sh +++ b/tests/test_sigusr1_dump.sh @@ -99,6 +99,31 @@ else fail "sigusr1: no dump on stderr after SIGUSR1" fi +# #915: if the observer gate is closed for this fixture (it has no observer +# surface, so it is), the FIRST dump has no trajectories to show — nothing was +# recorded before the signal. That dump must SAY so rather than print empty +# bands that read as "everything settled", and it arms observation from that +# point. A SIGUSR1 dump exists to inspect an already-running process, which is +# the one case where re-running under EIGS_OBS_FORCE=1 is not an option, so the +# capability is preserved across two signals instead of being dropped. +if grep -q "observer gate CLOSED" "$ERR1"; then + pass "sigusr1: gated first dump declares absence of data (not equilibrium)" + # Second signal: observation was armed by the first, so this one carries + # real trajectories. The row assertions below grep the whole file and so + # match this dump. + DUMPS_BEFORE=$(grep -c '^# end dump$' "$ERR1") + kill -USR1 "$PID" 2>/dev/null || true + for _ in $(seq 1 600); do + [ "$(grep -c '^# end dump$' "$ERR1")" -gt "$DUMPS_BEFORE" ] && break + sleep 0.1 + done + if [ "$(grep -c '^# end dump$' "$ERR1")" -gt "$DUMPS_BEFORE" ]; then + pass "sigusr1: second dump arrived after the gate armed" + else + fail "sigusr1: no second dump after the gate armed" + fi +fi + # Row shape: name | value | when= | entropy= | dH= | word. # when must have 4+ digits (READY is printed at assignment 5000 of # step_count, the signal lands strictly after) — a settled long-lived diff --git a/tools/obs_reader_sync_check.sh b/tools/obs_reader_sync_check.sh new file mode 100755 index 00000000..3dbaa719 --- /dev/null +++ b/tools/obs_reader_sync_check.sh @@ -0,0 +1,446 @@ +#!/bin/bash +# Observer-reader sync gate (#915 / #972). +# +# THE RULE THAT LIVES IN TWO HOMES +# +# "Which opcodes read observer state" is one rule with two implementations: +# +# 1. src/vm.h — the /*obs:READS*/ markers. The AUTHORITATIVE home: +# every opcode carries a hand-reviewed verdict, and +# tools/obs_marker_check.sh proves every opcode has one. +# 2. src/chunk.c — the `case OP_...:` arms of opcode_is_observer_reader(), +# the CONSUMER: the opcode half of the compile-time scan +# that decides whether a program may skip observer +# bookkeeping. (It was split out of chunk_reads_observer +# so the vm_run_bytecode / sandbox_run descriptor sites +# could run it against a chunk that never met the +# compiler. When it moved, this gate's extraction anchor +# found ZERO opcodes and SWITCH_FLOOR caught it — which +# is precisely why the floor is absolute and not a ratio.) +# +# Nothing tied them together, and they had ALREADY diverged when this gate was +# written: the C switch carries OP_INTERROGATE, which #1024's hand-reading +# marked obs:NONE. That divergence is harmless in its direction (extra readers +# cost gates, not answers) — but the OPPOSITE divergence is catastrophic and +# silent. A marker says READS, the switch does not list it, and a program using +# only that opcode gates itself off, reads slots nobody updated, and answers +# `equilibrium` forever with no crash and nothing to fail on. +# +# mechanical-gates §26: when one rule must live in two homes, GATE the sync — +# never hand-sync. This is that gate. It reads BOTH homes and asserts the +# relationship between them, rather than asserting either against a third list +# (§1: a list validated by a sibling list measures the list, not the tree). +# +# THE RELATIONSHIP IS AN IMPLICATION, NOT AN EQUALITY (§39) +# +# marker says READS => the C switch must list it [hard, silent-wrong] +# C switch lists it => marker says READS *or* it is a PINNED exemption +# +# The second direction is not equality because the switch is deliberately +# allowed to be MORE conservative. Each such entry is a waiver (§3): named, +# reasoned, and required to still be present — an exemption that stops firing +# must go red, not quiet, because it means the thing it waived changed shape. +# +# WHAT THIS GATE DOES NOT DO +# +# * It does not check that a marker's verdict is CORRECT. That rests on the +# handler having been read; obs_marker_check.sh owns "was a verdict +# recorded", and the reading for the initial 94 is on #972. +# * It does not see readers reached through the CONSTANT POOL (an aliased +# `local r is report` emits no reader opcode at all). That population is +# OBS_BUILTINS in the same function and is checked by suite section [99u] +# check 4, behaviourally. (It said [99n] — that is the VM operand-width +# comment-drift gate, #958. A pointer in a waiver is load-bearing, §6.) +# * The stronger design is to GENERATE the C set from the markers, the way +# tools/gen_lsp_builtin_index.sh generates its header — then the rule has +# one home and this gate is unnecessary. Filed rather than implied. +# +# Usage: tools/obs_reader_sync_check.sh [--selftest] +# Exit 0 = the two homes agree modulo the pinned exemptions. + +set -u +cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1 + +CHUNK_SRC="${CHUNK_SRC:-src/chunk.c}" +MARKER_TOOL="${MARKER_TOOL:-tools/obs_marker_check.sh}" + +# Floors, not exact counts (§5/§43). A DERIVED population can shrink silently; +# `-z` is an emptiness test and only catches losing ALL of them. These move only +# when coverage is REMOVED, which is always a review event. +# +# BOTH FLOORS ARE REDUNDANT WITH THE DIRECTION CHECKS — measured, not assumed +# (§42: a mutation that survives may mean redundant rather than untested, and +# the way to tell is differential execution). A blind critic zeroed each with +# everything still green and could not tell redundant from decorative; these are +# the two runs that settle it: +# +# derivation broken (anchor -> a function that does not exist), floors zeroed: +# still RED — "OP_INTERROGATE_NAMED is marked obs:READS ... but is NOT +# listed", i.e. the HARD direction catches an empty switch set on its own. +# marker tool emits nothing, floors zeroed: +# still RED — "chunk_reads_observer lists OP_INTERROGATE_NAMED, which is not +# obs:READS and is not a pinned exemption", i.e. the SOFT direction catches +# an empty marker set on its own. +# +# They are kept because they name the failure precisely (a floor says "the +# derivation returned 0", the direction check says "17 opcodes diverged", and +# the first is the one that sends you to the right file — that is exactly how +# the extraction anchor breaking when chunk_has_reader_opcode was split out got +# diagnosed in one read). No self-test row asserts them, deliberately: a row +# that cannot fail for its own reason is decoration. +READS_FLOOR="${READS_FLOOR:-15}" +SWITCH_FLOOR="${SWITCH_FLOOR:-17}" + +# The pinned exemptions: opcodes the C switch treats as readers although the +# markers do not. Each must be PRESENT in the switch and ABSENT from the marker +# READS set — if either stops holding, this gate fails and the entry is re-read. +# +# OP_INTERROGATE marked obs:NONE by #1024: the bare `when/where/why/how` on a +# VALUE operand returns the literal constants 0,0,0,1, because +# since #262 Step E observer state is binding-keyed and a bare +# value has no binding. Kept in the switch anyway: it costs a +# program its gate and being wrong in that direction is safe. +# OP_IMPORT not an observer reader at all. It is in the switch because +# it compiles a NEW unit at runtime whose own scan arrives too +# late to have observed this unit's earlier assignments — the +# ordering hazard, not a read. Its literal-target sibling +# `load_file` is handled by chunk_scan_static_loads instead; +# import's resolution is project-first-then-stdlib against a +# per-module dir and replicating it would be a second resolver +# free to drift (#737), so it stays conservative. +EXEMPT="OP_INTERROGATE OP_IMPORT" + +# ---- extraction ------------------------------------------------------------- +# +# awk portability (§63): no dynamic regex, no 3-argument match(), no gensub(). +# A dialect that silently fails a dynamic regex still prints a plausible count +# and turns every comparison false, which looks exactly like a clean pass. +# +# Block comments are stripped with real state (§54/§57), not a per-line regex: +# this switch's own comments discuss OP_LOOP_CAP_CHECK and OP_CALL by name, and +# a scanner that reads prose ABOUT its subject as its subject is the failure +# that gets worse the better the code is documented. +switch_reader_ops() { + awk -v fn="int opcode_is_observer_reader(" ' + index($0, fn) > 0 { infn = 1 } + infn == 0 { next } + { + line = $0; out = ""; i = 1; n = length(line) + while (i <= n) { + c = substr(line, i, 2) + if (incomment) { + if (c == "*/") { incomment = 0; i += 2 } else { i += 1 } + continue + } + if (c == "/*") { incomment = 1; i += 2; continue } + if (c == "//") { break } + out = out substr(line, i, 1); i += 1 + } + # Trim leading whitespace off the surviving CODE. + sub(/^[ \t]+/, "", out) + # BIND each label to its return group, do not merely collect it. + # Collecting `case OP_*:` labels alone made this walker blind to a + # DEMOTION: `case OP_IMPORT:` moved from the return-1 group to the + # return-0 group kept the label count at 17 and this gate printed a + # PASS byte-identical to the healthy run — while the runtime was + # silent-wrong on a 5-line import program (equilibrium where the + # forced arm says diverging). Deletion moved the count and failed; + # demotion did not. Found by a blind critic (round 11), executed. + # So: labels accumulate as PENDING, and only a `return 1` in the + # same fall-through run emits them; `return 0` (or default:) drops + # them. A label is a reader because of where it FALLS, not because + # it exists. + if (index(out, "case OP_") == 1) { + rest = substr(out, 6) # drop "case " + p = index(rest, ":") + if (p > 0) { pending[++npend] = substr(rest, 1, p - 1) } + } + if (index(out, "return 1") > 0) { + for (k = 1; k <= npend; k++) print pending[k] + npend = 0 + } + if (index(out, "return 0") > 0 || index(out, "default:") == 1) { + npend = 0 + } + } + /^}/ { if (infn) infn = 0 } + ' "$CHUNK_SRC" | sort -u +} + + +# ---- --selftest ------------------------------------------------------------- +# +# WHY THIS EXISTS. The first version of this file advertised --selftest in its +# Usage block and had no such branch; `--selftest` was silently ignored and the +# gate printed PASS. The "5-mutation train, all caught" behind it was a one-off +# run banked nowhere, so nothing re-ran it — and a blind critic then showed that +# EVERY substantive assertion could be gutted while the gate stayed green on a +# tree carrying the exact fault that assertion exists to catch. The count pin +# could not see it, because gutting a body leaves the number of loops unchanged +# (§45). A gate nobody mutation-tests is a gate nobody has shown to work (§19). +# +# TWO TRAINS, because they answer different questions: +# +# FAULTS — plant a real divergence in COPIES of the two homes and require the +# gate to go red. Proves the gate can fail at all. +# WITNESS — plant the same fault AND gut one assertion's body in a copy of the +# gate. The mutant must go GREEN, which proves that assertion is the +# SOLE witness for that fault. If it stays red, some neighbouring +# guard is covering for it (§21) and the assertion is untested. +# +# §66: the mutants run with ABSOLUTE CHUNK_SRC / MARKER_TOOL, so this file's +# opening `cd` is inert and a copy placed anywhere still resolves its inputs. +# The uniform-failure signature (every fixture failing identically) means the +# harness broke, not the artifact. +run_selftest() { + local tmp; tmp=$(mktemp -d); trap 'rm -rf "$tmp"' RETURN + local pass=0 fail=0 + # Resolve the gate under test ONCE, from the path this process was actually + # started with (§32). The previous form built "$PWD/${BASH_SOURCE[0]#./}", + # which is garbage when invoked by an ABSOLUTE path — the way the suite + # invokes it — and then silently fell back to the TRACKED file. That is a + # different artifact from the one running, so a copied mutant could never + # self-test itself and the substitution was invisible. + local real_gate; real_gate=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}") + local real_vm="$PWD/src/vm.h" real_chunk="$PWD/$CHUNK_SRC" + [ -f "$real_gate" ] || { echo "SELFTEST BROKEN: cannot resolve the gate under test"; return 1; } + + # Portable in-place sed, VERIFIED. Two defects in one helper (round 15): + # (a) suffix-less `sed -i` is GNU-only — BSD sed parses `-i 'script' file` + # as -i , errors, and leaves the file + # UNTOUCHED, so on the four macOS CI legs every mutation was inert: + # faults never planted, the selftest exited 1 (6 passed, 5 failed), + # and [99u] check 25 read `0 rows, floor 11`. Loud, but it blocks the + # release matrix. (`sedi ''` is not portable the OTHER way — GNU + # reads '' as the script — hence write-to-temp + mv.) + # (b) an INERT edit made the WITNESS rows pass vacuously: unplanted fault + # + ungutted gate both exit 0, which is the row's expected value. So + # this helper cmp-verifies the edit CHANGED the file and returns 1 + # otherwise — an inert plant now surfaces as a loud MISS on its row + # (the fault row expects rc=1 and gets the healthy 0), on every + # platform, instead of silently testing nothing. + sedi() { # sedi 'script' file + local SEDI_T + SEDI_T=$(mktemp) + sed -e "$1" "$2" > "$SEDI_T" || { rm -f "$SEDI_T"; return 1; } + if cmp -s "$SEDI_T" "$2"; then rm -f "$SEDI_T"; return 1; fi + mv "$SEDI_T" "$2" + } + # A marker tool bound to a MUTABLE copy of vm.h. + mk_marker() { + printf '#!/bin/bash\nVM_HEADER=%s exec %s/tools/obs_marker_check.sh "$@"\n' "$1" "$PWD" > "$tmp/marker.sh" + chmod +x "$tmp/marker.sh" + } + # run_gate -> prints rc + run_gate() { + mk_marker "$3" + CHUNK_SRC="$2" MARKER_TOOL="$tmp/marker.sh" bash "$1" >/dev/null 2>&1 + echo $? + } + say() { # name expected got + if [ "$2" = "$3" ]; then pass=$((pass+1)); echo " ok $1 (rc=$3)" + else fail=$((fail+1)); echo " MISS $1 — expected rc=$2, got rc=$3"; fi + } + + # Fault fixtures, each in its own pair of copies. + plant() { # name -> writes $tmp/.chunk.c and $tmp/.vm.h + cp "$real_chunk" "$tmp/$1.chunk.c"; cp "$real_vm" "$tmp/$1.vm.h" + case "$1" in + clean) ;; + E) sedi 's|OP_ADD, /\*obs:NONE\*/|OP_ADD, /*obs:READS*/|' "$tmp/$1.vm.h" ;; # NEW reader never added to the switch + # Anchored on the reader-set function's OWN indentation. When that + # function was extracted and re-indented, this sed silently stopped + # matching and the row reported MISS ("could not plant") rather than a + # false pass — which is the behaviour a mutation harness must have + # (§67: a self-test mutation anchored on line SHAPE breaks when the + # shape moves; make it fail loud, and verify the plant landed). + # B: stray opcode. Python, not sed: the replacement embeds a + # newline, which BSD sed rejects even with the -i spelling fixed. + # Same contract as fault G — an unmatched anchor leaves the file + # unchanged and the row MISSes loudly. + B) python3 - "$tmp/$1.chunk.c" <<'PYEOF' +import sys +p = sys.argv[1]; src = open(p).read() +old = " case OP_PREDICATE:\n" +if old in src: + open(p, "w").write(src.replace(old, " case OP_ADD:\n" + old, 1)) +PYEOF + ;; + D) : ;; # planted in the GATE's EXEMPT list, not the tree — see gut() + F) sedi 's|OP_INTERROGATE, /\*obs:NONE\*/|OP_INTERROGATE, /*obs:READS*/|' "$tmp/$1.vm.h" ;; # exemption spent + # G: DEMOTION — the round-11 fault verbatim. A case label moved from + # the return-1 run into the default/return-0 group keeps the LABEL + # COUNT unchanged, so the pre-round-11 walker (which collected labels + # without binding them to their return) passed byte-identically while + # the runtime was silent-wrong on a 5-line import program. This row + # exists so that regression cannot come back: it fails to plant + # (loud MISS, §67) if the switch's tail shape moves, and it must be + # caught by the direction check, with no floor movement to hide + # behind — the demoted label still appears in the file. + G) python3 - "$tmp/$1.chunk.c" <<'PYEOF' +import sys +p = sys.argv[1]; src = open(p).read() +old = " case OP_TRAJECTORY_NAME:\n" +tail = " return 1;\n default: return 0;" +if old not in src or tail not in src: + sys.exit(0) # plant fails -> row reports MISS loudly +src = src.replace(old, "", 1) +src = src.replace(tail, " return 1;\n case OP_TRAJECTORY_NAME:\n default: return 0;", 1) +open(p, "w").write(src) +PYEOF + ;; + esac + } + # Assertion gutters, keyed to the fault each one is the sole witness for. + # A gate copy can also carry the FAULT itself: fault D is an exemption that + # no longer fires, which is a property of the EXEMPT list, not of the tree. + # Planting it here instead of deleting a `case` keeps SWITCH_N at 17 so no + # floor moves and check 4a is the only thing that can see it. + gate_with_fault() { # name -> writes $tmp/.faultgate.sh + cp "$real_gate" "$tmp/$1.faultgate.sh" + case "$1" in + D) sedi 's|^EXEMPT="OP_INTERROGATE OP_IMPORT"|EXEMPT="OP_INTERROGATE OP_IMPORT OP_ADD"|' "$tmp/$1.faultgate.sh" ;; + esac + } + gut() { # name -> writes $tmp/.gate.sh + cp "$real_gate" "$tmp/$1.gate.sh" + case "$1" in + D) sedi 's|^EXEMPT="OP_INTERROGATE OP_IMPORT"|EXEMPT="OP_INTERROGATE OP_IMPORT OP_ADD"|' "$tmp/$1.gate.sh" ;; + esac + case "$1" in + E) sedi 's|\*) note_fail "opcode \$op is marked obs:READS.*|*) : ;;|' "$tmp/$1.gate.sh" ;; + B) sedi 's|\*) note_fail "chunk_reads_observer lists \$op, which is not.*|*) : ;;|' "$tmp/$1.gate.sh" ;; + D) sedi 's|\*) note_fail "pinned exemption \$op is no longer listed.*|*) : ;;|' "$tmp/$1.gate.sh" ;; + F) sedi 's|\*" \$op "\*) note_fail "pinned exemption \$op is now marked.*|*" $op "*) : ;;|' "$tmp/$1.gate.sh" ;; + esac + # The count pin would catch a gutted body only by accident; neutralise it + # so this train measures the ASSERTION, not the pin (§21: know which + # check fired). The pin is exercised by its own row below. + sedi 's|^if \[ "\$CHECKS" -ne "\$EXPECTED_CHECKS" \]; then|if false; then|' "$tmp/$1.gate.sh" + # Floors are genuinely redundant with the direction checks for some + # faults; neutralise them too so a survivor means "no witness", not + # "a floor covered for it". NOTE this is why fault E is planted by + # ADDING a marker rather than deleting a case: a deletion also trips + # SWITCH_FLOOR, so the FAULTS row passed for the wrong reason (§21) and + # the WITNESS claim held only in a configuration that is not production. + sedi 's|^READS_FLOOR=.*|READS_FLOOR=0|; s|^SWITCH_FLOOR=.*|SWITCH_FLOOR=0|' "$tmp/$1.gate.sh" + } + + echo "== control: unmutated copies must PASS ==" + plant clean; say "clean copies" 0 "$(run_gate "$real_gate" "$tmp/clean.chunk.c" "$tmp/clean.vm.h")" + + echo "== FAULTS: the real gate must go red on each ==" + for f in E B F G; do + plant "$f" + say "fault $f caught by the real gate" 1 "$(run_gate "$real_gate" "$tmp/$f.chunk.c" "$tmp/$f.vm.h")" + done + plant D; gate_with_fault D + say "fault D caught by the real gate" 1 "$(run_gate "$tmp/D.faultgate.sh" "$tmp/D.chunk.c" "$tmp/D.vm.h")" + + echo "== WITNESS: gutting the sole witness must let its fault SURVIVE ==" + for f in E B D F; do + gut "$f" + say "assertion $f is the sole witness" 0 "$(run_gate "$tmp/$f.gate.sh" "$tmp/$f.chunk.c" "$tmp/$f.vm.h")" + done + + echo "== the count pin catches a DELETED check ==" + cp "$real_gate" "$tmp/pin.gate.sh" + sedi 's|^ ck$||' "$tmp/pin.gate.sh" + plant clean + say "deleting a ck() trips the comparison pin" 1 "$(run_gate "$tmp/pin.gate.sh" "$tmp/clean.chunk.c" "$tmp/clean.vm.h")" + + echo "SELFTEST: $pass passed, $fail failed" + [ "$fail" -eq 0 ] || return 1 + return 0 +} + +if [ "${1:-}" = "--selftest" ]; then + run_selftest + exit $? +fi + +FAILED=0 +CHECKS=0 +# CHECKS counts COMPARISONS PERFORMED, not loops entered. Counting per-loop is +# invariant under the mutation that matters — gutting a loop's body leaves the +# count untouched while the gate stops checking anything (mechanical-gates §45: +# ask what change keeps the number constant while destroying what it counts). +# The count pin is still not sufficient on its own; --selftest is what proves +# each assertion body is load-bearing. +note_fail() { FAILED=$((FAILED + 1)); echo "FAIL: $*"; } +ck() { CHECKS=$((CHECKS + 1)); } + +# Space-separated, deliberately: the membership tests below are `case` globs of +# the form *" $op "*, which need a SPACE on both sides. Leaving these +# newline-separated makes every membership test false — and the symptom is every +# opcode failing BOTH directions at once, which is the harness-bug signature +# (§66), not a divergence. That is exactly how this gate first ran. +MARKER_READS=" $($MARKER_TOOL --reads 2>/dev/null | sort -u | tr '\n' ' ')" +SWITCH_OPS=" $(switch_reader_ops | tr '\n' ' ')" + +MARKER_N=$(printf '%s' "$MARKER_READS" | tr ' ' '\n' | grep -c '^OP_' || true) +SWITCH_N=$(printf '%s' "$SWITCH_OPS" | tr ' ' '\n' | grep -c '^OP_' || true) + +# 1. Non-vacuity. A derivation that silently returns nothing is the failure this +# whole gate exists to prevent, one level up: an empty marker set classifies +# every program as observer-free. +ck +if [ "$MARKER_N" -lt "$READS_FLOOR" ]; then + note_fail "marker READS set is $MARKER_N, floor is $READS_FLOOR" +fi +ck +if [ "$SWITCH_N" -lt "$SWITCH_FLOOR" ]; then + note_fail "chunk_reads_observer lists $SWITCH_N reader opcodes, floor is $SWITCH_FLOOR" +fi + +# 2. THE HARD DIRECTION. A marker-declared reader missing from the switch is the +# silent-wrong case: gated off, then reading slots nobody updated. +for op in $MARKER_READS; do + ck + case " $SWITCH_OPS " in + *" $op "*) ;; + *) note_fail "opcode $op is marked obs:READS in src/vm.h but is NOT listed in chunk_reads_observer" ;; + esac +done + +# 3. THE SOFT DIRECTION, modulo pinned exemptions. +for op in $SWITCH_OPS; do + ck + case " $MARKER_READS " in *" $op "*) continue ;; esac + case " $EXEMPT " in + *" $op "*) ;; + *) note_fail "chunk_reads_observer lists $op, which is not obs:READS and is not a pinned exemption" ;; + esac +done + +# 4. Every exemption must still FIRE (§3). An unused waiver means the thing it +# waived changed shape — exactly when a stale waiver starts covering +# something nobody agreed to. +for op in $EXEMPT; do + ck + case " $SWITCH_OPS " in + *" $op "*) ;; + *) note_fail "pinned exemption $op is no longer listed in chunk_reads_observer — remove the exemption" ;; + esac + ck + case " $MARKER_READS " in + *" $op "*) note_fail "pinned exemption $op is now marked obs:READS — the exemption is spent, remove it" ;; + *) ;; + esac +done + +# 5. Floor the comparisons actually performed (§37/§43). Every marker reader is +# compared once, every switch entry once, plus two rows per exemption and the +# two non-vacuity checks. A gate that examined fewer items than that has +# stopped checking something, whatever it prints. +EXPECTED_CHECKS=$((2 + MARKER_N + SWITCH_N + 2 * $(printf '%s\n' $EXEMPT | grep -c .))) +if [ "$CHECKS" -ne "$EXPECTED_CHECKS" ]; then + echo "FAIL: performed $CHECKS comparisons, expected $EXPECTED_CHECKS (a check was added, deleted or gutted)" + FAILED=$((FAILED + 1)) +fi + +if [ "$FAILED" -ne 0 ]; then + echo "RESULT: FAIL — the observer-reader rule has diverged between its two homes" + exit 1 +fi +echo "RESULT: PASS — $MARKER_N marker readers, $SWITCH_N switch entries, $(printf '%s\n' $EXEMPT | grep -c .) pinned exemptions, $CHECKS assertions" diff --git a/tools/observer_gate_diff.sh b/tools/observer_gate_diff.sh new file mode 100755 index 00000000..5c053b95 --- /dev/null +++ b/tools/observer_gate_diff.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +# observer_gate_diff.sh — full-corpus differential oracle for the #915 +# observer-emission gate. +# +# The gate skips observer bookkeeping for programs that can never interrogate +# it. The whole risk of that change is SILENT-WRONG: a program that does reach +# the observer, misclassified as one that does not, still runs and still prints +# something — just with a dead observer channel. No crash, no leak, no failing +# assert unless a test happens to assert on the affected binding. So the bar is +# not "the suite passes", it is "every tracked .eigs program produces +# BYTE-IDENTICAL stdout+stderr+exit code under both builds". +# +# WHY capture/compare INSTEAD OF two binary paths. The runtime resolves its +# stdlib relative to its own executable directory (/proc/self/exe), so a build +# copied or hard-linked outside src/ silently loses `load_file` of lib modules — +# it would still run, and the diff would then be measuring a broken stdlib path +# rather than the gate. `src/eigenscript` is a hard link the Makefile re-points +# per variant, so only ONE build is runnable at a time. This script therefore +# captures one build at a time and diffs the captures afterwards. +# +# EIGS_OBS_FORCE IS NOT A FULL BASELINE — read this before trusting a PASS. +# +# The force flag is checked in compile_ast ONE LINE BEFORE the eager load-target +# pre-pass, and arming the gate makes that pass not run. So the FORCE arm +# executes a DIFFERENT CODE PATH from the arm under test: it never exercises the +# eager pass at all. A clean diff therefore proves the two arms AGREE, not that +# the pre-pass is inert — anything the pre-pass does to the program's world +# (it runs the real compiler, which has side effects on trace arming) is +# invisible here except as a divergence. +# +# Bought (2026-08-21, blind critic round 5): the pre-pass was arming the trace +# history channel in the PARENT, so a literal `load_file` path and a computed +# one gave different answers to `prev of x`. This tool was green throughout, +# correctly — no corpus program has that shape. +# +# For a real baseline, capture with a PRE-FEATURE BINARY: +# git worktree add /tmp/wt-base && (cd /tmp/wt-base && make) +# EIGS_GATE_DIFF_BIN=/tmp/wt-base/src/eigenscript tools/observer_gate_diff.sh capture truebase +# tools/observer_gate_diff.sh compare truebase gated +# That arm runs the code that shipped before the gate existed, which is the +# question a user actually has. +# +# Usage: +# tools/observer_gate_diff.sh capture