Skip to content

bun:ffi: stop allocating TinyCC JIT memory from the CRT heap on Windows - #32013

Closed
robobun wants to merge 3 commits into
mainfrom
farm/b43b781b/tinycc-win32-virtualalloc
Closed

bun:ffi: stop allocating TinyCC JIT memory from the CRT heap on Windows#32013
robobun wants to merge 3 commits into
mainfrom
farm/b43b781b/tinycc-win32-virtualalloc

Conversation

@robobun

@robobun robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Crash

Windows x64 processes using bun:ffi segfault inside ntdll while compiling symbols, always with this stack (symbolicated from bun 1.3.14):

FFI.open / Function.compile (ffi.zig, ffi_body.rs on main)
  -> tcc_relocate -> tcc_relocate_ex        vendor/tinycc/tccrun.c
  -> win64_add_function_table               (the RtlAddFunctionTable call)
  -> RtlAddFunctionTable -> <ntdll frames> -> access violation

Fault addresses vary per event and look heap-like (0x1CB99EF6BB8, 0x189937629B8, 0x2C426471E98, ...). Crash reporting has 12,000+ events across bun 1.3.8 through 1.3.14 (report ID BUN-2V2K), 100% Windows, spread over many distinct machines and apps. There is no known deterministic reproduction; the crash depends on accumulated heap state.

Cause

The pinned oven-sh/tinycc commit allocates JIT "run memory" with tcc_malloc, i.e. from the CRT heap, which on Windows is the NT process heap. It then:

  • lays generated code and data out inside that heap block,
  • VirtualProtects the code pages to PAGE_EXECUTE_READWRITE, and
  • registers SEH unwind tables pointing into the block via RtlAddFunctionTable, ignoring the return value.

So executable JIT pages and registered unwind data live inside pages owned by the NT process heap. In bun that heap is used almost exclusively by TinyCC and by ntdll itself (bun's own allocations go through mimalloc, JSC's through bmalloc), which is why corrupted heap or unwind-table state surfaces precisely at the next RtlAddFunctionTable call: it allocates its dynamic-table node from the process heap and updates the process-wide table list.

Upstream TinyCC identified and fixed exactly this:

  • 6728a64f1b "win32: use VirtualAlloc for run memory", with the in-code comment "Generated code is page-protected below; avoid changing CRT heap pages."
  • 601a088214 "win32: improve tccrun protection diagnostics", which also treats RtlAddFunctionTable failure as an error instead of silently keeping a never-registered table pointer (previously that pointer was later handed to RtlDeleteFunctionTable).

The pinned fork commit predates both.

Fix

Backport the two upstream commits verbatim as patches/tinycc/tccrun.c.patch, registered in scripts/build/deps/tinycc.ts (the existing vendor-patch mechanism) with a note to drop the patch once TINYCC_COMMIT advances past an upstream merge containing 601a088214:

  • Windows run memory comes from a dedicated VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE) region, released with VirtualFree(MEM_RELEASE) after RtlDeleteFunctionTable (same unregister-before-free order as before).
  • RtlAddFunctionTable failure now fails the FFI compile with "RtlAddFunctionTable failed".
  • VirtualProtect failure gets a Windows-specific diagnostic.

Non-Windows behavior is byte-identical: every new branch is #ifdef _WIN32.

Other explanations audited and ruled out on current code: registration/unregistration are strictly paired (run_function_table is set once in tcc_relocate_ex and deleted only in tcc_run_free before the memory is released, and bun frees TCC states only through tcc_delete), so there is no code path that frees a registered table's memory without unregistering it. Concurrent Worker compiles hitting TinyCC globals remain theoretically possible but cannot account for the event volume.

Verification

  • bun bd re-fetches the pinned tinycc commit, applies the patch cleanly (the patch-content hash invalidates the cached source), and builds.
  • bun bd test test/js/bun/ffi/ matches main (the two ffi.test.js errors about /tmp/bun-ffi-test.dylib are pre-existing; they reproduce with the released bun).
  • A 20-iteration JSCallback + CFunction compile/call/close loop runs clean under the ASAN debug build.
  • Windows CI lanes compile the patched #ifdef _WIN32 branches and run the existing bun:ffi suite through the new allocation path.

Why there is no new test

