Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
8 changes: 8 additions & 0 deletions src/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions src/chunk.c
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "eigenscript.h"
#include "vm.h"
#include "jit.h"
#include "trace.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/vm.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=$?
Expand Down
75 changes: 75 additions & 0 deletions tests/test_temporal_producers_unarmed.eigs
Original file line number Diff line number Diff line change
@@ -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, "<module>", []]
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
Loading