Conversation
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
force-pushed
the
aleksisch/gc-carriers-no-jit
branch
from
September 16, 2026 21:08
bfe304f to
2178c11
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
The collector reads a local from the daslang frame at
SP + lv->stackTop(simulate_gc.cpp:1057), driven byFuncInfo::locals[]. A compiled frame keeps its locals somewhere else — LLVM allocas under the JIT, C++ natives under AOT — and nothing ever writes those slots. Soheap_collectreached from compiled code sweeps live objects.Reproduced on master, same program, only the backend differs:
worker=7-jit, beforeworker=-1-jit, afterworker=7-1is 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 laterdeletedouble-freed and tripped the Debugmemory_model.hassert while Release passed by luck.The fix
Function::needCallerStackFrameis already the transitive closure over direct calls seeded onheap_collect(ast_unused.cpp:586-595, seed atmodule_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:make_jit_planroutes a carrier toplan.disabled, beside[no_jit]NoAotMarkermarks itnoAotDeclined functions interpret at load, which is what
Program::linkCppAotalready does for everynoAotfunction. OneLOG_WARNINGper run carries the count;LOG_INFOnames each function.-exeand-librefuse instead. A standalone binary has no interpreter to fall back to — every used function must be compiled, which is whycollect_standalone_functionsalready 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_strictis alwaystrue, so the build stops:exit 1, no binary. An ordinary
-exestill builds and runs.AOT half, same probe:
What this does not fix
tests/gckeeps its-jitand--use-aotskip. The folder is still red for two reasons outside this change:force_escape_freenorforce_allocate_on_stack—test_gc_escape_free_freesreads5997000where0is expected, and scope-exit free aborts withdeleting ..., which is not a chunk pointertest_stackwalk_*cells drive aCollectingWalkerrather thanheap_collect, so they are not carriers, stay jitted, and their locals are not where the walk looks — same root cause, different seedPlease do not read this as "gc is JIT-safe now". It closes the
heap_collectentry point only.Notes
LLVM_JIT_CODEGEN_VERSIONbump. The DLL name foldsplan.candidates(jit_dll_basename), andprobe_dllcounts a cached DLL holding a now-disabled function as a mismatch, so the cache self-invalidates twice over.needCallerStackFrameis only ever set fromheap_collectand dasLiveHost's two collect entries, so a program that never references a collect is untouched.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-145documents it.Testing
-DDAS_LLVM_DISABLED=OFFbuild (LLVM 18.1.3). The negative control reverts onlyllvm_jit_plan.das— it is read at runtime, so no rebuild is needed to flip it.daslang -aot, same probe.daslib/aot_cpp.daslints clean.llvm_jit_plan.dasformat-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-exestill builds and prints.--test testssuite on the interpreter: 14530 tests, 14514 passed, 0 failed, 0 errors, 16 skipped.🤖 Generated with Claude Code