Skip to content

Commit b1de47d

Browse files
eastlondonerclaude
andcommitted
feat(shell): add AbortSignal support via .signal() method
Add a fluent `.signal(abortSignal)` method to ShellPromise that enables cancellation of shell commands using the standard AbortSignal API. Features: - AbortController.abort() sends SIGTERM to all spawned processes - Supports AbortSignal.timeout() for automatic timeouts - .nothrow() resolves with exit code 143 (128 + SIGTERM) instead of throwing - Custom abort reasons are preserved in the rejection - Already-aborted signals reject immediately without spawning processes - Pipelines abort all processes in the pipeline - Works with helper methods: .text(), .lines(), .json() - Calling .signal() after shell starts throws "Shell is already running" Implementation: - JS layer (shell.ts): Manages AbortSignal lifecycle, tracks abort state - Zig layer: Subprocess registry, abort signal wiring, cooperative builtin cancellation - Builtins (yes, cat, cp, seq): Check isAborted() at yield points for cooperative cancellation Closes #18247 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 1d50af7 commit b1de47d

File tree

12 files changed

+557
-0
lines changed

12 files changed

+557
-0
lines changed

packages/bun-types/shell.d.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,42 @@ declare module "bun" {
202202
* By default, this is configured to `true`.
203203
*/
204204
throws(shouldThrow: boolean): this;
205+
206+
/**
207+
* Attach an AbortSignal to cancel this shell command.
208+
*
209+
* When the signal aborts:
210+
* - All processes in the pipeline are terminated with SIGTERM
211+
* - The promise rejects with an AbortError (DOMException)
212+
* - If `.nothrow()` was used, resolves with exit code 143 (128 + SIGTERM)
213+
*
214+
* @param signal - The AbortSignal to attach
215+
* @returns this - for method chaining
216+
* @throws Error if the shell has already started executing
217+
*
218+
* @example
219+
* **Timeout after 5 seconds**
220+
* ```ts
221+
* await $`long-running-command`.signal(AbortSignal.timeout(5000));
222+
* ```
223+
*
224+
* @example
225+
* **Manual cancellation**
226+
* ```ts
227+
* const controller = new AbortController();
228+
* const promise = $`sleep 100`.signal(controller.signal);
229+
* setTimeout(() => controller.abort(), 1000);
230+
* await promise; // Rejects with AbortError after 1 second
231+
* ```
232+
*
233+
* @example
234+
* **With nothrow - returns exit code instead of throwing**
235+
* ```ts
236+
* const result = await $`sleep 100`.nothrow().signal(AbortSignal.timeout(1000));
237+
* console.log(result.exitCode); // 143 (128 + 15)
238+
* ```
239+
*/
240+
signal(signal: AbortSignal): this;
205241
}
206242

