Skip to content

test(ffi): speed up ffi.test.js and tighten its assertions - #39041

Open
robobun wants to merge 1 commit into
mainfrom
farm/c4495b31/ffi-test-speed
Open

test(ffi): speed up ffi.test.js and tighten its assertions#39041
robobun wants to merge 1 commit into
mainfrom
farm/c4495b31/ffi-test-speed

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • test/js/bun/ffi/ffi.test.js is one of the slowest files in the suite: CI build #97275 took 23.4s on debian x64-asan and 19.6s on alpine x64, against 4.6s on debian x64, and alpine x64 is 18.1-19.6s in every build I looked at (97206, 97275, 97480, 97784), so it is not runner noise.
  • The file made 395,627 expect() calls, almost all of them in the twelve integer identities work for all possible values tests (32768 expect(fn(i)).toBe(i) per type per runner). The CI runner sets BUN_GARBAGE_COLLECTOR_LEVEL=1 for every test process (scripts/runner.node.mjs, spawnBun), and at that level every matcher call ends in Expect::post_match -> auto_garbage_collect() -> mi_collect() plus a JSC collection request (src/runtime/test_runner/expect.rs:1735, src/jsc/VirtualMachine.rs:1257). So each of those 395k matcher calls was also a GC cycle: locally the old file goes from ~1.0s to 6.9s just by setting that variable (each sweep 4-20ms -> 300-660ms). Per lane that came to ~2.3s of test time on debian x64, ~14.6s on alpine x64 (the same path is ~6x more expensive per call on the musl build) and ~17s on the ASAN lane, where expect() itself is also instrumented bun code. With the debug build here the sweeps were 4-7s each, ~75% of the file, and 5-9 of them exceeded the default 5s per-test timeout on a loaded machine. The FFI calls themselves are not the cost: they run inside release JSC (the 400k-call CallFFI tiering test in the same file takes 73ms in debug).
  • The rest of the alpine gap is module load: the two cc -O2 fixture compiles take 3.4-4.7s on that lane against ~2-2.5s on debian (other files that shell out to cc show the same lane-specific slowdown: addr32.test.ts 0.2s vs 3.3s in expected-durations.json, fs-stat-seccomp-linux.test.ts 1.7s vs 9.6s and serve-file-slice-read-error.test.ts 1.8s vs 6.5s in #97275). Nothing else about musl is slow: over the same build the per-file alpine/debian median is 1.03-1.06, and spawn-, GC- and JIT-heavy files are ~1x there (jsc-stress.test.ts runs 83 subprocesses in 0.69s on alpine). The compiles are unchanged by this PR.
  • Next largest: the two worker-terminate subprocess tests (3.4-3.6s each in debug, almost all of it debug-build Worker startup; 65ms each in release) ran serially, followed separately by the three concurrent toBuffer subprocess tests.
  • Several assertions were weak: the threadsafe callback tests put their expect() inside the callback and then await 1, but a threadsafe callback is delivered as an event-loop task, so the assertion ran after the test had already passed; ffi print only checked length > 0; the 13 cb_identity_* fixture functions were dlopen'd and destructured but never called; dlopen failure only checked truthiness.

Fix

  • Integer identities: the 8-bit types are still checked exhaustively (256 values). Each wider type now round-trips a fixed value set through a single toEqual: both range ends and their neighbours, 0 and +-1, every power of two in range with its neighbours (and their negations for signed types), the 0x5555.../0xAAAA... patterns masked to the width, and a 16-step stride. That is 64-394 values per type (106/202/394 for int16/32/64, 64/112/208 for uint16/32/64); it keeps the min/max/0 checks the old test made explicitly and adds the boundaries its stride did not land on (2^31 and 0xAAAAAAAA for u32, 2^32, 2^53-1/2^53 and 2^63 for the 64-bit types, each power of two and its negation for the signed types). The values are then cycled through a 10k-iteration loop that records mismatches instead of calling expect() per iteration, so the call site is still exercised after it leaves the interpreter. Each test is now 0.4-1.3ms in release and 20-110ms in debug, and the file makes 1,452 expect() calls.
  • In the non-fast runner the toEqual against BigInt inputs now also asserts that int64_t/uint64_t return BigInts for every value; the fast runner gets an explicit test that i64_fast/u64_fast return a number for safe integers and a BigInt beyond them (probes at 2^53-2 and 2^53; u64_fast has returned a BigInt for exactly 2^53-1 since the TinyCC-era UINT64_TO_JSVALUE, so the probes stay off that one value).
  • Subprocess tests: the toBuffer block now directly follows the three top-level subprocess tests and all of them (plus the in-process finalizer test, which is the only user of the fixture state it reads) are concurrent, so bun:test runs the six child processes as one batch. The order is chosen so that the open PRs adding tests to this file (bun:ffi: keep ptr(typedArray) valid across DFG tier-up #32055, bun:ffi: handle negative byteOffset in read.* instead of panicking #32260, Tighten ffi pointer bounds, sparse archive extraction, and the Windows default trust store #31581 after read; ffi: toArrayBuffer/toBuffer throw RangeError instead of aborting on a huge byteLength #33353 after the Windows dlopen test; bun:ffi: accept FFIType.buffer and FFIType.buffer_length as numeric type tags #38430 after the CFunction block and inside the engine-native block) still apply cleanly.
  • Assertions: threadsafe callback tests resolve a promise from the callback, assert Bun.peek.status is pending right after the native call (queued, not inline, which the worker tests rely on) and await the delivered value, then close the callback; new C code calls back into JS callbacks of every return type test drives the 13 cb_identity_* functions through a JSCallback each and compares one result object; ffi print checks the generated receiver source (float not_a_callback(float arg0);, USES_FLOAT, HAS_ARGUMENTS present/absent) and that the callback "source" is signature-independent; dlopen failure checks code/syscall/message as one object; the 63-symbol test compares the symbol name set instead of its length; pointer checks use toBeGreaterThan/toBeNull; the roundtrip tests close the callbacks they create; the stray done parameter on a describe is gone.
  • Deliberately left alone: the per-test dlopens in the engine-native and ABI blocks (those tests are 0.04-0.6ms each in release, so hoisting buys nothing and those blocks are where other open PRs add tests); the setInterval in the terminate fixture (a keep-alive for a worker that is terminated from outside, not a wait); the libPath gate for the libc tests, which is dead on every Linux lane because isGlibcVersionAtLeast always returns false there (harness fix in test(harness): make isGlibcVersionAtLeast work with glibc's two-component version #39030).
  • Result on this PR's CI run (#98076), wall clock of the file per lane, before (#97275) -> after: debian x64 4.6s -> 2.7s, ubuntu x64 4.9s -> 3.0s, debian x64-asan 23.4s -> 8.1s (4.1s of tests plus ~4s of LSan at exit, which every file on that lane pays), alpine x64 19.6s -> 5.0s (4.7s of it is still the two compiles), alpine aarch64 7.3s -> 4.0s, ubuntu aarch64 7.7s -> 2.2s. On every lane the part after module load is now 0.2-0.3s (0.8s on ASAN). 149 pass / 5 skip everywhere; the 5 skips are the same as before (Windows-only test, 4 libc tests).
  • Verified locally: bun bd test test/js/bun/ffi/ffi.test.js 149 pass / 5 skip, 154 tests. 8 of 9 debug runs passed on a box with a load average of 100-245; the one failure was the worker-teardown test reaching the local 5s default timeout at 5.00s during a load spike (it takes 3.1-4.6s per run here in debug; CI passes a much larger per-test timeout, and before this change 6-9 tests per run failed the same way on this box). The libc block was also run once locally with its gate removed, since the darwin lane that normally runs it did not get an agent in this build.

Test count goes from 151 to 154 (the 13-function callback test in each runner, plus the fast-type policy test); nothing was removed or skipped.

Background

  • BUN_GARBAGE_COLLECTOR_LEVEL=1 is a test-only knob: bun's bun:test matchers, Bun.serve and a few other host functions call auto_garbage_collect() after their work, which at level 1 asks mimalloc to release memory and JSC to start a collection. CI sets it so GC-ordering bugs surface; the side effect is that a matcher call costs microseconds instead of nanoseconds there, which is why expect()-dense tests show up as slow only in CI.
  • bun:test runs consecutive tests marked concurrent as one group (src/runtime/test_runner/Order.rs); any non-concurrent test (including a skipped one) between them starts a new group, which is why the blocks have to be adjacent.
  • A threadsafe: true JSCallback never runs inside the native call: the engine hands the invocation to bun, which posts it as a task to the owning event loop (src/jsc/bindings/JSCFFIBridge.cpp), so the caller only sees it after an await that yields to the loop.
  • i64_fast/u64_fast are the bun:ffi 64-bit types that return a JS number when the value fits in a safe integer and a BigInt otherwise; int64_t/uint64_t always return a BigInt.
  • The fixtures (ffi-test.c, ffi-abi-fixture.c) are compiled once at module load with the system compiler via compileFixture from the harness; that is now nearly all of the file's time on every lane.
Timings

CI, wall clock of the file per lane from the Buildkite line timestamps ("load" is until the first test result is printed, i.e. module load including both fixture compiles; "tests" is the rest):

lane before (#97275) load / tests / total after (#98076) load / tests / total
debian 13 x64 2.07s / 2.55s / 4.6s 2.53s / 0.19s / 2.7s
ubuntu 25.04 x64 2.12s / 2.79s / 4.9s 2.74s / 0.22s / 3.0s
debian 13 x64-asan 2.28s / 17.4s / 19.6s (+3.7s exit) 3.30s / 0.79s / 4.1s (+4.0s exit)
alpine 3.23 x64 4.66s / 14.9s / 19.6s 4.71s / 0.26s / 5.0s
alpine 3.23 aarch64 3.15s / 4.1s / 7.3s 3.78s / 0.21s / 4.0s
ubuntu 25.04 aarch64 2.16s / 5.5s / 7.7s 1.96s / 0.21s / 2.2s

Alpine x64 in builds 97784 / 97480 / 97206 (old file): 19.0s / 18.1s / 19.0s total, 3.4s / 3.5s / 3.8s of it before the first test result.

Local release build (USE_SYSTEM_BUN=1), old file vs new file: plain ~1.0-1.3s vs ~0.55-1.25s (both mostly the fixture compiles); with the CI runner's BUN_GARBAGE_COLLECTOR_LEVEL=1 BUN_JSC_randomIntegrityAuditRate=1.0 8.1s vs 0.96s. With only BUN_GARBAGE_COLLECTOR_LEVEL=1 the old file takes 6.9s and each sweep 300-660ms (4-20ms without it); BUN_JSC_randomIntegrityAuditRate alone makes no measurable difference.

Local debug (ASAN) build, back to back on the same machine (12 cores, load average 95-245 during the runs, so absolute numbers are inflated):

before after
bun bd test test/js/bun/ffi/ffi.test.js 86.2s, 79.6s (6-9 sweeps hit the 5s timeout each run) 13.8s, 13.2s, 12.8s, 11.1s, 11.4s, 11.3s, 10.9s, 9.2s
a trivial test file importing harness and compiling both fixtures 4.1-4.3s same
integer identities tests 3.9-7.4s each (x12) 20-110ms each
worker-terminate tests 3.6s + 3.4s serial 3.1-4.6s each, overlapped with the 4 other subprocess tests
expect() calls 395,627 1,452

Slowest tests before (debug build): int64_t 4.8s, int16_t 4.7s, (fast) int32_t 4.4s, uint32_t 4.2s, uint16_t 4.1s, (fast) int16_t 4.0s, int32_t 3.9s, then the remaining sweeps at 5.2-7.4s once they started timing out; worker teardown 3.6s; worker terminate 3.4s; the three toBuffer subprocess tests 1.5s each (concurrent); (fast) primitives 1.1s; JSCallback exceptions subprocess 0.5s. Everything else was under 0.4s.

… batch the subprocess tests

ffi.test.js made 395k expect() calls, almost all of them in the twelve
"integer identities work for all possible values" sweeps (32768 steps per
type per runner). On the ASAN lane those sweeps were about 75% of the file's
time, and on a loaded machine individual sweeps exceeded the default 5s
per-test timeout.

Each wider type now round-trips a fixed set of values a conversion can get
wrong (range ends, 0/+-1, every power of two in range with its neighbours
and negations, alternating bit patterns, a 16-step stride) through one
toEqual, then cycles them through a 10k-iteration loop without per-call
expect() so the call site is still exercised past the interpreter. The 8-bit
types stay exhaustive.

The toBuffer subprocess block now directly follows the three top-level
subprocess tests and all of them are marked concurrent, so bun:test runs the
six child processes (plus the in-process finalizer test) as one batch
instead of serially.

Assertions tightened while here: the threadsafe callback tests await the
delivered value (the old expect() inside the callback ran after the test had
finished) and assert the call was queued rather than run inline; a new test
drives the previously unused cb_identity_* fixture functions through JS
callbacks of every return type with one structured toEqual; i64_fast/u64_fast
number-vs-BigInt return policy is asserted explicitly; "ffi print" checks the
generated receiver source instead of length > 0; the dlopen failure test
checks code/syscall/message; the 63-symbol test compares the symbol set, not
its length; callbacks created by the roundtrip tests are closed.
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:59 AM PT - Aug 15th, 2026

@robobun, your commit e78123c has some failures in Build #98076 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 39041

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

bun-39041 --bun

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 44727977-4483-4020-8834-5f968c54e7f1

📥 Commits

Reviewing files that changed from the base of the PR and between 88a6398 and e78123c.

📒 Files selected for processing (1)
  • test/js/bun/ffi/ffi.test.js

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

  • Origin: handoff from a CI slow-test scan (ffi.test.js: 23.4s on debian x64-asan and 19.6s on alpine x64 in build #97275 vs 4.6s on debian x64).
  • Cause, reproduced locally: 395k matcher calls in the integer sweeps, and the CI runner's BUN_GARBAGE_COLLECTOR_LEVEL=1 turns every matcher call into a GC cycle (old file: ~1.0s -> 6.9s locally with just that variable set). The remaining alpine time is the two fixture compiles, which that lane's compiler does slowly for every file that uses cc; details in the description.
  • This PR's CI (#98076): the file is 2.7s on debian x64, 5.0s on alpine x64 (4.7s of it the compiles), 8.1s wall on x64-asan (half of it LSan at exit); 149 pass / 5 skip on every lane that ran. The only non-passing jobs are unrelated flaky tests that passed on retry and a darwin test shard that never got an agent; the macOS-only assertion this PR touches was run locally with its gate removed instead.
  • Locally: bun bd test test/js/bun/ffi/ffi.test.js 80-86s before vs 9-14s after on the same machine.

@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.

LGTM — test-only speedup and assertion tightening; no coverage removed (151→154 tests), every changed assertion is strictly stronger.

What was reviewed:

  • valuesFor boundary generator: BigInt arithmetic, min/max/stride bounds, unsigned filtering of negatives, and the normalize path for both fast and non-fast 64-bit — all round-trip cleanly.
  • New cb_identity_* test against ffi-test.c: each fixture is return cb();, so the JSCallback return-value/expected pairs (including the big() split for i64/u64 in fast vs non-fast) line up.
  • Concurrent regrouping: the moved toBuffer block is byte-identical modulo it.concurrent.skipIf (used elsewhere in the suite); the in-process finalizer test's C static counter isn't read by any other concurrently-running test.
  • Threadsafe-callback rewrite now actually awaits delivery (Bun.peek.status pending → await promise) instead of the old fire-and-forget await 1.
Extended reasoning...

Overview

Single-file change to test/js/bun/ffi/ffi.test.js. Replaces twelve 32,768-step expect().toBe() sweeps with boundary-value sets plus a mismatch-recording 10k loop; regroups six subprocess tests (plus one in-process finalizer test) into one test.concurrent batch by moving the toBuffer describe block; tightens assertions in ffi print, threadsafe callback, dlopen error, JSCallback close, and the 63-symbol test; adds a new test driving the 13 previously-unused cb_identity_* fixture functions and a fast-int64 number-vs-BigInt policy test; wraps roundtrip callbacks in try/finally with .close().

Security risks

None. Test-only; no production code, no new external I/O, no network.

Level of scrutiny

Medium — it's a meaningful rewrite of test logic, not a mechanical tweak, so the risks are (a) silently dropping coverage and (b) new flakes. On (a): the 8-bit types remain exhaustive; the wider types now cover min/max/0 (which the old test asserted explicitly) plus every power-of-two boundary and alternating-bit pattern the old fixed stride skipped, and the 10k loop still exercises the JIT tier-up path. Every other assertion change is a strict strengthening (toBeGreaterThan(0) over != 0, error code/syscall/message over truthiness, sorted key-set equality over length, awaited promise over expect inside a callback that ran after the test passed). Test count goes up, nothing is skipped or deleted. On (b): checked that it.concurrent.skipIf is an established pattern in the suite, and that the finalizer test's shared C static (getDeallocatorCalledCount) is not touched by anything else in the concurrent group (the other six are subprocesses).

Other factors

The PR description is unusually thorough — profiled timings, per-lane CI breakdown, explicit "deliberately left alone" reasoning, and verified pass/skip counts with bun bd test. The changes directly implement several of REVIEW.md's own test rules (await the actual condition, release resources in try/finally before assertions, test.concurrent for independent subprocess tests, assert the strongest invariant). No CODEOWNERS entry covers this path. The bug-hunting pass found nothing.

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.

1 participant