Repository navigation
fix(jit): typecom scratch overflow + emulator/tool fixes (INFR-421) - #570
Merged
Merged
Conversation
freemod() skips free(m->prog) on amd64/arm64 because a compiled module's text is an mmap'd JIT mapping rather than pool memory. The guard was added with the 64-bit JITs to stop free() being called on an mmap'd pointer, but nothing was added to unmap it, so every compiled module's executable mapping leaked for the life of the process. The 32-bit compilers mallocz() their code buffer, so free() has always reclaimed it there; only amd64 and arm64 leak. Ordinary command execution drives this without bound: each `ls` reloads and recompiles Ls, each forked namespace reloads Cowfs. A 1,100-iteration `ls` loop leaves 1,932 live executable mappings and exhausts jitmalloc's entire 64 MB near-text hint band, after which every allocation costs up to 2,048 mmap/munmap syscalls. Long agent sessions can reach vm.max_map_count (65530 by default). Record the allocation size on the Module and unmap it in freemod(). Also release the ARM64 code buffer on compile()'s error path, which leaked it outright. Measured on the same workload: live executable mappings go from 1,932 to 11, and stop growing with the number of reloads. Refs: INFR-421 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvhCNDPcVUu1GSDW4dhxep
typecom() measures the code comi() and comd() will emit by generating it
into a scratch buffer first. Both walk t->map, which covers t->np*8
pointer slots, and emit per-slot code; the scratch buffer was a fixed 8KB
on amd64 and 4096 words on arm64, with no bound on what gets written into
it.
/dis/limbo.dis carries a type with np=542 and size=34672, which needs
roughly 100KB. Measuring it runs off the end of the buffer:
genw (comp-amd64.c:580) *(u32int*)code = (u32int)o;
code = typecom_tmp + 8191, buffer is 8192
-> SIGSEGV at the next page, si_code=1
This is the fault in INFR-421. It read as a JIT codegen bug because emu's
handler reports "PC not in any image (JIT-generated code?)": genw is
static, so dladdr cannot name it, and the handler guesses. The faulting
code is the compiler itself, in emu's own text.
On arm64 the same overrun runs past a malloc'd heap buffer instead of a
mapping edge, so it corrupts silently rather than faulting.
Size the buffer from t->np with a documented per-slot bound, grow it on
demand, and check the emitted size against the bound afterwards, with
slack so a wrong bound fails loudly instead of corrupting memory.
Reproducer: several concurrent loads of limbo.dis under -c1. On stock
a3a7a62 that gives 90 SIGSEGVs, all at the same PC and the same
page-aligned address, matching the campaign evidence exactly; with this
change, none.
Refs: INFR-421
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvhCNDPcVUu1GSDW4dhxep
…R-421) The Linux and macOS SEGV/BUS handlers print "PC not in any image (JIT-generated code?)" whenever dladdr() leaves dli_sname nil. But dladdr() only sees the dynamic symbol table, so a fault in any static function — including the JIT's own genw()/typecom() — resolves the image (dli_fname/dli_fbase are set) yet has no dynamic symbol name. The handler mislabelled that as "not in any image", which is how the INFR-421 typecom overflow read as a JIT codegen bug across three sessions when the faulting PC was in emu's own text the whole time. Distinguish the three cases: named symbol; image but no exported symbol (print the image and offset so it can be addr2line'd); and truly no image (the honest "JIT-generated code?" guess). On the reproducer the handler now prints "PC in .../o.emu+0x533c8 (no exported symbol; static function)", and addr2line resolves 0x533c8 to genw at comp-amd64.c:580. Refs: INFR-421 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvhCNDPcVUu1GSDW4dhxep
The exec tool ran a command in a worker goroutine and returned after a 5s timeout (or a 16KB output cap) by simply no longer reading it. The command kept running, unsupervised and outside the tool's accounting. Two such abandoned limbo.dis compiles are what were still JIT-compiling when the emulator faulted in the escape campaign, and INFR-434's step-9 exec never returned because a state-modifying command was left blocked with nothing to release it. Make the worker a process-group leader (NEWPGRP, as spawn.b already does for subagents) and have it report its pid. If the command has not finished when the tool stops reading, write "killgrp" to /prog/<pid>/ctl so the whole subtree dies with the tool call. The exec tool is exempt from the NODEVS restriction that hides /prog from other tools, so it can reach the control file; the kill is best-effort and degrades to the prior behaviour if /prog is absent. Regression: tests/host/exec_timeout_kill_test.sh pins the source contract and proves killgrp on a NEWPGRP leader removes the whole group from /prog. Refs: INFR-421 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvhCNDPcVUu1GSDW4dhxep
pdfinn
force-pushed
the
fix/infr-421-jit-code-leak
branch
from
August 29, 2026 22:52
9ffce85 to
861477d
Compare
This was referenced Aug 30, 2026
Merged
This was referenced Aug 31, 2026
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.
Summary
Root-causes and fixes the JIT crash that stopped the source-aware escape-room campaigns (INFR-421). The reported fault was not the suspects named on the ticket (typecom slab exhaustion / a JIT mapping leak) — it is a buffer overflow in the compiler's own
typecom(), misreported as JIT-generated code by an over-eager fault handler.Deterministic, no-LLM/no-credit reproducer: several concurrent loads of
limbo.disunder-c1. On stocka3a7a626that gives 90 SIGSEGVs, all at one PC and one page-aligned address, matching the campaign evidence exactly; with this branch, none.What the crash actually was
typecom()measures the codecomi()/comd()will emit by generating it into a scratch buffer first. Both walkt->map(up tot->np*8pointer slots); the buffer was a fixed 8KB on amd64 / 4096 words on arm64, with no bound./dis/limbo.discarries a type withnp=542needing ~100KB, so measuring it runs off the end:It read as a JIT codegen bug because emu's handler prints "PC not in any image (JIT-generated code?)":
genwisstatic, sodladdrcan't name it and the handler guessed. The faulting PC was in emu's own text the whole time.The agent that triggered it was doing exactly what the qualification gate asked — compiling a trivial probe — and the exec tool abandoned two timed-out
limbo.discompiles that were still running when the fault hit.Commits
fix(jit): size the typecom scratch buffer from the type— the crash. Size the scratch fromt->npwith a documented per-slot bound, grow on demand, and check the emitted size against the bound afterwards with slack. Both 64-bit compilers.fix(jit): release compiled module text on unload— a real, separate leak found on the way:freemod()skipsfree(m->prog)for compiled modules on amd64/arm64 (the text is an mmap'd mapping) but nothing unmapped it, so every compiled module's code leaked for the process lifetime.ls /dis×1100 → 1,932 live executable mappings; with the fix, 11 and flat.fix(emu): report faults in static functions as in-image, not JIT— distinguish named symbol / image-without-symbol / truly-no-image, so a static-function fault prints the image + offset toaddr2lineinstead of the misleading "JIT-generated code?".fix(veltro): kill a timed-out exec command's process group— the worker is aNEWPGRPleader; on timeout/overrun the toolkillgrps it instead of abandoning it. This is what left two compiles running into the fault, and why INFR-434's step-9 exec never returned.Validation
limboreproducer: 90 SEGV → 0 (re-confirmed after rebase).-c1: zero faults, same three pre-existing environmental failures.jittest181/181;jitbug/jitbug2/jitshift/jitcrashpass;amd64_jit_typecom_slabpass.amd64_jit_typecom_bound_test.sh— source pin + concurrent-load behavioural checkamd64_jit_code_leak_test.sh— mapping count flat vs stock's linear growth (539→1034 reverted; 31→31 fixed)exec_timeout_kill_test.sh—killgrpremoves the NEWPGRP group from/progSecurity note
The typecom defect is memory corruption, not just availability: an unbounded write sized by
t->npfrom a Dis type descriptor, landing next to the JIT's executable mappings. Here it hit an unmapped page and faulted; on arm64 the same overrun runs past a heap buffer and corrupts silently. This run showed no escape or exfiltration (audit chain verifies clean, no canary hits, no unexpected egress), but "candidate agent-triggerable DoS" understates it. Controllability was not investigated.Not in this PR
Rerunning the campaign stages (source-control → qualification → live) — operational, needs the rig.
🤖 Generated with Claude Code