From e0efe2464bb02deae9f2714b997f1672aabd3891 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sun, 6 Sep 2026 11:11:47 -0500 Subject: [PATCH 1/4] fix(embed): make observer recording open by default Native and assembled-code embedders could update observer slots without ever calling compile_ast, leaving the #915 gate closed and returning improving=0 where the VM returns 1. Start states with recording open and permit only an initial compile verdict to close the gate. Pin default embed initialization open because a subsequently compiled module cannot classify its native caller. Keep eigs_obs_enable idempotent and retain the compiler's existing scan logic. Add eigs_set_eval_observer_isolated(int enabled) on the attached EigsState. The opt-in promises that no eval unit interrogates a binding assigned by an earlier unit, directly or through called code. Default evals continue recording across units. Isolated evals may use individual compile verdicts, with sticky obs_history_gap rejection naming EIGS_OBS_FORCE=1 before a later observer read can return incomplete history. Preserve accumulated verdicts for retained functions; registered C callbacks pin recording because their code is opaque. Keep shared observer flags atomic and recording monotonic within each unit. Enroll a hermetic C regression in the full suite and warning-flag audit. Cover native/assembled execution, low-level module compilation, all three eval arms, retained functions, C callbacks, late registration, sticky errors, force-on recovery, and independent states. Update the embed API table, observer residual, header contracts, SPEC/COMPARISON references, CHANGELOG and validation record. Validation, run sequentially in /home/jon/src/wt/es-1038: - make embed-observer-test; bash tests/test_embed_observer.sh: embed observer: 28 passed, 0 failed - G1 against origin/main cd99388163c3ff6478851de1f5be906f7626341a: native improving=0; assembled improving=0; embed observer: 0 passed, 2 failed (exit 1). Branch --direct: both improving=1; 2 passed, 0 failed (exit 0). - G3 planted fault: remove only eval_source's missing-history rt_error; embed observer: 24 passed, 4 failed (exit 1). Restore the saved source and rebuild: 28 passed, 0 failed (exit 0). - make; cd tests; bash run_all_tests.sh: RESULTS: 4259/4259 passed, 0 failed - make asan; cd tests; ASAN_OPTIONS=detect_leaks=1 bash run_all_tests.sh: RESULTS: 4248/4248 passed, 0 failed; no leak reports; C regression 28/28. - bash tools/jit_diff.sh: jit_diff: OK (230 programs x {jit, osr} vs the interpreter; 4 arms adjudicated by replay; 0 ledgered) - make tsan; bash tests/test_tsan.sh: Results: 14 passed, 0 failed. All 13 clean programs, including test_obs_mt_race, reported no warnings; the seeded race emitted 5 warnings before its bounded 120-second timeout, proving detection remains live. - tools/observer_gate_diff.sh: 521 captured, 5 denied; compare main/branch: RESULT: PASS -- 497 programs byte-identical; 0 mismatches. 399 informative, 98 silent, 24 excluded by baseline self-difference. - EIGS_OBS_GATE_STATS=1 src/eigenscript -e $'x is 41\nprint of (x + 1)': obs-gate: unobserved ; output 42. - Warning audit: all 481 compile invocations across 29 targets + 7 scripts carry the three required -Werror flags. Full suites include classification, strict argument guards, suite labels and the observer gate regressions. - EMS="$PWD/build/observer-EigenMiniSat" N=5 ROWS=4 COLS=4 \ bash tools/observer_gate_measure.sh: base median: 287.78 s (289.21 288.18 287.78 285.53 285.97) gated median: 30.54 s (30.54 30.32 31.03 30.35 30.56) speedup: 9.42x RESULT: valid -- same search, same binary, interleaved. All 10 runs had identical counters: UNSAT, conflicts=9986, resolutions=33873, learnts=9985, learnt_lits=78856, peak_learnts=1595, max_level=19, decisions=15275, propagations=44166, restarts=11. Fixture cca0e1482da91ad5b2b61b3cf775a04d5a5ca4de needed only a scratch eigs.json project-root marker; solver/benchmark source was unchanged. An earlier interrupted four-pair attempt is excluded from these medians. The release binary SHA-256 remained unchanged after the sanitizer variants: 7260633c29791dd5bac01b2884b8203ffc601d2b2bc29a91c5718a3d9528384f Known pre-existing failure, reported separately as EigenScript#1112: tools/replay_diff.sh prints OK despite a SIGSEGV after the unsupported-thread replay diagnostic. The same bounded reproducer crashes 20/20 on unmodified main and 20/20 here; all recordings succeed. This is not a clean replay result. References #1038 and #1028. Closes #1038. Closes #1028. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Kpzyjv1SaLaqBf45FSFDhB --- CHANGELOG.md | 11 ++ Makefile | 9 ++ docs/COMPARISON.md | 7 ++ docs/EMBEDDING.md | 66 ++++++++++ docs/EMBED_OBSERVER_VALIDATION.md | 185 ++++++++++++++++++++++++++++ docs/OBSERVER.md | 16 ++- docs/SPEC.md | 7 ++ src/compiler.c | 31 ++--- src/eigenscript.c | 4 +- src/eigenscript.h | 64 +++++----- src/eigs_embed.c | 51 +++++++- src/eigs_embed.h | 18 ++- src/state.c | 3 + tests/run_all_tests.sh | 12 ++ tests/test_embed_observer.c | 197 ++++++++++++++++++++++++++++++ tests/test_embed_observer.sh | 34 ++++++ tools/werror_switch_check.sh | 4 +- 17 files changed, 659 insertions(+), 60 deletions(-) create mode 100644 docs/EMBED_OBSERVER_VALIDATION.md create mode 100644 tests/test_embed_observer.c create mode 100644 tests/test_embed_observer.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7d0721..bf2f0e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,17 @@ All notable changes to EigenScript are documented here. ### Added +- **Embed observer contract (#1038/#1028).** States start with recording open, + so native/assembled hosts need no startup `eigs_obs_enable()` workaround. + Embed initialization keeps later module compilations from closing recording + behind a native caller. Compile verdicts still close the CLI gate for read-free programs. The additive + `eigs_set_eval_observer_isolated` host opt-in allows read-free eval units to + skip observation; default evals retain cross-unit history. Missing history + rejects later reader units with `EIGS_OBS_FORCE=1` guidance, conservatively + even for independent bindings. Escaped compiled functions retain the gate's + accumulated verdict; registered C callbacks keep eval recording open. A C + regression runs under the suite's build variant. + - **`is_file of path` (#1058).** 1 iff the path names a REGULAR file (`S_ISREG`); 0 for a directory, a device/fifo/socket, a missing path, or a non-string. `read_file_util` admits only regular files, and a driver that diff --git a/Makefile b/Makefile index 642ef500..b26356fa 100644 --- a/Makefile +++ b/Makefile @@ -212,6 +212,15 @@ $(NATIVEFN_TEST): $(NATIVEFN_TEST_OBJ) $(filter-out build/release/main.o build/r nativefn-test: $(NATIVEFN_TEST) @echo "Native-fn identity test built: $(NATIVEFN_TEST)" +# #1038/#1028: same runtime variant as the suite; never repoint the CLI alias. +EMBED_OBSERVER_VARIANT ?= release +EMBED_OBSERVER_OBJ := $(filter-out build/$(EMBED_OBSERVER_VARIANT)/main.o,$(OBJ_$(EMBED_OBSERVER_VARIANT))) +build/$(EMBED_OBSERVER_VARIANT)/test_embed_observer: tests/test_embed_observer.c $(EMBED_OBSERVER_OBJ) $(wildcard $(SRC_DIR)/*.h) Makefile + $(CC) $(FLAGS_$(EMBED_OBSERVER_VARIANT)) -I$(SRC_DIR) -o $@ $< $(EMBED_OBSERVER_OBJ) $(LIBS_$(EMBED_OBSERVER_VARIANT)) +.PHONY: embed-observer-test +embed-observer-test: build/$(EMBED_OBSERVER_VARIANT)/test_embed_observer + @echo "Embed observer test built: $<" + # #1056: use the same variant as the CLI under test, without relinking it. ROAD_VARIANT ?= release EMBED_ROADS_OBJ := $(filter-out build/$(ROAD_VARIANT)/main.o,$(OBJ_$(ROAD_VARIANT))) diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 614864d7..af7fa942 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -533,3 +533,10 @@ The existing function-slot exception remains: a binder with no prior binding inside a function retains its final value after the loop on every road. A pre-existing parameter or local is restored. This change preserves that exception; see the scope notes in LANGUAGE_CONTRACT.md. + + +The C embedding API starts observer recording open. Source evals retain +cross-unit history by default; hosts may explicitly promise isolated observer +use with `eigs_set_eval_observer_isolated`. Missing history then raises +conservatively instead of answering a rest value. See the +[embedding observer contract](EMBEDDING.md#observer-contract-1038--1028). diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index 2550ea13..c757ebc0 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -137,6 +137,72 @@ if (!r) { } ``` +## Observer contract (#1038 / #1028) + +A newly opened state records observer updates by default. This also applies to +`eigs_state_new` plus attach/init: a native or assembled-code host that never +calls `compile_ast` has a live observer. Calling `eigs_obs_enable()` at startup +is **no longer required**. The call remains idempotent and can pin the current +unit open before compilation. Embed initialization pins recording open because +a subsequently compiled module cannot classify its surrounding native caller. +The explicit eval opt-in below renews permission to choose a compile verdict. +Only a compile verdict proving no observer reads +may close the gate; recording stays monotonic within that execution unit. +The CLI's read-free programs still compile as `unobserved`. + +By default, `eigs_eval_string` and `eigs_eval_file` keep recording across calls. +A later unit may interrogate a binding an earlier unit assigned, with its full +history available. Hosts can explicitly opt in to compile-time gating: + +```c +EigsState *st = eigs_open(); +eigs_set_eval_observer_isolated(1); +/* Each eval unit obeys the promise below. */ +``` + +**The host promises: no eval unit interrogates a binding assigned by an earlier +unit, directly or through called code.** This covers observer predicates, +reports and temporal queries. The setting belongs to the attached `EigsState`, +applies to both string and file evals, and defaults off on every new state. +Call the setter only between evals while the host has exclusive use of the +state, as required for eval itself. Globals still accumulate normally. + +| API | Contract | +|---|---| +| `eigs_set_eval_observer_isolated(int enabled)` | Nonzero opts in to the promise above; zero restores recording for subsequent work. | +| `eigs_obs_enable(void)` | Idempotently arms the current unit; does not reconstruct missing history. | + +An opted-in eval starts a new unit and may choose a new verdict. Use default +eval mode or `EIGS_OBS_FORCE=1` to keep subsequent units observed. + +With the opt-in, a read-free unit can run `unobserved`, visible through +`EIGS_OBS_GATE_STATS=1`. The existing source scan supplies each verdict. Functions +can escape through globals, module exports, containers and callbacks, so once +any functions have been compiled the state conservatively retains its accumulated +gate verdict across later evals; a later call site cannot rescan their source. +Registered C callbacks have no scannable source at all, so +`eigs_register_function` pins subsequent evals open, even if the host enables +the opt-in after registration. Registering a callback after a history gap causes +subsequent evals to raise conservatively before entering opaque host code. + +**Missing history is a sticky error, never a rest-value answer.** After an +unobserved unit executes, a subsequent unit whose scan reads observer state is +rejected before execution, with an error naming `EIGS_OBS_FORCE=1`. This is +conservative, like `load_file`: it can reject a unit even if that unit would +observe only independent bindings. Catching/clearing the error, calling +`eigs_obs_enable`, or disabling the opt-in does not restore the missing history. +Restart the state with `EIGS_OBS_FORCE=1` set **before the first eval**, or restart +without the opt-in, when cross-unit observation is needed. Force-on preserves +correct cross-unit queries even with the opt-in enabled. + +The regression instrument is `bash tests/test_embed_observer.sh`: native slot +updates and assembled bytecode without compilation, default cross-unit history, +isolated read-free units, a rejected cross-unit read, retained functions and the +force-on recovery path, plus C callback observation and late registration. It uses the same build variant as `src/eigenscript`, +including ASan, and is enrolled in the full suite. +The [validation record](EMBED_OBSERVER_VALIDATION.md) contains the baseline +reproducer, planted-fault output and measurement setup. + ## Error retrieval ```c diff --git a/docs/EMBED_OBSERVER_VALIDATION.md b/docs/EMBED_OBSERVER_VALIDATION.md new file mode 100644 index 00000000..92e45dbf --- /dev/null +++ b/docs/EMBED_OBSERVER_VALIDATION.md @@ -0,0 +1,185 @@ +# Embed observer contract: validation (#1038 / #1028) + +Baseline: `origin/main` at `cd99388163c3ff6478851de1f5be906f7626341a`. +The baseline runtime was built in this worktree before the runtime edits. +The regression is `tests/test_embed_observer.c`; its normal runner is +`bash tests/test_embed_observer.sh`, which selects the CLI's runtime variant. + +## Native/assembled oracle + +The direct arm never calls `compile_ast`, `eigs_eval_string` or +`eigs_obs_enable`. It feeds a descending numeric trajectory through the native +slot API, then assembles and executes assignments plus a named predicate. +The equivalent source trajectory returns `improving=1` in the baseline VM. + +Using the branch's test source with a baseline runtime/header, compile with +`-DEIGS_OBS_BASELINE_ONLY` to omit calls to the new API. For example, in a +worktree built from the baseline revision: + +```bash +make +objects=() +for obj in build/release/*.o; do + case "$obj" in */main.o|*/test_*.o) ;; *) objects+=("$obj");; esac +done +gcc -Wall -Wextra -Werror=switch -Werror=comment \ + -Werror=misleading-indentation -DEIGS_OBS_BASELINE_ONLY -Isrc \ + tests/test_embed_observer.c "${objects[@]}" -lm -lpthread \ + -o build/release/test_embed_observer_main +build/release/test_embed_observer_main --direct +``` + +Measured baseline (exit 1): + +```text +native improving=0 +FAIL: native slot updates observe without compile_ast +assembled improving=0 +FAIL: assembled writes and predicate match native improving=1 +embed observer: 0 passed, 2 failed +``` + +Branch: `make embed-observer-test` then +`build/release/test_embed_observer --direct` (exit 0): + +```text +native improving=1 +PASS: native slot updates observe without compile_ast +assembled improving=1 +PASS: assembled writes and predicate match native improving=1 +embed observer: 2 passed, 0 failed +``` + +## Eval seam and planted fault + +`bash tests/test_embed_observer.sh`: **28 passed, 0 failed**, exit 0. It checks +default cross-unit history, isolated units, sticky rejection, force-on recovery, +state independence, low-level module compilation, retained functions and opaque C callbacks. The harness +requires the exact assertion count, the real compiler's `unobserved` stats, +its post-execution gate witness, successful exit and no sanitizer diagnostics. + +With no suite running, saved `src/eigs_embed.c`, removed only the `rt_error` +call in `eval_source`'s history-gap guard, then ran that same runner: + +```text +FAIL: opt-in: cross-unit read raises instead of zero +FAIL: opt-in: repeated read cannot clear history gap +FAIL: disabling opt-in cannot repair lost history +FAIL: late callback: registration cannot hide missing history +embed observer: 24 passed, 4 failed +``` + +Exit 1. Restored the saved source (not a checkout from Git), rebuilt through the +same runner: **28 passed, 0 failed**, exit 0. + +A separate C callback reproducer initially returned `callback improving=0 +error=0` under the new opt-in despite creating its trajectory entirely within +the call. After callback registration pinned eval recording open, the exact +reproducer returned `callback improving=1 error=0`. The C regression preserves +that case, including enabling isolation *after* registering the callback. + +A native host/module probe exposed a second seam: C recorded a descending +trajectory, called `load_file` for a read-free module, then recorded a rising +trajectory. Before embed initialization pinned recording, it printed: + +```text +module error=0 gate=0 +after rising: improving=1 +``` + +Exit 1: the rising trajectory was answered from the stale descending window. +The control with an explicit startup `eigs_obs_enable()` returned 0 with +`gate=1` and `improving=0`. After the fix, the **same probe without that call** +returned 0 with `gate=1` and `improving=0`. The C test also directly compiles and +executes a read-free module before invoking native observation. Embed runtime +initialization pins recording so a module cannot classify its C caller; the +CLI's initial compile and the explicit eval opt-in retain their gating path. + +## CLI verdict + +```bash +EIGS_OBS_GATE_STATS=1 src/eigenscript -e 'x is 41 +print of (x + 1)' +``` + +```text +obs-gate: unobserved +42 +``` + +## Benchmark fixture provenance + +EigenMiniSat snapshot: `cca0e1482da91ad5b2b61b3cf775a04d5a5ca4de`, archived into +`build/observer-EigenMiniSat` inside this worktree. Its unmodified checkout has +no `eigs.json`; the current runtime's file-resolution contract rejects +`lib/dimacs.eigs` from `benchmarks/tseitin_ladder.eigs` (exit 1). This is a +fixture setup difference from the historical #915 measurement. + +Added only `eigs.json` containing `{"name":"observer-gate-benchmark"}` to that +scratch copy, so its root-relative module names use the documented project-root +resolution. No benchmark/solver source changes. The small preflight then +completed with `status=UNSAT`. The timing comparison uses this same configured +snapshot for both arms. + +Completed timing command: + +```bash +EMS="$PWD/build/observer-EigenMiniSat" N=5 ROWS=4 COLS=4 \ + bash tools/observer_gate_measure.sh +``` + +```text +base median: 287.78 s (289.21 288.18 287.78 285.53 285.97) +gated median: 30.54 s (30.54 30.32 31.03 30.35 30.56) +counters, identical across all 10 runs: + DONE case=tseitin-torus-4x4-odd rows=4 cols=4 vars=32 clauses=128 status=UNSAT conflicts=9986 resolutions=33873 learnts=9985 learnt_lits=78856 peak_learnts=1595 max_level=19 decisions=15275 propagations=44166 restarts=11 +speedup: 9.42x +RESULT: valid — same search, same binary, interleaved +``` + +Exit 0. This run measured **9.42x**, rather than the brief's historical 8.5x; +the current fixture and binary retain a substantial observer-elision benefit. +An earlier timing attempt was interrupted by the session's wall-clock limit +after four pairs. None of those partial-run timings enter these medians; the +reported run restarted all five pairs and completed every counter check. + +## Runtime gates + +Commands run sequentially in the worktree: + +| Command | Measured result | +|---|---| +| `make && (cd tests && bash run_all_tests.sh)` | 4259/4259 passed, 0 failed; all child tests completed. | +| `make asan && (cd tests && ASAN_OPTIONS=detect_leaks=1 bash run_all_tests.sh)` | 4248/4248 passed, 0 failed; no leak reports; C contract 28/28. | +| `make tsan && bash tests/test_tsan.sh` | 14 passed, 0 failed: 13 race-free programs, including `test_obs_mt_race`, and the live seeded-race control. | +| `bash tools/observer_gate_diff.sh capture main`, `capture main2`, then `capture branch` and `compare main branch` | 521 captured, 5 denied; 497 byte-identical (399 informative, 98 silent), 24 excluded by baseline self-difference, 0 mismatches. | +| `bash tools/jit_diff.sh` | 230 programs against interpreter, 4 arms adjudicated by replay, 0 ledgered differences. | + +Both full suites include the fail-soft classification, strict argument-guard +(`--no-baseline`), suite-label, observer-classification and warning-flag gates. +The warning audit checked **481 compile invocations across 29 targets and +7 scripts**, including the new C regression target. TSan's intentionally racy +control emitted **5 warnings** before its 120-second timeout; the 13 clean +programs completed without warnings. + +After restoring release with `make`, its SHA-256 remained +`7260633c29791dd5bac01b2884b8203ffc601d2b2bc29a91c5718a3d9528384f`, +identical to the binary used for the final release suite and differentials. +The read-free CLI probe still printed `obs-gate: unobserved ` and `42`. + +The replay oracle **returned OK but was not clean**: `bash tools/replay_diff.sh` +reported 230 programs, 12 documented boundaries, 0 nondeterministic cases and +0 ledgered differences, while printing a SIGSEGV for the replay arm of +`test_spawn_channel_exit.eigs`. A separate bounded reproducer confirmed the +crash on **unmodified main 20/20 and the branch 20/20**; all 40 recordings +succeeded and wrote tapes, and every replay printed the unsupported-concurrency +diagnostic before crashing. The boundary classifier then skips the crash. +Reported separately as [#1112](https://github.com/InauguralSystems/EigenScript/issues/1112). + +```bash +EIGS_JIT_OFF=1 EIGS_TRACE=/tmp/spawn-exit.tape src/eigenscript tests/test_spawn_channel_exit.eigs +EIGS_JIT_OFF=1 EIGS_REPLAY=/tmp/spawn-exit.tape src/eigenscript tests/test_spawn_channel_exit.eigs +``` + +The record exits 0; replay exits 139. This pre-existing failure must not be +read as a clean replay result merely because the harness exits 0. diff --git a/docs/OBSERVER.md b/docs/OBSERVER.md index 7b7f9bd4..63a3e61b 100644 --- a/docs/OBSERVER.md +++ b/docs/OBSERVER.md @@ -556,12 +556,16 @@ 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. +The native embedder instance of the #1027 family is closed by #1038: states +start with recording **open**, so a host that never compiles still records its +slot updates. The startup `eigs_obs_enable()` workaround is no longer required. +A descriptor (`vm_run_bytecode` / `sandbox_run`) reading an unrecorded binding +after a compiled host closed the gate raises through the #1027 guard; it cannot +recover the lost history. See the [embed observer contract](EMBEDDING.md#observer-contract-1038--1028) +for the default and the explicit #1028 eval opt-in. Default evals retain history +across calls; opted-in evals reject later observer-reading units conservatively +when an earlier unit ran unobserved. `EIGS_OBS_FORCE=1` from the start avoids +that gap. Retained compiled functions can keep the eval gate open. 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 diff --git a/docs/SPEC.md b/docs/SPEC.md index 6a521924..29400947 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1880,3 +1880,10 @@ The facts that govern every program, in one place: 8. **Observation**: every assignment outside `unobserved` updates the observer; predicates and interrogatives read it. Temporal queries additionally record history. + + +The C embedding API starts observer recording open. Source evals retain +cross-unit history by default; hosts may explicitly promise isolated observer +use with `eigs_set_eval_observer_isolated`. Missing history then raises +conservatively instead of answering a rest value. See the +[embedding observer contract](EMBEDDING.md#observer-contract-1038--1028). diff --git a/src/compiler.c b/src/compiler.c index bfbec22a..14efa0f0 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -3960,21 +3960,22 @@ EigsChunk *compile_ast(ASTNode *ast, Env *env, const char *src) { 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). */ + /* #1038: an uncompiled state starts OPEN. Consume the first-compile + * permission only before execution (hosts serialize compile/eval per state); + * explicit arming (REPL/default embed/native host) already consumed it. + * The existing scan below still decides the verdict, unchanged. Later + * compilations OR their evidence into this unit, never close its gate. */ + if (g_obs_compile_pending) { + obs_flag_store(obs_compile_pending, 0); + if (!g_obs_exec_started) + obs_flag_store(obs_needed, 0); + } + /* #1028: functions may escape through globals, module exports, containers + * or callbacks. Their later call sites have no source to scan. Preserve + * the unit's accumulated verdict across future embed evals once any + * compilation has emitted a function (including runtime module loads). */ + if (chunk->fn_count > 0) obs_flag_store(obs_eval_retains_code, 1); + /* #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 diff --git a/src/eigenscript.c b/src/eigenscript.c index 7171ff28..c036aa8a 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -625,7 +625,7 @@ void observer_slot_update(Env *e, int idx, Value *newval) { * 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 + * and by eigs_obs_enable() at runtime, never cleared within a unit. 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 @@ -3856,6 +3856,8 @@ Value* env_get_local_hashed(Env *env, const char *name, uint32_t h) { * 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 (!eigs_current) return; + obs_flag_store(obs_compile_pending, 0); if (g_obs_needed) return; if (g_obs_exec_started) obs_flag_store(obs_history_gap, 1); obs_flag_store(obs_needed, 1); diff --git a/src/eigenscript.h b/src/eigenscript.h index f9336cb8..6bcf9c77 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -559,35 +559,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. - */ + /* #915/#1038 observer gate: OPEN at state creation, including hosts that + * execute native or assembled code without compile_ast. Only the first + * compile verdict may close it; embed initialization/eigs_obs_enable pin + * that unit open. + * MONOTONIC within an execution unit. An explicitly isolated embed eval + * may start a new unit while the host has exclusive use of the state. + * Per-state so spawned workers inherit the compiling thread's verdict. */ 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. */ + /* First compile may choose the closed path, before any user execution. + * Embed initialization and explicit arming consume this permission. + * Accessed atomically because + * worker-reachable arming sites also consume it. */ + int obs_compile_pending; + /* Sticky missing-history evidence. Opening the gate cannot reconstruct + * prior assignments; catching an error must never clear this flag. */ 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. */ + /* 1 once user code in this unit has begun executing. Late arming records + * a history gap. Reset only at a serialized, opted-in embed boundary. */ int obs_exec_started; + /* Host configuration, changed only between evals with exclusive state + * access (never from a worker): permit per-unit compile verdicts. */ + int eval_observer_isolated; + /* Host callbacks have no source verdict. Registration pins evals open; + * registration and eval must be serialized by the host. */ + int eval_host_callbacks; + /* Compiled functions can outlive their defining eval. Once an eval has + * compiled any functions, retain the gate across later evals: their call + * sites cannot prove what previously compiled code will read. */ + int obs_eval_retains_code; 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) */ @@ -1104,7 +1104,8 @@ extern __thread EigsThread *eigs_current; #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 +/* ATOMIC, relaxed. The three execution flags 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 @@ -1114,7 +1115,7 @@ extern __thread EigsThread *eigs_current; * 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 + * 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 @@ -1126,6 +1127,9 @@ extern __thread EigsThread *eigs_current; * 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_compile_pending __atomic_load_n(&eigs_current->state->obs_compile_pending, __ATOMIC_RELAXED) +#define g_obs_eval_host_callbacks __atomic_load_n(&eigs_current->state->eval_host_callbacks, __ATOMIC_RELAXED) +#define g_obs_eval_retains_code __atomic_load_n(&eigs_current->state->obs_eval_retains_code, __ATOMIC_RELAXED) #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) @@ -1145,8 +1149,8 @@ extern __thread EigsThread *eigs_current; __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 +/* #915: the ONLY sanctioned way to arm recording mid-unit. `g_obs_needed` + * answers "is recording on?"; the 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 diff --git a/src/eigs_embed.c b/src/eigs_embed.c index c444c6a2..affeb66d 100644 --- a/src/eigs_embed.c +++ b/src/eigs_embed.c @@ -23,6 +23,10 @@ int eigs_state_init_runtime(EigsState *st) { if (!global) return -1; register_builtins(global); /* one seam: store/gfx ride inside (#742) */ g_global_env = global; + /* #1038: a module compiled later cannot classify the surrounding native + * host. Pin default embedding open; only the explicit eval opt-in may + * renew permission for a compile verdict. CLI state setup is separate. */ + eigs_obs_enable(); return 0; } @@ -66,6 +70,11 @@ void eigs_close(EigsState *st) { /* ---- Eval --------------------------------------------------------- */ +void eigs_set_eval_observer_isolated(int enabled) { + if (eigs_current) + eigs_current->state->eval_observer_isolated = enabled != 0; +} + static EigsValue *eval_source(const char *src, const char *file_dir) { if (!src || !eigs_current || !g_global_env) return NULL; Env *global = g_global_env; @@ -101,11 +110,23 @@ static EigsValue *eval_source(const char *src, const char *file_dir) { /* 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 */ + /* #1028: only an explicit host promise permits a new compile verdict. + * Snapshot missing history before resetting this unit's execution latch. + * A retained function may be invoked without a reader in the new source, + * so in that case keep the accumulated verdict instead. All boundary + * resets require exclusive state access; worker arming stays atomic. */ + if (eigs_current->state->eval_observer_isolated && + !g_obs_eval_host_callbacks) { + if (!g_obs_needed && g_obs_exec_started) + obs_flag_store(obs_history_gap, 1); + if (!g_obs_eval_retains_code) { + obs_flag_store(obs_exec_started, 0); + obs_flag_store(obs_needed, 1); + obs_flag_store(obs_compile_pending, 1); + } + } else { + eigs_obs_enable(); + } /* A file's explicit base must beat a caller frame while compiling, but * must never outlive compilation: runtime eval belongs to its own frame. * Keep this pair at the compile boundary for both embed entry points. */ @@ -118,7 +139,19 @@ static EigsValue *eval_source(const char *src, const char *file_dir) { free(saved_dir); } - Value *result = vm_execute(chunk, global); + /* Like load_file, reject conservatively at the compile boundary. The + * source scan cannot prove which binding a future read will reach. Keep + * this guard after opt-out as well: arming does not repair past history. */ + int obs_after_compile = obs_flag_load_acquire(obs_needed); + if (chunk && (chunk_reads_observer(chunk) || + g_obs_eval_host_callbacks) && + (!obs_after_compile || g_obs_history_gap)) { + rt_error(EK_VALUE, 1, + "embed eval reads observer state, but the observer gate was closed " + "during an earlier unit; its assignments have no recorded history. " + "Restart the state with EIGS_OBS_FORCE=1 before the first eval."); + } + Value *result = g_has_error || g_parse_errors ? NULL : vm_execute(chunk, global); chunk_free(chunk); free_ast(ast); free_tokenlist(&tl); @@ -327,6 +360,12 @@ void eigs_set_abort_flag(volatile int *flag) { void eigs_register_function(const char *name, EigsHostFn fn) { if (!name || !fn || !eigs_current || !g_global_env) return; + /* #1028: a C callback can observe its own assignments, but has no source + * the compile verdict can inspect. This pin survives enabling isolation + * after registration. Late registration records the gap; eval's guard + * refuses to enter opaque host code with incomplete history. */ + obs_flag_store(eval_host_callbacks, 1); + eigs_obs_enable(); Value *bv = make_builtin((BuiltinFn)fn); env_set_local_owned(g_global_env, name, bv); } diff --git a/src/eigs_embed.h b/src/eigs_embed.h index 8916259d..d8aee713 100644 --- a/src/eigs_embed.h +++ b/src/eigs_embed.h @@ -62,7 +62,7 @@ EigsThread *eigs_thread_attach(EigsState *st); EigsThread *eigs_thread_switch(EigsState *st); void eigs_thread_detach(void); /* Set up the global env + register stdlib builtins on the calling thread's - * state. Idempotent: returns 0 if already initialized. -1 if not attached. */ + * state, pinning default observer recording open for native callers. Idempotent: returns 0 if already initialized. -1 if not attached. */ int eigs_state_init_runtime(EigsState *st); /* ---- Eval --------------------------------------------------------- */ @@ -76,6 +76,22 @@ EigsValue *eigs_eval_string(const char *src); * `load_file` resolution to the file's directory. */ EigsValue *eigs_eval_file(const char *path); +/* Opt in (enabled != 0) to per-unit observer gating on this attached state. + * Host promise: no eval unit interrogates a binding assigned by an earlier + * unit, directly or through called code. Default off: evals record all units. + * Call only between evals, with exclusive state access. Applies to string and + * file evals. Missing history is sticky: subsequent observer-reading units + * conservatively fail with an error naming EIGS_OBS_FORCE=1, even if their + * bindings are independent. Restart with FORCE set before the first eval to + * recover. Retained compiled functions keep their accumulated gate verdict. + * Registered C callbacks pin evals open; registering after a history gap + * makes subsequent evals fail before entering opaque host code. + * Disabling the opt-in records future work; it cannot repair missing history. */ +void eigs_set_eval_observer_isolated(int enabled); +/* Recording starts open. Optional explicit arming pins the current unit open + * before compilation; idempotent, and never repairs missing history. */ +void eigs_obs_enable(void); + /* ---- Errors ------------------------------------------------------- */ /* Error message from the most recent eval/runtime failure on this thread, diff --git a/src/state.c b/src/state.c index e78a92d3..2438478f 100644 --- a/src/state.c +++ b/src/state.c @@ -31,6 +31,9 @@ EigsState *eigs_state_new(void) { pthread_mutex_init(&st->handle_mutex, NULL); pthread_mutex_init(&st->gc_lock, NULL); /* cycle-collector registry */ st->handle_next = 1; /* 0 reserved as invalid */ + /* #1038: absence of a compiler verdict means record, not discard. */ + st->obs_needed = 1; + st->obs_compile_pending = 1; /* Observer thresholds — same defaults as the legacy TLS globals. */ st->obs_dh_zero = 0.001; st->obs_dh_small = 0.01; diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 45574c93..f1f154a3 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -511,6 +511,18 @@ else fi echo "" +echo "[0f] Embed observer contract (#1038/#1028)" +check_binary_fingerprint +TOTAL=$((TOTAL + 1)) +if bash "$TESTS_DIR/test_embed_observer.sh"; then + PASS=$((PASS + 1)) + echo " PASS: embed observer contract" +else + FAIL=$((FAIL + 1)) + echo " FAIL: embed observer contract" +fi +echo "" + echo "[0d] Host frame line in traces from a builtin-run chunk" check_binary_fingerprint HFL_OUT=$($EIGS_TMO ./eigenscript ../tests/test_host_frame_line.eigs &1); HFL_RC=$? diff --git a/tests/test_embed_observer.c b/tests/test_embed_observer.c new file mode 100644 index 00000000..3b370388 --- /dev/null +++ b/tests/test_embed_observer.c @@ -0,0 +1,197 @@ +/* #1038/#1028: the host observer contract. --direct is intentionally usable + * with origin/main's runtime: compile with -DEIGS_OBS_BASELINE_ONLY to omit + * tests of the additive API. No compile_ast/eigs_obs_enable in the direct arm. */ +#include +#include +#include +#include "eigs_embed.h" +#include "eigenscript.h" +#include "vm.h" + +static int passed, failed; +static void check(int ok, const char *name) { + printf("%s: %s\n", ok ? "PASS" : "FAIL", name); + if (ok) passed++; else failed++; +} +static void emit(EigsChunk *c, uint8_t op, uint16_t arg) { + chunk_emit(c, op, 1); + chunk_emit_u16(c, arg, 1); +} +static int constant(EigsChunk *c, Value *v) { + int idx = chunk_add_constant(c, v); + val_decref(v); + return idx; +} +static void direct(void) { + EigsState *st = eigs_open(); + if (!st) { check(0, "open direct state"); return; } + Env *env = g_global_env; + env_set_local_owned(env, "native_x", make_num(100)); + int idx = env->count - 1; + double x = 100; + for (int i = 0; i < 12; i++) { + x *= 0.5; + env_set_local_owned(env, "native_x", make_num(x)); + observer_slot_update_num(env, idx, x); + } + int answer = observer_predicate_at(env, idx, 2 /* improving opcode operand */, 1); + printf("native improving=%d\n", answer); + check(answer == 1, "native slot updates observe without compile_ast"); + + EigsChunk *c = chunk_new(""); + int name = constant(c, make_str("assembled_x")); + x = 100; + for (int i = 0; i < 12; i++) { + x *= 0.5; + emit(c, OP_CONST, (uint16_t)constant(c, make_num(x))); + emit(c, OP_SET_NAME, (uint16_t)name); + emit(c, OP_OBSERVE_NAME_POST, (uint16_t)name); + chunk_emit(c, OP_POP, 1); + } + emit(c, OP_PREDICATE_NAME, 2 /* improving opcode operand */); + chunk_emit_u16(c, (uint16_t)name, 1); + chunk_emit(c, OP_RETURN, 1); + Value *r = vm_execute(c, env); + printf("assembled improving=%g\n", eigs_value_as_num(r)); + check(r && !eigs_has_error() && eigs_value_as_num(r) == 1, + "assembled writes and predicate match native improving=1"); + eigs_value_release(r); + chunk_free(c); + eigs_close(st); +} + +#ifndef EIGS_OBS_BASELINE_ONLY +/* Each assignment changes x last, so both named and bare predicates can be + * compared with the VM. The final step is still well outside the deadband. */ +static const char *series = + "x is 100\ni is 0\nloop while i < 10:\n" + " i is i + 1\n x is x * 0.5\nx\n"; +static void eval_ok(const char *source, const char *name) { + EigsValue *r = eigs_eval_string(source); + check(r && !eigs_has_error(), name); + eigs_value_release(r); +} +static void eval_num(const char *source, double want, const char *name) { + EigsValue *r = eigs_eval_string(source); + check(r && !eigs_has_error() && eigs_value_type(r) == EIGS_TYPE_NUM && + eigs_value_as_num(r) == want, name); + eigs_value_release(r); +} +static void gap(const char *source, const char *name) { + EigsValue *r = eigs_eval_string(source); + const char *msg = eigs_last_error_message(); + check(!r && eigs_has_error() && msg && strstr(msg, "observer gate") && + strstr(msg, "EIGS_OBS_FORCE=1"), name); + if (msg) printf("diagnostic: %s\n", msg); + eigs_value_release(r); +} +/* A callback observes bindings created during this very call: it satisfies + * the isolation promise, but the calling unit has no compiled observer op. */ +static EigsValue *host_reader(EigsValue *arg) { + (void)arg; + Env *env = env_new(NULL); + env_set_local_owned(env, "x", make_num(100)); + double x = 100; + for (int i = 0; i < 12; i++) { + x *= 0.5; + env_set_local_owned(env, "x", make_num(x)); + observer_slot_update_num(env, 0, x); + } + int answer = observer_predicate_at(env, 0, 2 /* improving */, 1); + env_decref(env); + return make_num(answer); +} +static void eval_contract(void) { + /* A native host can load/compile a module without routing through the + * eval API. That module's verdict says nothing about the C caller. */ + EigsState *native = eigs_open(); + const char *source = "noise is 42\nnoise\n"; + TokenList tl = tokenize(source); + ASTNode *ast = parse(&tl); + EigsChunk *chunk = compile_ast(ast, g_global_env, source); + EigsValue *value = vm_execute(chunk, g_global_env); + check(value && !eigs_has_error() && eigs_value_as_num(value) == 42, + "native host: low-level compiled module executes"); + eigs_value_release(value); + chunk_free(chunk); + free_ast(ast); + free_tokenlist(&tl); + check(g_obs_needed, "native host: module verdict cannot close recording"); + value = host_reader(NULL); + check(value && eigs_value_as_num(value) == 1, + "native host: observations after module compilation remain live"); + eigs_value_release(value); + eigs_close(native); + EigsState *st = eigs_open(); + eval_ok(series, "default: first unit records assignments"); + check(g_obs_needed, "default: read-free unit is observed"); + eval_num("improving of x", 1, "default: later unit reads correct history"); + eigs_close(st); + + st = eigs_open(); + eigs_set_eval_observer_isolated(1); + eval_num("z is 8\nz is 4\nz is 2\nz is 1\nz is 0.5\nimproving of z", 1, + "opt-in: observer-reading first unit is observed"); + eval_ok(series, "opt-in: read-free unit executes"); + printf("embed obs-gate: %s\n", g_obs_needed ? "observed" : "unobserved"); + check(!g_obs_needed, "opt-in: read-free unit ran unobserved"); + eval_num("independent is 42\nindependent", 42, + "opt-in: independent read-free next unit executes"); + check(!g_obs_needed, "opt-in: next read-free unit also unobserved"); + gap("improving of x", "opt-in: cross-unit read raises instead of zero"); + gap("report of x", "opt-in: repeated read cannot clear history gap"); + eigs_set_eval_observer_isolated(0); + gap("improving of x", "disabling opt-in cannot repair lost history"); + eigs_close(st); + + st = eigs_open(); + eigs_set_eval_observer_isolated(1); + eval_ok("define local_reader(a) as:\n" + " local y is 16\n y is 8\n y is 4\n" + " y is 2\n y is 1\n return improving of y\n", + "retained function: compile reader in first unit"); + eval_num("local_reader of 0", 1, + "retained function: later read-free call site preserves recording"); + check(g_obs_needed, "retained function: conservative observed verdict"); + eigs_close(st); + + st = eigs_open(); + eigs_register_function("host_reader", host_reader); + eigs_set_eval_observer_isolated(1); + eval_num("host_reader of 0", 1, + "host callback: unseen observer work is recorded"); + check(g_obs_needed, "host callback: registration pins evals observed"); + eigs_close(st); + + st = eigs_open(); + eigs_set_eval_observer_isolated(1); + eval_ok(series, "late callback: earlier unit ran without a reader"); + eigs_register_function("host_reader", host_reader); + gap("host_reader of 0", "late callback: registration cannot hide missing history"); + eigs_close(st); + + setenv("EIGS_OBS_FORCE", "1", 1); + st = eigs_open(); + eigs_set_eval_observer_isolated(1); + eval_ok(series, "FORCE: first isolated unit records history"); + check(g_obs_needed, "FORCE: read-free unit is observed"); + eval_num("improving of x", 1, "FORCE: cross-unit read has complete history"); + eigs_close(st); + unsetenv("EIGS_OBS_FORCE"); + + st = eigs_open(); + eval_ok(series, "fresh state: opt-in does not leak between states"); + eval_num("improving of x", 1, "fresh state: complete history still available"); + eigs_close(st); +} +#endif +int main(int argc, char **argv) { + direct(); +#ifndef EIGS_OBS_BASELINE_ONLY + if (!(argc == 2 && strcmp(argv[1], "--direct") == 0)) eval_contract(); +#else + (void)argc; (void)argv; +#endif + printf("embed observer: %d passed, %d failed\n", passed, failed); + return failed ? 1 : 0; +} diff --git a/tests/test_embed_observer.sh b/tests/test_embed_observer.sh new file mode 100644 index 00000000..1951fa2b --- /dev/null +++ b/tests/test_embed_observer.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Hermetic C contract against the same runtime (including sanitizer) as the +# suite. Build only the auxiliary file target, which never relinks the CLI. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +variant= +for candidate in "$ROOT"/build/*/eigenscript; do + if [[ "$ROOT/src/eigenscript" -ef "$candidate" ]]; then + variant=$(basename "$(dirname "$candidate")") + break + fi +done +if [[ -z "$variant" ]]; then + echo 'FAIL: observer contract cannot identify the CLI build variant' + exit 1 +fi +make --no-print-directory -C "$ROOT" embed-observer-test "EMBED_OBSERVER_VARIANT=$variant" +unset EIGS_OBS_FORCE EIGS_TRACE EIGS_REPLAY +out=$(mktemp) +trap 'rm -f "$out"' EXIT +rc=0 +EIGS_OBS_GATE_STATS=1 "$ROOT/build/$variant/test_embed_observer" > "$out" 2>&1 || rc=$? +cat "$out" +# Strict: reject sanitizer diagnostics even at rc=0. The shared classifier +# distinguishes leak/hard/none; this new harness tolerates neither kind. +source "$ROOT/tests/lsan_classify.sh" +classification=0 +lsan_classify "$(cat "$out")" || classification=$? +if [[ "$rc" -ne 0 || "$classification" -ne 2 ]]; then + exit 1 +fi +grep -q '^embed observer: 28 passed, 0 failed$' "$out" +grep -q '^embed obs-gate: unobserved$' "$out" +grep -q '^obs-gate: unobserved ' "$out" diff --git a/tools/werror_switch_check.sh b/tools/werror_switch_check.sh index 21bdefe2..90792eb0 100755 --- a/tools/werror_switch_check.sh +++ b/tools/werror_switch_check.sh @@ -104,7 +104,7 @@ MIN_LINES=100 # coverage-clean/fuzz-run (no compiles). TARGETS="build full http zlib net gfx asan asan-http asan-gfx tsan valgrind poison \ lsp dap jit-smoke lib embed-smoke embed-smoke-gfx embed-concurrent pgo coverage \ - fuzz fuzz-libfuzzer freestanding-libc-diff sandbox-intern-test errline-test nativefn-test embed-roads" + fuzz fuzz-libfuzzer freestanding-libc-diff sandbox-intern-test errline-test nativefn-test embed-roads embed-observer-test" # GNU make emits a shared prerequisite only once when several goals are in # one invocation. `embed-smoke-gfx` depends on `gfx`, so keeping that goal in @@ -119,6 +119,7 @@ TARGET_BATCHES=( "errline-test" "nativefn-test" "embed-roads" + "embed-observer-test" ) # TARGET_BATCHES must cover TARGETS exactly. Keep the hand-written batches @@ -278,6 +279,7 @@ jit-smoke 1 lib 1 embed-smoke 1 embed-roads 25 +embed-observer-test 25 embed-concurrent 1 embed-smoke-gfx 27 pgo 2 From 7c7a11d84c4a4ace5e8b198cefa0547771f58b2e Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sun, 6 Sep 2026 11:23:36 -0500 Subject: [PATCH 2/4] test_embed_observer: default to release under the build.sh layout (#1038 CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new observer-contract harness identified the CLI variant by hard-link (build//eigenscript -ef src/eigenscript), which only exists in the Makefile objdir layout. The linux/macos CI lanes build via ./build.sh, which compiles src/eigenscript as a real file with no build/ counterpart, so detection failed with "cannot identify the CLI build variant" and the [0f] section (and the [99p] child-exit ledger) went red — same class as the embed_roads fix in #1102. Default to the release variant when no hard link matches; the sanitizer suite still resolves to asan and is used there. The observer contract is variant-independent (no sanitizer semantics). Verified: normal run 28/28 via the release hard link; with all build//eigenscript moved aside (build.sh layout simulated) the fallback prints "using release" and passes 28/28. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Kpzyjv1SaLaqBf45FSFDhB --- tests/test_embed_observer.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_embed_observer.sh b/tests/test_embed_observer.sh index 1951fa2b..80b32c1f 100644 --- a/tests/test_embed_observer.sh +++ b/tests/test_embed_observer.sh @@ -11,8 +11,14 @@ for candidate in "$ROOT"/build/*/eigenscript; do fi done if [[ -z "$variant" ]]; then - echo 'FAIL: observer contract cannot identify the CLI build variant' - exit 1 + # No build//eigenscript hard-links to src/eigenscript: this is the + # build.sh layout (the linux/macos CI lanes), which compiles src/eigenscript + # as a real file. The observer contract is variant-independent (no sanitizer + # semantics), so build and test the release objects. On the sanitizer suite + # the hard link above resolves to "asan" and is used instead. (#1038, mirrors + # the embed_roads build-layout fix.) + variant=release + echo "observer contract: no matching CLI variant (build.sh layout); using release" fi make --no-print-directory -C "$ROOT" embed-observer-test "EMBED_OBSERVER_VARIANT=$variant" unset EIGS_OBS_FORCE EIGS_TRACE EIGS_REPLAY From babd15f33ed9d95ef61e1fb38e9152fccc56bcf0 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sun, 6 Sep 2026 12:55:38 -0500 Subject: [PATCH 3/4] test(embed): pin the raw observer default independently of initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing C regression always entered through eigs_open, whose runtime initialization explicitly arms recording. Reverting only state.c's default therefore left all 28 checks green. Add a --raw-host arm using eigs_state_new and eigs_thread_attach, with no runtime initialization, compilation or explicit arming. It records twelve descending updates and requires both an open gate and improving=1. Keep the original eigs_open/native and assembled-code arms. Cover serialized raw first compilation followed by worker arming and atomic flag reads, and run the C contract in the TSan lane with sanitizer failures fatal. Raise the C harness's exact assertion count from 28 to 31 and require the raw-host result marker. F2's compiler clear already uses obs_flag_store, which expands to a RELEASE __atomic_store_n. Keep that operation and clarify its serialization boundary: an atomic exchange of one flag would not make the whole check-then-clear decision atomic against arbitrary concurrent native arming. In-tree initial compilation precedes execution/spawn; embed initialization consumes permission to close; opted-in eval boundaries require exclusive state access. No runtime instructions or observer scan logic changed. Clarify F3 in EMBEDDING.md: direct host predicates between isolated eval units bypass the eval guard. Recording must be arranged before relevant assignments (eigs_obs_enable for direct host work, or EIGS_OBS_FORCE=1 before the first eval); arming after assignments cannot recover missing history. Validation on fix-1038, starting clean at 7c7a11d; one heavy job at a time: - make embed-observer-test; bash tests/test_embed_observer.sh: embed observer: 31 passed, 0 failed - build/release/test_embed_observer --raw-host: raw host: obs_needed=1 improving=1 embed observer: 1 passed, 0 failed (exit 0) - cp -a the worktree to /tmp/es1038-r2-plant; change only state.c's st->obs_needed = 1 to 0; plain make; same C runner: embed observer: 30 passed, 1 failed (exit 1) Isolated --raw-host: obs_needed=0 improving=0; embed observer: 0 passed, 1 failed (exit 1). The eigs_open-path checks remain green. Reconfirmed using the exact final C source, byte-identical between the branch and planted copy. - Release full suite, once: RESULTS: 4259/4259 passed, 0 failed - ASan full suite, once, ASAN_OPTIONS=detect_leaks=1: RESULTS: 4248/4248 passed, 0 failed; no leak reports; C contract 31/31. - make tsan; bash tests/test_tsan.sh: Results: 15 passed, 0 failed 13 clean programs plus C contract 31/31; seeded race detected with 14 warnings before its bounded 120-second timeout. - Both full suites: warning audit checks all 481 compile invocations across 29 targets and seven scripts; 89 scripts recognized, six shape waivers. - EIGS_OBS_GATE_STATS=1 src/eigenscript -e $'x is 41\nprint of (x + 1)': obs-gate: unobserved ; output 42. Fresh baseline for the observer differential: cp -a the worktree to /tmp/es1038-r2-main, reverse git diff origin/main -- src using patch -R, then make clean and plain make. All six changed runtime files match git show origin/main: byte-for-byte. Baseline source revision: cd99388163c3ff6478851de1f5be906f7626341a Fresh baseline binary SHA-256: 305b8c73a74e5d6478d48f0c3c20f872e97b9aec41ad4f40f8c125a2a5f83be5 Restored release binary SHA-256 (unchanged from round 1): 7260633c29791dd5bac01b2884b8203ffc601d2b2bc29a91c5718a3d9528384f Observer differential commands (run from the worktree): export EIGS_GATE_DIFF_DIR="$PWD/build/observer-r2-captures" export EIGS_GATE_DIFF_BIN=/tmp/es1038-r2-main/src/eigenscript bash tools/observer_gate_diff.sh capture main bash tools/observer_gate_diff.sh capture main2 # Reapply the runtime patch and rebuild the branch in the SAME scratch path. bash tools/observer_gate_diff.sh capture branch_samepath bash tools/observer_gate_diff.sh compare main branch_samepath Each capture: 521 programs, 5 denied. Compare exits 0: provenance: base=305b8c73a74e(force=0) ref=305b8c73a74e(force=0) gated=7260633c2979(force=0) corpus entries with captures: 520 nondeterministic under a FIXED build (excluded): 23 compared: 497 (informative: 401, silent: 96) mismatches: 0 RESULT: PASS — 497 programs byte-identical Measurement corrections and limits: - An initial compare using the original worktree's branch executable reported seven differences: two import-shadow warnings and five missing-file diagnostics depended on the different executable roots. Rebuilding the branch in the baseline's scratch path removed all seven. No output normalization or exclusion rules were changed. - The 521 attempts / 520 valid entries exposed an existing capture limitation: test_terminal.eigs reads the corpus loop's stdin, truncating the next path to ts/test_throw_unwind.eigs. All three arms captured that invalid path's error instead of tests/test_throw_unwind.eigs; comparison skips its missing capture in all arms. The 497-program claim excludes that test. Documented as a separate harness gap; observer_gate_diff.sh is unchanged this round. Final artifact: six modified files on fix-1038; git diff --check clean. No commit or push attempted: Git metadata is read-only in this session. References #1038 and #1028; closes the round-2 raw-host fixture gap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Kpzyjv1SaLaqBf45FSFDhB --- docs/EMBEDDING.md | 5 ++ docs/EMBED_OBSERVER_VALIDATION.md | 113 ++++++++++++++++++++++++++++++ src/compiler.c | 3 + tests/test_embed_observer.c | 85 ++++++++++++++++++++-- tests/test_embed_observer.sh | 3 +- tests/test_tsan.sh | 8 +++ 6 files changed, 211 insertions(+), 6 deletions(-) diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index c757ebc0..57711f3d 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -166,6 +166,11 @@ reports and temporal queries. The setting belongs to the attached `EigsState`, applies to both string and file evals, and defaults off on every new state. Call the setter only between evals while the host has exclusive use of the state, as required for eval itself. Globals still accumulate normally. +This promise covers eval-unit cross-reads; a host calling predicates directly +between isolated units must arrange recording before the relevant assignments +(`eigs_obs_enable` for direct host work, or `EIGS_OBS_FORCE=1` before the first +eval), because direct predicate calls bypass the eval guard and arming after +the assignments cannot recover their missing history. | API | Contract | |---|---| diff --git a/docs/EMBED_OBSERVER_VALIDATION.md b/docs/EMBED_OBSERVER_VALIDATION.md index 92e45dbf..c89359c7 100644 --- a/docs/EMBED_OBSERVER_VALIDATION.md +++ b/docs/EMBED_OBSERVER_VALIDATION.md @@ -1,5 +1,9 @@ # Embed observer contract: validation (#1038 / #1028) +The original measurements below are from round 1; the +[round-2 record](#round-2-raw-host-coverage) distinguishes state creation from +the separate embed-initialization pin. + Baseline: `origin/main` at `cd99388163c3ff6478851de1f5be906f7626341a`. The baseline runtime was built in this worktree before the runtime edits. The regression is `tests/test_embed_observer.c`; its normal runner is @@ -183,3 +187,112 @@ EIGS_JIT_OFF=1 EIGS_REPLAY=/tmp/spawn-exit.tape src/eigenscript tests/test_spawn The record exits 0; replay exits 139. This pre-existing failure must not be read as a clean replay result merely because the harness exits 0. + +## Round 2: raw-host coverage + +Starting tree: clean `fix-1038` at `7c7a11d`. Both critics found that the +round-1 test's `eigs_open` path always armed through `eigs_state_init_runtime`, +so it could not detect reverting the separate `eigs_state_new` default. +The shipped test now also has `--raw-host`: create and attach a raw state, +allocate an environment, record twelve descending updates, and interrogate +the resulting trajectory. It never initializes the runtime, compiles source +or explicitly arms recording. The original `eigs_open` checks remain. + +`make embed-observer-test` and `bash tests/test_embed_observer.sh`: +**31 passed, 0 failed**, exit 0. Isolated witness: + +```text +raw host: obs_needed=1 improving=1 +PASS: raw host: state creation records without init_runtime +embed observer: 1 passed, 0 failed +``` + +Copied the worktree with `cp -a` to `/tmp/es1038-r2-plant`, changed only +`src/state.c`'s `st->obs_needed = 1;` to `0`, and ran plain `make` followed by +the same C runner. It exited 1 with **30 passed, 1 failed**. Its isolated +`build/release/test_embed_observer --raw-host` also exited 1: + +```text +raw host: obs_needed=0 improving=0 +FAIL: raw host: state creation records without init_runtime +embed observer: 0 passed, 1 failed +``` + +F2's clear already uses `obs_flag_store`, whose implementation is +`__atomic_store_n(..., __ATOMIC_RELEASE)`; preprocessing `src/compiler.c` +confirmed that exact expansion. No runtime store was changed. The CLI's first +compile precedes execution/spawn; normal embedding consumes the first-compile +permission at initialization; isolated eval boundaries require exclusive state +access. A raw host must likewise serialize its first compile against native +arming/execution. An exchange of `obs_needed` alone would not serialize the +surrounding check-then-clear decision. The source comment now names that limit, +and the C test covers a raw first compile followed by concurrent worker arming +and atomic flag reads. The TSan lane now runs the C contract with failures and +sanitizer reports fatal, in addition to its existing concurrency slice. + +F3 documents that direct host predicates between isolated eval units bypass +the eval guard: recording must be arranged before the relevant assignments, +and late arming cannot recover missing history. + +Round-2 gates, run sequentially (each full suite once): + +| Command | Measured result | +|---|---| +| `make && (cd tests && bash run_all_tests.sh)` | 4259/4259 passed, 0 failed. | +| `make asan && (cd tests && ASAN_OPTIONS=detect_leaks=1 bash run_all_tests.sh)` | 4248/4248 passed, 0 failed; no leak reports; C contract 31/31. | +| `make tsan && bash tests/test_tsan.sh` | 15 passed, 0 failed: 13 clean programs, the C contract (31/31), and the seeded race (14 warnings before its 120-second timeout). | + +Both full suites audited 481 compile invocations across 29 targets and seven +scripts. After restoring release, the CLI probe still printed +`obs-gate: unobserved ` and `42`; its SHA-256 remained +`7260633c29791dd5bac01b2884b8203ffc601d2b2bc29a91c5718a3d9528384f`. + +Fresh differential baseline: copied the worktree with `cp -a` to +`/tmp/es1038-r2-main`, reversed `git diff origin/main -- src` with `patch -R`, +then ran `make clean` and plain `make` in that copy. All six changed runtime +files were compared byte-for-byte with `git show origin/main:` before +using the binary. Source revision: +`cd99388163c3ff6478851de1f5be906f7626341a`; rebuilt binary SHA-256: +`305b8c73a74e5d6478d48f0c3c20f872e97b9aec41ad4f40f8c125a2a5f83be5`. +The canonical checkout's binary was not used. + +Captured this fresh baseline twice (`main`, `main2`), then reapplied the runtime +patch and rebuilt the branch **in that same scratch directory**. Its binary +SHA-256 matched the validated worktree release binary above. Holding the +executable path constant matters: an initial comparison with the branch binary +in the original worktree reported seven differences, all executable-relative +import diagnostics (two shadow warnings and five missing-file diagnostics). +No output normalization or exclusion rule was changed to remove those +differences. + +With `EIGS_GATE_DIFF_DIR="$PWD/build/observer-r2-captures"` and +`EIGS_GATE_DIFF_BIN=/tmp/es1038-r2-main/src/eigenscript`, ran +`bash tools/observer_gate_diff.sh capture main`, `capture main2`, then, after +the in-place branch rebuild, `capture branch_samepath` and +`compare main branch_samepath`. Each capture reported **521 programs, 5 denied**. +The comparison exited 0: + +```text +provenance: base=305b8c73a74e(force=0) ref=305b8c73a74e(force=0) gated=7260633c2979(force=0) +corpus entries with captures: 520 +nondeterministic under a FIXED build (excluded): 23 +compared: 497 (informative: 401, silent: 96) +mismatches: 0 +RESULT: PASS — 497 programs byte-identical +``` + +The attempt/entry count difference exposed an existing capture limitation: +`test_terminal.eigs` reads from the corpus loop's stdin, truncating the next +path to `ts/test_throw_unwind.eigs`. All three captures contain that invalid +path's error, while the actual `tests/test_throw_unwind.eigs` capture is absent +from all three and therefore skipped by comparison. The 497-program claim +does **not** include that test. This is recorded as a separate harness gap; +the observer differential tool was unchanged in this round. + +The independent read-free probe, run after restoring release: + +```bash +EIGS_OBS_GATE_STATS=1 src/eigenscript -e $'x is 41\nprint of (x + 1)' +``` + +It exited 0 with `obs-gate: unobserved ` and output `42`. diff --git a/src/compiler.c b/src/compiler.c index 14efa0f0..7ba3d309 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -3967,6 +3967,9 @@ EigsChunk *compile_ast(ASTNode *ast, Env *env, const char *src) { * compilations OR their evidence into this unit, never close its gate. */ if (g_obs_compile_pending) { obs_flag_store(obs_compile_pending, 0); + /* Already a RELEASE atomic store. The surrounding decision requires + * serialized first compilation; exchanging this one flag would not + * make check-then-clear atomic against concurrent native arming. */ if (!g_obs_exec_started) obs_flag_store(obs_needed, 0); } diff --git a/tests/test_embed_observer.c b/tests/test_embed_observer.c index 3b370388..6913842f 100644 --- a/tests/test_embed_observer.c +++ b/tests/test_embed_observer.c @@ -1,7 +1,9 @@ /* #1038/#1028: the host observer contract. --direct is intentionally usable * with origin/main's runtime: compile with -DEIGS_OBS_BASELINE_ONLY to omit - * tests of the additive API. No compile_ast/eigs_obs_enable in the direct arm. */ + * tests of the additive API. No compile_ast/eigs_obs_enable in the direct arm. + * --raw-host also bypasses init_runtime, independently pinning state creation. */ #include +#include #include #include #include "eigs_embed.h" @@ -22,6 +24,31 @@ static int constant(EigsChunk *c, Value *v) { val_decref(v); return idx; } +/* Pin state.c's default independently of the eigs_open/init_runtime pin. + * No runtime initialization, compilation or explicit arming on this path. */ +static void raw_host(void) { + EigsState *st = eigs_state_new(); + if (!st || !eigs_thread_attach(st)) { + check(0, "raw host: attach state"); + eigs_state_destroy(st); + return; + } + Env *env = env_new(NULL); + env_set_local_owned(env, "raw_x", make_num(100)); + double x = 100; + for (int i = 0; i < 12; i++) { + x *= 0.5; + env_set_local_owned(env, "raw_x", make_num(x)); + observer_slot_update_num(env, 0, x); + } + int answer = observer_predicate_at(env, 0, 2 /* improving */, 1); + printf("raw host: obs_needed=%d improving=%d\n", g_obs_needed, answer); + check(g_obs_needed && answer == 1, + "raw host: state creation records without init_runtime"); + env_decref(env); + eigs_thread_detach(); + eigs_state_destroy(st); +} static void direct(void) { EigsState *st = eigs_open(); if (!st) { check(0, "open direct state"); return; } @@ -61,6 +88,50 @@ static void direct(void) { } #ifndef EIGS_OBS_BASELINE_ONLY +static void *arm_from_worker(void *arg) { + EigsState *st = arg; + if (!eigs_thread_attach(st)) return NULL; + for (int i = 0; i < 10000; i++) eigs_obs_enable(); + eigs_thread_detach(); + return st; +} +/* The first compile is serialized BEFORE a worker can arm. Atomic flag + * accesses then allow concurrent arming/readers; they do not make a whole + * compile-and-clear transaction safe against arbitrary concurrent host code. */ +static void raw_compile_then_arm(void) { + EigsState *st = eigs_state_new(); + if (!st || !eigs_thread_attach(st)) { + check(0, "raw compile: attach state"); + eigs_state_destroy(st); + return; + } + Env *env = env_new(NULL); + const char *source = "42\n"; + TokenList tl = tokenize(source); + ASTNode *ast = parse(&tl); + EigsChunk *chunk = compile_ast(ast, env, source); + check(chunk && !g_parse_errors && !g_has_error && !g_obs_needed, + "raw compile: first read-free verdict closes before workers"); + pthread_t worker; + int rc = pthread_create(&worker, NULL, arm_from_worker, st); + void *result = NULL; + if (rc == 0) { + for (int i = 0; i < 10000; i++) { + (void)g_obs_needed; + (void)g_obs_compile_pending; + } + rc = pthread_join(worker, &result); + } + check(rc == 0 && result == st && g_obs_needed && + !g_obs_compile_pending && !g_obs_history_gap, + "raw compile: worker arming preserves the open verdict"); + chunk_free(chunk); + free_ast(ast); + free_tokenlist(&tl); + env_decref(env); + eigs_thread_detach(); + eigs_state_destroy(st); +} /* Each assignment changes x last, so both named and bare predicates can be * compared with the VM. The final step is still well outside the deadband. */ static const char *series = @@ -186,11 +257,15 @@ static void eval_contract(void) { } #endif int main(int argc, char **argv) { - direct(); + int raw_only = argc == 2 && strcmp(argv[1], "--raw-host") == 0; + int direct_only = argc == 2 && strcmp(argv[1], "--direct") == 0; + if (!direct_only) raw_host(); + if (!raw_only) direct(); #ifndef EIGS_OBS_BASELINE_ONLY - if (!(argc == 2 && strcmp(argv[1], "--direct") == 0)) eval_contract(); -#else - (void)argc; (void)argv; + if (!raw_only && !direct_only) { + raw_compile_then_arm(); + eval_contract(); + } #endif printf("embed observer: %d passed, %d failed\n", passed, failed); return failed ? 1 : 0; diff --git a/tests/test_embed_observer.sh b/tests/test_embed_observer.sh index 80b32c1f..83ed94a6 100644 --- a/tests/test_embed_observer.sh +++ b/tests/test_embed_observer.sh @@ -35,6 +35,7 @@ lsan_classify "$(cat "$out")" || classification=$? if [[ "$rc" -ne 0 || "$classification" -ne 2 ]]; then exit 1 fi -grep -q '^embed observer: 28 passed, 0 failed$' "$out" +grep -q '^embed observer: 31 passed, 0 failed$' "$out" +grep -q '^raw host: obs_needed=1 improving=1$' "$out" grep -q '^embed obs-gate: unobserved$' "$out" grep -q '^obs-gate: unobserved ' "$out" diff --git a/tests/test_tsan.sh b/tests/test_tsan.sh index 03915c1f..a522c317 100755 --- a/tests/test_tsan.sh +++ b/tests/test_tsan.sh @@ -54,6 +54,14 @@ for t in $SLICE; do fi done +echo "=== C embed observer contract (raw state and worker arming) ===" +if TSAN_OPTIONS="halt_on_error=1 exitcode=66" setarch -R \ + bash "$TESTS_DIR/test_embed_observer.sh"; then + echo " PASS: C embed observer contract"; PASS=$((PASS + 1)) +else + echo " FAIL: C embed observer contract"; FAIL=$((FAIL + 1)) +fi + echo "=== gate self-validation: a seeded race MUST be caught ===" tsan_warnings "$TESTS_DIR/tsan_seeded_race.eigs" w=$WARNINGS From ac302750d1c31f61c11d2a2a33c6bdeb37692eaa Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sun, 6 Sep 2026 14:35:45 -0500 Subject: [PATCH 4/4] fix(embed): preserve explicit host arming across an isolated eval boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An isolated host following the documented eigs_obs_enable-before-eval recipe still lost its observer history: eval_source renewed compile permission after the arm, a read-free verdict closed recording, and direct improving(x) returned 0 with gap=0 and no error. Make the recipe work instead of replacing it with an opt-out recipe. Separate explicit host requests from internal compiler/runtime arming. The public eigs_obs_enable sets an atomic pending host request and delegates to the existing arming helper. The next eval compilation boundary consumes that request with an atomic exchange and withholds permission to close that unit. Internal arming uses eigs_obs_enable_runtime without creating a future host request. The existing gap-before-needed release stores and source scan verdict logic are unchanged. The new field owns no runtime objects and adds no tape records or per-assignment work. Add an isolated-host C fixture: opt in, arm twice, execute a read-free unit, and call observer_predicate_at directly. Require improving=1 and no gap/error, then require the following read-free unit to run unobserved. Extend the existing worker-arming fixture to read/check the new atomic flag under TSan. Keep the raw-state and eigs_open-path fixtures and all cross-unit gap checks. Document F5's host obligation: a raw state's first compile must be serialized against worker arming/execution. The atomic obs_needed store does not make the surrounding check-then-clear a transaction. Isolated eval boundaries already require exclusive state access. Remove F6's stale numeric flag count. Update the embed contract/header, changelog and validation record together. Validation started from clean fix-1038 at babd15f. One heavy job at a time: - Before the runtime change, make embed-observer-test then --isolated-host: isolated host: DIRECT improving=0 obs_needed=0 gap=0 embed observer: 3 passed, 1 failed (exit 1). Full bash tests/test_embed_observer.sh: 34 passed, 1 failed (exit 1). - After the change, plain make then bash tests/test_embed_observer.sh: embed observer: 35 passed, 0 failed (exit 0). Isolated fixture: DIRECT improving=1 obs_needed=1 gap=0; embed observer: 4 passed, 0 failed (exit 0). - Release full suite, once: (cd tests && bash run_all_tests.sh) RESULTS: 4259/4259 passed, 0 failed Every child .sh test completed; 0 nonzero child exits. Warning audit: 481 compile invocations, 29 targets, 7 scripts; recognizer: 89 scripts, 6 shape waivers. - ASan full suite, once: make asan; (cd tests && ASAN_OPTIONS=detect_leaks=1 bash run_all_tests.sh) RESULTS: 4248/4248 passed, 0 failed Every child .sh test completed; 0 nonzero child exits; no ASan/UBSan/leak diagnostic lines. C preflight and suite C contract: 35/35, leaks enabled. Same warning audit: 481 invocations / 29 targets / 7 scripts; recognizer: 89 scripts, 6 shape waivers. - make tsan; bash tests/test_tsan.sh: Results: 15 passed, 0 failed 13 clean programs; C contract 35/35; seeded race detected (16 warnings). - Restored release with make. Read-free EIGS_OBS_GATE_STATS=1 probe: obs-gate: unobserved ; output 42; exit 0. Release SHA-256: 11310dc5efbab32e4a3d1a4b66168de50ed923173990613790709ea69ad7bf19 Fresh same-path observer differential: - cp -a the worktree to /tmp/es1038-r3-oracle; reverse the complete git diff origin/main -- src using patch -R; verify all nine changed runtime files byte-for-byte against git show origin/main:; make clean; make. Baseline source: cd99388163c3ff6478851de1f5be906f7626341a Fresh baseline SHA-256: 305b8c73a74e5d6478d48f0c3c20f872e97b9aec41ad4f40f8c125a2a5f83be5 - From the original worktree, set EIGS_GATE_DIFF_DIR="$PWD/build/observer-r3-captures" EIGS_GATE_DIFF_BIN=/tmp/es1038-r3-oracle/src/eigenscript and unset EIGS_OBS_FORCE, EIGS_OBS_GATE_STATS, EIGS_TRACE, EIGS_REPLAY, EIGS_JIT_OFF. Run bash tools/observer_gate_diff.sh capture main, then capture main2. Reapply the runtime patch and rebuild the branch IN PLACE, then capture branch and compare main branch. Scratch branch source files and binary SHA match the validated worktree. The manifest rev is the calling worktree's babd15f; source verification identifies the executable. No canonical build or binary was used. - Each capture: 521 programs, 5 denied. Compare exits 0: provenance: base=305b8c73a74e(force=0) ref=305b8c73a74e(force=0) gated=11310dc5efba(force=0) corpus entries with captures: 520 nondeterministic under a FIXED build (excluded): 23 compared: 497 (informative: 401, silent: 96) mismatches: 0 RESULT: PASS — 497 programs byte-identical Known measurement limit, unchanged from round 2: test_terminal.eigs consumes corpus stdin, truncating the next path to ts/test_throw_unwind.eigs in all three captures. The real tests/test_throw_unwind.eigs is outside the 497 compared programs. No differential normalization, exclusions or floors were changed. No new measurement contradicted the round-3 brief. Final artifact: git diff --check clean; 13 modified files on fix-1038. Git metadata is read-only in this session; no commit or push attempted. References #1038 and #1028; closes round-3 F4, F5 and F6. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Kpzyjv1SaLaqBf45FSFDhB --- CHANGELOG.md | 7 +- docs/EMBEDDING.md | 34 +++++++-- docs/EMBED_OBSERVER_VALIDATION.md | 116 +++++++++++++++++++++++++++++- src/builtins.c | 8 +-- src/compiler.c | 32 ++++----- src/eigenscript.c | 10 ++- src/eigenscript.h | 13 +++- src/eigs_embed.c | 12 ++-- src/eigs_embed.h | 6 +- src/repl.c | 2 +- src/vm.c | 4 +- tests/test_embed_observer.c | 33 +++++++-- tests/test_embed_observer.sh | 3 +- 13 files changed, 231 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2f0e3b..76e3c921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,8 +45,11 @@ All notable changes to EigenScript are documented here. skip observation; default evals retain cross-unit history. Missing history rejects later reader units with `EIGS_OBS_FORCE=1` guidance, conservatively even for independent bindings. Escaped compiled functions retain the gate's - accumulated verdict; registered C callbacks keep eval recording open. A C - regression runs under the suite's build variant. + accumulated verdict; registered C callbacks keep eval recording open. + Explicit host `eigs_obs_enable()` also pins the next isolated eval open, + so direct host predicates can read that unit's recorded assignments; the + request is consumed at one eval boundary. A C regression runs under the + suite's build variant. - **`is_file of path` (#1058).** 1 iff the path names a REGULAR file (`S_ISREG`); 0 for a directory, a device/fifo/socket, a missing path, or a diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index 57711f3d..bcdf3350 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -143,13 +143,22 @@ A newly opened state records observer updates by default. This also applies to `eigs_state_new` plus attach/init: a native or assembled-code host that never calls `compile_ast` has a live observer. Calling `eigs_obs_enable()` at startup is **no longer required**. The call remains idempotent and can pin the current -unit open before compilation. Embed initialization pins recording open because +unit open before compilation; an explicit host call also pins the next embed +eval that reaches compilation, including an isolated eval. Embed initialization +pins recording open because a subsequently compiled module cannot classify its surrounding native caller. The explicit eval opt-in below renews permission to choose a compile verdict. Only a compile verdict proving no observer reads may close the gate; recording stays monotonic within that execution unit. The CLI's read-free programs still compile as `unobserved`. +A raw host using `compile_ast` before runtime initialization must serialize +the first compilation against worker arming and execution: no worker may call +`eigs_obs_enable()` until that compile returns. Atomic flag accesses do not +make the compiler's check-then-clear decision a transaction. Subsequent arming +and flag reads may be concurrent within the established execution unit; +isolated eval boundaries still require exclusive state access. + By default, `eigs_eval_string` and `eigs_eval_file` keep recording across calls. A later unit may interrogate a binding an earlier unit assigned, with its full history available. Hosts can explicitly opt in to compile-time gating: @@ -168,17 +177,30 @@ Call the setter only between evals while the host has exclusive use of the state, as required for eval itself. Globals still accumulate normally. This promise covers eval-unit cross-reads; a host calling predicates directly between isolated units must arrange recording before the relevant assignments -(`eigs_obs_enable` for direct host work, or `EIGS_OBS_FORCE=1` before the first -eval), because direct predicate calls bypass the eval guard and arming after +(`eigs_obs_enable()` before the eval that makes those assignments, or +`EIGS_OBS_FORCE=1` before the first eval), because direct predicate calls bypass +the eval guard and arming after the assignments cannot recover their missing history. +An explicit arm is consumed at one eval compilation boundary; call it again +before each unit whose assignments the host will interrogate directly. Arming +by a source scan or by internal runtime code does not create that host request. + +```c +eigs_set_eval_observer_isolated(1); +eigs_obs_enable(); +EigsValue *r = eigs_eval_string(assignments); /* this unit stays observed */ +eigs_value_release(r); +/* Direct observer_predicate_at calls can now read this unit's history. */ +``` | API | Contract | |---|---| | `eigs_set_eval_observer_isolated(int enabled)` | Nonzero opts in to the promise above; zero restores recording for subsequent work. | -| `eigs_obs_enable(void)` | Idempotently arms the current unit; does not reconstruct missing history. | +| `eigs_obs_enable(void)` | Idempotently arms the current unit and pins the next eval compilation boundary open; does not reconstruct missing history. | -An opted-in eval starts a new unit and may choose a new verdict. Use default -eval mode or `EIGS_OBS_FORCE=1` to keep subsequent units observed. +An opted-in eval starts a new unit and may choose a new verdict unless the +host explicitly armed it. Use default eval mode or `EIGS_OBS_FORCE=1` to keep +all subsequent units observed. With the opt-in, a read-free unit can run `unobserved`, visible through `EIGS_OBS_GATE_STATS=1`. The existing source scan supplies each verdict. Functions diff --git a/docs/EMBED_OBSERVER_VALIDATION.md b/docs/EMBED_OBSERVER_VALIDATION.md index c89359c7..45b0c72b 100644 --- a/docs/EMBED_OBSERVER_VALIDATION.md +++ b/docs/EMBED_OBSERVER_VALIDATION.md @@ -1,8 +1,10 @@ # Embed observer contract: validation (#1038 / #1028) -The original measurements below are from round 1; the +The first sections record round 1. The [round-2 record](#round-2-raw-host-coverage) distinguishes state creation from -the separate embed-initialization pin. +the separate embed-initialization pin; the +[round-3 record](#round-3-explicit-host-arming-across-an-isolated-eval-boundary) +corrects the explicit-host-arm recipe across an isolated eval boundary. Baseline: `origin/main` at `cd99388163c3ff6478851de1f5be906f7626341a`. The baseline runtime was built in this worktree before the runtime edits. @@ -296,3 +298,113 @@ EIGS_OBS_GATE_STATS=1 src/eigenscript -e $'x is 41\nprint of (x + 1)' ``` It exited 0 with `obs-gate: unobserved ` and output `42`. + +## Round 3: explicit host arming across an isolated eval boundary + +Starting tree: clean `fix-1038` at `babd15f`. The round-2 direct-host recipe +was wrong: an isolated eval renewed compile permission after the host armed, +so its read-free verdict discarded the assignments that C would interrogate. +This round makes the recipe work rather than replacing it with an opt-out. + +The public `eigs_obs_enable()` now sets a separate atomic host request and +arms the current unit through the existing recording helper. At the next eval +compilation boundary, an atomic exchange consumes that request and suppresses +permission to close that unit. Internal compiler/runtime calls use +`eigs_obs_enable_runtime()` without creating a future host request. Its +gap-before-needed release stores are unchanged, as is the compile scan's +verdict logic. A subsequent read-free unit can gate again. The new flag owns +no runtime objects and adds no tape record or per-assignment work. + +The C regression's `--isolated-host` arm follows the actual host recipe: +`eigs_open`, isolation on, explicit arm twice (idempotence), read-free eval, +then a direct `observer_predicate_at` on `x`. No compiled predicate can rescue +the eval. The arm also executes another read-free unit and requires its gate +to close, proving the host request is consumed once. + +Before changing runtime code, built the fixture with +`make embed-observer-test` against `babd15f`: + +```text +isolated host: DIRECT improving=0 obs_needed=0 gap=0 +FAIL: isolated host: explicit arming survives the eval boundary +embed observer: 3 passed, 1 failed +``` + +`build/release/test_embed_observer --isolated-host` exited 1, and the complete +`bash tests/test_embed_observer.sh` exited 1 with **34 passed, 1 failed**. +After the fix and a release rebuild: + +```text +isolated host: DIRECT improving=1 obs_needed=1 gap=0 +PASS: isolated host: explicit arming survives the eval boundary +embed observer: 4 passed, 0 failed +``` + +The isolated arm exited 0; the complete C runner reported **35 passed, +0 failed**. Existing raw-host and cross-unit history-gap arms remain enrolled. +The worker-arming test now reads the new atomic host-request flag concurrently +and checks it after joining the worker; the TSan lane runs this C fixture. + +F5 is resolved by documenting the supported host serialization contract in +`EMBEDDING.md`: a raw host must serialize its first compilation against worker +arming/execution. Atomic flag stores do not make that multi-flag decision a +transaction. Opted-in eval boundaries already require exclusive state access. +F6 removes the stale numeric flag count from the atomic-access comment. + +Round-3 gates, run sequentially (each full suite once): + +| Command | Measured result | +|---|---| +| `make && (cd tests && bash run_all_tests.sh)` | 4259/4259 passed, 0 failed; every child completed, 0 nonzero exits. | +| `make asan && (cd tests && ASAN_OPTIONS=detect_leaks=1 bash run_all_tests.sh)` | 4248/4248 passed, 0 failed; every child completed, 0 nonzero exits; no ASan/UBSan/leak reports. | +| `make tsan && bash tests/test_tsan.sh` | 15 passed, 0 failed: 13 clean programs, the C contract (35/35), and the seeded race (16 warnings detected). | + +Both full suites ran the C contract (35/35), audited **481 compile invocations +across 29 targets and seven scripts**, and recognized **89 scripts with six +shape waivers**. The ASan C preflight also passed 35/35 with leak detection on. + +After restoring release with `make`, the read-free probe +`EIGS_OBS_GATE_STATS=1 src/eigenscript -e $'x is 41\nprint of (x + 1)'` +exited 0 with `obs-gate: unobserved ` and `42`. Release binary SHA-256: +`11310dc5efbab32e4a3d1a4b66168de50ed923173990613790709ea69ad7bf19`. + +For the round-3 differential, copied the worktree with `cp -a` to +`/tmp/es1038-r3-oracle`, reversed `git diff origin/main -- src` with `patch -R`, +and verified all **nine** affected runtime files against +`git show origin/main:`. Ran `make clean` and plain `make` in the copy. +Baseline source: `cd99388163c3ff6478851de1f5be906f7626341a`; fresh binary SHA-256: +`305b8c73a74e5d6478d48f0c3c20f872e97b9aec41ad4f40f8c125a2a5f83be5`. + +Captured `main` and `main2` with that binary, then reapplied the runtime patch +and rebuilt the branch in the **same scratch directory**. All nine runtime +files matched the validated worktree, and the resulting binary matched its +release SHA-256 above. The capture manifest's `rev` identifies the calling +worktree (`babd15f`); the source comparison and binary SHA identify the actual +baseline executable. The canonical checkout was not built or used. + +From the original worktree, with +`EIGS_GATE_DIFF_DIR="$PWD/build/observer-r3-captures"` and +`EIGS_GATE_DIFF_BIN=/tmp/es1038-r3-oracle/src/eigenscript`, ran +`bash tools/observer_gate_diff.sh capture main`, `capture main2`, then after +the branch rebuild, `capture branch` and `compare main branch`. Captures unset +`EIGS_OBS_FORCE`, `EIGS_OBS_GATE_STATS`, `EIGS_TRACE`, `EIGS_REPLAY` and +`EIGS_JIT_OFF`; both baseline captures reported **521 programs, 5 denied**. + +The branch capture also reported **521 programs, 5 denied**. Comparison exited +0 with the following totals: + +```text +provenance: base=305b8c73a74e(force=0) ref=305b8c73a74e(force=0) gated=11310dc5efba(force=0) +corpus entries with captures: 520 +nondeterministic under a FIXED build (excluded): 23 +compared: 497 (informative: 401, silent: 96) +mismatches: 0 +RESULT: PASS — 497 programs byte-identical +``` + +The round-2 capture limitation remains: the terminal fixture consumes corpus +stdin, leaving `ts/test_throw_unwind.eigs` instead of the real next path in +all three captures. `tests/test_throw_unwind.eigs` is therefore outside the +497-program comparison. No differential-tool normalization, exclusion rule +or coverage floor was changed. No new measurement contradicted the round-3 +brief; the reported F4 silent-wrong recipe was reproduced and repaired. diff --git a/src/builtins.c b/src/builtins.c index 016ff18c..42e0a3d3 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -3197,7 +3197,7 @@ static EigsChunk *vm_build_chunk_desc(Value *desc, int off, int sandbox_mode) { * 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 + * SOLVED — the descriptor's OWN work. eigs_obs_enable_runtime() 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 @@ -3235,9 +3235,9 @@ Value* builtin_vm_run_bytecode(Value *arg) { * 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- + * Through eigs_obs_enable_runtime, not a bare assignment: this flip happens mid- * execution, so it must also record that earlier bindings have no history. */ - eigs_obs_enable(); + eigs_obs_enable_runtime(); /* #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 @@ -3452,7 +3452,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 */ + if (chunk) eigs_obs_enable_runtime(); /* #915: see vm_run_bytecode */ Value *out = make_dict(2); if (!chunk) { /* Descriptor verification may already have interned constants before diff --git a/src/compiler.c b/src/compiler.c index 7ba3d309..d6cb44fd 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -3704,14 +3704,14 @@ static void obs_gate_resolve_static_loads(EigsChunk *chunk) { * 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 (g_obs_gate_depth >= OBS_GATE_MAX_DEPTH) { eigs_obs_enable_runtime(); return; } L.bases = xcalloc_array(OBS_GATE_MAX_LOADS, sizeof(char *)); L.base = chunk->src && chunk->src->resolve_dir ? chunk->src->resolve_dir : eigs_current_file_dir(); - 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 */ + if (chunk_scan_static_loads(chunk, obs_gate_note_load, &L)) { eigs_obs_enable_runtime(); goto done; } + if (L.overflow) { eigs_obs_enable_runtime(); 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 @@ -3728,7 +3728,7 @@ static void obs_gate_resolve_static_loads(EigsChunk *chunk) { * 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; + eigs_obs_enable_runtime(); goto done; } /* HEAP, not stack. As `char resolved[8192]` inside the loop this frame @@ -3740,7 +3740,7 @@ static void obs_gate_resolve_static_loads(EigsChunk *chunk) { * 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; } + if (!resolved) { eigs_obs_enable_runtime(); goto done; } } for (int i = 0; i < L.count && !g_obs_needed; i++) { @@ -3761,7 +3761,7 @@ static void obs_gate_resolve_static_loads(EigsChunk *chunk) { #else int resolved_ok = 0; #endif - if (!resolved_ok) { eigs_obs_enable(); break; } + if (!resolved_ok) { eigs_obs_enable_runtime(); break; } #if !EIGENSCRIPT_FREESTANDING /* STAT BEFORE OPEN. This pass reads files on behalf of code the program @@ -3794,25 +3794,25 @@ static void obs_gate_resolve_static_loads(EigsChunk *chunk) { 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; + eigs_obs_enable_runtime(); break; } module_dir = eigs_file_directory(resolved); struct stat dir_st; - if (stat(module_dir, &dir_st) != 0) { eigs_obs_enable(); break; } + if (stat(module_dir, &dir_st) != 0) { eigs_obs_enable_runtime(); break; } if (obs_memo_seen(st.st_dev, st.st_ino, dir_st.st_dev, dir_st.st_ino)) continue; if (g_obs_spec_bytes + st.st_size > OBS_GATE_SPECULATIVE_BUDGET) { - eigs_obs_enable(); break; + eigs_obs_enable_runtime(); break; } g_obs_spec_bytes += st.st_size; source = read_file_util(resolved, &size); #endif - if (!source) { eigs_obs_enable(); break; } + if (!source) { eigs_obs_enable_runtime(); break; } #if !EIGENSCRIPT_FREESTANDING obs_memo_add(st.st_dev, st.st_ino, dir_st.st_dev, dir_st.st_ino); #endif int muted = obs_gate_mute_stderr(); - if (muted < 0) { free(source); eigs_obs_enable(); break; } + if (muted < 0) { free(source); eigs_obs_enable_runtime(); break; } /* BEFORE tokenize, not after. lexer.c zeroes all five first_error * fields unconditionally at tokenize depth 0, so a snapshot taken after @@ -3838,7 +3838,7 @@ static void obs_gate_resolve_static_loads(EigsChunk *chunk) { 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; + eigs_obs_enable_runtime(); break; } /* #1031: answer from the AST -- no compile_ast here any more. The @@ -3852,12 +3852,12 @@ static void obs_gate_resolve_static_loads(EigsChunk *chunk) { * chunk's own list. Nothing compiles, so nothing arms trace recording * in the parent (the trace_arm_snapshot dance is gone with it). */ L.base = module_dir; - if (obs_ast_scan(mast, &L) || L.overflow) eigs_obs_enable(); + if (obs_ast_scan(mast, &L) || L.overflow) eigs_obs_enable_runtime(); 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); - if (g_parse_errors > 0) eigs_obs_enable(); + if (g_parse_errors > 0) eigs_obs_enable_runtime(); g_parse_errors = saved_errors; free_ast(mast); free_tokenlist(&tl); free(source); obs_gate_unmute_stderr(muted); @@ -3994,8 +3994,8 @@ EigsChunk *compile_ast(ASTNode *ast, Env *env, const char *src) { * `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. */ - if (!g_obs_needed && eigs_env_flag("EIGS_OBS_FORCE")) eigs_obs_enable(); - if (!g_obs_needed && chunk_reads_observer(chunk)) eigs_obs_enable(); + if (!g_obs_needed && eigs_env_flag("EIGS_OBS_FORCE")) eigs_obs_enable_runtime(); + if (!g_obs_needed && chunk_reads_observer(chunk)) eigs_obs_enable_runtime(); 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 diff --git a/src/eigenscript.c b/src/eigenscript.c index c036aa8a..50309d3b 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -625,7 +625,7 @@ void observer_slot_update(Env *e, int idx, Value *newval) { * 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 runtime, never cleared within a unit. The + * and by eigs_obs_enable_runtime() at runtime, never cleared within a unit. 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 @@ -3855,7 +3855,7 @@ Value* env_get_local_hashed(Env *env, const char *name, uint32_t h) { * 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) { +void eigs_obs_enable_runtime(void) { if (!eigs_current) return; obs_flag_store(obs_compile_pending, 0); if (g_obs_needed) return; @@ -3863,6 +3863,12 @@ void eigs_obs_enable(void) { obs_flag_store(obs_needed, 1); } +void eigs_obs_enable(void) { + if (!eigs_current) return; + obs_flag_store(obs_host_arm_pending, 1); + eigs_obs_enable_runtime(); +} + 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 6bcf9c77..3d46f39c 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -572,6 +572,9 @@ struct EigsState { * Accessed atomically because * worker-reachable arming sites also consume it. */ int obs_compile_pending; + /* Explicit public arming pins the next embed eval boundary too. Internal + * compiler/runtime arming must not set this one-unit host request. */ + int obs_host_arm_pending; /* Sticky missing-history evidence. Opening the gate cannot reconstruct * prior assignments; catching an error must never clear this flag. */ int obs_history_gap; @@ -1104,8 +1107,8 @@ extern __thread EigsThread *eigs_current; #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. The three execution flags are read at every safepoint - * and STORED from +/* ATOMIC, relaxed. Execution flags are read at every safepoint; the other + * observer flags also cross threads. They are 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 @@ -1128,6 +1131,7 @@ extern __thread EigsThread *eigs_current; * fails to compile and must go through obs_flag_store — the write sites stay * enumerable. */ #define g_obs_compile_pending __atomic_load_n(&eigs_current->state->obs_compile_pending, __ATOMIC_RELAXED) +#define g_obs_host_arm_pending __atomic_load_n(&eigs_current->state->obs_host_arm_pending, __ATOMIC_RELAXED) #define g_obs_eval_host_callbacks __atomic_load_n(&eigs_current->state->eval_host_callbacks, __ATOMIC_RELAXED) #define g_obs_eval_retains_code __atomic_load_n(&eigs_current->state->obs_eval_retains_code, __ATOMIC_RELAXED) #define g_obs_needed __atomic_load_n(&eigs_current->state->obs_needed, __ATOMIC_RELAXED) @@ -1149,7 +1153,7 @@ extern __thread EigsThread *eigs_current; __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 arm recording mid-unit. `g_obs_needed` +/* #915: the runtime helper for arming recording mid-unit. `g_obs_needed` * answers "is recording on?"; the 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 @@ -1158,6 +1162,9 @@ extern __thread EigsThread *eigs_current; * 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_runtime(void); +/* Public host arming also pins the next embed eval boundary; internal source + * scan/runtime evidence uses the helper above, without a future host pin. */ 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 diff --git a/src/eigs_embed.c b/src/eigs_embed.c index affeb66d..6a05b4d4 100644 --- a/src/eigs_embed.c +++ b/src/eigs_embed.c @@ -26,7 +26,7 @@ int eigs_state_init_runtime(EigsState *st) { /* #1038: a module compiled later cannot classify the surrounding native * host. Pin default embedding open; only the explicit eval opt-in may * renew permission for a compile verdict. CLI state setup is separate. */ - eigs_obs_enable(); + eigs_obs_enable_runtime(); return 0; } @@ -115,6 +115,8 @@ static EigsValue *eval_source(const char *src, const char *file_dir) { * A retained function may be invoked without a reader in the new source, * so in that case keep the accumulated verdict instead. All boundary * resets require exclusive state access; worker arming stays atomic. */ + int host_armed = __atomic_exchange_n( + &eigs_current->state->obs_host_arm_pending, 0, __ATOMIC_ACQ_REL); if (eigs_current->state->eval_observer_isolated && !g_obs_eval_host_callbacks) { if (!g_obs_needed && g_obs_exec_started) @@ -122,10 +124,12 @@ static EigsValue *eval_source(const char *src, const char *file_dir) { if (!g_obs_eval_retains_code) { obs_flag_store(obs_exec_started, 0); obs_flag_store(obs_needed, 1); - obs_flag_store(obs_compile_pending, 1); + /* A public host arm applies across this boundary. The compiler's + * own previous verdict does not carry such a one-unit request. */ + obs_flag_store(obs_compile_pending, !host_armed); } } else { - eigs_obs_enable(); + eigs_obs_enable_runtime(); } /* A file's explicit base must beat a caller frame while compiling, but * must never outlive compilation: runtime eval belongs to its own frame. @@ -365,7 +369,7 @@ void eigs_register_function(const char *name, EigsHostFn fn) { * after registration. Late registration records the gap; eval's guard * refuses to enter opaque host code with incomplete history. */ obs_flag_store(eval_host_callbacks, 1); - eigs_obs_enable(); + eigs_obs_enable_runtime(); Value *bv = make_builtin((BuiltinFn)fn); env_set_local_owned(g_global_env, name, bv); } diff --git a/src/eigs_embed.h b/src/eigs_embed.h index d8aee713..73ec4fbc 100644 --- a/src/eigs_embed.h +++ b/src/eigs_embed.h @@ -88,8 +88,10 @@ EigsValue *eigs_eval_file(const char *path); * makes subsequent evals fail before entering opaque host code. * Disabling the opt-in records future work; it cannot repair missing history. */ void eigs_set_eval_observer_isolated(int enabled); -/* Recording starts open. Optional explicit arming pins the current unit open - * before compilation; idempotent, and never repairs missing history. */ +/* Recording starts open. Explicit host arming pins the current unit and the + * next eval that reaches compilation open, including an isolated eval. The + * next-boundary request is consumed once; arm before each unit whose writes + * a direct host predicate needs. Idempotent; never repairs missing history. */ void eigs_obs_enable(void); /* ---- Errors ------------------------------------------------------- */ diff --git a/src/repl.c b/src/repl.c index 1e7c2573..d7b12e75 100644 --- a/src/repl.c +++ b/src/repl.c @@ -73,7 +73,7 @@ static int repl_eval_buffer(Env *env, strbuf *input) { * 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 */ + eigs_obs_enable_runtime(); /* #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); diff --git a/src/vm.c b/src/vm.c index 92b105c3..eb313418 100644 --- a/src/vm.c +++ b/src/vm.c @@ -235,7 +235,7 @@ static void eigs_observer_dump(Env *leaf) { * 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(); + eigs_obs_enable_runtime(); } obs_dump_scope("module", root, 1, NULL); Env *fn_env = NULL; @@ -7067,7 +7067,7 @@ 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() + /* #915: user code is now executing, so from here on an eigs_obs_enable_runtime() * 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. diff --git a/tests/test_embed_observer.c b/tests/test_embed_observer.c index 6913842f..c788e046 100644 --- a/tests/test_embed_observer.c +++ b/tests/test_embed_observer.c @@ -119,11 +119,12 @@ static void raw_compile_then_arm(void) { for (int i = 0; i < 10000; i++) { (void)g_obs_needed; (void)g_obs_compile_pending; + (void)g_obs_host_arm_pending; } rc = pthread_join(worker, &result); } check(rc == 0 && result == st && g_obs_needed && - !g_obs_compile_pending && !g_obs_history_gap, + !g_obs_compile_pending && g_obs_host_arm_pending && !g_obs_history_gap, "raw compile: worker arming preserves the open verdict"); chunk_free(chunk); free_ast(ast); @@ -172,6 +173,27 @@ static EigsValue *host_reader(EigsValue *arg) { env_decref(env); return make_num(answer); } +/* Execute the documented explicit-host-arm recipe. No compiled predicate + * may rescue the read-free unit: interrogate its slot directly from C. */ +static void isolated_host(void) { + EigsState *st = eigs_open(); + if (!st) { check(0, "isolated host: open state"); return; } + eigs_set_eval_observer_isolated(1); + eigs_obs_enable(); + eigs_obs_enable(); /* idempotent, including the next-boundary pin */ + eval_ok(series, "isolated host: explicitly armed read-free unit executes"); + int slot = -1; + for (int i = 0; i < g_global_env->count; i++) + if (!strcmp(g_global_env->names[i], "x")) { slot = i; break; } + int answer = slot < 0 ? -1 : observer_predicate_at(g_global_env, slot, 2, 1); + printf("isolated host: DIRECT improving=%d obs_needed=%d gap=%d\n", + answer, g_obs_needed, g_obs_history_gap); + check(answer == 1 && g_obs_needed && !g_obs_history_gap && !eigs_has_error(), + "isolated host: explicit arming survives the eval boundary"); + eval_ok("fresh is 42\nfresh\n", "isolated host: following unit executes"); + check(!g_obs_needed, "isolated host: explicit pin is consumed by one unit"); + eigs_close(st); +} static void eval_contract(void) { /* A native host can load/compile a module without routing through the * eval API. That module's verdict says nothing about the C caller. */ @@ -259,12 +281,15 @@ static void eval_contract(void) { int main(int argc, char **argv) { int raw_only = argc == 2 && strcmp(argv[1], "--raw-host") == 0; int direct_only = argc == 2 && strcmp(argv[1], "--direct") == 0; - if (!direct_only) raw_host(); - if (!raw_only) direct(); + int isolated_only = argc == 2 && strcmp(argv[1], "--isolated-host") == 0; + if (!direct_only && !isolated_only) raw_host(); + if (!raw_only && !isolated_only) direct(); #ifndef EIGS_OBS_BASELINE_ONLY - if (!raw_only && !direct_only) { + if (isolated_only) isolated_host(); + else if (!raw_only && !direct_only) { raw_compile_then_arm(); eval_contract(); + isolated_host(); } #endif printf("embed observer: %d passed, %d failed\n", passed, failed); diff --git a/tests/test_embed_observer.sh b/tests/test_embed_observer.sh index 83ed94a6..16093bb0 100644 --- a/tests/test_embed_observer.sh +++ b/tests/test_embed_observer.sh @@ -35,7 +35,8 @@ lsan_classify "$(cat "$out")" || classification=$? if [[ "$rc" -ne 0 || "$classification" -ne 2 ]]; then exit 1 fi -grep -q '^embed observer: 31 passed, 0 failed$' "$out" +grep -q '^embed observer: 35 passed, 0 failed$' "$out" grep -q '^raw host: obs_needed=1 improving=1$' "$out" +grep -q '^isolated host: DIRECT improving=1 obs_needed=1 gap=0$' "$out" grep -q '^embed obs-gate: unobserved$' "$out" grep -q '^obs-gate: unobserved ' "$out"