Skip to content
Draft
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
36 changes: 36 additions & 0 deletions packages/bun-types/shell.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,42 @@ declare module "bun" {
* By default, this is configured to `true`.
*/
throws(shouldThrow: boolean): this;

/**
* Attach an AbortSignal to cancel this shell command.
*
* When the signal aborts:
* - All processes in the pipeline are terminated with SIGTERM
* - The promise rejects with an AbortError (DOMException)
* - If `.nothrow()` was used, resolves with exit code 143 (128 + SIGTERM)
*
* @param signal - The AbortSignal to attach
* @returns this - for method chaining
* @throws Error if the shell has already started executing
*
* @example
* **Timeout after 5 seconds**
* ```ts
* await $`long-running-command`.signal(AbortSignal.timeout(5000));
* ```
*
* @example
* **Manual cancellation**
* ```ts
* const controller = new AbortController();
* const promise = $`sleep 100`.signal(controller.signal);
* setTimeout(() => controller.abort(), 1000);
* await promise; // Rejects with AbortError after 1 second
* ```
*
* @example
* **With nothrow - returns exit code instead of throwing**
* ```ts
* const result = await $`sleep 100`.nothrow().signal(AbortSignal.timeout(1000));
* console.log(result.exitCode); // 143 (128 + 15)
* ```
*/
signal(signal: AbortSignal): this;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/bun.js/api/ParsedShellScript.classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export default [
fn: "setQuiet",
length: 1,
},
setSignal: {
fn: "setSignal",
length: 1,
},
},
}),
];
67 changes: 67 additions & 0 deletions src/js/builtins/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
#args: $ZigGeneratedClasses.ParsedShellScript | undefined = undefined;
#hasRun: boolean = false;
#throws: boolean = true;
#signal?: AbortSignal; // Store the abort signal
#abortedByUs: boolean = false; // Tracks if OUR abort listener fired
#resolve: (code: number, stdout: Buffer, stderr: Buffer) => void;
#reject: (code: number, stdout: Buffer, stderr: Buffer) => void;

