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
20 changes: 12 additions & 8 deletions .claude/rules/c-runtime-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@ iteration, or a collector that quietly stops working).
ref via `env_incref`/`env_decref`. Never stash a bare `Env*` that
outlives its creator. The collector's `GC_FOR_EACH_CHILD` walker and
`gc_clear_node` (eigenscript.c) must move in lockstep with the ownership
model: a new owning edge out of Value/Env/Chunk goes into both, and only
*counted* edges may be traversed (an uncounted edge trips the accounting
abort and collection silently stops working). Conservative direction:
missing an edge leaks; inventing one frees live memory. A new `ValType`
or `ASTType` is a **build error** at every switch that must learn about
it (#737/#738: no `default:` arms on closed-enum switches —
`-Werror=switch` enforces exhaustiveness; don't add a `default:` back,
enumerate the no-op cases instead).
model, so both are generated from ONE table: `GC_EDGE_TABLE`
(eigenscript.c). A new owning edge out of Value/Env/Chunk is one new row
there, and only *counted* edges may be walked (an uncounted edge trips
the accounting abort and collection silently stops working).
Conservative direction: missing an edge leaks; inventing one frees live
memory. A new `ValType` or `ASTType` is a **build error** at every switch
that must learn about it (#737/#738: no `default:` arms on closed-enum
switches — `-Werror=switch` enforces exhaustiveness; don't add a
`default:` back, enumerate the no-op cases instead). The table's rows are
selected by `_v->type == VAL_x` guards, which `-Werror=switch` cannot
see, so `gc_value_is_node` carries that gate for it: its switch is
exhaustive, and a value type is a node exactly when it has rows.
- **Trace gating**: `g_trace_hist` (assignment history) and
`g_trace_obs_hist` (observer snapshots) are compiler-set flags —
recording is off unless the program contains a temporal query
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@ All notable changes to EigenScript are documented here.

## [Unreleased]

### Fixed

- **The GC's two lockstep dispatches are generated from one table, and
`CallFrame` init/release have one definition each (#743).** The
lockstep between `GC_FOR_EACH_CHILD` and `gc_clear_node` was enforced
only by prose, and had already drifted: the walker reported a
`VAL_FN`'s compiled-chunk edge (`body_count == -1`, the ref taken in
`OP_CLOSURE`) that `gc_clear_node` never cleared, so that ref was
recovered only later by the value destructor in the unpin pass — a
third mirror the comment never named. Both dispatches now expand one
`GC_EDGE_TABLE` X-macro (one row per owned edge, walk predicate and
clear action side by side), so a new owning edge out of
Value/Env/Chunk is written once and the two cannot drift; the
destructor is documented as the non-cycle mirror, and the two safety
invariants of the chunk row's clear are spelled out at the table
(`make_fn` leaves `body = NULL`, which is what makes the
unconditional `chunk_decref` type-safe; the `body = NULL` in the
clear is load-bearing against a later double-decref). Since rows are
selected by `_v->type == VAL_x` guards, which `-Werror=switch` cannot
see, `gc_value_is_node` carries #738's build-error property for the
table: its switch is exhaustive and a value type is a node exactly
when it has rows (verified by planting an 11th enumerator).
Alongside it, `CallFrame` had five hand-written init/teardown sites:
all four push sites (`jit_helper_call`, `vm_run_ex` entry, `OP_CALL`,
`OP_DISPATCH`) now use `callframe_init`, and all three saved-frame
teardown loops (`task_sched_thread_free` and `task_do_kill` in vm.c,
`task_free` in builtins.c) share `callframe_release` — declared in
vm.h, the one definition of the owned-field set {env iff `owns_env`,
chunk}. The live-frame POP paths keep their inline drops on purpose
(they also drain the operand-stack window, restore the per-frame
stall globals, and park reusable envs — a plain release would be a
subset), and vm.h now flags them for audit. The refactor fixes the
bug it was designed to catch: the `OP_DISPATCH` push never stamped
`saved_stall_count`/`saved_loop_iter`, so interpreted dispatch
restored unstamped counters on RETURN — a dispatch-per-iteration
loop's `__loop_iterations__` froze at 1 after the first dispatch
return, and now tracks the loop. Contributed by @Nitjsefnie (#810);
section [87]'s strict closure-cycle leak gate covers the collector
half.

### Changed

- **Build variants now coexist: per-variant objdirs with dependency
Expand Down
9 changes: 6 additions & 3 deletions src/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -5512,10 +5512,13 @@ void task_free(Task *t) {
CallFrame *f = &t->saved_frames[i];
/* Same rebalance as task_do_kill: a task torn down while suspended
* inside a try never runs its TRY_ENDs, and the leftover process-
* global depth silences every later uncaught error (#726). */
* global depth silences every later uncaught error (#726). The
* g_try_depth fixup is frame bookkeeping, not an owned ref, so it
* stays here; the owned-field drop (env iff owns_env, then chunk)
* goes through vm.c's shared callframe_release (#743) — this is the
* third saved-frame teardown loop, matching the two in vm.c. */
g_try_depth -= f->try_count;
if (f->owns_env && f->env) env_decref(f->env);
if (f->chunk) chunk_decref(f->chunk);
callframe_release(f);
}
if (g_try_depth < 0) g_try_depth = 0;
free(t->saved_frames);
Expand Down
Loading
Loading