Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/test/lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ test("cleanup after test", () => {
});
```

Not supported in concurrent tests; use `test.serial` instead.
Works inside concurrent tests when `onTestFinished()` is called synchronously from the test callback body (including microtasks drained before the first suspension point). Registrations made after yielding to a later event-loop turn — for example after `await`ing a timer — may not resolve which concurrent sequence they belong to.

## Global Setup and Teardown

Expand Down
22 changes: 20 additions & 2 deletions packages/bun-types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,12 @@ declare module "bun:test" {
*
* Can only be called inside a test, not in describe blocks.
*
* Works inside concurrent tests when `onTestFinished()` is called
* synchronously from the test callback body (including microtasks drained
* before the first suspension point). Registrations made after yielding to
* a later event-loop turn — for example after `await`ing a timer — may
* not resolve which concurrent sequence they belong to.
*
* @example
* test("my test", () => {
* onTestFinished(() => {
Expand Down Expand Up @@ -705,12 +711,24 @@ declare module "bun:test" {
unreachable(msg?: string | Error): never;

/**
* Ensures that an assertion is made
* Ensures that an assertion is made.
*
* Not supported in concurrent tests — use `test.serial` or remove
* `.concurrent` from the enclosing test. Unlike `onTestFinished()`,
* this cannot be resolved to the owning sequence across `await`
* boundaries, so it throws unconditionally rather than silently
* miscounting.
*/
hasAssertions(): void;

/**
* Ensures that a specific number of assertions are made
* Ensures that a specific number of assertions are made.
*
* Not supported in concurrent tests — use `test.serial` or remove
* `.concurrent` from the enclosing test. Unlike `onTestFinished()`,
* this cannot be resolved to the owning sequence across `await`
* boundaries, so it throws unconditionally rather than silently
* miscounting.
*/
assertions(neededAssertions: number): void;
}
Expand Down
15 changes: 15 additions & 0 deletions src/bun.js/test/Execution.zig
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,21 @@ fn stepSequenceOne(buntest_strong: bun_test.BunTestPtr, globalThis: *jsc.JSGloba
};
groupLog.log("runSequence queued callback: {f}", .{callback_data});

// Mark this sequence/entry as the synchronously-executing callback
// so that `onTestFinished()` can look up which concurrent sequence
// it belongs to. Any awaited microtasks that are drained by
// `runCallbackWithResultAndForcefullyDrainMicrotasks` also see this
// context. The context is popped once the JS call returns, even if
// the returned promise is still pending.
//
// `expect.assertions()` / `expect.hasAssertions()` / snapshot
// matchers deliberately do NOT use this stack — they reject
// upfront in multi-sequence concurrent groups (see `expect.zig`)
// because their subsequent `expect(v)` matchers can't resolve the
// owning sequence once control has returned to the event loop.
buntest.pushCurrentCallback(callback_data);
defer buntest.popCurrentCallback();

if (BunTest.runTestCallback(buntest_strong, globalThis, cb.get(), next_item.has_done_parameter, callback_data, &next_item.timespec) != null) {
now.* = bun.timespec.now(.force_real_time);
_ = next_item.evaluateTimeout(sequence, now);
Expand Down
57 changes: 55 additions & 2 deletions src/bun.js/test/bun_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,18 @@ pub const js_fns = struct {
.execution => {
const active = bunTest.getCurrentStateData();
const sequence, _ = bunTest.execution.getCurrentAndValidExecutionSequence(active) orelse {
// We only reach this branch when `current_callback_stack` is
// empty *and* the active concurrent group has more than one
// sequence in flight. The common way to hit this is calling
// the hook after the first `await` in a concurrent test —
// by then the synchronous push/pop has already been popped
// and we can no longer tell which sequence the caller
// belongs to. Tell the user to hoist the call above the
// first `await`, which is the only actionable fix here.
const message = if (tag == .onTestFinished)
"Cannot call {s}() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent."
"Cannot call {s}() here. In a concurrent test, call it synchronously before the first `await`."
else
"Cannot call {s}() here. It cannot be called inside a concurrent test. Call it inside describe() instead.";
"Cannot call {s}() here. In a concurrent test, call it synchronously before the first `await`, or move it into a describe() block.";
return globalThis.throw(message, .{@tagName(tag)});
};

Expand Down Expand Up @@ -219,6 +227,17 @@ pub const BunTest = struct {
first_last: BunTestRoot.FirstLast,
extra_execution_entries: std.array_list.Managed(*ExecutionEntry),
wants_wakeup: bool = false,
/// Stack of concurrent-sequence contexts whose JS callback is currently
/// being executed synchronously. Pushed by `Execution.stepSequenceOne`
/// before invoking `runTestCallback` and popped after it returns.
///
/// During synchronous JS execution (including drained microtasks), the
/// top of this stack tells `getCurrentStateData` which concurrent
/// sequence the calling code belongs to, so `onTestFinished()` resolves
/// to the correct test. `expect.assertions()` / `expect.hasAssertions()`
/// / snapshot matchers intentionally do not use this stack — see
/// `expect.zig` for why they reject concurrent-test calls outright.
current_callback_stack: std.array_list.Managed(RefDataValue),

phase: enum {
collection,
Expand Down Expand Up @@ -253,6 +272,7 @@ pub const BunTest = struct {
.default_concurrent = default_concurrent,
.first_last = first_last,
.extra_execution_entries = .init(this.gpa),
.current_callback_stack = .init(this.gpa),
};
}
pub fn deinit(this: *BunTest) void {
Expand All @@ -268,6 +288,10 @@ pub const BunTest = struct {
entry.destroy(this.gpa);
}
this.extra_execution_entries.deinit();
// every pushCurrentCallback must be paired with popCurrentCallback —
// a non-empty stack at teardown means we leaked a frame.
bun.debugAssert(this.current_callback_stack.items.len == 0);
this.current_callback_stack.deinit();

this.execution.deinit();
this.collection.deinit();
Expand Down Expand Up @@ -350,6 +374,16 @@ pub const BunTest = struct {
return switch (this.phase) {
.collection => .{ .collection = .{ .active_scope = this.collection.active_scope } },
.execution => blk: {
// If a JS callback is currently executing synchronously (including
// during drained microtasks), the innermost push on
// `current_callback_stack` tells us exactly which sequence/entry
// we're inside. This disambiguates the concurrent case, where
// multiple sequences may be in-flight at the same time.
if (this.current_callback_stack.items.len > 0) {
const top = this.current_callback_stack.items[this.current_callback_stack.items.len - 1];
if (top == .execution) break :blk top;
}

const active_group = this.execution.activeGroup() orelse {
bun.debugAssert(false); // should have switched phase if we're calling getCurrentStateData, but it could happen with re-entry maybe
break :blk .{ .done = .{} };
Expand Down Expand Up @@ -384,6 +418,25 @@ pub const BunTest = struct {
.done => .{ .done = .{} },
};
}

/// Push an entry onto the callback-execution stack. Must be paired with
/// `popCurrentCallback`. Call this immediately before invoking user JS
/// from a concurrent-safe context so nested hooks (`onTestFinished`,
/// `expect.assertions`) can recover which sequence they belong to.
Comment on lines +422 to +425
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.

🟡 The pushCurrentCallback method docstring (lines 422-425 of bun_test.zig) still lists expect.assertions as a beneficiary of the callback stack, claiming it can "recover which sequence they belong to" — but the current_callback_stack field docstring in the same struct (lines 237-246) correctly says the opposite: these functions intentionally do not use this stack and throw unconditionally instead. Commit 64eb666 fixed the identical claim in Execution.zig but left the method docstring stale, creating an internal contradiction within the same struct.

Extended reasoning...

What the bug is:

The pushCurrentCallback method docstring in bun_test.zig lines 422-425 reads:

/// Push an entry onto the callback-execution stack. Must be paired with
/// popCurrentCallback. Call this immediately before invoking user JS
/// from a concurrent-safe context so nested hooks (onTestFinished,
/// expect.assertions) can recover which sequence they belong to.

This directly contradicts the current_callback_stack field docstring in the same struct (lines 237-246), which correctly states: 'expect.assertions() / expect.hasAssertions() / snapshot matchers intentionally do not use this stack — see expect.zig for why they reject concurrent-test calls outright.'

The specific code path that demonstrates the inaccuracy:

In expect.zig, both hasAssertions() and assertions() call isInMultiSequenceConcurrentGroup(buntest) before ever consulting getCurrentStateData() or the callback stack. The throw fires unconditionally before the stack is ever accessed. expect.assertions does not 'recover which sequence it belongs to' via the stack — it doesn't use the stack at all.

Why existing code does not prevent the mismatch:

Commit 64eb666 correctly updated the Execution.zig callsite comment to say these functions 'deliberately do NOT use this stack' and updated the current_callback_stack field docstring to match. However, the pushCurrentCallback method docstring was not updated, leaving an internal contradiction: the field doc and the caller's doc both say one thing, while the method doc says the opposite.

Impact:

This is documentation-only with no runtime impact. The only harm is that a future contributor reading the pushCurrentCallback method doc in isolation could conclude that expect.assertions stack-based recovery is already working, and attempt to build on a false premise when trying to relax the restriction.

How to fix:

Change the method docstring from listing 'onTestFinished, expect.assertions' to just 'onTestFinished', analogous to what 64eb666 already applied to Execution.zig and the field docstring.

Step-by-step proof:

  1. bun_test.zig lines 422-425: pushCurrentCallback docstring says expect.assertions can recover via the stack.
  2. bun_test.zig lines 241-246: current_callback_stack field docstring says expect.assertions intentionally does NOT use this stack.
  3. expect.zig: isInMultiSequenceConcurrentGroup throws before getCurrentStateData() is called — the stack is never consulted.
  4. Commit 64eb666 fixed the equivalent statement in Execution.zig but missed the method docstring in bun_test.zig.

pub fn pushCurrentCallback(this: *BunTest, data: RefDataValue) void {
bun.handleOom(this.current_callback_stack.append(data));
}

/// Pop the innermost callback-execution entry. Cheaply tolerates an
/// out-of-order pop during teardown — the only correct caller is the
/// defer that paired a preceding `pushCurrentCallback`.
pub fn popCurrentCallback(this: *BunTest) void {
if (this.current_callback_stack.items.len == 0) {
bun.debugAssert(false);
return;
}
_ = this.current_callback_stack.pop();
}
pub fn ref(this_strong: BunTestPtr, phase: RefDataValue) *RefData {
group.begin(@src());
defer group.end();
Expand Down
39 changes: 36 additions & 3 deletions src/bun.js/test/expect.zig
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,12 @@ pub const Expect = struct {
var buntest_strong = parent.bunTest() orelse return error.TestNotActive;
defer buntest_strong.deinit();
const buntest = buntest_strong.get();
// Snapshots in a concurrent group have the same limitation as
// `expect.assertions` — the sync call works, but a post-await call
// in the same test body can't resolve back to the right sequence.
// Reject at call time rather than silently writing to the wrong
// snapshot slot. See https://github.com/oven-sh/bun/issues/29236.
if (isInMultiSequenceConcurrentGroup(buntest)) return error.SnapshotInConcurrentGroup;
const execution_entry = parent.phase.entry(buntest) orelse return error.SnapshotInConcurrentGroup;

const test_name = execution_entry.base.name orelse "(unnamed)";
Expand Down Expand Up @@ -1178,18 +1184,39 @@ pub const Expect = struct {
_ = callFrame;
defer globalThis.bunVM().autoGarbageCollect();

var buntest_strong = bun.jsc.Jest.bun_test.cloneActiveStrong() orelse return globalThis.throw("expect.assertions() must be called within a test", .{});
var buntest_strong = bun.jsc.Jest.bun_test.cloneActiveStrong() orelse return globalThis.throw("expect.hasAssertions() must be called within a test", .{});
defer buntest_strong.deinit();
const buntest = buntest_strong.get();
if (isInMultiSequenceConcurrentGroup(buntest)) {
// expect.hasAssertions() and expect.assertions() can't work in
// concurrent tests: we can set the constraint on the calling
// sequence synchronously, but subsequent expect() matchers that
// resume after an `await` have no way to resolve back to the
// same sequence, so the counter diverges and the test ends with
// a spurious "expected N assertions" failure. Throw at call
// time instead of silently miscounting later.
return globalThis.throw("expect.hasAssertions() is not supported in concurrent tests. Remove `.concurrent` from the test or use `test.serial` instead.", .{});
}
const state_data = buntest.getCurrentStateData();
const execution = state_data.sequence(buntest) orelse return globalThis.throw("expect.assertions() is not supported in the describe phase, in concurrent tests, between tests, or after test execution has completed", .{});
const execution = state_data.sequence(buntest) orelse return globalThis.throw("expect.hasAssertions() is not supported in the describe phase, between tests, or after test execution has completed", .{});
if (execution.expect_assertions != .exact) {
execution.expect_assertions = .at_least_one;
}

return .js_undefined;
}

/// Returns true if the active execution group has more than one sequence
/// in flight (i.e. the caller is inside a `test.concurrent` group).
/// Used by `expect.assertions`/`expect.hasAssertions`, which can't safely
/// track per-sequence counters across `await` boundaries in a concurrent
/// group (see https://github.com/oven-sh/bun/issues/29236).
fn isInMultiSequenceConcurrentGroup(buntest: *bun.jsc.Jest.bun_test.BunTest) bool {
if (buntest.phase != .execution) return false;
const active_group = buntest.execution.activeGroup() orelse return false;
return active_group.sequences(&buntest.execution).len > 1;
}

pub fn assertions(globalThis: *JSGlobalObject, callFrame: *CallFrame) bun.JSError!JSValue {
defer globalThis.bunVM().autoGarbageCollect();

Expand Down Expand Up @@ -1218,8 +1245,14 @@ pub const Expect = struct {
var buntest_strong = bun.jsc.Jest.bun_test.cloneActiveStrong() orelse return globalThis.throw("expect.assertions() must be called within a test", .{});
defer buntest_strong.deinit();
const buntest = buntest_strong.get();
if (isInMultiSequenceConcurrentGroup(buntest)) {
// Same limitation as `expect.hasAssertions` — see that function's
// comment for why we throw at call time instead of letting the
// per-sequence counter silently diverge across `await` boundaries.
return globalThis.throw("expect.assertions() is not supported in concurrent tests. Remove `.concurrent` from the test or use `test.serial` instead.", .{});
}
const state_data = buntest.getCurrentStateData();
const execution = state_data.sequence(buntest) orelse return globalThis.throw("expect.assertions() is not supported in the describe phase, in concurrent tests, between tests, or after test execution has completed", .{});
const execution = state_data.sequence(buntest) orelse return globalThis.throw("expect.assertions() is not supported in the describe phase, between tests, or after test execution has completed", .{});
execution.expect_assertions = .{ .exact = unsigned_expected_assertions };

return .js_undefined;
Expand Down
8 changes: 7 additions & 1 deletion test/js/bun/test/bun_test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ test("describe/test", async () => {
});
const exitCode = await result.exited;
const stdout = await result.stdout.text();
const stderr = await result.stderr.text();
// ASAN builds unconditionally print a "WARNING: ASAN interferes with JSC
// signal handlers..." banner to stderr from WebKit's Options.cpp; strip it
// so the inline snapshot stays portable across sanitizer and release lanes.
const stderr = (await result.stderr.text())
.split(/\r?\n/)
.filter(s => !s.startsWith("WARNING: ASAN interferes"))
.join("\n");
expect({
exitCode,
stdout: normalizeBunSnapshot(stdout),
Expand Down
44 changes: 24 additions & 20 deletions test/js/bun/test/test-on-test-finished.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,26 +68,30 @@ describe("async onTestFinished", () => {
});
});

// Test that onTestFinished throws proper error in concurrent tests
describe("onTestFinished errors", () => {
test.concurrent("cannot be called in concurrent test 1", () => {
expect(() => {
onTestFinished(() => {
console.log("should not run");
});
}).toThrow(
"Cannot call onTestFinished() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent.",
);
});

test.concurrent("cannot be called in concurrent test 2", () => {
expect(() => {
onTestFinished(() => {
console.log("should not run");
});
}).toThrow(
"Cannot call onTestFinished() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent.",
);
// https://github.com/oven-sh/bun/issues/29236 — onTestFinished() is
// callable from inside a concurrent test. Each sequence accumulates its
// own hooks and runs them at the end of that sequence.
describe("onTestFinished in concurrent tests", () => {
const a_output: string[] = [];
const b_output: string[] = [];

test.concurrent("test a", () => {
onTestFinished(() => {
a_output.push("a-finished");
});
a_output.push("a-body");
});

test.concurrent("test b", () => {
onTestFinished(() => {
b_output.push("b-finished");
});
b_output.push("b-body");
});

test("verify each sequence ran its own hook", () => {
expect(a_output).toEqual(["a-body", "a-finished"]);
expect(b_output).toEqual(["b-body", "b-finished"]);
});
});

Expand Down
Loading
Loading