Expand All @@ -121,6 +123,34 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
super((res, rej) => {
resolve = (code, stdout, stderr) => {
const out = new ShellOutput(stdout, stderr, code);

// Check if operation was aborted by our signal.
// We check BOTH conditions:
// 1. #abortedByUs - our abort listener fired (definitively know our signal caused it)
// 2. code >= 128 - process was killed by a signal (sanity check)
//
// This avoids false positives where:
// - The signal fires after normal completion (code would be 0 or small)
// - The process was killed by something else (Ctrl+C) but signal wasn't ours
//
// Exit code 128+N indicates the process was killed by signal N.
// SIGTERM (15) -> 143, SIGKILL (9) -> 137
const wasAborted = this.#abortedByUs && code >= 128;

if (wasAborted) {
if (this.#throws) {
// Reject with the signal's reason, or a default AbortError
const reason = this.#signal!.reason ?? new DOMException("The operation was aborted.", "AbortError");
rej(reason);
} else {
// nothrow mode: resolve normally with the exit code
potentialError = undefined;
res(out);
}
return;
}

// Normal (non-abort) exit handling (existing code)
if (this.#throws && code !== 0) {
potentialError!.initialize(out, code);
rej(potentialError);
Expand Down Expand Up @@ -169,6 +199,16 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
if (!this.#hasRun) {
this.#hasRun = true;

// Handle already-aborted signals entirely in JS
// This avoids spawning anything and immediately settles the promise
if (this.#signal?.aborted) {
// Simulate a process killed by SIGTERM (exit code 128 + 15 = 143)
// The resolve callback will see #abortedByUs=true and code>=128,
// then reject with AbortError (or resolve if .nothrow() was used)
this.#resolve(128 + 15, Buffer.alloc(0), Buffer.alloc(0));
return;
}

let interp = createShellInterpreter(this.#resolve, this.#reject, this.#args!);
this.#args = undefined;
interp.run();
Expand All @@ -195,6 +235,33 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
return this;
}

signal(sig: AbortSignal): this {
this.#throwIfRunning();
this.#signal = sig;

// Track when our signal fires - this definitively tells us the abort
// was triggered by our signal, not some other termination cause
if (sig.aborted) {
// Signal is already aborted - handle entirely in JS
// We'll short-circuit in #run() and never spawn anything
this.#abortedByUs = true;
// Don't pass to Zig - we'll handle it in #run()
} else {
// Listen for future abort
sig.addEventListener(
"abort",
() => {
this.#abortedByUs = true;
},
{ once: true },
);
// Pass signal to ParsedShellScript so Zig can access it
this.#args!.setSignal(sig);
}

return this;
}

async text(encoding) {
const { stdout } = (await this.#quiet(true)) as ShellOutput;
return stdout.toString(encoding);
Expand Down
16 changes: 16 additions & 0 deletions src/shell/ParsedShellScript.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jsobjs: std.array_list.Managed(JSValue),
export_env: ?EnvMap = null,
quiet: bool = false,
cwd: ?bun.String = null,
abort_signal: ?*webcore.AbortSignal = null,
this_jsvalue: JSValue = .zero,
estimated_size_for_gc: usize = 0,

Expand Down Expand Up @@ -45,17 +46,20 @@ pub fn take(
out_quiet: *bool,
out_cwd: *?bun.String,
out_export_env: *?EnvMap,
out_abort_signal: *?*webcore.AbortSignal,
) void {
out_args.* = this.args.?;
out_jsobjs.* = this.jsobjs;
out_quiet.* = this.quiet;
out_cwd.* = this.cwd;
out_export_env.* = this.export_env;
out_abort_signal.* = this.abort_signal;

this.args = null;
this.jsobjs = std.array_list.Managed(JSValue).init(bun.default_allocator);
this.cwd = null;
this.export_env = null;
this.abort_signal = null; // ownership transferred
}

pub fn finalize(
Expand All @@ -65,6 +69,7 @@ pub fn finalize(

if (this.export_env) |*env| env.deinit();
if (this.cwd) |*cwd| cwd.deref();
if (this.abort_signal) |signal| signal.unref();
if (this.args) |a| a.deinit();
bun.destroy(this);
}
Expand All @@ -86,6 +91,16 @@ pub fn setQuiet(this: *ParsedShellScript, _: *JSGlobalObject, callframe: *jsc.Ca
return .js_undefined;
}

pub fn setSignal(this: *ParsedShellScript, globalThis: *JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const signal_val = callframe.argument(0);
const signal = signal_val.as(jsc.WebCore.AbortSignal) orelse {
return globalThis.throwInvalidArgumentTypeValue("signal", "AbortSignal", signal_val);
};
if (this.abort_signal) |old| old.unref();
this.abort_signal = signal.ref();
return .js_undefined;
}

pub fn setEnv(this: *ParsedShellScript, globalThis: *JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const value1 = callframe.argument(0).getObject() orelse {
return globalThis.throwInvalidArguments("env must be an object", .{});
Expand Down Expand Up @@ -205,6 +220,7 @@ const assert = bun.assert;
const jsc = bun.jsc;
const JSGlobalObject = jsc.JSGlobalObject;
const JSValue = jsc.JSValue;
const webcore = bun.webcore;

const CallFrame = jsc.CallFrame;
const ArgumentsSlice = jsc.CallFrame.ArgumentsSlice;
Expand Down
5 changes: 5 additions & 0 deletions src/shell/builtin/cat.zig
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ pub fn start(this: *Cat) Yield {
}

pub fn next(this: *Cat) Yield {
// Check for abort before processing next file
if (this.bltn().parentCmd().base.interpreter.isAborted()) {
return this.bltn().done(128 + @intFromEnum(bun.SignalCode.SIGTERM));
}

switch (this.state) {
.idle => @panic("Invalid state"),
.exec_stdin => {
Expand Down
5 changes: 5 additions & 0 deletions src/shell/builtin/cp.zig
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ pub fn ignoreEbusyErrorIfPossible(this: *Cp) Yield {

pub fn next(this: *Cp) Yield {
while (this.state != .done) {
// Check for abort before continuing
if (this.bltn().parentCmd().base.interpreter.isAborted()) {
return this.bltn().done(128 + @intFromEnum(bun.SignalCode.SIGTERM));
}

switch (this.state) {
.idle => @panic("Invalid state for \"Cp\": idle, this indicates a bug in Bun. Please file a GitHub issue"),
.exec => {
Expand Down
5 changes: 5 additions & 0 deletions src/shell/builtin/seq.zig
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ fn do(this: *@This()) Yield {
defer arena.deinit();

while (if (this.increment > 0) current <= this._end else current >= this._end) : (current += this.increment) {
// Check for abort before continuing
if (this.bltn().parentCmd().base.interpreter.isAborted()) {
return this.bltn().done(128 + @intFromEnum(bun.SignalCode.SIGTERM));
}

const str = bun.handleOom(std.fmt.allocPrint(arena.allocator(), "{d}", .{current}));
defer _ = arena.reset(.retain_capacity);
_ = this.print(str);
Expand Down
5 changes: 5 additions & 0 deletions src/shell/builtin/yes.zig
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ pub fn start(this: *@This()) Yield {
/// We write 4 8kb chunks and then suspend execution to the task.
/// This is to avoid blocking the main thread forever.
fn writeNoIO(this: *@This()) Yield {
// Check for abort before continuing the infinite loop
if (this.bltn().parentCmd().base.interpreter.isAborted()) {
return this.bltn().done(128 + @intFromEnum(bun.SignalCode.SIGTERM));
}

if (this.writeOnceNoIO(this.buffer[0..this.buffer_used])) |yield| return yield;
if (this.writeOnceNoIO(this.buffer[0..this.buffer_used])) |yield| return yield;
if (this.writeOnceNoIO(this.buffer[0..this.buffer_used])) |yield| return yield;
Expand Down
Loading