diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7d0721..76e3c921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,20 @@ 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. + 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 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..bcdf3350 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -137,6 +137,99 @@ 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; 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: + +```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. +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()` 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 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 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 +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..45b0c72b --- /dev/null +++ b/docs/EMBED_OBSERVER_VALIDATION.md @@ -0,0 +1,410 @@ +# Embed observer contract: validation (#1038 / #1028) + +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 +[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. +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. + +## 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`. + +## 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/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/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 bfbec22a..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); @@ -3960,21 +3960,25 @@ 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); + /* 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); + } + /* #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 @@ -3990,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 7171ff28..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 the runtime arming sites, never cleared. 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,12 +3855,20 @@ 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; if (g_obs_exec_started) obs_flag_store(obs_history_gap, 1); 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 f9336cb8..3d46f39c 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -559,35 +559,38 @@ 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; + /* 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; - /* #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 +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. These three 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 @@ -1114,7 +1118,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 +1130,10 @@ 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_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) #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 +1153,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 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 * nothing, or the multithreaded bail in the eager pass — set the bit and @@ -1154,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 c444c6a2..6a05b4d4 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_runtime(); 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,27 @@ 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. */ + 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) + 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); + /* 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_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. * Keep this pair at the compile boundary for both embed entry points. */ @@ -118,7 +143,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 +364,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_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 8916259d..73ec4fbc 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,24 @@ 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. 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 ------------------------------------------------------- */ /* Error message from the most recent eval/runtime failure on this thread, 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/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/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/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..c788e046 --- /dev/null +++ b/tests/test_embed_observer.c @@ -0,0 +1,297 @@ +/* #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. + * --raw-host also bypasses init_runtime, independently pinning state creation. */ +#include +#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; +} +/* 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; } + 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 +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; + (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_host_arm_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 = + "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); +} +/* 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. */ + 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) { + int raw_only = argc == 2 && strcmp(argv[1], "--raw-host") == 0; + int direct_only = argc == 2 && strcmp(argv[1], "--direct") == 0; + 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 (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); + return failed ? 1 : 0; +} diff --git a/tests/test_embed_observer.sh b/tests/test_embed_observer.sh new file mode 100644 index 00000000..16093bb0 --- /dev/null +++ b/tests/test_embed_observer.sh @@ -0,0 +1,42 @@ +#!/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 + # 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 +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: 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" 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 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