Skip to content

[JSC] A direct tail call to a host function can return into freed JIT code - #649

Merged
Jarred-Sumner merged 2 commits into
mainfrom
robobun/cefaeaa7/direct-tail-call-host-function-freed-code
Sep 14, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
robobun/cefaeaa7/direct-tail-call-host-function-freed-code

Conversation

@robobun

@robobun robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A host function that runs a user hook, reached by a direct tail call from optimized code, can return into machine code that a collection inside the hook freed. Bun 1.4.3 canary (b99371011, linux x64 release) crashes 6 of 6 runs on the script in the notes: panic(main thread): Segmentation fault at address 0x0, Illegal instruction at address 0x7f..., or abort() called. BUN_JSC_useJIT=0 prints the expected output.
  • DFG and FTL inline the host call thunk into the caller's own code for a direct call to a NativeExecutable (dfg/DFGSpeculativeJIT64.cpp:1043, ftl/FTLLowerDFGToB3.cpp:14132, added in 273947@main). A tail call destroys the caller's frame, so while the host function runs nothing on the machine stack refers to the caller's CodeBlock.
  • The conservative stack scan then cannot put it in CodeBlockSet::m_currentlyExecuting. A watchpoint fire plus a collection inside the hook jettisons the caller and frees its code. The host call returns into that memory.

Fix

  • Emit the inline thunk only for a direct call that keeps its frame. A direct tail call links through DirectCallLinkInfo again, as it did before 273947@main.
  • Correct because the target of a linked direct tail call to a host function is ExecutableBase::generatedJITCodeWithArityCheckForCall (DirectCallLinkInfo::repatchSpeculatively). That thunk lives as long as the VM, and it returns to the caller's caller, not into the caller's code.
  • A non-tail direct call keeps the optimization. Its frame stays on the stack, the frame header holds the CodeBlock, and the scan marks it.
  • Verified: new JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js. Stock jsc fails it 5 of 5 in both configurations it runs, this build 0 of 5. 379 call, tail-call and inlining stress tests give identical output on both builds.

Background

  • A direct call is a call whose callee executable the compiler knows. It needs no callee check, so the call can be linked to the callee's entry.
  • A tail call replaces the caller's frame with the callee's frame. The caller's frame header, which holds the caller's CodeBlock pointer, is gone.
  • JSC keeps code that is still running alive through the conservative scan of the machine stack. CodeBlockSetInlines.h states the assumption: m_currentlyExecuting "is strongly assuming that this catches all the currently executing CodeBlock".
  • A jettison replaces the optimized CodeBlock in its executable. The block then lives only as long as the stack keeps it, and its machine code is freed with it.
Notes

Bun repro (bun g.js, release build of bun main 09bb54630 10/10, 1.4.2 5/6, 1.4.3 canary 6/6, no engine options):

"use strict";
const util = require("util");
let armed = false;
function churn() {                                   // make the JIT allocate new code
  for (let k = 0; k < 300; k++) { const f = new Function("a", "let s = 0; for (let i = 0; i < a; i++) s += i ^ " + k + "; return s;"); for (let j = 0; j < 40; j++) f(200); }
}
const obj = { [util.inspect.custom]() { if (armed) { Bun.gc(true); churn(); } return "x"; } };
function hot(v) { return Bun.inspect(v); }
for (let i = 0; i < 50000; i++) hot(obj);            // hot() reaches the DFG tier
armed = true;
const out = []; for (let i = 0; i < 3; i++) out.push(hot(obj));
console.log("result", out.join(","), "survived");

What each ingredient does:

  • hot() is hot enough for DFG and FTL. Bun.inspect is a constant, so the call is a DirectTailCall to a NativeExecutable and the compiler inlines the host call thunk into hot's code. The dump shows the node and the caller's code range.
  • The first read of Bun.gc inside the hook materializes a lazy property on the Bun object. The structure transition fires the watchpoint hot's code holds for that structure, which jettisons hot.
  • Bun.gc(true) collects. Nothing marks the jettisoned CodeBlock, so the code is freed.
  • churn() makes the JIT reuse the memory. Bun.inspect then returns into it.

Engine-level test:

  • gc() inside the hook is enough. The test uses --zeroExecutableMemoryOnFree=1, which fills freed code with zeroes, so the return into it crashes every run instead of depending on what reuses the memory. Without that option the same test crashes 2 of 5 on this build and 0 of 5 on an LTO build.
  • The test runs twice, with and without --useFTLJIT=0, because the DFG site and the FTL site both emit the thunk. Stock jsc fails both.
  • A non-tail variant of the same test (const r = o.f(v); return r + "";) passes on stock jsc, as expected: the caller's frame is still on the stack.

Why a given case crashes or not:

  • Whether the caller's code is freed depends on whether a dead stack slot still holds the caller's CodeBlock pointer when the scan runs. That is a property of the stack layout, so the same script crashes on one build and not on another.
  • With the conservative scan logged: on a surviving run the caller's optimized CodeBlock is found in a dead stack slot, so the code is not freed. On a crashing run it is not found at all.
  • In Bun the same shape crashes through Bun.inspect, Bun.deepEquals, Bun.deepMatch, Bun.inspect.table, Bun.YAML.stringify, Bun.JSON5.stringify, Bun.TOML.stringify, Bun.gzipSync, Bun.deflateSync, Bun.escapeHTML, Bun.markdown.html and Bun.indexOfLine, and survives through Bun.stringWidth, Bun.stripANSI, Bun.sliceAnsi, Bun.wrapAnsi and Bun.zstdCompressSync. The DFG and FTL dumps of a crashing door and a surviving door are the same, down to the DirectTailCall(<NativeExecutable>, <host function>) node.

