trace: bound the temporal history and stop arming it on dead code (#827) - #829
Conversation
The per-name assignment history behind `prev of x`, `<kw> is x at <line>`
and `state_at` was append-only, uncapped, and held a reference to every
value it recorded. Any long-running program that so much as mentioned
`prev of` grew linearly until the machine died. It froze a 4 GB box for
~20 minutes with no OOM kill — the kernel thrashed rather than killing,
so the box had to be power-cycled. LeakSanitizer never saw a byte:
everything was reachable from the history table and freed at exit. This
is unbounded RETENTION, not a leak.
Repro (the `prev of` is in a function that is NEVER CALLED):
define never_called(v) as:
return prev of v
x is 0
i is 0
loop while i < N:
x is i * 1.5
i is i + 1
peak RSS, /usr/bin/time -f %M, N=200k / 800k / 1.6M
before 9088 / 28032 / 53120 kB (~33 B per assignment, no plateau)
after 2944 / 2944 / 2944 kB (= the no-temporal-query floor)
live `prev of x` in the loop, N=1.6M: 203008 kB -> 2944 kB
live `what is x at L`, N=1.6M: 53248 kB -> 2944 kB
Two independent defects, both fixed, with NO change to any answer.
(B) Unbounded retention. A backward query returns the LATEST assignment
whose line is <= L, which makes most entries provably unreachable: entry
i is dead exactly when some later entry j has line[j] <= line[i], since
any L admitting i admits j too and j wins for being later. What survives
are the strict suffix minima of the line sequence — so the live entries
are line-sorted and can never outnumber the distinct source lines that
assign that name. Bounded by program TEXT, not by runtime: a loop
reassigning one name a billion times keeps one entry and pins one value.
Maintenance is one pop-while at append.
Two facts pruning would otherwise lose are carried explicitly, which is
why the answers are identical:
- `prev of x at L` wants the value of the assignment immediately
preceding (in EXECUTION order) the one that answers L — usually a
pruned entry — so every live entry stores its own predecessor.
- `when is x at L` counts assignments with line <= L, pruned ones
included; counting is order-independent, so a per-name
(line -> count) histogram carries it exactly. Also bounded.
The observer snapshot for `where/why/how at L` moved inside the live
entry (trace_record_obs always targets the newest, which is always live).
Backward queries became a binary search over the sorted live array,
retiring the periodic line-floor segment index that existed only to make
scanning an unbounded array survivable.
(A) Whole-program arming. g_trace_hist was set by a source scan, so a
`prev of v` in a function nothing calls armed recording for every name.
Both history-reading forms compile to a NAMED opcode carrying a
compile-time identifier, so the reachable name set is exactly known: the
compiler arms only those names and assignments to any other name record
nothing. `state_at` (queries every name), an open tape, and turning
recording on without naming a name (REPL, `record_history of 1`) still
arm the wildcard. No site outside trace.c writes g_trace_hist now.
Semantics unchanged; tape untouched. tests/test_temporal_pruning.eigs
(22 checks) pins the four cases a naive prune breaks — including the
backward-line-jump counterexample a line-keyed table gets wrong — and
passes on the PRE-FIX binary too. A 200-seed differential fuzz of every
query form against the pre-fix binary diverges zero times, JIT on and
EIGS_JIT_OFF. `A` records are written independently of the history
table, so tapes before and after are byte-identical and a pre-fix tape
replays byte-identically on the fixed binary — no format bump (#411).
Gates: release suite 3541/3541; ASan+UBSan with detect_leaks=1
3539/3539, leak tally still 0; make dap + DAP suite 30/30;
test_replay.sh 24/24; test_trace_on_fail.sh 7/7; freestanding-check;
jit-smoke; embed_stack_soak; --lint clean. Also ~30% faster on the live
`prev of` loop (0.45s -> 0.31s, n=3) — it stopped reallocating.
New gate [70d] (tests/test_temporal_memory.sh): peak RSS ceiling AND
flatness across an 8x iteration range for dead-code, live-prev and
live-at programs, under ulimit -v. Validated red-then-green — 6/6 fail
on the pre-fix binary, and it also catches an answer-preserving but
unbounded prune (`>` instead of `>=`: 13952 -> 90496 kB) that the
semantic tests pass.
Closes #827
Closes #827
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Consumer follow-up filed: InauguralSystems/dynamics#24 — drop the |
There was a problem hiding this comment.
Pull request overview
Fixes a major temporal-trace retention issue in the runtime by making per-name assignment history bounded (reachability-pruned) and by preventing history recording from being armed by unreachable/dead code. This keeps long-running programs using (or merely containing) temporal queries from growing RSS linearly.
Changes:
- Implement reachability-pruned per-name history in
src/trace.c, plus per-name (vs whole-program) arming for temporal history recording. - Add new regression gates: a semantics pin test (
test_temporal_pruning.eigs) and a process-RSS boundedness test (test_temporal_memory.sh), and wire them into the full test runner. - Update trace documentation and changelog to reflect the new behavior and complexity model.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_temporal_pruning.eigs | New line-number-sensitive semantics pin ensuring pruning preserves temporal answers (including backward-jump counterexample). |
| tests/test_temporal_memory.sh | New peak-RSS ceiling + flatness gate to prevent unbounded retention regressions. |
| tests/run_all_tests.sh | Hooks the new [70c]/[70d] gates into the main test runner. |
| src/trace.h | Documents new arming entry points and updated complexity/semantics notes for temporal queries. |
| src/trace.c | Core implementation: bounded history via pruning, per-name arming set, histogram for when, and binary search for backward queries. |
| src/repl.c | REPL now arms wildcard history recording via the new arming API. |
| src/compiler.c | Compiler arms history per targeted identifier for NAMED temporal queries; wildcard for cases that can’t be enumerated. |
| src/builtins.c | record_history now uses the new arming/disable entry points. |
| docs/TRACE.md | Updates documentation for per-name arming and bounded/pruned history behavior. |
| CHANGELOG.md | Adds an Unreleased entry describing the fix and new test gates. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| TMEM_OUTPUT=$(bash "$TESTS_DIR/test_temporal_memory.sh" 2>&1); TMEM_RC=$? | ||
| echo "$TMEM_OUTPUT" | grep -E "^ (PASS|FAIL|SKIP|baseline|dead|live|at_live)" | ||
| TMEM_N=$(echo "$TMEM_OUTPUT" | sed -n 's/^TEMPORAL_MEM: \([0-9]*\) passed.*/\1/p') | ||
| TMEM_F=$(echo "$TMEM_OUTPUT" | sed -n 's/^TEMPORAL_MEM: [0-9]* passed, \([0-9]*\) failed.*/\1/p') | ||
| TOTAL=$((TOTAL + TMEM_N + TMEM_F)) | ||
| PASS=$((PASS + TMEM_N)) | ||
| FAIL=$((FAIL + TMEM_F)) | ||
| if [ "$TMEM_RC" -ne 0 ]; then | ||
| echo " FAIL: temporal history memory gate (rc=$TMEM_RC)" | ||
| fi |
| if (e->lc_count >= e->lc_cap) { | ||
| int nc = e->lc_cap ? e->lc_cap * 2 : 8; | ||
| LineCount *nl = realloc(e->lc, (size_t)nc * sizeof(LineCount)); | ||
| if (!nl) return; /* `when at L` under-counts rather than aborting */ | ||
| e->lc = nl; |
…off Linux Two defects in my own #827 fix, found by reviewing the new shared state and by CI's macOS legs. 1. USE-AFTER-FREE under `spawn`. #827 filters the (per-thread, #739) history table through a PROCESS-global armed-name set that the compiler grows with `realloc`. Single-threaded that is fine — compile, then run. But a worker calling `eval`/`load_file` compiles *concurrently* with other workers recording assignments, so the realloc lands under a reader walking the array: a UAF, not the benign torn-int race `g_trace_hist` already had. Nothing in the suite or CI's TSan leg exercises eval-on-a-worker with a temporal query, so this would not have been caught. Fix: `spawn` widens to the wildcard as its last single-threaded act, before the first `pthread_create` — so the value is published to every worker by the same happens-before #297 relies on, and from then on the filter reads two ints and the name array is never touched again. It uses a separate entry point (`trace_arm_history_all_mt`) that does NOT set `g_trace_hist`: a program with no temporal query must not start recording just because it made a thread. Verified — a spawning program with no temporal query stays at the 2944 kB floor, flat from 200k to 1.6M iterations, and a spawning program that does use `prev of` / `at` / `when` still answers correctly. The narrowing is a per-assign CPU optimization for the single-threaded long-running programs #827 was actually about; giving it up under MT costs nothing that matters, because the history is bounded either way. 2. macOS: the new RSS gate is Linux-only and must SKIP, not fail. BSD `/usr/bin/time` has no `-f` and no `%M`, and `ulimit -v` is a no-op there, so both macOS legs went red on `[70d]`. The gate now probes the exact invocation (`/usr/bin/time -f ... true`) rather than the platform name — a Linux box without GNU time skips for the same reason — and additionally requires `/proc`, since the thresholds are calibrated against Linux VmRSS accounting. Same platform reasoning as test_http_rss_growth.sh. Validated in both directions, because a gate that skips when it shouldn't is a dead gate: with a stub that mimics BSD `time` it prints SKIP and exits 0; on Linux it still runs all 6 checks; and a genuinely missing binary still FAILS loudly rather than skipping. Gates re-run on the final tree: release suite 3541/3541; ASan+UBSan with detect_leaks=1, leak tally still 0; make dap + DAP suite 30/30; freestanding-check; jit-smoke. Closes #827 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Second commit (121ce4f) fixes two defects in the first, one found by review and one by CI. 1. Use-after-free under
Giving up the narrowing under MT costs nothing that matters — the history is bounded either way; the narrowing is a per-assign CPU optimization for the single-threaded long-running programs #827 was about. 2. macOS legs (the red ones above). The new RSS gate is Linux-only and must SKIP, not fail: BSD Validated in both directions, because a gate that skips when it shouldn't is a dead gate: against a stub mimicking BSD All gates re-run on the final tree: release suite 3541/3541; ASan+UBSan |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/trace.c:293
lc_bumpreturns early onreallocfailure without recording anything, which makeswhen is x at Lsilently under-count and can stay wrong even if later allocations succeed. It would be safer to mark the histogram as disabled on OOM and havewhen ... atreturn a miss (null) rather than an incorrect count.
/* Bump the (line -> count) histogram for `when is x at L`. Sorted insert;
* after the first few assigns this is a pure binary-search hit. */
static void lc_bump(PrevEntry *e, int line) {
int lo = 0, hi = e->lc_count - 1;
while (lo <= hi) {
int mid = (int)(((unsigned)lo + (unsigned)hi) >> 1);
if (e->lc[mid].line == line) { e->lc[mid].count++; return; }
if (e->lc[mid].line < line) lo = mid + 1;
else hi = mid - 1;
}
if (e->lc_count >= e->lc_cap) {
int nc = e->lc_cap ? e->lc_cap * 2 : 8;
LineCount *nl = realloc(e->lc, (size_t)nc * sizeof(LineCount));
if (!nl) return; /* `when at L` under-counts rather than aborting */
e->lc = nl;
e->lc_cap = nc;
}
src/trace.c:446
- If the per-name
(line -> count)histogram is unavailable (e.g. due to OOM while growing it),trace_query_atcurrently still returns a numeric count (possibly 0) which is indistinguishable from a real count and can be silently wrong. Consider returning 0 (miss) so the VM producesnullinstead of an incorrect number when exact counting cannot be maintained.
if (kind == 2) {
/* `when is x at L` — count of assignments with line ≤ L. Summed
* from the histogram, which counts pruned assignments too (#827). */
long long count = 0;
for (int i = 0; i < e->lc_count && e->lc[i].line <= line; i++)
count += e->lc[i].count;
*out = slot_from_num((double)count);
return 1;
}
tests/run_all_tests.sh:2838
- If
test_temporal_memory.shexits non-zero before emitting a parsableTEMPORAL_MEM:summary line, this block will log a FAIL but may not incrementFAIL, so the overall suite can still report success. Treat a non-zeroTMEM_RCwith no parsed failures as at least one failing check.
if [ "$TMEM_RC" -ne 0 ]; then
echo " FAIL: temporal history memory gate (rc=$TMEM_RC)"
fi
VERSION 0.35.1; CHANGELOG [Unreleased] -> [0.35.1] - 2026-08-03. CLAUDE.md's "Latest release" line and docs/llms.txt's version stamp move with it — doc_drift_check.sh rules 2, 3 and 5 gate all three, and the release build runs its own suite AFTER the tag exists, so a stale line fails the release itself (bit the v0.27.0 cut). A one-commit patch release: fa1a0a3 (#827 via #829), which bounds the temporal assignment history and stops arming it on dead code. Machine-freeze class — the unbounded retention froze a 4 GB box for ~20 minutes with no OOM kill — so this is cut on its own rather than waiting for the next feature batch. #826 is deliberately NOT in this cut. Two things the accumulated [Unreleased] block was missing, added here after reconstructing the entry from `git log v0.35.0..main`: - the second half of #829 — `spawn` widens the armed-name set to the wildcard as its last single-threaded act (the compiler grows that array with realloc, so a worker calling eval/load_file could realloc it under a reader: a UAF in #827's own fix). Never shipped, but the behavior is user-visible and belongs in the entry. - [70d] is a Linux-only gate: it probes for GNU `/usr/bin/time -f %M` and /proc and SKIPs where either is missing. Also added the consumer-facing note the release exists for: every runtime through v0.35.0 is affected, merely mentioning `prev of` / an `at`-qualified interrogative / `state_at` in never-executed code was enough, and workarounds pinned to dodge it (dynamics' `record_history of 0`) can be dropped at v0.35.1. No prior CHANGELOG claim is falsified by fa1a0a3: the v0.34.0 per-thread prev-table entry (#739 ownership split) still holds — the new armed-name set is separate, process-global, and frozen before the first thread — and v0.12.0's compile-gating entry remains true as written at its granularity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bug
The per-name assignment history behind
prev of x,<kw> is x at <line>andstate_atwas append-only, uncapped, and held a reference to every value itrecorded. Any long-running program that so much as mentioned
prev ofgrewlinearly until the machine died — and a whole-program compile scan armed it even
when the
prev ofsat in a function that was never called.This froze a 4 GB box: ~20 minutes of sustained pressure with no OOM kill —
the kernel thrashed instead of killing, so the machine had to be power-cycled.
The failure mode is a machine freeze, not a crash.
LeakSanitizer never saw a byte of it. Every allocation was reachable from the
history table and correctly freed at exit. This was unbounded retention, not
a leak — which is why it survived a zero-leak-tally gate for its whole life.
Measured (peak RSS,
/usr/bin/time -f %M, underulimit -v)Repro — the
prev ofis in a function nothing calls, and the loop uses notemporal semantics at all:
prev of— beforeprev of— afterprev of x— beforeprev of x— afterwhat is x at L— beforewhat is x at L— afterEvery case now sits on the no-temporal-query floor and is flat in iteration
count. Also ~30% faster on the live
prev ofloop (0.45s → 0.31s, n=3) — itstopped reallocating an ever-growing array.
The design, and why
Two independent defects. Neither fix changes any answer.
(B) Unbounded retention — the half that freezes machines
The key observation is that a backward query returns the latest assignment
whose line is
<= L— a temporal walk. That makes most recorded entriesprovably unreachable:
(any
Lthat admitsialso admitsj, andjwins for being later). Whatsurvives are the strict suffix minima of the line sequence — so the live
entries are sorted by line and can never outnumber the distinct source lines
that assign that name. Bounded by program TEXT, not by runtime. A loop
reassigning one name a billion times keeps one entry and pins one value.
Maintenance is one pop-while at append.
Two facts pruning would otherwise lose are carried explicitly, which is exactly
why the answers are identical:
prev of x at Lwants the value of the assignment immediately preceding(in execution order) the one that answers
L— that predecessor is usuallya pruned entry, so every live entry stores its own.
when is x at Lcounts assignments withline <= L, pruned onesincluded. Counting is order-independent, so a per-name
(line -> count)histogram carries it exactly — also bounded by distinct assigning lines.
The observer snapshot for
where/why/how at Lmoved inside the live entry(
trace_record_obsalways targets the newest entry, which is always live).Backward queries became a binary search over the sorted live array, retiring
the periodic line-floor segment index — that index existed only to make scanning
an unbounded array survivable.
What I rejected:
older than N becomes unavailable. Unnecessary — the structural bound is exact.
at line 5, then ask at
L=15: the answer is the line-5 value, because thatassignment happened later. A line-keyed map answers line-12. This is pinned as
an explicit test.
insufficient — it does not bound the alternating-lines case (12,13,12,13,…).
The suffix-minima rule bounds both; both shapes are pinned as tests.
bounded — a handful of entries pin a handful of values.
(A) Whole-program arming — the dead-code half
g_trace_histwas set by a source scan, so oneprev of vin unreachable codearmed recording for every name in the program. Both history-reading forms
(
prev of x,<kw> is x at L) compile to a NAMED opcode carrying acompile-time identifier, so the set of names a temporal query can reach is
exactly known. The compiler now arms only those names; an assignment to any
other name records nothing. Sound by construction — a name no query names cannot
be asked about.
Three things still force the wildcard, because they can reach names the compiler
cannot enumerate:
state_at(queries every name), an open tape(
EIGS_TRACE/ embed sink), and turning recording on without naming a name (theREPL,
record_history of 1). Arming only ever widens within a session. Nosite outside
trace.cwritesg_trace_histany more, so the flag can't be setwithout arming something.
Semantics: unchanged. Tape: untouched.
tests/test_temporal_pruning.eigs(22 checks) pins the four cases a naiveprune silently breaks — the backward-line-jump counterexample,
prev of x at Lwhen the predecessor was pruned,
whencounting pruned assignments, andalternating lines. It passes on the pre-fix binary too, which is the point:
it is a semantics pin, not a regression test.
(
what/who/when/where/why/how/prev, with and withoutat, plusstate_at), over programs with backward line jumps and loop re-assigns —reports zero divergences, JIT on and
EIGS_JIT_OFF.Arecords are written bytrace_assignindependently of the history table, one per assignment asbefore, and an open tape arms every name anyway. Verified: same program, old
vs new binary → byte-identical tapes (668 records, A=207, L=358, S=101, N=1
on both); record→replay→byte-diff clean under
EIGS_REPLAY_STRICT=1on bothtiers; and a pre-fix tape replays byte-identically on the fixed binary.
This was a retention bug, not a format one.
make dap+DAP suite 30/30, including the trajectory checks.
The regression gate
tests/test_temporal_memory.sh(suite [70d]): peak RSS at two iterationcounts 8x apart for three programs (dead-code
prev of, liveprev of, liveat), asserting both a ceiling and flatness, underulimit -vso aregression on a small box fails the test instead of taking the box down.
Planted-fault validated, twice:
live203136 kB,+175232 kB over 8x).
> lineinstead of>= line, keeping same-line duplicates): every temporal answer stays correctand the differential fuzz stays green, but
livegoes 13952 → 90496 kB andthis gate goes red. That fault is exactly why the memory gate is a separate
file from the semantic one.
The semantic tests were validated the same way — the fuzzer catches a naive
prev of x at L(reads the neighbouring live entry) and awhencounted offthe pruned array, both in 3 of 40 seeds.
Gates
detect_leaks=1make dap+ DAP suitetests/test_replay.shtests/test_trace_on_fail.shmake freestanding-checkmake jit-smoketools/embed_stack_soak.sh--linttest_temporal.eigsalso carries)ASan-stressed the prune path specifically with heap values (lists/dicts/strings
reassigned through same-line, alternating-line and backward-jump shapes) — clean,
no UAF, no leaks.
Consumer follow-up needed:
dynamicsdynamicsworks around this bug today:orbit.eigs:707callsrecord_history of 0for the lifetime of a lab window session, and its memorygate
tests/test_bif_mem.sh:205carries a planted fault that removes thatcall and must go red. Once this lands,
record_history of 0is no longerload-bearing — so that planted fault will stop failing and the gate needs
rework alongside dropping the workaround.
That work belongs to the rung-1 UI ladder rooted at
InauguralSystems/dynamics#20, whose memory-gated slice is
InauguralSystems/dynamics#23 (the bifurcation sweep view). Filing the successor
issue there is the follow-up; it should not block this fix.
Closes #827
Closes #827
🤖 Generated with Claude Code