From 7e7aaccc46f86773a26525f9d43a6a07ab5afc3a Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Wed, 22 Jul 2026 11:41:49 +0000 Subject: [PATCH 1/2] SIGUSR1 live observer dump: ask a running process how it's progressing (#660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kill -USR1 prints one row per live binding to stderr at the next loop safepoint: name | value | when= | entropy= | dH= | trajectory The sigaction handler (SA_RESTART, installed by the CLI) only sets a volatile sig_atomic_t; the dump runs in normal thread context at the loop-cap per-iteration check (interpreter LOOP_STALL/CAP_CHECK cases + the shared eigs_loop_*_step cores the JIT/AOT call), so idle cost is one flag test — unmeasurable on bench_dmg_shape (166ms -> 163ms n=5 medians), so no EIGS_OBSERVE gate. First thread to see the flag dumps (atomic test-and-clear), covering module scope (root of the env chain) plus the dumping thread's live call frame; the module walk holds the existing #607 g_module_env_lock under MT — no new locking scheme. Every row carries its assign count so a fresh per-call binding (when=1) reads differently from a settled one — the issue's silent-tolerance failure mode. env->assign_counts alone can't back that: the SET_LOCAL fast path never bumps it (the compiler only keeps it consistent for interrogated names), so the dump prints max(assign_counts, obs_age), which vm_park_call_env resets per call. Unobserved slots say "unobserved" rather than borrowing a window-less verdict. Test: tests/test_sigusr1_dump.sh (suite block [99d]) drives the signal against a live process — observable READY/DONE barriers, no sleeps — asserts the row shapes incl. when= counts single-threaded and with a spawned task, and requires DONE + exit 0 afterward. Suite: 3143/3143 release (baseline 3130/3130), 3147/3147 under make asan (ASan+UBSan+detect_leaks); freestanding-check green. Co-Authored-By: Kimi K3 --- src/eigenscript.c | 8 ++ src/eigenscript.h | 11 ++ src/main.c | 13 +++ src/vm.c | 175 +++++++++++++++++++++++++++++ tests/run_all_tests.sh | 23 ++++ tests/test_sigusr1_dump.sh | 222 +++++++++++++++++++++++++++++++++++++ 6 files changed, 452 insertions(+) create mode 100644 tests/test_sigusr1_dump.sh diff --git a/src/eigenscript.c b/src/eigenscript.c index 6dbee12b..610e6e85 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -1644,6 +1644,14 @@ static inline void env_shared_unlock(const Env *e) { if (env_mt_shared(e)) pthread_mutex_unlock(&g_module_env_lock); } +/* #660: exported for the SIGUSR1 observer dump (vm.c) — the dump's + * module-scope walk holds the existing #607 module-env lock under MT + * (no new locking scheme; a no-op single-threaded via the env_mt_shared + * gate). Only the module env is structurally mutated cross-thread, and + * only by the main thread; the dump's frame/loop envs are thread-local. */ +void env_dump_lock(Env *e) { env_shared_lock(e); } +void env_dump_unlock(Env *e) { env_shared_unlock(e); } + /* Park a grown-out block on the env; freed at park/destroy time. */ static void env_retire_block(Env *env, void *block) { if (!block) return; diff --git a/src/eigenscript.h b/src/eigenscript.h index 39540935..de166894 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -341,6 +341,17 @@ void observer_slot_reset(struct Env *e); * run the same halting as the interpreter/JIT. (vm.c) */ int eigs_loop_stall_step(struct Env *e); int eigs_loop_cap_step(struct Env *e); +/* #660 SIGUSR1 live observer dump: `kill -USR1 ` prints one row per + * live binding (module scope + the dumping thread's live frame) to stderr at + * the next loop safepoint. The handler (installed by the CLI, SA_RESTART) + * only sets the flag — no allocation, no I/O; the VM's loop-cap safepoints + * test-and-clear it and dump from normal thread context. (vm.c) */ +extern volatile sig_atomic_t g_eigs_sigusr1_pending; +void eigs_sigusr1_handler(int sig); +/* #660: hold/release the existing #607 module-env lock for the dump's + * module-scope walk under MT; no-op single-threaded. (eigenscript.c) */ +void env_dump_lock(struct Env *e); +void env_dump_unlock(struct Env *e); /* Implemented in vm.c: drops the last-observed-slot tracker if it points at e * (called from observer_slot_reset so a torn-down env can't be read stale). */ void vm_obs_slot_dropped(struct Env *e); diff --git a/src/main.c b/src/main.c index df222f4f..5400a643 100644 --- a/src/main.c +++ b/src/main.c @@ -168,6 +168,19 @@ int main(int argc, char **argv) { eigs_thread_attach(eigs_st); set_exe_dir(argc > 0 ? argv[0] : NULL); + /* #660: SIGUSR1 live observer dump — an outside process can ask a + * long-running program whether it is progressing. SA_RESTART so an + * interrupted blocking syscall (channel recv, proc read) resumes; the + * handler only raises a flag consumed at the next loop safepoint. */ + { + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = eigs_sigusr1_handler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESTART; + sigaction(SIGUSR1, &sa, NULL); + } + /* --lint flag (optionally --lint --json for machine-readable output; * --json may appear before or after the path). */ if (argc >= 2 && strcmp(argv[1], "--lint") == 0) { diff --git a/src/vm.c b/src/vm.c index daba1b8f..48143f27 100644 --- a/src/vm.c +++ b/src/vm.c @@ -45,6 +45,177 @@ void vm_obs_slot_dropped(Env *e) { if (g_last_obs_slot_env == e) { g_last_obs_slot_env = NULL; g_last_obs_slot_idx = -1; } } +/* ---- #660: SIGUSR1 live observer dump ---- + * An outside process asks a long-running program whether it is progressing: + * `kill -USR1 ` prints one row per live binding to stderr: + * name | value | when= | entropy= | dH= | trajectory + * The signal handler (installed by the CLI in main.c, SA_RESTART) ONLY sets + * g_eigs_sigusr1_pending — no allocation, no I/O. The dump runs at the next + * loop safepoint (the OP_LOOP_STALL_CHECK / OP_LOOP_CAP_CHECK per-iteration + * check and the shared eigs_loop_*_step cores the JIT/AOT call), in normal + * thread context, on whichever thread sees the flag first. Cost when idle is + * one flag test per loop iteration, mirroring eigs_loop_cap_step's profile. */ +volatile sig_atomic_t g_eigs_sigusr1_pending = 0; + +void eigs_sigusr1_handler(int sig) { + (void)sig; + g_eigs_sigusr1_pending = 1; +} + +/* Number formatting mirrors value_to_string's VAL_NUM case: exact integers + * print bare, anything else shortest round-trip %.15g..%.17g. The range + * check runs BEFORE the long long cast — casting an out-of-range double is + * UB and this path sees arbitrary user values under UBSan. */ +static void obs_dump_num(double n, char *buf, size_t nbuf) { + if (fabs(n) < 9007199254740992.0 && n == (long long)n) { + snprintf(buf, nbuf, "%lld", (long long)n); + } else { + for (int prec = 15; prec <= 17; prec++) { + snprintf(buf, nbuf, "%.*g", prec, n); + if (strtod(buf, NULL) == n) break; + } + } +} + +/* Compact, bounded rendering of a slot's value: numbers inline, strings + * truncated to 100 chars with control characters blanked (one row per + * binding must stay one line), containers as summaries — no + * deep walks (a 65k-element list must not dump a megabyte, and walking a + * shared container's items while another thread mutates them is the #607 + * out-of-scope slot-value race class; a summary reads only the header + * words). No allocation. */ +static void obs_dump_value(EigsSlot s, char *buf, size_t nbuf) { + if (slot_is_num(s)) { obs_dump_num(s.d, buf, nbuf); return; } + if (slot_is_null(s)) { snprintf(buf, nbuf, "null"); return; } + if (slot_is_bool(s)) { snprintf(buf, nbuf, "%s", slot_as_bool(s) ? "true" : "false"); return; } + if (slot_is_ptr(s)) { + Value *v = slot_as_ptr(s); + switch (v->type) { + case VAL_NUM: obs_dump_num(v->data.num, buf, nbuf); break; + case VAL_STR: { + size_t o = 0, i; + buf[o++] = '"'; + for (i = 0; v->data.str[i] && i < 100 && o + 2 < nbuf; i++) { + unsigned char c = (unsigned char)v->data.str[i]; + buf[o++] = (c >= 32 && c < 127) ? (char)c : ' '; + } + if (v->data.str[i]) { memcpy(buf + o, "...", 3); o += 3; } + if (o + 2 > nbuf) o = nbuf - 2; + buf[o++] = '"'; buf[o] = 0; + break; + } + case VAL_LIST: snprintf(buf, nbuf, "", v->data.list.count); break; + case VAL_DICT: snprintf(buf, nbuf, "", v->data.dict.count); break; + case VAL_FN: snprintf(buf, nbuf, "", v->data.fn.name); break; + case VAL_BUILTIN: snprintf(buf, nbuf, ""); break; + case VAL_BUFFER: snprintf(buf, nbuf, "", v->data.buffer.count); break; + case VAL_TEXT_BUILDER: + snprintf(buf, nbuf, "", v->data.text_builder.len); break; + case VAL_JSON_RAW: snprintf(buf, nbuf, ""); break; + default: snprintf(buf, nbuf, "null"); break; + } + return; + } + snprintf(buf, nbuf, "?"); /* sentinel / reserved tag — not a binding value */ +} + +/* One scope's rows. `shared` holds the existing #607 module-env lock for the + * walk (a no-op single-threaded): only the module env is structurally + * mutated cross-thread, and only by the main thread; frame/loop envs are + * thread-local and need no lock. Skips runtime-internal `__*` bindings and + * builtins (stdlib noise — every registered builtin lives in the module env). + * `name_chunk` resolves call-env slots reserved by env_reserve_slots, which + * are addressed purely by slot index and carry NULL names in the env — their + * names live in the frame chunk's local_names (OP_SET_LOCAL's own name + * source, e.g. the trace hook). Every row carries its assign count: when=1 + * (a fresh per-call binding) must read differently from a settled long-lived + * one, and a bare trajectory word with no sample count behind it is the + * silent-tolerance failure this feature exists to avoid. An observed slot's + * word comes from the same observer_slot_report `report of` uses; a slot + * with no observation yet says "unobserved" rather than borrowing a + * window-less verdict. */ +static void obs_dump_scope(const char *scope, Env *e, int shared, + const EigsChunk *name_chunk) { + if (shared) env_dump_lock(e); + fprintf(stderr, "# scope=%s\n", scope); + char vbuf[160]; + for (int i = 0; i < e->count; i++) { + const char *name = e->names[i]; + if (!name && name_chunk && name_chunk->local_names && + i < name_chunk->local_count) + name = name_chunk->local_names[i]; + if (!name) continue; + if (name[0] == '_' && name[1] == '_') continue; + EigsSlot s = e->values[i]; + if (slot_is_ptr(s)) { + Value *v = slot_as_ptr(s); + if (v && v->type == VAL_BUILTIN) continue; + } + obs_dump_value(s, vbuf, sizeof(vbuf)); + /* `when` merges the two assignment counters, because neither alone + * is complete. env->assign_counts is bumped only by the env write + * paths (env_set*, param binding) — the compiler keeps it consistent + * for `when is x` by forcing interrogated names onto those paths + * (compiler.c "interrogated"), but a plain fn-local's SET_LOCAL fast + * path never touches it (reads 0 after thousands of assignments). + * The observer slot's obs_age IS bumped once per compiled assignment + * (OBSERVE_ASSIGN_LOCAL / OBSERVE_NAME_POST), and recycled call envs + * reset both counters (vm_park_call_env), preserving per-call + * semantics: max() of the two is the true assignment count. */ + const ObserverSlot *os = (e->obs && i < e->obs_cap) ? &e->obs[i] : NULL; + long when = e->assign_counts ? e->assign_counts[i] : -1; + if (os && os->used && os->obs_age > when) when = os->obs_age; + char whenbuf[16]; + if (when >= 0) snprintf(whenbuf, sizeof(whenbuf), "%ld", when); + else snprintf(whenbuf, sizeof(whenbuf), "?"); + if (os && os->used) { + fprintf(stderr, "%s | %s | when=%s | entropy=%.6g | dH=%.6g | %s\n", + name, vbuf, whenbuf, os->entropy, os->dH, observer_slot_report(os)); + } else { + fprintf(stderr, "%s | %s | when=%s | entropy=- | dH=- | unobserved\n", + name, vbuf, whenbuf); + } + } + if (shared) env_dump_unlock(e); +} + +/* Dump module-scope bindings plus the current live call frame's bindings. + * The module env is the root of the current env chain (post-#373 a worker + * fn's env still chains back to it); the live frame is the dumping thread's + * innermost VM frame (fn_env), plus its current loop-child env when that + * differs. Under MT the module walk holds the existing #607 lock — no new + * locking scheme; frame/loop envs are this thread's own. */ +static void eigs_observer_dump(Env *leaf) { + if (!leaf) return; + Env *root = leaf; + while (root->parent) root = root->parent; + fprintf(stderr, "# eigenscript observer dump (SIGUSR1) — module scope + dumping thread's live frame\n"); + obs_dump_scope("module", root, 1, NULL); + Env *fn_env = NULL; + const EigsChunk *fn_chunk = NULL; + if (eigs_current && eigs_current->vm && g_vm.frame_count > 0) { + fn_env = g_vm.frames[g_vm.frame_count - 1].fn_env; + fn_chunk = g_vm.frames[g_vm.frame_count - 1].chunk; + } + if (fn_env && fn_env != root) + obs_dump_scope("frame", fn_env, 0, fn_chunk); + /* The leaf differs from fn_env when the safepoint caught a loop-child + * env (LOOP_ENV_FRESH); its bindings are hash-named, no chunk needed. */ + if (leaf != root && leaf != fn_env) + obs_dump_scope("loop", leaf, 0, NULL); + fprintf(stderr, "# end dump\n"); + fflush(stderr); +} + +/* Safepoint, tested once per loop iteration alongside the loop-cap check. + * The first thread to see the flag set performs the dump (atomic + * test-and-clear, so MT dumps exactly once per signal). */ +static inline void eigs_observe_safepoint(Env *e) { + if (__builtin_expect(g_eigs_sigusr1_pending != 0, 0) && + __sync_bool_compare_and_swap(&g_eigs_sigusr1_pending, 1, 0)) + eigs_observer_dump(e); +} + /* Classify an observer slot's trajectory by predicate kind (0..5), matching the * bare OP_PREDICATE dispatch. Shared by the named OP_PREDICATE_SLOT/NAME ops, * which read a SPECIFIC binding's slot instead of the global last-observed one. */ @@ -1417,6 +1588,7 @@ static void loop_iter_store(Env *env, double n) { * eigenscript.h. The jit_helper_* wrappers below feed them the current frame's * env, so interpreter, JIT, and AOT share one implementation (no drift). */ int eigs_loop_stall_step(Env *e) { + eigs_observe_safepoint(e); /* #660 */ g_loop_iterations++; int should_exit = 0; if (g_unobserved_depth == 0) { @@ -1450,6 +1622,7 @@ int eigs_loop_stall_step(Env *e) { } int eigs_loop_cap_step(Env *e) { + eigs_observe_safepoint(e); /* #660 */ g_loop_iterations++; int should_exit = 0; if (g_loop_iterations >= (g_sandbox_loop_max ? g_sandbox_loop_max : 100000000)) { @@ -4725,6 +4898,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { CASE(LOOP_STALL_CHECK): { uint16_t exit_offset = read_u16(ip); ip += 2; + eigs_observe_safepoint(frame->env); /* #660: SIGUSR1 dump at the per-iteration safepoint */ g_loop_iterations++; int should_exit = 0; if (g_unobserved_depth == 0) { @@ -4767,6 +4941,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { * block; both engines key off the opcode, set at compile time, so the * interpreter and JIT can never disagree on the classification. */ uint16_t exit_offset = read_u16(ip); ip += 2; + eigs_observe_safepoint(frame->env); /* #660: SIGUSR1 dump at the per-iteration safepoint */ g_loop_iterations++; int should_exit = 0; if (g_loop_iterations >= (g_sandbox_loop_max ? g_sandbox_loop_max : 100000000)) { diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 528446de..e08b71ea 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -3456,6 +3456,29 @@ else fi echo "" +# [99d] SIGUSR1 live observer dump (#660) — the only suite check that drives +# a signal against a live process from OUTSIDE: spawn a long-running +# program, kill -USR1, assert the stderr dump's row shape (incl. `when=` +# assign counts distinguishing a fresh when=1 per-call binding from a +# settled one) and that the program completes normally afterward — once +# single-threaded, once with a spawned task live. Synchronization inside is +# on observable state (READY/DONE markers), never sleeps — see +# tests/test_sigusr1_dump.sh. +echo "[99d] SIGUSR1 observer dump (#660)" +OD_OUTPUT=$(bash "$TESTS_DIR/test_sigusr1_dump.sh" 2>&1) +OD_PASS=$(echo "$OD_OUTPUT" | grep -c "PASS:" || true) +OD_FAIL=$(echo "$OD_OUTPUT" | grep -c "FAIL:" || true) +TOTAL=$((TOTAL + OD_PASS + OD_FAIL)) +PASS=$((PASS + OD_PASS)) +FAIL=$((FAIL + OD_FAIL)) +if [ "$OD_FAIL" -gt 0 ]; then + echo " FAIL: $OD_FAIL SIGUSR1 dump check(s) failed" + echo "$OD_OUTPUT" | grep "FAIL:" | head -5 +else + echo " PASS: all $OD_PASS SIGUSR1 dump checks" +fi +echo "" + echo "============================================" echo " RESULTS: $PASS/$TOTAL passed, $FAIL failed" if [ "$LEAKED" -gt 0 ]; then diff --git a/tests/test_sigusr1_dump.sh b/tests/test_sigusr1_dump.sh new file mode 100644 index 00000000..a70b5da6 --- /dev/null +++ b/tests/test_sigusr1_dump.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# [99d] SIGUSR1 live observer dump (#660) — drive the signal against a LIVE +# process from outside, assert the dump's shape (incl. `when=` assign +# counts), and require the program to continue and exit normally. +# +# Synchronization is on OBSERVABLE STATE only (the #604 proc_stream rule: a +# spin loop only hopes the child was scheduled in time): the child prints a +# READY marker to stdout (builtin_print fflushes) once inside its long loop; +# we poll for that line, then `kill -USR1`. The dump's own "# end dump" +# trailer on stderr acknowledges the safepoint fired. Then we wait for the +# child to finish ON ITS OWN (bounded — a hung child is a FAIL, never a hung +# suite) and require the DONE marker + exit 0. +# +# Subtest 1 (single thread): the entire wait runs INSIDE one train() call, +# so the dump deterministically catches a live frame: module-scope +# step_count must show a large `when` (settled, long-lived) while the +# per-call parameter n shows when=1 (fresh) — the when=1-vs-settled +# distinction the issue calls this feature's silent-tolerance failure when +# the assign count is missing. +# +# Subtest 2 (spawned task): same module-row assertions while a worker churns +# module-env reads concurrently (exercises the #607 module-env lock the dump +# holds under MT). Whichever thread observes the flag performs the dump, so +# only module-scope rows are asserted here. Under LeakSanitizer the known +# spawn-thread leak shapes are tolerated exactly like the main runner's +# rc_ok (pass + note); AddressSanitizer/UBSan errors never are. + +set -u +TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" +EIGS="$TESTS_DIR/../src/eigenscript" + +FIX1=/tmp/eigs_sigusr1_a_$$.eigs +FIX2=/tmp/eigs_sigusr1_b_$$.eigs +OUT1=/tmp/eigs_sigusr1_a_$$.out +ERR1=/tmp/eigs_sigusr1_a_$$.err +OUT2=/tmp/eigs_sigusr1_b_$$.out +ERR2=/tmp/eigs_sigusr1_b_$$.err +PIDS="" + +cleanup() { + for p in $PIDS; do + kill "$p" 2>/dev/null || true + wait "$p" 2>/dev/null || true + done + rm -f "$FIX1" "$FIX2" "$OUT1" "$ERR1" "$OUT2" "$ERR2" +} +trap cleanup EXIT + +pass() { echo "PASS: $1"; } +fail() { echo "FAIL: $1"; } + +# poll_file — wait up to /10 seconds for +# grep -qE to succeed (observable-state barrier). +poll_file() { + local f=$1 pat=$2 tenths=$3 i + for i in $(seq 1 "$tenths"); do + grep -qE "$pat" "$f" 2>/dev/null && return 0 + sleep 0.1 + done + return 1 +} + +cat > "$FIX1" <<'EOF' +step_count is 0 +epsilon is 1.0 +define train(n) as: + loss is 5.0 + scale is 0.999 + loop while step_count < n: + step_count is step_count + 1 + loss is loss * scale + 0.001 + epsilon is epsilon * 0.99999 + if step_count == 5000: + print of "READY" + return loss +r is train of 2000000 +print of "DONE" +EOF + +# ---- Subtest 1: single-thread dump shape ------------------------------- + +"$EIGS" "$FIX1" > "$OUT1" 2> "$ERR1" & +PID=$! +PIDS="$PIDS $PID" + +if poll_file "$OUT1" "^READY$" 600; then + pass "sigusr1: child reached its loop (READY barrier)" +else + fail "sigusr1: child never printed READY (dead or hung before the loop)" +fi + +if ! kill -USR1 "$PID" 2>/dev/null; then + fail "sigusr1: child exited before the signal (no live process to dump)" +fi + +if poll_file "$ERR1" "^# end dump$" 600; then + pass "sigusr1: dump arrived at a loop safepoint" +else + fail "sigusr1: no dump on stderr after SIGUSR1" +fi + +# Row shape: name | value | when= | entropy= | dH= | word. +# when must have 4+ digits (READY is printed at assignment 5000 of +# step_count, the signal lands strictly after) — a settled long-lived +# binding, visibly distinct from a fresh per-call one. +if grep -qE '^step_count \| [0-9]+ \| when=[0-9]{4,} \| entropy=[^ ]+ \| dH=[^ ]+ \| [a-z]+$' "$ERR1"; then + pass "sigusr1: module row shape (name|value|when|entropy|dH|trajectory) with settled when" +else + fail "sigusr1: module step_count row missing/misshaped" +fi + +# The when=1 counterproof: the live frame's parameter n was bound once this +# call — it must NOT read as settled. +if grep -qE '^# scope=frame$' "$ERR1" \ + && grep -qE '^n \| 2000000 \| when=1 \| entropy=- \| dH=- \| unobserved$' "$ERR1"; then + pass "sigusr1: live-frame row with fresh when=1 binding (distinguishable from settled)" +else + fail "sigusr1: live-frame when=1 row for parameter n missing" +fi + +if grep -qE '^loss \| [0-9.]+ \| when=[0-9]{4,} \| entropy=[^ ]+ \| dH=[^ ]+ \| [a-z]+$' "$ERR1"; then + pass "sigusr1: fn-local row carries its accumulated when count" +else + fail "sigusr1: fn-local loss row missing/misshaped" +fi + +# The program must continue correctly after the dump: DONE marker, then a +# clean exit on its own. DONE is the observable barrier; the wait below only +# reaps (teardown after DONE cannot hang). +if poll_file "$OUT1" "^DONE$" 1200; then + pass "sigusr1: program completed (DONE) after the dump" +else + fail "sigusr1: program never completed after the dump" +fi +wait "$PID"; RC1=$? +PIDS="${PIDS% $PID}" +if [ "$RC1" = "0" ]; then + pass "sigusr1: exit code 0 after the dump" +else + fail "sigusr1: exit code $RC1 after the dump (expected 0)" +fi + +# Sanitizer hygiene on the dump path itself (release build: all absent). +if grep -qE 'AddressSanitizer|LeakSanitizer|runtime error:' "$ERR1"; then + fail "sigusr1: sanitizer report on stderr (single-thread run)" +else + pass "sigusr1: no sanitizer report (single-thread run)" +fi + +# ---- Subtest 2: dump with a spawned task live -------------------------- + +cat > "$FIX2" <<'EOF' +step_count is 0 +define churn(n) as: + i is 0 + probe is 0 + loop while i < n: + i is i + 1 + probe is step_count + return probe +t is spawn of [churn, 2500000] +define train(n) as: + loss is 5.0 + loop while step_count < n: + step_count is step_count + 1 + loss is loss * 0.999 + 0.001 + if step_count == 5000: + print of "READY" + return loss +r is train of 2000000 +thread_join of t +print of "DONE" +EOF + +"$EIGS" "$FIX2" > "$OUT2" 2> "$ERR2" & +PID=$! +PIDS="$PIDS $PID" + +if poll_file "$OUT2" "^READY$" 600; then + pass "sigusr1-mt: child reached its loop with a task live" +else + fail "sigusr1-mt: child never printed READY" +fi + +if ! kill -USR1 "$PID" 2>/dev/null; then + fail "sigusr1-mt: child exited before the signal" +fi + +# Whichever thread observes the flag dumps; the module scope is in both +# views, so the settled module row is deterministic. +if poll_file "$ERR2" "^# end dump$" 600; then + pass "sigusr1-mt: dump arrived under a live task" +else + fail "sigusr1-mt: no dump on stderr after SIGUSR1" +fi + +if grep -qE '^step_count \| [0-9]+ \| when=[0-9]{4,} \| entropy=[^ ]+ \| dH=[^ ]+ \| [a-z]+$' "$ERR2"; then + pass "sigusr1-mt: module row shape with settled when" +else + fail "sigusr1-mt: module step_count row missing/misshaped" +fi + +if poll_file "$OUT2" "^DONE$" 1200; then + pass "sigusr1-mt: program completed (DONE) after the dump" +else + fail "sigusr1-mt: program never completed after the dump" +fi +wait "$PID"; RC2=$? +PIDS="${PIDS% $PID}" + +# Mirror the main runner's rc_ok: a LeakSanitizer nonzero exit from the +# known spawn-thread leak shapes counts as a pass-with-note; any hard +# ASan/UBSan report is a failure in both subtests. +if grep -qE 'AddressSanitizer|runtime error:' "$ERR2"; then + fail "sigusr1-mt: ASan/UBSan report on stderr" +elif [ "$RC2" = "0" ]; then + pass "sigusr1-mt: clean exit after the dump" +elif grep -q 'LeakSanitizer: detected memory leaks' "$ERR2"; then + pass "sigusr1-mt: LeakSanitizer nonzero exit (known spawn-thread leak shape; tolerated like rc_ok)" +else + fail "sigusr1-mt: exit code $RC2 after the dump (expected 0)" +fi From b9915317ddf84b274a66ce8527fd10e153f719df Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Wed, 22 Jul 2026 12:16:56 +0000 Subject: [PATCH 2/2] =?UTF-8?q?vm:=20dump=20string=20scan=20=E2=80=94=20ra?= =?UTF-8?q?nge-check=20before=20dereference,=20bound=20the=20truncation=20?= =?UTF-8?q?marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the CodeQL annotation on #696; no behavior change. Round-trip check compares bit patterns (memcmp) — exact, -0.0-preserving. Co-Authored-By: Kimi K2.7 Code --- src/vm.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/vm.c b/src/vm.c index 48143f27..3573fa50 100644 --- a/src/vm.c +++ b/src/vm.c @@ -72,7 +72,8 @@ static void obs_dump_num(double n, char *buf, size_t nbuf) { } else { for (int prec = 15; prec <= 17; prec++) { snprintf(buf, nbuf, "%.*g", prec, n); - if (strtod(buf, NULL) == n) break; + double back = strtod(buf, NULL); + if (memcmp(&back, &n, sizeof back) == 0) break; } } } @@ -95,11 +96,11 @@ static void obs_dump_value(EigsSlot s, char *buf, size_t nbuf) { case VAL_STR: { size_t o = 0, i; buf[o++] = '"'; - for (i = 0; v->data.str[i] && i < 100 && o + 2 < nbuf; i++) { + for (i = 0; i < 100 && v->data.str[i] && o + 2 < nbuf; i++) { unsigned char c = (unsigned char)v->data.str[i]; buf[o++] = (c >= 32 && c < 127) ? (char)c : ' '; } - if (v->data.str[i]) { memcpy(buf + o, "...", 3); o += 3; } + if (v->data.str[i] && o + 5 <= nbuf) { memcpy(buf + o, "...", 3); o += 3; } if (o + 2 > nbuf) o = nbuf - 2; buf[o++] = '"'; buf[o] = 0; break;