From 5cc5807a3e9ecb14cef68fd84b2edb95c0e5d994 Mon Sep 17 00:00:00 2001 From: Churkin Aleksey Date: Thu, 17 Sep 2026 00:08:13 +0300 Subject: [PATCH 1/3] jit: a function that can reach heap_collect keeps interpreter mode 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. --- modules/dasLLVM/daslib/llvm_jit_plan.das | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/modules/dasLLVM/daslib/llvm_jit_plan.das b/modules/dasLLVM/daslib/llvm_jit_plan.das index b2fff9aad6..a34ce217b0 100644 --- a/modules/dasLLVM/daslib/llvm_jit_plan.das +++ b/modules/dasLLVM/daslib/llvm_jit_plan.das @@ -902,6 +902,7 @@ def public make_jit_plan(prog : Program?; var ctx : Context?; announce : bool) : plan.use_host_cpu = empty(plan.baseline) && (plan.emit_aot_object ? true : (!plan.gen_exe || plan.exe_host_cpu)) plan.log_jit_time = prog._options |> find_arg("log_compile_time") ?as tBool ?? prog.policies.log_compile_time + var gc_carriers = 0 prog |> for_each_module() $(mod) { mod |> for_each_function("") $(fun) { // In object mode emit the whole used, non-noAot set (all modules + @@ -910,7 +911,14 @@ def public make_jit_plan(prog : Program?; var ctx : Context?; announce : bool) : if (is_used(prog, fun) && (!plan.emit_aot_object || !fun.flags.noAot) && get_function_by_mangled_name_hash(hash(get_mangled_name(fun)), *ctx) != null && jit_selects(fun, fun.moreFlags.requestJit, plan.aot_host, plan.jit_all_functions, ctx)) { - if (!fun.moreFlags.requestNoJit) { + let gc_carrier = fun.moreFlags2.needCallerStackFrame && !plan.gen_exe && !plan.gen_lib + if (gc_carrier && !fun.moreFlags.requestNoJit) { + gc_carriers++ + if (announce) { + to_log(LOG_INFO, "LLVM JIT: '{fun.name}' can reach heap_collect - keeping interpreter mode\n") + } + } + if (!fun.moreFlags.requestNoJit && !gc_carrier) { plan.candidates |> emplace(fun) } else { plan.disabled |> emplace(fun) @@ -918,6 +926,10 @@ def public make_jit_plan(prog : Program?; var ctx : Context?; announce : bool) : } } } + if (announce && gc_carriers != 0) { + to_log(LOG_WARNING, "LLVM JIT: {gc_carriers} function(s) reach heap_collect and keep interpreter mode - " + + "a compiled frame's locals are not GC roots, so a collect under one would sweep them while live\n") + } // Content-address the DLL: distinct (AST + codegen version + opt flags) → distinct files, same // inputs → same filename → cache hit. Only in dll-mode with the default output path; a pinned // jit_output_path or an exe keeps the user's path. From ed2dffe21b3af44a17f34ba89ba1c0806902e00e Mon Sep 17 00:00:00 2001 From: Churkin Aleksey Date: Thu, 17 Sep 2026 00:08:27 +0300 Subject: [PATCH 2/3] aot: a function that can reach heap_collect is marked noAot 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. --- daslib/aot_cpp.das | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/daslib/aot_cpp.das b/daslib/aot_cpp.das index 0db170c1b0..512f1e91c2 100644 --- a/daslib/aot_cpp.das +++ b/daslib/aot_cpp.das @@ -569,6 +569,10 @@ class public NoAotMarker : AstVisitor { } def override preVisitFunction(var f : FunctionPtr) { func = f; + if (func.moreFlags2.needCallerStackFrame && !func.flags.noAot) { + func.flags.noAot = true + to_log(LOG_INFO, "AOT: '{func.name}' can reach heap_collect - keeping interpreter mode\n") + } } def override visitFunction(var that : FunctionPtr) : FunctionPtr { var tmp := func From 2178c11c7536ad09d21f2a077f90551bf7a25ba7 Mon Sep 17 00:00:00 2001 From: Churkin Aleksey Date: Thu, 17 Sep 2026 00:08:27 +0300 Subject: [PATCH 3/3] exe: a standalone build refuses a collect carrier instead of dropping it 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. --- modules/dasLLVM/daslib/llvm_exe.das | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/dasLLVM/daslib/llvm_exe.das b/modules/dasLLVM/daslib/llvm_exe.das index 45632be773..aa871e69e4 100644 --- a/modules/dasLLVM/daslib/llvm_exe.das +++ b/modules/dasLLVM/daslib/llvm_exe.das @@ -1056,6 +1056,10 @@ def public collect_standalone_functions(prog : Program?; strict : bool) : Standa has_no_jit = true to_log(LOG_ERROR, "LLVM EXE: function '{fun.name}' is marked no_jit but standalone exe requires all functions to be JIT-compiled\n") } + if (!fun.flags.builtIn && fun.moreFlags2.needCallerStackFrame) { + has_no_jit = true + to_log(LOG_ERROR, "LLVM EXE: function '{fun.name}' can reach heap_collect, whose locals a compiled frame does not expose as GC roots; there is no interpreter here to fall back to\n") + } if (fun.moreFlags.pinvoke) { any_pinvoke = true }