Stress test sweep on linux x64 (RelWithDebInfo, --useDollarVM=1, default options): every file in JSTests/stress whose name matches tail, direct-call, call-link, poly-call, host-call, ^dfg-.*call, ^ftl-.*call, apply, spread, varargs, bound-function or inlin, 379 files, same exit status and same output before and after.

Bun PR that pins the preview build of this branch and adds the Bun test: to follow.

… code

DFG and FTL inline the host call thunk into the caller's own code for a
direct call to a NativeExecutable (273947@main). For a tail call that is
not safe: the tail call destroys the caller's frame, so while the host
function runs nothing on the machine stack refers to the caller's
CodeBlock. The conservative stack scan cannot add it to
CodeBlockSet::m_currentlyExecuting, which is what keeps executing code
alive. A watchpoint fire plus a collection inside the host call then
jettisons the caller and frees its machine code. The host call returns
into that memory.

Emit the inline thunk only for a call that keeps its frame. A direct
tail call links through DirectCallLinkInfo again. Its target for a host
function is the executable's host call thunk, which lives as long as the
VM, and that thunk returns to the caller's caller.

* JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js: Added.
* Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp:
(JSC::DFG::SpeculativeJIT::emitCall):
* Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp:

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I checked that in both tiers the removed fast path falls through to the pre-existing DirectCallLinkInfo / emitDirectTailCallFastPath code (DFGSpeculativeJIT64.cpp:1071, FTLLowerDFGToB3.cpp:14139) — this is a targeted revert of the tail-call half of 273947@ main, not new codegen. Also confirmed FTL's emitCallTarget lambda is still reached by the non-tail native path at FTLLowerDFGToB3.cpp:14167, so no dead code was introduced.

Extended reasoning...

The fix disables the inline host-call thunk only for the isTail case in both DFG and FTL, letting direct tail calls to native functions fall back to the DirectCallLinkInfo path that predates 273947@ main. I read the surrounding ~140 lines in each file to confirm the fallthrough targets are the unchanged, pre-existing tail-call linking code, and that the FTL emitCallTarget lambda (defined before the isTail branch) is still consumed by the non-tail native path so the removal doesn't leave it dead. The engine change itself is small, well-commented, and mirrored across both tiers as JSC conventions require; the only outstanding issue is the stress test's hardcoded iteration count, already flagged inline.

Comment thread JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js Outdated
@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: cda96b51-42b9-46b1-a6c0-7d396a71bbd9

📥 Commits

Reviewing files that changed from the base of the PR and between 635ce3a and 553df4e.

📒 Files selected for processing (1)
  • JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js

Included review availability: Your plan provides up to 5 included reviews per hour; 0 remain after this review.


Walkthrough

DFG and FTL lowerers now route native tail calls through the direct linked path instead of the current CodeBlock host thunk. A stress test verifies behavior when optimized caller code is freed during argument stringification.

Changes

Host-function tail-call lifetime

Layer / File(s) Summary
Direct tail-call lowering
Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp, Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
DFG excludes tail calls from the native host-thunk path. FTL removes the specialized frame setup, target emission, and immediate return path.
Code reclamation stress test
JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js
The test warms up a direct tail-call wrapper for encodeURIComponent, triggers object mutation and garbage collection during stringification, and checks the return value and hook count.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 553df

No actionable merge-blocking risk was identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the JavaScriptCore bug: a direct tail call to a host function can return into freed JIT code.
Description check ✅ Passed The description is detailed and on-topic. It explains the problem, fix, technical cause, test coverage, and validation results. It does not include the Bugzilla link, reviewer line, or explicit change…

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


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

@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Preview Builds

Commit Release Date
553df4e7 autobuild-preview-pr-649-553df4e7 2026-09-13 21:15:10 UTC
635ce3a4 autobuild-preview-pr-649-635ce3a4 2026-09-13 20:07:00 UTC

JSTests/README.md rule 2. The two configurations pin the tier-up
thresholds, so testLoopCount is 3000 in both and each run takes about
11 ms on a build with the fix. Stock jsc still fails both 5 of 5.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner merged commit 3cf2a3d into main Sep 14, 2026
48 checks passed
dylan-conway added a commit that referenced this pull request Sep 14, 2026
main has the same bug fixed another way (#649): a direct tail call to a host
function no longer takes the inline call path, and links through the
executable's host call thunk, which lives as long as the VM and returns to the
caller's caller. It comes with a regression test that frees the caller's code
deterministically.

This branch kept the inline call and parked the CodeBlock's pointer in a stack
slot across it (f66e3bc). Both fix the crash. The inline tail call is
about 0.7 ns faster per call (17.6 ns against 18.4 ns); main's version leaves
no JIT code running without a frame that names its CodeBlock, which is the
assumption CodeBlockSet's currently-executing set states. DFGSpeculativeJIT64.cpp
and FTLLowerDFGToB3.cpp are main's.
dylan-conway added a commit to oven-sh/bun that referenced this pull request Sep 14, 2026
The preview release autobuild-preview-pr-645-b6d2430a: #645 merged with
oven-sh/WebKit main, which brings the fix for a direct tail call to a host
function returning into freed JIT code (oven-sh/WebKit#649). Temporary: once
#645 is merged this becomes the sha of the autobuild from main.
robobun added a commit to oven-sh/bun that referenced this pull request Sep 14, 2026
The preview tag goes away now that the WebKit PR has merged. The new pin
is fork main. It also picks up the five other commits that landed there
since cf1b36ec8703: oven-sh/WebKit#636, #634, #632, #652 and #646. Bun
builds against the new headers with no source change.
robobun added a commit to oven-sh/bun that referenced this pull request Sep 15, 2026
main pins 9b02218df662 (#42556), one commit past 3cf2a3dfd259. This branch
keeps main's pin and carries only the test.
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.

2 participants