Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
d84feb4b44 Add shell command cancellation support via AbortSignal
Co-authored-by: zack <zack@theradisic.com>
2025-06-27 23:24:26 +00:00
17 changed files with 433 additions and 25 deletions

View File

@@ -109,6 +109,7 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
#throws: boolean = true;
#resolve: (code: number, stdout: Buffer, stderr: Buffer) => void;
#reject: (code: number, stdout: Buffer, stderr: Buffer) => void;
#interpreter: $ZigGeneratedClasses.ShellInterpreter | undefined = undefined;
constructor(args: $ZigGeneratedClasses.ParsedShellScript, throws: boolean) {
// Create the error immediately so it captures the stacktrace at the point
@@ -169,9 +170,9 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
if (!this.#hasRun) {
this.#hasRun = true;
let interp = createShellInterpreter(this.#resolve, this.#reject, this.#args!);
this.#interpreter = createShellInterpreter(this.#resolve, this.#reject, this.#args!);
this.#args = undefined;
interp.run();
this.#interpreter.run();
}
}
@@ -195,6 +196,41 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
return this;
}
signal(signal: AbortSignal): this {
if (signal.aborted) {
// If already aborted, cancel immediately
if (this.#hasRun && this.#interpreter) {
this.#interpreter.cancel();
} else if (this.#args) {
// If not yet run, we need to cancel once it starts
const origRun = this.#run.bind(this);
this.#run = () => {
origRun();
if (this.#interpreter) {
this.#interpreter.cancel();
}
};
}
} else {
// Listen for abort event
signal.addEventListener('abort', () => {
if (this.#hasRun && this.#interpreter) {
this.#interpreter.cancel();
} else if (this.#args) {
// If not yet run, we need to cancel once it starts
const origRun = this.#run.bind(this);
this.#run = () => {
origRun();
if (this.#interpreter) {
this.#interpreter.cancel();
}
};
}
}, { once: true });
}
return this;
}
async text(encoding) {
const { stdout } = (await this.#quiet()) as ShellOutput;
return stdout.toString(encoding);

View File

@@ -626,6 +626,16 @@ pub fn start(this: *Builtin) Yield {
return this.callImpl(Yield, "start", .{});
}
pub fn cancel(this: *Builtin) void {
// Call the specific builtin's cancel method if it exists
this.callImpl(void, "cancel", .{});
// Set exit code to CANCELLED_EXIT_CODE if not already set
if (this.exit_code == null) {
this.exit_code = CANCELLED_EXIT_CODE;
}
}
pub fn deinit(this: *Builtin) void {
this.callImpl(void, "deinit", .{});
@@ -771,6 +781,7 @@ const Builtin = Interpreter.Builtin;
const JSC = bun.JSC;
const Maybe = bun.sys.Maybe;
const ExitCode = shell.interpret.ExitCode;
const CANCELLED_EXIT_CODE = shell.interpret.CANCELLED_EXIT_CODE;
const EnvMap = shell.interpret.EnvMap;
const log = shell.interpret.log;
const Syscall = bun.sys;

View File

@@ -99,19 +99,63 @@ pub const Yield = union(enum) {
// Note that we're using labelled switch statements but _not_
// re-assigning `this`, so the `this` variable is stale after the first
// execution. Don't touch it.
state: switch (this) {
var current_yield = this;
state: switch (current_yield) {
.pipeline => |x| {
pipeline_stack.append(x) catch bun.outOfMemory();
if (x.base.interpreter.is_cancelled.load(.monotonic)) {
continue :state x.cancel();
}
continue :state x.next();
},
.cmd => |x| {
if (x.base.interpreter.is_cancelled.load(.monotonic)) {
continue :state x.cancel();
}
continue :state x.next();
},
.script => |x| {
if (x.base.interpreter.is_cancelled.load(.monotonic)) {
continue :state x.cancel();
}
continue :state x.next();
},
.stmt => |x| {
if (x.base.interpreter.is_cancelled.load(.monotonic)) {
continue :state x.cancel();
}
continue :state x.next();
},
.assigns => |x| {
if (x.base.interpreter.is_cancelled.load(.monotonic)) {
continue :state x.cancel();
}
continue :state x.next();
},
.expansion => |x| {
if (x.base.interpreter.is_cancelled.load(.monotonic)) {
continue :state x.cancel();
}
continue :state x.next();
},
.@"if" => |x| {
if (x.base.interpreter.is_cancelled.load(.monotonic)) {
continue :state x.cancel();
}
continue :state x.next();
},
.subshell => |x| {
if (x.base.interpreter.is_cancelled.load(.monotonic)) {
continue :state x.cancel();
}
continue :state x.next();
},
.cond_expr => |x| {
if (x.base.interpreter.is_cancelled.load(.monotonic)) {
continue :state x.cancel();
}
continue :state x.next();
},
.cmd => |x| continue :state x.next(),
.script => |x| continue :state x.next(),
.stmt => |x| continue :state x.next(),
.assigns => |x| continue :state x.next(),
.expansion => |x| continue :state x.next(),
.@"if" => |x| continue :state x.next(),
.subshell => |x| continue :state x.next(),
.cond_expr => |x| continue :state x.next(),
.on_io_writer_chunk => |x| {
const child = IOWriterChildPtr.fromAnyOpaque(x.child);
continue :state child.onIOWriterChunk(x.written, x.err);

View File

@@ -134,6 +134,7 @@ pub const OutputNeedsIOSafeGuard = enum(u0) { output_needs_io };
pub const CallstackGuard = enum(u0) { __i_know_what_i_am_doing };
pub const ExitCode = u16;
pub const CANCELLED_EXIT_CODE: ExitCode = 130; // 128 + SIGINT
pub const StateKind = enum(u8) {
script,
@@ -282,6 +283,7 @@ pub const Interpreter = struct {
has_pending_activity: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
started: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
is_cancelled: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
// Necessary for builtin commands.
keep_alive: bun.Async.KeepAlive = .{},
@@ -1166,21 +1168,33 @@ pub const Interpreter = struct {
this.exit_code = exit_code;
const this_jsvalue = this.this_jsvalue;
if (this_jsvalue != .zero) {
if (JSC.Codegen.JSShellInterpreter.resolveGetCached(this_jsvalue)) |resolve| {
const loop = this.event_loop.js;
const globalThis = this.globalThis;
this.this_jsvalue = .zero;
this.keep_alive.disable();
loop.enter();
_ = resolve.call(globalThis, .js_undefined, &.{
JSValue.jsNumberFromU16(exit_code),
this.getBufferedStdout(globalThis),
this.getBufferedStderr(globalThis),
}) catch |err| globalThis.reportActiveExceptionAsUnhandled(err);
JSC.Codegen.JSShellInterpreter.resolveSetCached(this_jsvalue, globalThis, .js_undefined);
JSC.Codegen.JSShellInterpreter.rejectSetCached(this_jsvalue, globalThis, .js_undefined);
loop.exit();
const loop = this.event_loop.js;
const globalThis = this.globalThis;
this.this_jsvalue = .zero;
this.keep_alive.disable();
loop.enter();
// Handle cancellation by rejecting with AbortError
if (exit_code == CANCELLED_EXIT_CODE) {
if (JSC.Codegen.JSShellInterpreter.rejectGetCached(this_jsvalue)) |reject| {
// Create a DOMException with name "AbortError"
const error_str = bun.String.static("The operation was aborted");
const abort_error = JSC.DOMException.createAbortError(globalThis, &error_str);
_ = reject.call(globalThis, .js_undefined, &.{abort_error}) catch |err| globalThis.reportActiveExceptionAsUnhandled(err);
}
} else {
if (JSC.Codegen.JSShellInterpreter.resolveGetCached(this_jsvalue)) |resolve| {
_ = resolve.call(globalThis, .js_undefined, &.{
JSValue.jsNumberFromU16(exit_code),
this.getBufferedStdout(globalThis),
this.getBufferedStderr(globalThis),
}) catch |err| globalThis.reportActiveExceptionAsUnhandled(err);
}
}
JSC.Codegen.JSShellInterpreter.resolveSetCached(this_jsvalue, globalThis, .js_undefined);
JSC.Codegen.JSShellInterpreter.rejectSetCached(this_jsvalue, globalThis, .js_undefined);
loop.exit();
}
} else {
this.flags.done = true;
@@ -1308,6 +1322,11 @@ pub const Interpreter = struct {
this.deinitFromFinalizer();
}
pub fn cancel(this: *ThisInterpreter) callconv(.C) void {
log("Interpreter(0x{x}) cancel", .{@intFromPtr(this)});
this.is_cancelled.store(true, .seq_cst);
}
pub fn hasPendingActivity(this: *ThisInterpreter) bool {
return this.has_pending_activity.load(.seq_cst) > 0;
}
@@ -1479,6 +1498,20 @@ pub fn StatePtrUnion(comptime TypesValue: anytype) type {
unknownTag(this.tagInt());
}
/// Cancels the state node
pub fn cancel(this: @This()) Yield {
const tags = comptime std.meta.fields(Ptr.Tag);
inline for (tags) |tag| {
if (this.tagInt() == tag.value) {
const Ty = comptime Ptr.typeFromTag(tag.value);
Ptr.assert_type(Ty);
var casted = this.as(Ty);
return casted.cancel();
}
}
unknownTag(this.tagInt());
}
pub fn unknownTag(tag: Ptr.TagInt) noreturn {
return bun.Output.panic("Unknown tag for shell state node: {d}\n", .{tag});
}

View File

@@ -22,6 +22,7 @@ pub const Interpreter = interpret.Interpreter;
pub const ParsedShellScript = interpret.ParsedShellScript;
pub const Subprocess = subproc.ShellSubprocess;
pub const ExitCode = interpret.ExitCode;
pub const CANCELLED_EXIT_CODE = interpret.CANCELLED_EXIT_CODE;
pub const IOWriter = Interpreter.IOWriter;
pub const IOReader = Interpreter.IOReader;
// pub const IOWriter = interpret.IOWriter;

View File

@@ -39,6 +39,18 @@ pub inline fn deinit(this: *Assigns) void {
if (this.owned) this.parent.destroy(this);
}
pub fn cancel(this: *Assigns) Yield {
log("Assigns(0x{x}) cancel", .{@intFromPtr(this)});
// Clean up current expansion if any
if (this.state == .expanding) {
this.state.expanding.current_expansion_result.clearAndFree();
}
// Propagate cancellation to parent
return this.parent.childDone(this, CANCELLED_EXIT_CODE);
}
pub fn start(this: *Assigns) Yield {
return .{ .assigns = this };
}
@@ -220,6 +232,7 @@ const Interpreter = bun.shell.Interpreter;
const StatePtrUnion = bun.shell.interpret.StatePtrUnion;
const ast = bun.shell.AST;
const ExitCode = bun.shell.ExitCode;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const ShellExecEnv = Interpreter.ShellExecEnv;
const State = bun.shell.Interpreter.State;
const IO = bun.shell.Interpreter.IO;

View File

@@ -131,6 +131,22 @@ pub fn childDone(this: *Async, child_ptr: ChildPtr, exit_code: ExitCode) Yield {
return .suspended;
}
pub fn cancel(this: *Async) Yield {
log("{} cancel", .{this});
// Cancel the child if it's running
if (this.state == .exec) {
if (this.state.exec.child) |child| {
_ = child.cancel();
}
}
// Mark as done with CANCELLED_EXIT_CODE
this.state = .{ .done = CANCELLED_EXIT_CODE };
this.enqueueSelf();
return .suspended;
}
/// This function is purposefully empty as a hack to ensure Async runs in the background while appearing to
/// the parent that it is done immediately.
///
@@ -164,6 +180,7 @@ const Interpreter = bun.shell.Interpreter;
const StatePtrUnion = bun.shell.interpret.StatePtrUnion;
const ast = bun.shell.AST;
const ExitCode = bun.shell.ExitCode;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const ShellExecEnv = Interpreter.ShellExecEnv;
const State = bun.shell.Interpreter.State;
const IO = bun.shell.Interpreter.IO;

View File

@@ -141,6 +141,18 @@ pub fn childDone(this: *Binary, child: ChildPtr, exit_code: ExitCode) Yield {
return this.parent.childDone(this, exit_code);
}
pub fn cancel(this: *Binary) Yield {
log("binary cancel {x} ({s})", .{ @intFromPtr(this), @tagName(this.node.op) });
// Cancel the currently executing child if any
if (this.currently_executing) |child| {
return child.cancel();
}
// Propagate cancellation to parent
return this.parent.childDone(this, CANCELLED_EXIT_CODE);
}
pub fn deinit(this: *Binary) void {
if (this.currently_executing) |child| {
child.deinit();
@@ -157,6 +169,7 @@ const Interpreter = bun.shell.Interpreter;
const StatePtrUnion = bun.shell.interpret.StatePtrUnion;
const ast = bun.shell.AST;
const ExitCode = bun.shell.ExitCode;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const ShellExecEnv = Interpreter.ShellExecEnv;
const State = bun.shell.Interpreter.State;
const IO = bun.shell.Interpreter.IO;

View File

@@ -694,6 +694,39 @@ pub fn onExit(this: *Cmd, exit_code: ExitCode) void {
}
}
pub fn cancel(this: *Cmd) Yield {
log("Cmd(0x{x}, {s}) cancel", .{ @intFromPtr(this), @tagName(this.exec) });
// Set internal state to cancelling
if (this.exit_code == null) {
this.exit_code = CANCELLED_EXIT_CODE;
}
// Cancel subprocess or builtin
if (this.exec == .subproc) {
// Send SIGTERM to subprocess
_ = this.exec.subproc.child.tryKill(9); // TODO: Use SIGTERM instead of SIGKILL
} else if (this.exec == .bltn) {
// Cancel builtin
this.exec.bltn.cancel();
}
// Cancel chunks in IOWriter for stdout/stderr if they are file descriptors
if (this.io.stdout == .fd) {
if (this.io.stdout.fd.writer) |writer| {
writer.cancelChunks(this);
}
}
if (this.io.stderr == .fd) {
if (this.io.stderr.fd.writer) |writer| {
writer.cancelChunks(this);
}
}
// State machine will handle cleanup via normal childDone pathway
return .suspended;
}
// TODO check that this also makes sure that the poll ref is killed because if it isn't then this Cmd pointer will be stale and so when the event for pid exit happens it will cause crash
pub fn deinit(this: *Cmd) void {
log("Cmd(0x{x}, {s}) cmd deinit", .{ @intFromPtr(this), @tagName(this.exec) });
@@ -807,6 +840,7 @@ const Interpreter = bun.shell.Interpreter;
const StatePtrUnion = bun.shell.interpret.StatePtrUnion;
const ast = bun.shell.AST;
const ExitCode = bun.shell.ExitCode;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const ShellExecEnv = Interpreter.ShellExecEnv;
const State = bun.shell.Interpreter.State;
const IO = bun.shell.Interpreter.IO;

View File

@@ -210,6 +210,13 @@ fn doStat(this: *CondExpr) Yield {
return .suspended;
}
pub fn cancel(this: *CondExpr) Yield {
log("{} cancel", .{this});
// Propagate cancellation to parent with CANCELLED_EXIT_CODE
return this.parent.childDone(this, CANCELLED_EXIT_CODE);
}
pub fn deinit(this: *CondExpr) void {
this.io.deinit();
for (this.args.items) |item| {
@@ -276,6 +283,7 @@ const Interpreter = bun.shell.Interpreter;
const StatePtrUnion = bun.shell.interpret.StatePtrUnion;
const ast = bun.shell.AST;
const ExitCode = bun.shell.ExitCode;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const ShellExecEnv = Interpreter.ShellExecEnv;
const State = bun.shell.Interpreter.State;
const IO = bun.shell.Interpreter.IO;

View File

@@ -146,6 +146,24 @@ pub fn init(
expansion.current_out = std.ArrayList(u8).init(expansion.base.allocator());
}
pub fn cancel(this: *Expansion) Yield {
log("Expansion(0x{x}) cancel", .{@intFromPtr(this)});
// If a command substitution is running, cancel it
if (this.child_state == .cmd_subst) {
return this.child_state.cmd_subst.cmd.cancel();
}
// Clean up state
this.current_out.deinit();
if (this.child_state == .glob) {
this.child_state.glob.walker.deinit(true);
}
// Propagate cancellation to parent
return this.parent.childDone(this, CANCELLED_EXIT_CODE);
}
pub fn deinit(expansion: *Expansion) void {
log("Expansion(0x{x}) deinit", .{@intFromPtr(expansion)});
expansion.current_out.deinit();
@@ -857,6 +875,7 @@ const Interpreter = bun.shell.Interpreter;
const StatePtrUnion = bun.shell.interpret.StatePtrUnion;
const ast = bun.shell.AST;
const ExitCode = bun.shell.ExitCode;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const GlobWalker = bun.shell.interpret.GlobWalker;
const ShellExecEnv = Interpreter.ShellExecEnv;
const State = bun.shell.Interpreter.State;

View File

@@ -148,6 +148,13 @@ pub fn next(this: *If) Yield {
return this.parent.childDone(this, 0);
}
pub fn cancel(this: *If) Yield {
log("{} cancel", .{this});
// Propagate cancellation to parent with CANCELLED_EXIT_CODE
return this.parent.childDone(this, CANCELLED_EXIT_CODE);
}
pub fn deinit(this: *If) void {
log("{} deinit", .{this});
this.io.deref();
@@ -191,6 +198,7 @@ const Interpreter = bun.shell.Interpreter;
const StatePtrUnion = bun.shell.interpret.StatePtrUnion;
const ast = bun.shell.AST;
const ExitCode = bun.shell.ExitCode;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const ShellExecEnv = Interpreter.ShellExecEnv;
const State = bun.shell.Interpreter.State;
const IO = bun.shell.Interpreter.IO;

View File

@@ -260,6 +260,31 @@ pub fn childDone(this: *Pipeline, child: ChildPtr, exit_code: ExitCode) Yield {
return .suspended;
}
pub fn cancel(this: *Pipeline) Yield {
log("Pipeline(0x{x}) cancel", .{@intFromPtr(this)});
// First close all pipes to unblock any processes stuck on I/O
if (this.pipes) |pipes| {
for (pipes) |*pipe| {
closefd(pipe[0]);
closefd(pipe[1]);
}
}
// Cancel all running commands
if (this.cmds) |cmds| {
for (cmds) |cmd_or_result| {
if (cmd_or_result == .cmd) {
// Cancel the command
_ = cmd_or_result.cmd.call("cancel", .{}, Yield);
}
}
}
// The pipeline will wait for all children to report childDone before propagating cancellation
return .suspended;
}
pub fn deinit(this: *Pipeline) void {
// If commands was zero then we didn't allocate anything
if (this.cmds == null) return;
@@ -333,6 +358,7 @@ const Interpreter = bun.shell.Interpreter;
const StatePtrUnion = bun.shell.interpret.StatePtrUnion;
const ast = bun.shell.AST;
const ExitCode = bun.shell.ExitCode;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const ShellExecEnv = Interpreter.ShellExecEnv;
const State = bun.shell.Interpreter.State;
const IO = bun.shell.Interpreter.IO;

View File

@@ -91,6 +91,14 @@ pub fn childDone(this: *Script, child: ChildPtr, exit_code: ExitCode) Yield {
return this.next();
}
pub fn cancel(this: *Script) Yield {
log("Script(0x{x}) cancel", .{@intFromPtr(this)});
// Since scripts don't have direct children running in parallel,
// we just propagate the cancellation to the parent with CANCELLED_EXIT_CODE
return this.finish(CANCELLED_EXIT_CODE);
}
pub fn deinit(this: *Script) void {
log("Script(0x{x}) deinit", .{@intFromPtr(this)});
this.io.deref();
@@ -122,6 +130,8 @@ const InterpreterChildPtr = Interpreter.InterpreterChildPtr;
const StatePtrUnion = bun.shell.interpret.StatePtrUnion;
const ast = bun.shell.AST;
const ExitCode = bun.shell.ExitCode;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const ShellExecEnv = Interpreter.ShellExecEnv;
const State = bun.shell.Interpreter.State;
const IO = bun.shell.Interpreter.IO;

View File

@@ -124,6 +124,18 @@ pub fn childDone(this: *Stmt, child: ChildPtr, exit_code: ExitCode) Yield {
return this.next();
}
pub fn cancel(this: *Stmt) Yield {
log("Stmt(0x{x}) cancel", .{@intFromPtr(this)});
// Cancel the currently executing child if any
if (this.currently_executing) |child| {
return child.cancel();
}
// Otherwise propagate cancellation to parent
return this.parent.childDone(this, CANCELLED_EXIT_CODE);
}
pub fn deinit(this: *Stmt) void {
log("Stmt(0x{x}) deinit", .{@intFromPtr(this)});
this.io.deinit();
@@ -141,6 +153,8 @@ const Interpreter = bun.shell.Interpreter;
const StatePtrUnion = bun.shell.interpret.StatePtrUnion;
const ast = bun.shell.AST;
const ExitCode = bun.shell.ExitCode;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const ShellExecEnv = Interpreter.ShellExecEnv;
const State = bun.shell.Interpreter.State;
const IO = bun.shell.Interpreter.IO;

View File

@@ -170,6 +170,13 @@ pub fn onIOWriterChunk(this: *Subshell, _: usize, err: ?JSC.SystemError) Yield {
return this.parent.childDone(this, this.exit_code);
}
pub fn cancel(this: *Subshell) Yield {
log("{} cancel", .{this});
// Propagate cancellation to parent with CANCELLED_EXIT_CODE
return this.parent.childDone(this, CANCELLED_EXIT_CODE);
}
pub fn deinit(this: *Subshell) void {
this.base.shell.deinit();
this.io.deref();
@@ -196,6 +203,7 @@ const Interpreter = bun.shell.Interpreter;
const StatePtrUnion = bun.shell.interpret.StatePtrUnion;
const ast = bun.shell.AST;
const ExitCode = bun.shell.ExitCode;
const CANCELLED_EXIT_CODE = bun.shell.CANCELLED_EXIT_CODE;
const ShellExecEnv = Interpreter.ShellExecEnv;
const State = bun.shell.Interpreter.State;
const IO = bun.shell.Interpreter.IO;

113
test_shell_cancellation.js Normal file
View File

@@ -0,0 +1,113 @@
const { $ } = require('bun');
async function testShellCancellation() {
console.log('Testing shell cancellation...\n');
// Test 1: Cancel a simple long-running command
{
console.log('Test 1: Cancelling sleep command');
const controller = new AbortController();
const { signal } = controller;
const start = Date.now();
const promise = $`sleep 5`.signal(signal);
// Cancel after 1 second
setTimeout(() => {
console.log(' Sending abort signal...');
controller.abort();
}, 1000);
try {
await promise;
console.log(' ❌ Expected command to be cancelled');
} catch (err) {
const elapsed = Date.now() - start;
if (err.name === 'AbortError') {
console.log(` ✅ Command cancelled after ${elapsed}ms (AbortError)`);
} else {
console.log(` ❌ Unexpected error: ${err.name} - ${err.message}`);
}
}
}
// Test 2: Cancel a pipeline
{
console.log('\nTest 2: Cancelling pipeline');
const controller = new AbortController();
const { signal } = controller;
const start = Date.now();
const promise = $`yes | head -n 100000`.signal(signal);
// Cancel after 500ms
setTimeout(() => {
console.log(' Sending abort signal...');
controller.abort();
}, 500);
try {
await promise;
console.log(' ❌ Expected pipeline to be cancelled');
} catch (err) {
const elapsed = Date.now() - start;
if (err.name === 'AbortError') {
console.log(` ✅ Pipeline cancelled after ${elapsed}ms (AbortError)`);
} else {
console.log(` ❌ Unexpected error: ${err.name} - ${err.message}`);
}
}
}
// Test 3: Pre-aborted signal
{
console.log('\nTest 3: Pre-aborted signal');
const controller = new AbortController();
controller.abort();
const start = Date.now();
try {
await $`sleep 5`.signal(controller.signal);
console.log(' ❌ Expected command to be cancelled immediately');
} catch (err) {
const elapsed = Date.now() - start;
if (err.name === 'AbortError' && elapsed < 100) {
console.log(` ✅ Command cancelled immediately (${elapsed}ms)`);
} else {
console.log(` ❌ Unexpected error or timing: ${err.name} - ${elapsed}ms`);
}
}
}
// Test 4: Cancel command substitution
{
console.log('\nTest 4: Cancelling command substitution');
const controller = new AbortController();
const { signal } = controller;
const start = Date.now();
const promise = $`echo $(sleep 5 && echo "done")`.signal(signal);
// Cancel after 1 second
setTimeout(() => {
console.log(' Sending abort signal...');
controller.abort();
}, 1000);
try {
await promise;
console.log(' ❌ Expected command substitution to be cancelled');
} catch (err) {
const elapsed = Date.now() - start;
if (err.name === 'AbortError') {
console.log(` ✅ Command substitution cancelled after ${elapsed}ms`);
} else {
console.log(` ❌ Unexpected error: ${err.name} - ${err.message}`);
}
}
}
console.log('\nAll tests completed!');
}
testShellCancellation().catch(console.error);