From 635ce3a42570968d630ac4897ee66b4dc23f2ca9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:39:25 +0000 Subject: [PATCH 1/2] [JSC] A direct tail call to a host function can return into freed JIT 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: --- ...to-host-function-code-freed-during-call.js | 48 +++++++++++++++++++ .../dfg/DFGSpeculativeJIT64.cpp | 17 +++---- Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp | 17 +++---- 3 files changed, 62 insertions(+), 20 deletions(-) create mode 100644 JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js diff --git a/JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js b/JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js new file mode 100644 index 0000000000000..6f503a6660cb9 --- /dev/null +++ b/JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js @@ -0,0 +1,48 @@ +//@ runDefault("--zeroExecutableMemoryOnFree=1", "--useConcurrentJIT=0") +//@ runDefault("--zeroExecutableMemoryOnFree=1", "--useConcurrentJIT=0", "--useFTLJIT=0") + +// A direct tail call to a host function must not run the call thunk from the +// caller's own JIT code. The tail call destroys the caller's frame, so nothing +// on the stack refers to the caller's CodeBlock while the host function runs. +// A jettison plus a collection inside the host function then frees the code +// that the host call returns into. +// +// The host function here is encodeURIComponent. It calls toString on its +// argument, and that hook: +// 1. adds a property to the object whose structure the caller's code watches, +// which jettisons the caller's optimized code, and +// 2. collects, which frees the jettisoned code. +// --zeroExecutableMemoryOnFree fills the freed code with zeroes, so a return +// into it crashes every time instead of once in a while. + +"use strict"; + +const o = { f: encodeURIComponent }; + +let armed = false; +let hookCalls = 0; +const arg = { + toString() { + hookCalls++; + if (!armed) + return "x"; + o.g = 1; + gc(); + return "x"; + } +}; + +function hot(v) { return o.f(v); } + +for (let i = 0; i < 50000; i++) + hot(arg); + +armed = true; +for (let i = 0; i < 3; i++) { + const result = hot(arg); + if (result !== "x") + throw new Error(`expected "x", got ${result}`); +} + +if (hookCalls !== 50003) + throw new Error(`expected 50003 hook calls, got ${hookCalls}`); diff --git a/Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp b/Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp index 08ca976c6a2f5..24be64066b424 100644 --- a/Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp +++ b/Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp @@ -1018,7 +1018,13 @@ void SpeculativeJIT::emitCall(Node* node) nativeFunction = uncheckedDowncast(executable)->function(); } - if (nativeFunction && !vm().isDebuggerHookInjected()) { + // A tail call must not run the thunk from this CodeBlock's own code. The tail call + // destroys this frame, so the conservative stack scan no longer finds this CodeBlock and + // cannot keep it alive (CodeBlockSet::m_currentlyExecuting). A jettison plus a collection + // inside the host function then frees the code the host call returns into. A linked direct + // tail call jumps to the executable's host call thunk, which lives as long as the VM, and + // that thunk returns to our caller. + if (nativeFunction && !isTail && !vm().isDebuggerHookInjected()) { auto emitCallTarget = [&]() { emitFunctionPrologue(); emitPutToCallFrameHeader(nullptr, CallFrameSlot::codeBlock); @@ -1040,15 +1046,6 @@ void SpeculativeJIT::emitCall(Node* node) emitFunctionEpilogue(); }; - if (isTail) { - emitStoreCallSiteIndex(callSite); - CallFrameShuffler(*this, shuffleData).prepareForTailCall(); - emitCallTarget(); - ret(); - useChildren(node); - return; - } - auto done = jump(); auto callTarget = label(); emitCallTarget(); diff --git a/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp b/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp index 427fea3385b83..ab257b234b2a9 100644 --- a/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp +++ b/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp @@ -14129,16 +14129,13 @@ IGNORE_CLANG_WARNINGS_END shuffleData.numParameters = jit.codeBlock()->numParameters(); shuffleData.setupCalleeSaveRegisters(state->jitCode->calleeSaveRegisters()); - if (nativeFunction && !vm->isDebuggerHookInjected()) { - jit.store32( - CCallHelpers::TrustedImm32(callSiteIndex.bits()), - CCallHelpers::highWordFor(CallFrameSlot::argumentCountIncludingThis)); - CallFrameShuffler(jit, shuffleData).prepareForTailCall(); - emitCallTarget(); - jit.ret(); - return; - } - + // A tail call must not run the thunk from this CodeBlock's own code. The tail + // call destroys this frame, so the conservative stack scan no longer finds + // this CodeBlock and cannot keep it alive + // (CodeBlockSet::m_currentlyExecuting). A jettison plus a collection inside + // the host function then frees the code the host call returns into. A linked + // direct tail call jumps to the executable's host call thunk, which lives as + // long as the VM, and that thunk returns to our caller. auto* callLinkInfo = state->jitCode->common.m_directCallLinkInfos.add(semanticNodeOrigin, CallLinkInfo::UseDataIC::No, state->graph.m_codeBlock, executable); callLinkInfo->setCallType(CallLinkInfo::DirectTailCall); if (numAllocatedArgs > numPassedArgs) From 553df4e77ba81d73a6a24fb268fcd8b141ae7e98 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:46:48 +0000 Subject: [PATCH 2/2] Drive the stress test's warm-up with testLoopCount 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. --- ...ail-call-to-host-function-code-freed-during-call.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js b/JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js index 6f503a6660cb9..c3a55bc7dd58e 100644 --- a/JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js +++ b/JSTests/stress/direct-tail-call-to-host-function-code-freed-during-call.js @@ -1,5 +1,5 @@ -//@ runDefault("--zeroExecutableMemoryOnFree=1", "--useConcurrentJIT=0") -//@ runDefault("--zeroExecutableMemoryOnFree=1", "--useConcurrentJIT=0", "--useFTLJIT=0") +//@ runDefault("--zeroExecutableMemoryOnFree=1", "--useConcurrentJIT=0", "--thresholdForOptimizeAfterWarmUp=100", "--thresholdForFTLOptimizeAfterWarmUp=1000") +//@ runDefault("--zeroExecutableMemoryOnFree=1", "--useConcurrentJIT=0", "--thresholdForOptimizeAfterWarmUp=100", "--thresholdForFTLOptimizeAfterWarmUp=1000", "--useFTLJIT=0") // A direct tail call to a host function must not run the call thunk from the // caller's own JIT code. The tail call destroys the caller's frame, so nothing @@ -34,7 +34,7 @@ const arg = { function hot(v) { return o.f(v); } -for (let i = 0; i < 50000; i++) +for (let i = 0; i < testLoopCount; i++) hot(arg); armed = true; @@ -44,5 +44,5 @@ for (let i = 0; i < 3; i++) { throw new Error(`expected "x", got ${result}`); } -if (hookCalls !== 50003) - throw new Error(`expected 50003 hook calls, got ${hookCalls}`); +if (hookCalls !== testLoopCount + 3) + throw new Error(`expected ${testLoopCount + 3} hook calls, got ${hookCalls}`);