The changed code is Windows-only vendored C, dead on Linux, and the crash has no deterministic repro (heap-state dependent). No test can fail before and pass after this change on Linux, and a Windows stress test would not reliably fail on the unfixed build either. The existing Windows FFI tests exercise the new path on every CI run.

Related issue

Should address #31941 (not auto-closing: the link is causal reasoning, not an observed before/after; please reopen if it recurs on a release containing this patch).

That report is the same memory class seen from the other side: a Windows x64 standalone executable doing sustained FFI polling crashes after ~2 hours while executing the compiled trampoline (JSFFIFunction::trampoline calling into an anonymous executable region), with the same heap-range fault addresses (0x1A5F8AF0108, 0x210D7F40108) as the compile-time crashes above. The workload churns the CRT heap heavily around the live JIT block (libuv allocates from it; the session had 1,484 spawns and 12k fetches). With this patch the JIT code no longer lives in heap pages at all. The issue's 2-hour repro has not been run against the patched build; if the crash recurs on a release containing this fix, the issue should be reopened.

@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 AM PT - Jul 7th, 2026

@Jarred-Sumner, your commit 9a17f14 has 2 failures in Build #69805 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32013

That installs a local version of the PR into your bun-32013 executable, so you can run:

bun-32013 --bun

@github-actions github-actions Bot added the claude label Jun 9, 2026
@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is ready; no CI run has shown a failure related to it.

