Skip to content

gc: a collect carrier keeps interpreter mode under JIT and AOT - #4048

Draft
aleksisch wants to merge 3 commits into
masterfrom
aleksisch/gc-carriers-no-jit
Draft

aleksisch wants to merge 3 commits into
masterfrom
aleksisch/gc-carriers-no-jit

Conversation

@aleksisch

@aleksisch aleksisch commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

The bug

The collector reads a local from the daslang frame at SP + lv->stackTop (simulate_gc.cpp:1057), driven by FuncInfo::locals[]. A compiled frame keeps its locals somewhere else — LLVM allocas under the JIT, C++ natives under AOT — and nothing ever writes those slots. So heap_collect reached from compiled code sweeps live objects.

Reproduced on master, same program, only the backend differs:

options gc
options persistent_heap

struct Node { x : int }

def do_collect { unsafe(heap_collect(true, false)) }

def worker : int {
    var n = new Node(x = 7)
    do_collect()
    var churn : array<Node?>
    for (i in range(64)) { churn |> push(new Node(x = -1)) }
    return n.x
}
result
interpreter worker=7
-jit, before worker=-1
-jit, after worker=7

-1 is the churn value read back out of the reused slot. This is the signature recorded in 93cfca0 ("interp prints n.x=7, jit prints n.x=-1"), where a later delete double-freed and tripped the Debug memory_model.h assert while Release passed by luck.

The fix

Function::needCallerStackFrame is already the transitive closure over direct calls seeded on heap_collect (ast_unused.cpp:586-595, seed at module_builtin_runtime.cpp:2505), and the interpreter already denies those functions fastcall for exactly this reason — a frameless carrier breaks the same walk. Both compiled back-ends now decline them as well:

  • JITmake_jit_plan routes a carrier to plan.disabled, beside [no_jit]
  • AOTNoAotMarker marks it noAot

Declined functions interpret at load, which is what Program::linkCppAot already does for every noAot function. One LOG_WARNING per run carries the count; LOG_INFO names each function.

-exe and -lib refuse instead. A standalone binary has no interpreter to fall back to — every used function must be compiled, which is why collect_standalone_functions already errors on [no_jit]. Declining a carrier there would have left it out of codegen with nothing to run, so the decline is scoped to the JIT and AOT-object paths and the exe path reports a carrier the same way it reports [no_jit]. plan.exe_strict is always true, so the build stops:

LLVM EXE: function 'worker' can reach heap_collect, whose locals a compiled frame
does not expose as GC roots; there is no interpreter here to fall back to
Cannot build standalone exe: some functions are no_jit in strict mode

exit 1, no binary. An ordinary -exe still builds and runs.

AOT half, same probe:

before: worker emitted as C++ (8 references in the generated file)
after : 0 — the carrier is excluded and interprets

What this does not fix

tests/gc keeps its -jit and --use-aot skip. The folder is still red for two reasons outside this change:

  • the JIT implements neither force_escape_free nor force_allocate_on_stacktest_gc_escape_free_frees reads 5997000 where 0 is expected, and scope-exit free aborts with deleting ..., which is not a chunk pointer
  • the test_stackwalk_* cells drive a CollectingWalker rather than heap_collect, so they are not carriers, stay jitted, and their locals are not where the walk looks — same root cause, different seed

Please do not read this as "gc is JIT-safe now". It closes the heap_collect entry point only.

Notes

  • No LLVM_JIT_CODEGEN_VERSION bump. The DLL name folds plan.candidates (jit_dll_basename), and probe_dll counts a cached DLL holding a now-disabled function as a mismatch, so the cache self-invalidates twice over.
  • Blast radius is narrow. needCallerStackFrame is only ever set from heap_collect and dasLiveHost's two collect entries, so a program that never references a collect is untouched.
  • Residual, unchanged from the interpreter. The closure does not cross invoke edges and under-reports on recursive cycles (both stated in the code comment at ast_unused.cpp:588-590). A collect reached only through a block, lambda or function pointer is still caught at runtime by the refusal pre-scan (simulate_gc.cpp:960-978), same as today. contexts.rst:141-145 documents it.

Testing

  • JIT before/after probe above, on a local -DDAS_LLVM_DISABLED=OFF build (LLVM 18.1.3). The negative control reverts only llvm_jit_plan.das — it is read at runtime, so no rebuild is needed to flip it.
  • AOT before/after via daslang -aot, same probe.
  • daslib/aot_cpp.das lints clean. llvm_jit_plan.das format-verifies; it cannot be linted standalone (missing LLVM prerequisite outside a JIT build).
  • -exe: the probe is refused with the message above, exit 1, no binary; a hello-world -exe still builds and prints.
  • Full --test tests suite on the interpreter: 14530 tests, 14514 passed, 0 failed, 0 errors, 16 skipped.

🤖 Generated with Claude Code

The collector reads a local from the daslang frame at its stackTop. A
jitted frame keeps its locals in LLVM allocas that nothing writes there,
so a heap_collect under one sweeps them while live: the same program
reads 7 interpreted and -1 under -jit, off the reused slot, and a later
delete is a double free.

needCallerStackFrame is already the transitive closure over direct calls
seeded on heap_collect (ast_unused.cpp), and the interpreter already
denies those functions fastcall for exactly this reason. The JIT plan now
routes a carrier to plan.disabled beside [no_jit], where linkCppAot
interprets it. One warning per run carries the count, LOG_INFO names each
function.

Scoped away from -exe and -lib, which have no interpreter to fall back
to; the next commits cover the C++ AOT emitter and that refusal.

No LLVM_JIT_CODEGEN_VERSION bump - the DLL name folds plan.candidates and
probe_dll counts a cached DLL holding a now-disabled function as a
mismatch, so the cache self-invalidates twice over.
Same defect as the JIT one commit back, in the C++ emitter: an AOT frame
keeps its locals in C++ natives, so the stackTop slots the collector
reads are allocated and never written. Worse than the JIT case, because
SimNode_Aot means the interpreter calls an AOT function through the
ordinary Context::call path, which fills a fully honest prologue - the
walk then reads recycled stack bytes as whatever the local declares,
rather than skipping the frame.

NoAotMarker is the AOT twin of the JIT plan filter, so a carrier gets
noAot there and interprets at load, as every noAot function already does.
Generated C++ for the probe: worker appeared 8 times before, 0 after.
A standalone exe and a -lib have no interpreter to fall back to - every
used function must be compiled, which is why collect_standalone_functions
already errors on [no_jit]. A carrier is the same shape: the two commits
back declined it everywhere else, and the JIT plan skips it, so leaving
it out of a standalone codegen would leave nothing to run.

The exe path now reports a carrier the way it reports [no_jit], and
exe_strict is always true, so the build stops before emitting a binary.
An ordinary -exe still builds and runs.
@aleksisch
aleksisch force-pushed the aleksisch/gc-carriers-no-jit branch from bfe304f to 2178c11 Compare September 16, 2026 21:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant