Repository navigation
[JSC] Read an FFI buffer address after the argument conversions that can run JS #713
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
66ff410
5b5a546
0574faf
0c28955
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| //@ requireOptions("--useDollarVM=1", "--useConcurrentJIT=0", "--jitPolicyScale=0", "--useExecutableAllocationFuzz=false") | ||
|
|
||
| // Marshalling one FFI argument list can run JS in the middle of it: an integer or a float argument | ||
| // coerces through valueOf / Symbol.toPrimitive, and an object passed for "ptr" has its "ptr" | ||
| // property read. That JS can detach, transfer, or resize a buffer another argument is read from, | ||
| // and then the address or the "buffer_length" of that argument describes memory that is gone. So | ||
| // every tier reads the addresses and the lengths after all of that JS has run: the pointer of a | ||
| // detached view is null, and the length behind a shrunk view is the new one. Each case runs cold | ||
| // (the C++ host path) and hot through a monomorphic exact-arity caller (the IC stub, the DFG, the | ||
| // FTL), so no tier may disagree. | ||
|
|
||
| if (!$vm.useJIT()) | ||
| quit(); | ||
|
|
||
| const fixture = name => $vm.ffiFixture(name); | ||
|
|
||
| // ffi_ptr_identity returns its first argument, so the call answers with the address the engine | ||
| // read for that argument: null once the view behind it is detached. | ||
| const pointerOfThenInt = $vm.ffiFunction({ args: ["ptr", "i32"], returns: "ptr" }, fixture("ffi_ptr_identity"), "ffi_ptr_identity(ptr,i32)"); | ||
| const pointerOfThenPointer = $vm.ffiFunction({ args: ["ptr", "ptr"], returns: "ptr" }, fixture("ffi_ptr_identity"), "ffi_ptr_identity(ptr,ptr)"); | ||
| // "cstring" takes a view like "ptr" does, and an untyped "cstring" argument also makes the DFG | ||
| // and the FTL bracket the call with the string arena. | ||
| const pointerOfCStringThenInt = $vm.ffiFunction({ args: ["cstring", "i32"], returns: "ptr" }, fixture("ffi_ptr_identity"), "ffi_ptr_identity(cstring,i32)"); | ||
| // The widest signature there is: the view first, the conversion that runs JS last. | ||
| const pointerOfThenManyInts = $vm.ffiFunction({ args: ["ptr", ...Array(31).fill("i32")], returns: "ptr" }, fixture("ffi_ptr_identity"), "ffi_ptr_identity(32 arguments)"); | ||
| // ffi_add_u64 adds its two arguments, so one answer carries both marshalled slots. | ||
| const sumOfPointers = $vm.ffiFunction({ args: ["ptr", "ptr"], returns: "u64" }, fixture("ffi_add_u64"), "ffi_add_u64(ptr,ptr)"); | ||
| // ffi_view_byte_length returns the length it was given. ffi_view_last_byte reads the last byte of | ||
| // the address and length pair it was given, which reads out of bounds if the two disagree. | ||
| const byteLength = $vm.ffiFunction({ args: ["buffer", "buffer_length", "i32"], returns: "u64" }, fixture("ffi_view_byte_length"), "ffi_view_byte_length"); | ||
| const lastByte = $vm.ffiFunction({ args: ["buffer", "buffer_length", "i32"], returns: "i32" }, fixture("ffi_view_last_byte"), "ffi_view_last_byte"); | ||
|
|
||
| let failures = 0; | ||
| function check(actual, expected, label) { | ||
| if (Object.is(actual, expected)) | ||
| return; | ||
| print(`FAIL [${label}]: got ${String(actual)} (${typeof actual}), expected ${String(expected)} (${typeof expected})`); | ||
| if (++failures > 8) | ||
| throw new Error("too many failures"); | ||
| } | ||
|
|
||
| // A NEW caller function per case and per tier that holds its callable as a closure constant and | ||
| // passes exactly as many arguments as the signature takes: the only call site shape the DFG | ||
| // converts to a CallFFI node. `tag` keeps the source text unique, because two `new Function` calls | ||
| // with the same text share one unlinked code block, and then only the first caller gets the | ||
| // conversion. | ||
| function makeCaller(callable, arity, tag) { | ||
| const argumentList = Array.from({ length: arity }, (_, i) => "args[" + i + "]").join(", "); | ||
| return new Function("callable", `/* ${tag} */ return function (args) { return callable(${argumentList}); };`)(callable); | ||
| } | ||
|
|
||
| // The nested case makes FFI calls outside its hot caller. They go through these two helpers, which | ||
| // are never inlined and are compiled in every tier right here, before any case reads the compile | ||
| // counts. So a CallFFI node that is compiled while a case runs belongs to that case's hot caller. | ||
| function addressOf(view) { return BigInt(pointerOfThenInt(view, 0)); } | ||
| function byteLengthOf(view) { return byteLength(view, view, 0); } | ||
| noInline(addressOf); | ||
| noInline(byteLengthOf); | ||
| { | ||
| const view = new Uint8Array(64); | ||
| for (let i = 0; i < 1e3; ++i) { | ||
| addressOf(view); | ||
| byteLengthOf(view); | ||
| } | ||
| } | ||
|
|
||
| // Every call below detaches or shrinks the buffer it gets, so each one needs a buffer of its own. | ||
| const cases = [ | ||
| { | ||
| name: "valueOf transfers the view passed for ptr", | ||
| callable: pointerOfThenInt, | ||
| arity: 2, | ||
| setUp() { | ||
| const view = new Uint8Array(64); | ||
| return { args: [view, { valueOf() { view.buffer.transfer(); return 1; } }], expected: null }; | ||
| }, | ||
| }, | ||
| { | ||
| name: "Symbol.toPrimitive transfers the view passed for ptr", | ||
| callable: pointerOfThenInt, | ||
| arity: 2, | ||
| setUp() { | ||
| const view = new Uint8Array(64); | ||
| return { args: [view, { [Symbol.toPrimitive]() { view.buffer.transfer(); return 1; } }], expected: null }; | ||
| }, | ||
| }, | ||
| { | ||
| name: "a later ptr getter transfers the view passed for ptr", | ||
| callable: pointerOfThenPointer, | ||
| arity: 2, | ||
| setUp() { | ||
| const view = new Uint8Array(64); | ||
| return { args: [view, { get ptr() { view.buffer.transfer(); return 1; } }], expected: null }; | ||
| }, | ||
| }, | ||
| { | ||
| name: "valueOf shrinks the view behind buffer_length", | ||
| callable: byteLength, | ||
| arity: 3, | ||
| setUp() { | ||
| const buffer = new ArrayBuffer(1024, { maxByteLength: 1024 }); | ||
| const view = new Uint8Array(buffer).fill(7); | ||
| return { args: [view, view, { valueOf() { buffer.resize(16); return 1; } }], expected: 16n }; | ||
| }, | ||
| }, | ||
| { | ||
| name: "the address and buffer_length still agree after a shrink", | ||
| callable: lastByte, | ||
| arity: 3, | ||
| setUp() { | ||
| const buffer = new ArrayBuffer(1024, { maxByteLength: 1024 }); | ||
| const view = new Uint8Array(buffer).fill(7); | ||
| return { args: [view, view, { valueOf() { buffer.resize(16); return 1; } }], expected: 7 }; | ||
| }, | ||
| }, | ||
| { | ||
| name: "valueOf transfers the view passed for cstring", | ||
| callable: pointerOfCStringThenInt, | ||
| arity: 2, | ||
| setUp() { | ||
| const view = new Uint8Array(64); | ||
| return { args: [view, { valueOf() { view.buffer.transfer(); return 1; } }], expected: null }; | ||
| }, | ||
| }, | ||
| { | ||
| name: "the last of 32 arguments transfers the view passed for the first", | ||
| callable: pointerOfThenManyInts, | ||
| arity: 32, | ||
| setUp() { | ||
| const view = new Uint8Array(64); | ||
| const args = [view]; | ||
| for (let i = 1; i < 31; ++i) | ||
| args.push(i); | ||
| args.push({ valueOf() { view.buffer.transfer(); return 1; } }); | ||
| return { args, expected: null }; | ||
| }, | ||
| }, | ||
| { | ||
| // The getter makes a whole FFI call of its own before the outer call reads its first | ||
| // argument again. The answer is the live address plus the 1 the getter returned. | ||
| name: "a nested FFI call inside a ptr getter leaves the outer arguments alone", | ||
| callable: sumOfPointers, | ||
| arity: 2, | ||
| setUp() { | ||
| const view = new Uint8Array(64); | ||
| const nested = new Uint8Array(1024); | ||
| const argument = { | ||
| get ptr() { | ||
| if (byteLengthOf(nested) !== 1024n) | ||
| throw new Error("the nested call marshalled the wrong length"); | ||
| return 1; | ||
| }, | ||
| }; | ||
| return { args: [view, argument], expected: addressOf(view) + 1n }; | ||
| }, | ||
| }, | ||
| { | ||
| name: "a live view keeps its length", | ||
| callable: byteLength, | ||
| arity: 3, | ||
| setUp() { | ||
| const view = new Uint8Array(1024).fill(7); | ||
| return { args: [view, view, { valueOf() { return 1; } }], expected: 1024n }; | ||
| }, | ||
| }, | ||
| ]; | ||
|
|
||
| function runCase(testCase, caller, label) { | ||
| const { args, expected } = testCase.setUp(); | ||
| check(caller(args), expected, label + ": " + testCase.name); | ||
| } | ||
|
|
||
| for (const testCase of cases) { | ||
| // Cold: this call site has nothing compiled yet, so the call runs the C++ host path (the IC | ||
| // stub hands every argument that needs real conversion to it). | ||
| runCase(testCase, makeCaller(testCase.callable, testCase.arity, "cold " + testCase.name), "cold"); | ||
|
|
||
| const before = $vm.ffiCompileCounts(); | ||
| const hot = makeCaller(testCase.callable, testCase.arity, "hot " + testCase.name); | ||
| noInline(hot); | ||
| for (let i = 0; i < 1e3; ++i) | ||
| runCase(testCase, hot, "hot"); | ||
| // The loop must have reached the inline conversions the DFG and the FTL emit, not only the | ||
| // host path: that is where a snapshot an earlier argument took has to be taken again. | ||
| if (numberOfDFGCompiles(hot) > 0) { | ||
| const counts = $vm.ffiCompileCounts(); | ||
| if (counts.dfgCallFFI + counts.ftlCallFFI === before.dfgCallFFI + before.ftlCallFFI) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| throw new Error("the DFG compiled the hot caller but no CallFFI node was compiled: " + JSON.stringify(counts)); | ||
| } | ||
| } | ||
|
|
||
| // A getter on a pointer argument must run exactly once: reading an address again after JS has run | ||
| // must not read the property a second time. | ||
| { | ||
| const view = new Uint8Array(64); | ||
| let reads = 0; | ||
| const argument = { get ptr() { ++reads; return 1; } }; | ||
| const hot = makeCaller(pointerOfThenPointer, 2, "ptr getter runs once"); | ||
| noInline(hot); | ||
| for (let i = 0; i < 1e3; ++i) { | ||
| reads = 0; | ||
| hot([view, argument]); | ||
| if (reads !== 1) | ||
| throw new Error("the 'ptr' getter ran " + reads + " times, expected once"); | ||
| } | ||
| // The live view still marshals to a real address. | ||
| check(hot([view, argument]) === null, false, "a live view has a non-null address"); | ||
| } | ||
|
|
||
| if (failures) | ||
| throw new Error(failures + " case(s) failed"); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,9 +59,28 @@ static ALWAYS_INLINE EncodedJSValue ffiCall(JSGlobalObject* globalObject, CallFr | |
| ASSERT(argumentCount <= Signature::maxArguments); | ||
| uint64_t slots[Signature::maxArguments + 1]; | ||
|
|
||
| // Two passes over the arguments. A conversion can run JS: an integer or a float argument | ||
| // coerces through valueOf or Symbol.toPrimitive, and an object passed for a pointer has its | ||
| // 'ptr' property read. That JS can detach, transfer, or resize a buffer, so every address and | ||
| // byte length read off a live buffer is taken in the second pass, after all of it has run. | ||
| // Without that, well-behaved C gets a pointer and a length for memory that is gone. | ||
| static_assert(Signature::maxArguments <= 32); | ||
| uint32_t snapshots = 0; | ||
| for (unsigned i = 0; i < argumentCount; ++i) { | ||
| Type type = signature.argumentType(i); | ||
| writeSlotFromJSValue(globalObject, context, type, callFrame->argument(i), slots[i], &context.stringArena()); | ||
| JSValue argument = callFrame->argument(i); | ||
| if (slotIsBufferSnapshot(type, argument)) { | ||
| snapshots |= 1u << i; | ||
| continue; | ||
| } | ||
| writeSlotFromJSValue(globalObject, context, type, argument, slots[i], &context.stringArena()); | ||
| RETURN_IF_EXCEPTION(scope, { }); | ||
| } | ||
| for (unsigned i = 0; snapshots; ++i) { | ||
| if (!(snapshots & (1u << i))) | ||
| continue; | ||
| snapshots &= ~(1u << i); | ||
| writeSlotFromJSValue(globalObject, context, signature.argumentType(i), callFrame->argument(i), slots[i], &context.stringArena()); | ||
| RETURN_IF_EXCEPTION(scope, { }); | ||
| } | ||
| slots[argumentCount] = 0; | ||
|
Comment on lines
+79
to
86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: Pre-existing, conditional: if a Bun before-hook runs JS that detaches or resizes a buffer, native code still gets the stale address this fix was meant to remove. ffiCall now takes every buffer snapshot in its second pass (FFICallHost.cpp:79-85), but hooks->before at FFICallHost.cpp:98 runs after that pass and is allowed to run JS (its exception is caught at :101, and the in-tree test hook reads owner.hookLog through a getter). Fix: for a hooked function, take the snapshot pass after hooks->before returns, or re-take the snapshots there, so no JS can run between a buffer read and the native call, while keeping the before/after bracketing and token round trip unchanged. [also at: Source/JavaScriptCore/ffi/FFICallHost.cpp:85 - Pre-existing, conditional: an FFI function created with CallHooks whose before hook runs JS still hands native code a stale buffer address or length.] Why this was flaggedA JSFFIFunction created with non-null CallHooks is host-path-only (JSFFIFunction.h:77), so every call goes through ffiCall in FFICallHost.cpp. The two passes at FFICallHost.cpp:69-85 write every slot, including view->vector() and byteLength() for 'ptr', 'buffer' and 'buffer_length' arguments. Only then does hooks->before(globalObject, callFrame) run at FFICallHost.cpp:98, with the native call at :100 following it. The hook type (JSFFIFunction.h:42-45) takes a JSGlobalObject and a CallFrame and its exception is checked at :99-103, so hooks are permitted to run JS; the only in-tree implementation, dollarVMTestHookBefore in JSDollarVM.cpp:5203-5215, does owner->get("hookLog") and JSArray::push, which run a user getter. If such a hook (or a Bun hook outside this checkout) transfers, detaches or resizes a buffer that was passed for a pointer or length argument, slots still holds the old address and length and the thunk at :100 hands them to C, the same freed-memory write the PR describes. The base branch… Verification: pre-existing (the base already takes every slot before hooks->before by the same route; this change fixes the conversion-ordering hazard on the non-hooked paths and does not reach the hook path). Trigger: a JSFFIFunction created with non-null CallHooks whose before-hook runs JS that detaches, transfers or resizes a buffer already marshalled for a… | pre-existing; triggered when a JSFFIFunction… |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ | |
| #include "Error.h" | ||
| #include "ExceptionHelpers.h" | ||
| #include "FFIContext.h" | ||
| #include "FFISignature.h" | ||
| #include "FFIType.h" | ||
| #include "FrameTracers.h" | ||
| #include "JSArrayBuffer.h" | ||
|
|
@@ -431,6 +432,25 @@ bool writeSlotFromJSValue(JSGlobalObject* globalObject, FFIContext& context, Typ | |
| return false; | ||
| } | ||
|
|
||
| bool slotIsBufferSnapshot(Type type, JSValue value) | ||
| { | ||
| switch (type) { | ||
| case Type::Pointer: | ||
| case Type::CString: | ||
| case Type::Function: | ||
| case Type::Buffer: | ||
| case Type::BufferLength: | ||
| break; | ||
| default: | ||
| return false; | ||
| } | ||
|
|
||
| if (!value.isCell()) | ||
| return false; | ||
| JSCell* cell = value.asCell(); | ||
| return dynamicDowncast<JSArrayBufferView>(cell) || dynamicDowncast<JSArrayBuffer>(cell); | ||
|
Comment on lines
+435
to
+451
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Callers passing a plain ArrayBuffer for a 'buffer' or 'buffer_length' argument now see later arguments coerced (valueOf / Symbol.toPrimitive / 'ptr' getters run) before the TypeError, and a later argument's error can win instead. slotIsBufferSnapshot at FFIConversions.cpp:451 counts a JSArrayBuffer as a snapshot for Type::Buffer and Type::BufferLength, but writePointerSlot and writeBufferLengthSlot reject an ArrayBuffer for those types, so ffiCall defers a conversion that throws to the second pass. The JIT slow paths still throw in argument order, so the host path and the tiers disagree. … Why this was flagged…Fix: return true for Buffer and BufferLength only when the value is a JSArrayBufferView, so a deferred write can never throw and argument-order exceptions stay identical across the host path, the IC stub, the DFG and the FTL. An FFI function with args ["buffer", "i32"] is called as fn(new ArrayBuffer(8), { valueOf() { sideEffect(); return 1; } }) through the C++ host path (ffiCall, Source/JavaScriptCore/ffi/FFICallHost.cpp:72). slotIsBufferSnapshot (FFIConversions.cpp:435-451) returns true for Type::Buffer with a JSArrayBuffer cell, so the first pass skips argument 0 and converts argument 1, running valueOf. The second pass then calls writeSlotFromJSValue for argument 0, and writePointerSlot (FFIConversions.cpp:313) throws "bun:ffi 'buffer' argument must be a TypedArray or DataView"; writeBufferLengthSlot (FFIConversions.cpp:305) does the same for 'buffer_length'. On the base branch the slots were written left to right, so the TypeError was thrown before valueOf ran; with a throwing second argument (a Symbol), the base threw the buffer error and the new code throws the i32… Verification: nit — triggered when a caller passes a plain ArrayBuffer (not a view) for a 'buffer' or 'buffer_length' argument together with a later argument whose conversion runs JS, through the host path (ffiCall). Mechanism verified: |
||
| } | ||
|
|
||
| JSValue jsValueFromSlot(JSGlobalObject* globalObject, FFIContext&, Type type, uint64_t slot) | ||
| { | ||
| switch (type) { | ||
|
|
@@ -525,6 +545,39 @@ JSC_DEFINE_JIT_OPERATION(operationFFIWriteSlot, void, (JSGlobalObject* globalObj | |
| OPERATION_RETURN(scope); | ||
| } | ||
|
|
||
| JSC_DEFINE_JIT_OPERATION(operationFFIWriteSlotAndRetakeSnapshots, void, (JSGlobalObject* globalObject, FFI::Signature* signature, uint32_t index, EncodedJSValue value, uint64_t* slot)) | ||
| { | ||
| VM& vm = globalObject->vm(); | ||
| CallFrame* callFrame = DECLARE_CALL_FRAME(vm); | ||
| JITOperationPrologueCallFrameTracer tracer(vm, callFrame); | ||
| auto scope = DECLARE_THROW_SCOPE(vm); | ||
|
|
||
| ASSERT(signature); | ||
| ASSERT(index < signature->argumentCount()); | ||
| FFI::FFIContext& context = globalObject->ffiContext(); | ||
| uint64_t* slots = slot - index; | ||
| const EncodedJSValue* parkedValues = reinterpret_cast<const EncodedJSValue*>(slots + signature->slotCount()); | ||
|
|
||
| FFI::writeSlotFromJSValue(globalObject, context, signature->argumentType(index), JSValue::decode(value), *slot, &context.arena()); | ||
| OPERATION_RETURN_IF_EXCEPTION(scope); | ||
|
|
||
| // This conversion can have run JS: an integer or a float argument coerces through valueOf or | ||
| // Symbol.toPrimitive, and an object passed for a pointer has its 'ptr' property read. That JS | ||
| // can detach, transfer, or resize a buffer an earlier argument took its address or its byte | ||
| // length from. So take those snapshots again. Only the parked arguments that are still a | ||
| // snapshot are read again: a 'ptr' property getter must not run a second time. | ||
| for (unsigned i = 0; i < index; ++i) { | ||
| FFI::Type type = signature->argumentType(i); | ||
| JSValue parked = JSValue::decode(parkedValues[i]); | ||
| if (!FFI::slotIsBufferSnapshot(type, parked)) | ||
| continue; | ||
| FFI::writeSlotFromJSValue(globalObject, context, type, parked, slots[i], &context.arena()); | ||
| OPERATION_RETURN_IF_EXCEPTION(scope); | ||
| } | ||
|
Comment on lines
+569
to
+576
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Hot FFI callers can crash or pass a garbage pointer to native code when a snapshot argument sits between a JS-running conversion and a later non-JS-running slow path. The JIT tiers park a JSValue only for arguments followed by a conversion that can run JS, but operationFFIWriteSlotAndRetakeSnapshots (FFIConversions.cpp:569) walks every earlier argument and, for any Pointer/CString/Function/Buffer/BufferLength type, decodes the parked word whether or not it was stored. Fix: only retake from arguments the tier actually parked, e.g. set retakeSnapshots only when conversionCanRunJS(type, useKind) is true at FFIDFGCodegen.cpp:359 and FTLLowerDFGToB3.cpp:15454 (then every earlier snapshot-type argument is guaranteed parked), or pass the parked mask into the operation. Why this was flaggedSignature (ptr, i32, buffer, buffer_length) called hot with [view, {valueOf(){...}}, view2, view3]. The backward scan in FFIDFGCodegen.cpp:107-115 and FTLLowerDFGToB3.cpp:15367-15375 parks only argument 0 (bit0): argument 2 (buffer) is not followed by any conversion that can run JS, so its parked word at argumentValueOffset(slotCount, 2) is never written and holds whatever the outgoing parameter area / locked stack slot had before. Argument 3 (buffer_length) always takes the slow path (FFIDFGCodegen.cpp:335, FTLLowerDFGToB3.cpp:15470-15472); retakeSnapshots = parkedValues & 0b111 is nonzero, so the slow path calls operationFFIWriteSlotAndRetakeSnapshots with index 3. Its loop at FFIConversions.cpp:569-576 reaches i = 2, type Buffer, and does JSValue::decode(parkedValues[2]) on uninitialized stack memory; slotIsBufferSnapshot (FFIConversions.cpp:448-451) treats any pointer-shaped word as a cell and calls dynamicDowncast on it, reading through a stale or invalid pointer (segfault), and if the garbage happens to look like a view it rewrites slots[2] with a bogus vector that the native… Verification: normal — triggered whenever a DFG/FTL-compiled CallFFI has a signature where a JS-capable UntypedUse conversion precedes a snapshot-type argument that is itself not followed by a JS-capable conversion, and a later conversion takes the slow path (e.g. args ["ptr","ptr","buffer","buffer_length"] or ["ptr","i32"(object-profiled),"buffer","buffer_length"]; buffer_length ALWAYS takes… |
||
|
|
||
| OPERATION_RETURN(scope); | ||
| } | ||
|
|
||
| JSC_DEFINE_JIT_OPERATION(operationFFIArenaEnter, void, (JSGlobalObject* globalObject)) | ||
| { | ||
| VM& vm = globalObject->vm(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 nit (optional): the count in JSTests/BUN-TEST-DIFFERENCES.md:122 ("32 ×
stress/ffi-*.js" with--useExecutableAllocationFuzz=false) is now stale; this new test is the 33rd file with that directive. Fix: bump the count in that table row when adding the test, as CLAUDE.md asks that file to track every test carrying a fork-specific directive.Why this was flagged
The new test's first line adds --useExecutableAllocationFuzz=false to requireOptions, the same opt-out that JSTests/BUN-TEST-DIFFERENCES.md:122 records for 32 files. A grep for the option across JSTests/stress/ffi-*.js now finds 33 files, so a maintainer following the root CLAUDE.md procedure for sorting a failure will find the table row undercounts. No runtime effect; documentation drift only.
Verification: nit. Triggering condition: whenever anyone reads the table row after this PR merges. Mechanism verified: /home/claude/webkit/JSTests/stress/ffi-argument-buffer-snapshot.js:1 is
//@ requireOptions("--useDollarVM=1", "--useConcurrentJIT=0", "--jitPolicyScale=0", "--useExecutableAllocationFuzz=false"), and /home/claude/webkit/JSTests/BUN-TEST-DIFFERENCES.md:122 still reads `| 32 ×… | nit —…