Build 69805 (sha 9a17f14, Jarred's rebase onto current main + comment trim), final: 285 jobs passed, 1 failed. Binary size delta is +0.0 KB on every target vs main canary #69776, matching the fact that the only code change is inside #ifdef _WIN32. All Windows build and test lanes pass, so the bun:ffi suite runs through the VirtualAlloc path.

The single failed job is one darwin-14-aarch64 test shard with two flaky tests:

  • test/integration/next-pages/test/dev-server.test.ts (next.js + puppeteer/chrome)
  • test/js/bun/webview/webview.test.ts ("WebView host process killed by signal 5")

Both fail identically on other unrelated PR branches today (builds 69752, 69781, 69787, 69797), and neither has any connection to Windows-only TinyCC JIT memory allocation, which is dead code on darwin.

Run history: 61473 (f81b81e) red only on the then repo-wide bunx.test.ts breakage, since skipped on main by #32042; 61646 (main-merge) red only on expired build agents; 61698 (retrigger) green except one bootstrap-dead agent + 2 expired darwin shards; 69805 as above. Needs a maintainer to merge over the darwin flakes or retry that shard (the Buildkite token here is read-only).

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Segfault in JSFFIFunction::trampoline on Windows standalone executable after sustained FFI polling #31941 - Windows x64 segfault in JSFFIFunction::trampoline after sustained FFI polling; heap-like fault addresses and time/load-dependent crash pattern match the CRT-heap JIT memory corruption this PR fixes

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #31941

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Windows runtime execution in TinyCC now uses platform-specific memory management (VirtualAlloc/VirtualFree), 64-bit exception table registration (RtlAddFunctionTable), and corresponding error handling. A new patch file defines these changes, and the build configuration registers it.

Changes

Windows Runtime Fixes for TinyCC

Layer / File(s) Summary
Windows memory allocation and cleanup
patches/tinycc/tccrun.c.patch
Memory allocation switches to VirtualAlloc on Windows with early failure reporting. Cleanup frees memory via VirtualFree on Windows and calls win64_del_function_table on 64-bit Windows; non-Windows paths remain unchanged.
Error handling for page protection and function table registration
patches/tinycc/tccrun.c.patch
Page protection errors report platform-specific messages (VirtualProtect failed on Windows; mprotect failed elsewhere). RtlAddFunctionTable calls now check for failure and emit error messages instead of proceeding silently.
Build configuration update
scripts/build/deps/tinycc.ts
The new tccrun.c patch is added to the TinyCC dependency's patch list alongside the existing tcc.h patch.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: switching TinyCC JIT memory allocation from CRT heap to platform-specific allocation on Windows.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR explains the change and verification clearly, though it uses custom section headings instead of the repo template.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Additional field evidence: Sentry group BUN-3K30 (https://bun-p9.sentry.io/issues/?query=issue%3ABUN-3K30) is the same crash seen from bun 1.4.x. Sentry split it into a separate group because the 1.4.x Rust-port frames (bun_runtime::ffi::ffi_body::Function::compilebun_tcc_sys::tcc::State::relocate) don't hash-match the 1.3.x Zig frames, but the top native frame is identical (tcc_relocatetcc_relocate_exwin64_add_function_table). Roughly a dozen events so far, all from standalone-executable builds, all Windows x64.

I re-audited src/runtime/ffi/ffi_body.rs::Function::compile against src/runtime/ffi/ffi.zig::compile to rule out a port regression: the tcc option string, output type, CompilerRT define/inject sequence, symbol registration order, and tcc_relocate invocation are step-for-step identical, and neither version carries any Windows-specific tcc_add_symbol calls. The tinycc build config (commit, defines, per-TU flag consistency) is unchanged between 1.3.14 and main. So there is no separate Rust-side fix to make; this PR covers BUN-3K30 as well.

robobun and others added 3 commits July 7, 2026 09:57
Backport upstream tinycc mob 6728a64f1b (win32: use VirtualAlloc for run
memory) and 601a088214 (win32: improve tccrun protection diagnostics) as a
vendor patch.

The pinned tinycc commit mallocs JIT run memory from the CRT heap, makes
pages inside that heap block executable with VirtualProtect, and registers
SEH unwind tables pointing into it with RtlAddFunctionTable, ignoring the
return value. Windows x64 processes using bun:ffi crash with an access
violation inside ntdll under RtlAddFunctionTable during a later FFI
compile. With the patch, run memory comes from a dedicated VirtualAlloc
region, is released with VirtualFree after RtlDeleteFunctionTable, and
RtlAddFunctionTable failures surface as compile errors.

Non-Windows paths are unchanged (new branches are ifdef _WIN32).
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Heads-up: #29476 bumps TINYCC_COMMIT from 12882eee to c49c2204 for Windows ARM64 support. I verified c49c2204 does not already contain the two upstream commits this patch backports (6728a64f1b, 601a088214), so this patch is still needed after that bump — but whichever PR lands second should re-verify patches/tinycc/tccrun.c.patch still applies against the moved tccrun.c. Cleanest end-state is one oven-sh/tinycc bump that carries both.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Verified: patches/tinycc/tccrun.c.patch applies cleanly to tccrun.c at oven-sh/tinycc c49c2204 via the same git apply --ignore-whitespace --ignore-space-change --no-index path the build uses.

The only tccrun.c delta between 12882eee and c49c2204 is the 4-line _WIN64 && __aarch64__ ucontext block around line 1204; none of this patch's four hunks (lines ~130, ~188, ~414, ~487) touch that region. After applying, the result carries all four changes (VirtualAlloc/VirtualFree/"VirtualProtect failed"/"RtlAddFunctionTable failed"). And c49c2204 has zero occurrences of VirtualAlloc/VirtualFree before patching, matching your check that the two upstream commits aren't in it.

So whichever PR lands second, bun bd will re-fetch (patch-content hash invalidates the vendor source) and re-apply without edits.

Agreed the cleanest end-state is landing both in oven-sh/tinycc and bumping past it, at which point the patches: entry drops per the comment above it. That's an oven-sh/tinycc change; happy to prepare it separately if useful.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found, but this changes JIT executable-memory allocation and SEH unwind-table handling in vendored C — worth a human confirming the backport matches upstream 6728a64f1b/601a088214 against the pinned fork before merge.

Extended reasoning...

Overview

This PR adds patches/tinycc/tccrun.c.patch and registers it in scripts/build/deps/tinycc.ts. The patch backports two upstream TinyCC commits so that on Windows, JIT run memory is allocated via VirtualAlloc/VirtualFree instead of the CRT heap, and RtlAddFunctionTable failures are surfaced as errors. All new code is behind #ifdef _WIN32; non-Windows paths are byte-identical.

Security risks

The change governs allocation and page-protection of executable JIT memory and registration of SEH unwind tables — memory-safety-critical territory. The direction of the change is strictly safer (dedicated VirtualAlloc region instead of page-protecting CRT heap blocks, error-checking a previously unchecked RtlAddFunctionTable), and it is a verbatim backport of upstream fixes. I don't see new attack surface, but the domain itself is sensitive enough that a human should sign off.

Level of scrutiny

Medium-high. It's a small, focused patch to a vendored dependency using the established patches/ mechanism, and Windows CI exercises the new path via the existing bun:ffi suite. However, it modifies executable-memory lifecycle in C, has no deterministic regression test (justified: heap-state-dependent crash with no reliable repro), and the correctness hinges on the patch context matching the pinned oven-sh/tinycc fork commit exactly.

Other factors

The bug-hunting system found no issues. CI is green on Windows including the FFI suite. The PR description is unusually thorough (root-cause analysis, upstream commit references, alternative-explanation audit). A maintainer has already engaged with the branch. Given all that, this is close to approvable — I'm deferring only because vendored-C changes to JIT memory allocation fall outside what I'd auto-approve without a human glance at the upstream diff.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Re the bot's verification request: applying the two upstream commits' tccrun.c hunks to the pinned fork commit produces a byte-identical file to applying patches/tinycc/tccrun.c.patch. One-liner a reviewer can run:

curl -sL https://raw.githubusercontent.com/oven-sh/tinycc/12882eee/tccrun.c | tee a.c > tccrun.c \
  && for sha in 6728a64f1b 601a088214; do gh api repos/TinyCC/tinycc/commits/$sha \
       --jq '.files[]|select(.filename=="tccrun.c")|.patch' | patch -s a.c; done \
  && git apply --no-index patches/tinycc/tccrun.c.patch \
  && diff a.c tccrun.c && echo IDENTICAL

Output here: IDENTICAL. Same holds against c49c2204 (the #29476 bump) per the comment above.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Per-issue verification of the find-issues bot's list (1 candidate)

Issue State Verdict Evidence
#31941 open CANNOT-VERIFY (from a Linux box) The only functional change in this PR is patches/tinycc/tccrun.c.patch, and every new branch is Windows-only (VirtualAlloc/VirtualFree/RtlAddFunctionTable under _WIN32/_WIN64) — the changed code never executes on Linux. The issue itself is a Windows x64 standalone-exe segfault in JSFFIFunction::trampoline after ~2 hours of sustained FFI polling with no deterministic reproduction (heap-state dependent). There is no repro I can run that fails on main and passes on this branch, on any platform.

Fixes block: empty. No issue in the bot's list has an empirically proven FIXES verdict, so nothing should be added on that basis.

Note on the existing Fixes #31941 in the PR description. The PR body already carries that auto-close line. To be explicit: I did not find evidence the PR doesn't fix it — the causal argument (JIT code + registered unwind tables living in NT process-heap pages, matching heap-range fault addresses across BUN-2V2K / BUN-3K30 / #31941) is plausible and the PR description itself is candid that "the issue's 2-hour repro has not been run against the patched build." But that means the auto-close rests on reasoning, not on an observed before/after. If maintainers want to be conservative, replace Fixes #31941 with a plain reference (e.g. "should address #31941 — please reopen if it recurs on a release containing this patch") so the issue only closes once someone with the Windows workload confirms; otherwise keep it with that reopen caveat, which the PR body already states.

Not repeated here: robobun's earlier comments on this PR verify patch mechanics (clean apply at both 12882eee and c49c2204, byte-identical to the two upstream commits), not whether #31941 is fixed — so this is the first per-issue verdict, and it is a non-verdict for lack of a runnable reproduction.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed, and taken the conservative option: the PR body now says "Should address #31941" with an explicit note that the link rests on causal reasoning rather than an observed before/after, so merging will not auto-close it. The two Sentry groups (BUN-2V2K, BUN-3K30) are where the field signal will show up; if #31941's reporter confirms on a release carrying this patch, it can be closed then.

@robobun robobun mentioned this pull request Jul 8, 2026
6 tasks
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #33653, which bumps TINYCC_COMMIT to oven-sh/tinycc c22ca054. That commit already contains both upstream changes this PR backports (6728a64f VirtualAlloc for run memory, 601a0882 tccrun diagnostics / RtlAddFunctionTable error check), so the patch file here is no longer needed once that lands.

Verified tccrun.c at c22ca054:

  • line 135: ptr = VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
  • line 524: tcc_error_noabort("RtlAddFunctionTable failed");

The crash analysis and Sentry group references (BUN-2V2K, BUN-3K30, #31941) have been carried over into #33653's description.

@robobun robobun closed this Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants