Skip to content

fix(jit): typecom scratch overflow + emulator/tool fixes (INFR-421) - #570

Merged
pdfinn merged 4 commits into
masterfrom
fix/infr-421-jit-code-leak
Aug 29, 2026
Merged

pdfinn merged 4 commits into
masterfrom
fix/infr-421-jit-code-leak

Conversation

@pdfinn

@pdfinn pdfinn commented Aug 29, 2026 •

Copy link
Copy Markdown
Member

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.dis under -c1. On stock a3a7a626 that gives 90 SIGSEGVs, all at one PC and one page-aligned address, matching the campaign evidence exactly; with this branch, none.

Rebased onto current master. The INFR-434 grind.py scorer fix that this branch originally carried is now dropped — #569 landed the same fix independently, so this PR is purely the INFR-421 emulator/JIT/tool work and no longer touches tests/agent-harness/grind.py.

What the crash actually was

typecom() measures the code comi()/comd() will emit by generating it into a scratch buffer first. Both walk t->map (up to t->np*8 pointer slots); the buffer was a fixed 8KB on amd64 / 4096 words on arm64, with no bound. /dis/limbo.dis carries a type with np=542 needing ~100KB, so measuring it runs off the end:

genw (comp-amd64.c:580)  *(u32int*)code = (u32int)o;
  code = typecom_tmp + 8191, buffer is 8192   ->  SIGSEGV, next (unmapped) page

It read as a JIT codegen bug because emu's handler prints "PC not in any image (JIT-generated code?)": genw is static, so dladdr can'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.dis compiles that were still running when the fault hit.

Commits

  • fix(jit): size the typecom scratch buffer from the type — the crash. Size the scratch from t->np with 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() skips free(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 to addr2line instead of the misleading "JIT-generated code?".
  • fix(veltro): kill a timed-out exec command's process group — the worker is a NEWPGRP leader; on timeout/overrun the tool killgrps 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

  • Concurrent-limbo reproducer: 90 SEGV → 0 (re-confirmed after rebase).
  • Full internal suite under -c1: zero faults, same three pre-existing environmental failures.
  • jittest 181/181; jitbug/jitbug2/jitshift/jitcrash pass; amd64_jit_typecom_slab pass.
  • New tests, each negative-controlled against stock:
    • amd64_jit_typecom_bound_test.sh — source pin + concurrent-load behavioural check
    • amd64_jit_code_leak_test.sh — mapping count flat vs stock's linear growth (539→1034 reverted; 31→31 fixed)
    • exec_timeout_kill_test.sh — killgrp removes the NEWPGRP group from /prog

Security note

The typecom defect is memory corruption, not just availability: an unbounded write sized by t->np from 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

pdfinn and others added 4 commits August 30, 2026 05:51
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
pdfinn force-pushed the fix/infr-421-jit-code-leak branch from 9ffce85 to 861477d Compare August 29, 2026 22:52
@pdfinn pdfinn changed the title fix(jit): typecom scratch overflow + related standing fixes (INFR-421/434) fix(jit): typecom scratch overflow + emulator/tool fixes (INFR-421) Aug 29, 2026
@pdfinn
pdfinn merged commit e1023a2 into master Aug 29, 2026
21 of 23 checks passed
@pdfinn
pdfinn deleted the fix/infr-421-jit-code-leak branch August 29, 2026 23:28
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