From a41806f7e7fb0531cc252b6a6cbc0e640548d419 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Wed, 5 Aug 2026 04:10:43 -0500 Subject: [PATCH] trace: descriptor-assembled chunks arm their own history recording (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit g_trace_hist was set only by the bytecode compiler's source scan, so a chunk assembled by vm_run_bytecode/sandbox_run containing the history-reading opcodes recorded nothing and its own prev/at reads answered null — unless the HOST program's text happened to contain a temporal query, a relationship no bytecode producer can reason about. Reproduces back to v0.34.0 (the non-regression half split from #830). chunk_arm_temporal (src/chunk.c) replays the compiler's scan over the verified code stream, stepping with the same op_verify_operands table and recursing into nested function chunks: per-name arming for OP_INTERROGATE_NAMED kind 6 (prev) and every OP_INTERROGATE_NAMED_AT, observer-state capture for the where/why/how-at kinds, and the wildcard on a GET_NAME of state_at. Called from both descriptor sites after chunk_verify accepts, so a chunk with no temporal opcode arms nothing and temporal-free producers keep recording off. Suite [70f] (test_temporal_producers_unarmed.eigs) is [70e]'s missing half: a host with NO temporal query anywhere — all 5 checks answer null without the fix. Closes #831 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 17 +++++ src/builtins.c | 8 +++ src/chunk.c | 43 +++++++++++++ src/vm.h | 4 ++ tests/run_all_tests.sh | 8 +++ tests/test_temporal_producers_unarmed.eigs | 75 ++++++++++++++++++++++ 6 files changed, 155 insertions(+) create mode 100644 tests/test_temporal_producers_unarmed.eigs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4b8696..0a26d627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,23 @@ All notable changes to EigenScript are documented here. ### Fixed +- **vm_run_bytecode/sandbox_run: an assembled chunk's temporal opcodes + now turn history recording on themselves (#831).** `g_trace_hist` was + set only by the bytecode compiler's source scan, so a descriptor + containing `prev`/`at` interrogate opcodes recorded nothing and its + own temporal reads answered null — unless the HOST program's text + happened to contain a temporal query, a relationship no bytecode + producer (ouroboros codegen, iLambdaAi's vendored copy) can reason + about. The descriptor assembler now replays the compiler's scan over + the verified bytecode (`chunk_arm_temporal`, recursing into nested + function chunks): per-name arming for `prev` (kind 6) and every + `at`-qualified form, observer-state capture for where/why/how-at, and + the wildcard on a `state_at` reference. Pay-for-what-you-use — a + chunk with no temporal opcode arms nothing, so temporal-free + producers keep recording off. Reproduced back to v0.34.0 (not a + regression; split from #830, whose provenance rule fixed the + filtering half). New suite `[70f]`. + - **ui: dispatch no longer swallows the rapid second click on widgets without a declared double-click meaning (#847).** The double-click branch consumed any second mousedown on the same id inside 400ms — diff --git a/src/builtins.c b/src/builtins.c index 80b0c097..b9391b9f 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -2780,6 +2780,11 @@ Value* builtin_vm_run_bytecode(Value *arg) { if (abi_err) { rt_error(EK_VALUE, 0, "%s", abi_err); return make_null(); } EigsChunk *chunk = vm_build_chunk_desc(arg, 1); if (!chunk) return make_null(); + /* #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 + * host program happens to contain no temporal query. */ + chunk_arm_temporal(chunk); Env *target = g_builtin_call_env ? g_builtin_call_env : g_global_env; Value *result = vm_execute(chunk, target); chunk_free(chunk); @@ -2939,6 +2944,9 @@ Value* builtin_sandbox_run(Value *arg) { dict_set_owned(out, "error", ev); return out; } + /* #831: same as vm_run_bytecode — the temporal opcodes in an assembled + * chunk must arm recording themselves; the compiler never scanned it. */ + chunk_arm_temporal(chunk); /* SEALED restricted env. The parent link is NULL, not g_global_env: the * sandbox env is a root, and the allowed builtins are COPIED into it. diff --git a/src/chunk.c b/src/chunk.c index eabc2306..d7aabcc9 100644 --- a/src/chunk.c +++ b/src/chunk.c @@ -5,6 +5,7 @@ #include "eigenscript.h" #include "vm.h" #include "jit.h" +#include "trace.h" #include #include #include @@ -590,6 +591,48 @@ int chunk_verify(EigsChunk *chunk) { return ok; } +/* #831: descriptor-assembled chunks bypass the compiler's source scan — the + * only thing that turns history recording on (g_trace_hist) and arms the + * queried names — so an assembled chunk's own `prev of` / `at` reads answered + * null unless the HOST program's text happened to contain a temporal query, + * a relationship no bytecode producer can reason about. This walk is the + * assembler's twin of that scan: it steps the code stream (chunk_verify has + * already pinned every opcode and operand in bounds — call this only on a + * verified chunk) and arms exactly what compiling the same program would: + * OP_INTERROGATE_NAMED, kind 6 (`prev`) -> arm that name + * OP_INTERROGATE_NAMED_AT, any kind -> arm that name; the observer + * kinds (3-5: where/why/how) also need observer-state capture + * OP_GET_NAME of "state_at" -> wildcard (queries every name) + * Pay-for-what-you-use: a chunk with no temporal opcode arms nothing, so + * temporal-free vm_run_bytecode users keep recording off. */ +void chunk_arm_temporal(const EigsChunk *chunk) { + const uint8_t *code = chunk->code; + int n = chunk->code_len, i = 0; + while (i < n) { + uint8_t op = code[i]; + if (op == OP_LINE) { i += 1 + 4; continue; } + VerifyRole roles[3]; + int nops = op_verify_operands(op, roles); + if (op == OP_INTERROGATE_NAMED || op == OP_INTERROGATE_NAMED_AT) { + int kind = code[i + 1] | (code[i + 2] << 8); + int name_idx = code[i + 3] | (code[i + 4] << 8); + /* VR_NAME (verified) guarantees a string constant. */ + if (op == OP_INTERROGATE_NAMED_AT || kind == 6) { + trace_arm_history_name(chunk->constants[name_idx]->data.str); + if (op == OP_INTERROGATE_NAMED_AT && kind >= 3 && kind <= 5) + g_trace_obs_hist = 1; + } + } else if (op == OP_GET_NAME) { + int name_idx = code[i + 1] | (code[i + 2] << 8); + if (strcmp(chunk->constants[name_idx]->data.str, "state_at") == 0) + trace_arm_history_all(); + } + i += 1 + 2 * nops; + } + for (int f = 0; f < chunk->fn_count; f++) + chunk_arm_temporal(chunk->functions[f]); +} + /* ---- #366: leaf-accessor scan ---- * * Marks a function chunk whose body is one pure expression over its own diff --git a/src/vm.h b/src/vm.h index 06f8d0e2..0ac1b48c 100644 --- a/src/vm.h +++ b/src/vm.h @@ -631,6 +631,10 @@ const char *op_name(uint8_t op); /* Verify an assembled (untrusted) chunk's bytecode is in-bounds before the VM * runs it. Returns 1 if safe to execute, 0 if it must be rejected. */ int chunk_verify(EigsChunk *chunk); +/* #831: arm history recording for the temporal opcodes an assembled chunk + * contains (the compiler's source scan, replayed over verified bytecode). + * Only call on a chunk tree chunk_verify accepted. */ +void chunk_arm_temporal(const EigsChunk *chunk); /* Compiler */ EigsChunk *compile_ast(ASTNode *ast, Env *env, const char *src); diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index f6946585..6d5e3b4e 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2872,6 +2872,14 @@ echo "" # src/embed_smoke.c, gated by `make embed-smoke` in CI. check_eigs_suite "temporal reads from a non-compiler producer (#830)" test_temporal_producers.eigs "All tests passed" 5 +# [70f] #831 — the other half: a descriptor must turn recording ON itself. +# [70e] proves reads work once recording is on, but its own source contains the +# `prev of` that arms it. Here the host program has NO temporal query anywhere, +# so every answer exists only if the descriptor assembler's bytecode walk +# (chunk_arm_temporal) armed the chunk's names — pre-fix, all of these were +# null, on every version back to v0.34.0. +check_eigs_suite "descriptor arms its own history recording (#831)" test_temporal_producers_unarmed.eigs "All tests passed" 5 + # [98] Cross-thread channel dict-key survival (#293). echo "[98] Cross-thread Channel Dict Keys (7 checks)" XCD_OUTPUT=$(./eigenscript ../tests/test_chan_dict_xthread.eigs 2>&1); XCD_OUTPUT_RC=$? diff --git a/tests/test_temporal_producers_unarmed.eigs b/tests/test_temporal_producers_unarmed.eigs new file mode 100644 index 00000000..cfdee543 --- /dev/null +++ b/tests/test_temporal_producers_unarmed.eigs @@ -0,0 +1,75 @@ +# test_temporal_producers_unarmed.eigs — a descriptor must arm history ITSELF (#831). +# +# test_temporal_producers.eigs (suite [70e], #830) proves temporal reads work +# from an assembled chunk once recording is on — but its control section turns +# recording on by containing a `prev of` in its own compiled source. #831 is +# the other half: `g_trace_hist` was set only by the bytecode compiler's source +# scan, so when the HOST program contains no temporal query of its own, an +# assembled chunk's history-reading opcodes answered null. The host program's +# TEXT decided whether a descriptor's temporal reads worked — a relationship no +# bytecode producer can reason about. +# +# THIS FILE MUST NEVER CONTAIN A SOURCE-LEVEL TEMPORAL QUERY (no prev-of, no +# at-qualified interrogative, no reference to the state-at builtin outside a +# constant pool). That absence is the test's precondition: every temporal +# answer below exists only if the descriptor assembler's own walk +# (chunk_arm_temporal, src/chunk.c) armed the names. Word-matches in comments +# are fine — the compiler scans the AST, not the text. + +load_file of "lib/test.eigs" + +# ---- opcode numbers (src/vm.h enum order), hardcoded as an external producer +# would. See tests/test_vm_run_bytecode.eigs for the ABI-stamp contract. +ABI is 1 +CONST is 0 +GET_NAME is 25 +SET_NAME_LOCAL is 27 +POP is 35 +CLOSURE is 38 +CALL is 39 +RETURN is 40 +RETURN_NULL is 41 +LINE is 68 +INTERROGATE_NAMED is 76 +INTERROGATE_NAMED_AT is 77 + +# ---- the #831 repro: assign twice, ask prev — with nothing anywhere else +# turning recording on. Answered null before the assembler-side arming. +# LINE 10; tmp831 is 11; LINE 20; tmp831 is 22; prev of tmp831 -> 11 +prevcode is [LINE,10,0,0,0, CONST,0,0, SET_NAME_LOCAL,1,0, POP, + LINE,20,0,0,0, CONST,2,0, SET_NAME_LOCAL,1,0, POP, + INTERROGATE_NAMED,6,0,1,0, RETURN] +got_prev is vm_run_bytecode of [ABI, prevcode, [11, "tmp831", 22]] +assert_eq of [got_prev, 11, "#831: prev in a descriptor arms itself (was null)"] + +# ---- the at-qualified form (kind 0 = what), both sides of the line boundary. +# Fresh name per test: the prev-table is per-thread and outlives each call. +atcode is [LINE,10,0,0,0, CONST,0,0, SET_NAME_LOCAL,1,0, POP, + LINE,20,0,0,0, CONST,2,0, SET_NAME_LOCAL,1,0, POP, + CONST,3,0, INTERROGATE_NAMED_AT,0,0,1,0, RETURN] +got_at15 is vm_run_bytecode of [ABI, atcode, [11, "tmp831a", 22, 15]] +assert_eq of [got_at15, 11, "#831: what-at in a descriptor arms itself (was null)"] +got_at99 is vm_run_bytecode of [ABI, atcode, [11, "tmp831a", 22, 99]] +assert_eq of [got_at99, 22, "#831: what-at past the last assignment"] + +# ---- the temporal opcode buried in a NESTED function chunk: the arming walk +# must recurse into functions[], not just scan the module chunk. +q_desc is [[LINE,10,0,0,0, CONST,0,0, SET_NAME_LOCAL,1,0, POP, + LINE,20,0,0,0, CONST,2,0, SET_NAME_LOCAL,1,0, POP, + INTERROGATE_NAMED,6,0,1,0, RETURN, RETURN_NULL], + [11, "tmp831n", 22], [], 0, "q831", []] +mod_code is [CLOSURE,0,0, SET_NAME_LOCAL,0,0, POP, GET_NAME,0,0, CALL,0,0, RETURN] +got_nested is vm_run_bytecode of [ABI, mod_code, ["q831"], [q_desc], 0, "", []] +assert_eq of [got_nested, 11, "#831: prev inside a nested function chunk"] + +# ---- a state-at reference (GET_NAME of the builtin's name) forces the +# wildcard, exactly as the compiler's scan does for a source-level reference. +# LAST deliberately: after this the wildcard is on for the rest of the process, +# so it must not mask a per-name arming failure in the tests above. +stcode is [LINE,10,0,0,0, CONST,0,0, SET_NAME_LOCAL,1,0, POP, + GET_NAME,2,0, CONST,3,0, CALL,1,0, RETURN] +st is vm_run_bytecode of [ABI, stcode, [5, "tmp831s", "state_at", 30]] +got_st is st["tmp831s"] +assert_eq of [got_st, 5, "#831: state-at reference in a descriptor arms the wildcard"] + +test_summary of null