207243
/**

src/bun.js/api/ParsedShellScript.classes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export default [
2525
fn: "setQuiet",
2626
length: 1,
2727
},
28+
setSignal: {
29+
fn: "setSignal",
30+
length: 1,
31+
},
2832
},
2933
}),
3034
];

src/js/builtins/shell.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
107107
#args: $ZigGeneratedClasses.ParsedShellScript | undefined = undefined;
108108
#hasRun: boolean = false;
109109
#throws: boolean = true;
110+
#signal?: AbortSignal; // Store the abort signal
111+
#abortedByUs: boolean = false; // Tracks if OUR abort listener fired
110112
#resolve: (code: number, stdout: Buffer, stderr: Buffer) => void;
111113
#reject: (code: number, stdout: Buffer, stderr: Buffer) => void;
112114

@@ -121,6 +123,34 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
121123
super((res, rej) => {
122124
resolve = (code, stdout, stderr) => {
123125
const out = new ShellOutput(stdout, stderr, code);
126+
127+
// Check if operation was aborted by our signal.
128+
// We check BOTH conditions:
129+
// 1. #abortedByUs - our abort listener fired (definitively know our signal caused it)
130+
// 2. code >= 128 - process was killed by a signal (sanity check)
131+
//
132+
// This avoids false positives where:
133+
// - The signal fires after normal completion (code would be 0 or small)
134+
// - The process was killed by something else (Ctrl+C) but signal wasn't ours
135+
//
136+
// Exit code 128+N indicates the process was killed by signal N.
137+
// SIGTERM (15) -> 143, SIGKILL (9) -> 137
138+
const wasAborted = this.#abortedByUs && code >= 128;
139+
140+
if (wasAborted) {
141+
if (this.#throws) {
142+
// Reject with the signal's reason, or a default AbortError
143+
const reason = this.#signal!.reason ?? new DOMException("The operation was aborted.", "AbortError");
144+
rej(reason);
145+
} else {
146+
// nothrow mode: resolve normally with the exit code
147+
potentialError = undefined;
148+
res(out);
149+
}
150+
return;
151+
}
152+
153+
// Normal (non-abort) exit handling (existing code)
124154
if (this.#throws && code !== 0) {
125155
potentialError!.initialize(out, code);
126156
rej(potentialError);
@@ -169,6 +199,16 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
169199
if (!this.#hasRun) {
170200
this.#hasRun = true;
171201

202+
// Handle already-aborted signals entirely in JS
203+
// This avoids spawning anything and immediately settles the promise
204+
if (this.#signal?.aborted) {
205+
// Simulate a process killed by SIGTERM (exit code 128 + 15 = 143)
206+
// The resolve callback will see #abortedByUs=true and code>=128,
207+
// then reject with AbortError (or resolve if .nothrow() was used)
208+
this.#resolve(128 + 15, Buffer.alloc(0), Buffer.alloc(0));
209+
return;
210+
}
211+
172212
let interp = createShellInterpreter(this.#resolve, this.#reject, this.#args!);
173213
this.#args = undefined;
174214
interp.run();
@@ -195,6 +235,33 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
195235
return this;
196236
}
197237

238+
signal(sig: AbortSignal): this {
239+
this.#throwIfRunning();
240+
this.#signal = sig;
241+
242+
// Track when our signal fires - this definitively tells us the abort
243+
// was triggered by our signal, not some other termination cause
244+
if (sig.aborted) {
245+
// Signal is already aborted - handle entirely in JS
246+
// We'll short-circuit in #run() and never spawn anything
247+
this.#abortedByUs = true;
248+
// Don't pass to Zig - we'll handle it in #run()
249+
} else {
250+
// Listen for future abort
251+
sig.addEventListener(
252+
"abort",
253+
() => {
254+
this.#abortedByUs = true;
255+
},
256+
{ once: true },
257+
);
258+
// Pass signal to ParsedShellScript so Zig can access it
259+
this.#args!.setSignal(sig);
260+
}
261+
262+
return this;
263+
}
264+
198265
async text(encoding) {
199266
const { stdout } = (await this.#quiet(true)) as ShellOutput;
200267
return stdout.toString(encoding);

src/shell/ParsedShellScript.zig

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ jsobjs: std.array_list.Managed(JSValue),
1111
export_env: ?EnvMap = null,
1212
quiet: bool = false,
1313
cwd: ?bun.String = null,
14+
abort_signal: ?*webcore.AbortSignal = null,
1415
this_jsvalue: JSValue = .zero,
1516
estimated_size_for_gc: usize = 0,
1617

@@ -45,17 +46,20 @@ pub fn take(
4546
out_quiet: *bool,
4647
out_cwd: *?bun.String,
4748
out_export_env: *?EnvMap,
49+
out_abort_signal: *?*webcore.AbortSignal,
4850
) void {
4951
out_args.* = this.args.?;
5052
out_jsobjs.* = this.jsobjs;
5153
out_quiet.* = this.quiet;
5254
out_cwd.* = this.cwd;
5355
out_export_env.* = this.export_env;
56+
out_abort_signal.* = this.abort_signal;
5457

5558
this.args = null;
5659
this.jsobjs = std.array_list.Managed(JSValue).init(bun.default_allocator);
5760
this.cwd = null;
5861
this.export_env = null;
62+
this.abort_signal = null; // ownership transferred
5963
}
6064

6165
pub fn finalize(
@@ -65,6 +69,7 @@ pub fn finalize(
6569

6670
if (this.export_env) |*env| env.deinit();
6771
if (this.cwd) |*cwd| cwd.deref();
72+
if (this.abort_signal) |signal| signal.unref();
6873
if (this.args) |a| a.deinit();
6974
bun.destroy(this);
7075
}
@@ -86,6 +91,16 @@ pub fn setQuiet(this: *ParsedShellScript, _: *JSGlobalObject, callframe: *jsc.Ca
8691
return .js_undefined;
8792
}
8893

94+
pub fn setSignal(this: *ParsedShellScript, globalThis: *JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
95+
const signal_val = callframe.argument(0);
96+
const signal = signal_val.as(jsc.WebCore.AbortSignal) orelse {
97+
return globalThis.throwInvalidArgumentTypeValue("signal", "AbortSignal", signal_val);
98+
};
99+
if (this.abort_signal) |old| old.unref();
100+
this.abort_signal = signal.ref();
101+
return .js_undefined;
102+
}
103+
89104
pub fn setEnv(this: *ParsedShellScript, globalThis: *JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
90105
const value1 = callframe.argument(0).getObject() orelse {
91106
return globalThis.throwInvalidArguments("env must be an object", .{});
@@ -205,6 +220,7 @@ const assert = bun.assert;
205220
const jsc = bun.jsc;
206221
const JSGlobalObject = jsc.JSGlobalObject;
207222
const JSValue = jsc.JSValue;
223+
const webcore = bun.webcore;
208224

209225
const CallFrame = jsc.CallFrame;
210226
const ArgumentsSlice = jsc.CallFrame.ArgumentsSlice;

src/shell/builtin/cat.zig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ pub fn start(this: *Cat) Yield {
6969
}
7070

7171
pub fn next(this: *Cat) Yield {
72+
// Check for abort before processing next file
73+
if (this.bltn().parentCmd().base.interpreter.isAborted()) {
74+
return this.bltn().done(128 + @intFromEnum(bun.SignalCode.SIGTERM));
75+
}
76+
7277
switch (this.state) {
7378
.idle => @panic("Invalid state"),
7479
.exec_stdin => {

src/shell/builtin/cp.zig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ pub fn ignoreEbusyErrorIfPossible(this: *Cp) Yield {
125125

126126
pub fn next(this: *Cp) Yield {
127127
while (this.state != .done) {
128+
// Check for abort before continuing
129+
if (this.bltn().parentCmd().base.interpreter.isAborted()) {
130+
return this.bltn().done(128 + @intFromEnum(bun.SignalCode.SIGTERM));
131+
}
132+
128133
switch (this.state) {
129134
.idle => @panic("Invalid state for \"Cp\": idle, this indicates a bug in Bun. Please file a GitHub issue"),
130135
.exec => {

src/shell/builtin/seq.zig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ fn do(this: *@This()) Yield {
8686
defer arena.deinit();
8787

8888
while (if (this.increment > 0) current <= this._end else current >= this._end) : (current += this.increment) {
89+
// Check for abort before continuing
90+
if (this.bltn().parentCmd().base.interpreter.isAborted()) {
91+
return this.bltn().done(128 + @intFromEnum(bun.SignalCode.SIGTERM));
92+
}
93+
8994
const str = bun.handleOom(std.fmt.allocPrint(arena.allocator(), "{d}", .{current}));
9095
defer _ = arena.reset(.retain_capacity);
9196
_ = this.print(str);

src/shell/builtin/yes.zig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ pub fn start(this: *@This()) Yield {
8484
/// We write 4 8kb chunks and then suspend execution to the task.
8585
/// This is to avoid blocking the main thread forever.
8686
fn writeNoIO(this: *@This()) Yield {
87+
// Check for abort before continuing the infinite loop
88+
if (this.bltn().parentCmd().base.interpreter.isAborted()) {
89+
return this.bltn().done(128 + @intFromEnum(bun.SignalCode.SIGTERM));
90+
}
91+
8792
if (this.writeOnceNoIO(this.buffer[0..this.buffer_used])) |yield| return yield;
8893
if (this.writeOnceNoIO(this.buffer[0..this.buffer_used])) |yield| return yield;
8994
if (this.writeOnceNoIO(this.buffer[0..this.buffer_used])) |yield| return yield;

0 commit comments

Comments
 (0)