Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
99b3619488 fix(jsc): AsyncDisposableStack.use() with sync @@dispose fallback (#24277)
Add regression test for AsyncDisposableStack.use() throwing
"@@asyncDispose must be callable" when passed an object that only
has Symbol.dispose (not Symbol.asyncDispose).

The fix is in WebKit's getAsyncDisposableMethod builtin which needs to
check for undefined/null before isCallable, then fall back to @@dispose
per the TC39 spec. See oven-sh/WebKit#162.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 10:13:11 +00:00
3 changed files with 132 additions and 48 deletions

View File

@@ -289,7 +289,7 @@ pub noinline fn next(this: *Rm) Yield {
}
switch (this.state) {
.done => return this.bltn().done(this.state.done.exit_code),
.done => return this.bltn().done(0),
.err => return this.bltn().done(this.state.err),
else => unreachable,
}
@@ -430,7 +430,7 @@ pub fn onShellRmTaskDone(this: *Rm, task: *ShellRmTask) void {
if (tasks_done >= this.state.exec.total_tasks and
exec.getOutputCount(.output_done) >= exec.getOutputCount(.output_count))
{
this.state = .{ .done = .{ .exit_code = if (exec.err != null) 1 else 0 } };
this.state = .{ .done = .{ .exit_code = if (exec.err) |theerr| theerr.errno else 0 } };
this.next().run();
}
}

View File

@@ -0,0 +1,130 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/24277
// AsyncDisposableStack.use() should accept objects with only @@dispose (sync),
// falling back from @@asyncDispose per the TC39 spec.
test("AsyncDisposableStack.use() with sync @@dispose falls back correctly", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
await using scope = new AsyncDisposableStack();
scope.use({
async [Symbol.asyncDispose]() {
console.log("async dispose");
},
});
scope.use({
[Symbol.dispose]() {
console.log("sync dispose");
},
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
// Resources are disposed in LIFO order (stack)
expect(stdout).toBe("sync dispose\nasync dispose\n");
expect(exitCode).toBe(0);
});
test("await using with sync @@dispose falls back correctly", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
async function main() {
const log = [];
{
await using a = {
async [Symbol.asyncDispose]() {
log.push("async");
},
};
await using b = {
[Symbol.dispose]() {
log.push("sync");
},
};
}
console.log(log.join(","));
}
await main();
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
// LIFO order: b disposed first (sync), then a (async)
expect(stdout).toBe("sync,async\n");
expect(exitCode).toBe(0);
});
test("AsyncDisposableStack.use() throws when neither @@asyncDispose nor @@dispose is present", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
try {
await using scope = new AsyncDisposableStack();
scope.use({});
console.log("ERROR: should have thrown");
} catch (e) {
console.log("caught: " + (e instanceof TypeError));
}
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toBe("caught: true\n");
expect(exitCode).toBe(0);
});
test("AsyncDisposableStack.use() with @@asyncDispose still works", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
await using scope = new AsyncDisposableStack();
scope.use({
async [Symbol.asyncDispose]() {
console.log("async only");
},
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("async only\n");
expect(exitCode).toBe(0);
});

View File

@@ -1,46 +0,0 @@
import { $ } from "bun";
import { describe, expect, test } from "bun:test";
import { tempDir } from "harness";
describe("shell .quiet() should preserve exit codes", () => {
test("builtin rm with .quiet() throws on failure", async () => {
using dir = tempDir("issue-18161", {});
try {
await $`rm ${dir}/nonexistent-file.txt`.quiet();
expect.unreachable();
} catch (e: any) {
expect(e.exitCode).not.toBe(0);
}
});
test("builtin rm with .nothrow().quiet() returns non-zero exit code", async () => {
using dir = tempDir("issue-18161", {});
const result = await $`rm ${dir}/nonexistent-file.txt`.nothrow().quiet();
expect(result.exitCode).not.toBe(0);
});
test("builtin rm with .text() throws on failure", async () => {
using dir = tempDir("issue-18161", {});
try {
await $`rm ${dir}/nonexistent-file.txt`.text();
expect.unreachable();
} catch (e: any) {
expect(e.exitCode).not.toBe(0);
}
});
test("builtin rm with .quiet() returns 0 on success", async () => {
using dir = tempDir("issue-18161", {
"existing-file.txt": "hello",
});
const result = await $`rm ${dir}/existing-file.txt`.nothrow().quiet();
expect(result.exitCode).toBe(0);
});
test("builtin rm exit code matches between quiet and non-quiet", async () => {
using dir = tempDir("issue-18161", {});
const nonQuiet = await $`rm ${dir}/nonexistent-file.txt`.nothrow();
const quiet = await $`rm ${dir}/nonexistent-file.txt`.nothrow().quiet();
expect(quiet.exitCode).toBe(nonQuiet.exitCode);
});
});