diff --git a/src/js/builtins/shell.ts b/src/js/builtins/shell.ts index 2b52695099..d29f2d7d9c 100644 --- a/src/js/builtins/shell.ts +++ b/src/js/builtins/shell.ts @@ -170,6 +170,18 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa this.#hasRun = true; let interp = createShellInterpreter(this.#resolve, this.#reject, this.#args!); + + // Store the interpreter reference for abort signal handling + if (this.#args) { + (this.#args as any).__interpreter = interp; + + // Check if we should cancel immediately + const abortSignal = (this.#args as any).__abortSignal; + if (abortSignal && abortSignal.aborted) { + interp.cancel(); + } + } + this.#args = undefined; interp.run(); } @@ -238,6 +250,34 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa return this; } + signal(signal: AbortSignal): this { + this.#throwIfRunning(); + + if (signal.aborted) { + // Signal is already aborted, cancel immediately when run + const args = this.#args; + if (args) { + // Store a flag to cancel when interpreter is created + (args as any).__abortSignal = signal; + } + } else { + // Listen for abort event + const args = this.#args; + if (args) { + signal.addEventListener('abort', () => { + // Get the interpreter from the parsed shell script + const jsInterpreter = (args as any).__interpreter; + if (jsInterpreter) { + jsInterpreter.cancel(); + } + }, { once: true }); + (args as any).__abortSignal = signal; + } + } + + return this; + } + then(onfulfilled, onrejected) { this.#run(); diff --git a/src/shell/Builtin.zig b/src/shell/Builtin.zig index 0379b3f0fd..015c986b76 100644 --- a/src/shell/Builtin.zig +++ b/src/shell/Builtin.zig @@ -626,6 +626,10 @@ pub fn start(this: *Builtin) Yield { return this.callImpl(Yield, "start", .{}); } +pub fn cancel(this: *Builtin) void { + this.callImpl(void, "cancel", .{}); +} + pub fn deinit(this: *Builtin) void { this.callImpl(void, "deinit", .{}); @@ -646,7 +650,7 @@ pub fn stdBufferedBytelist(this: *Builtin, comptime io_kind: @Type(.enum_literal @compileError("Bad IO" ++ @tagName(io_kind)); } - const io: *BuiltinIO = &@field(this, @tagName(io_kind)); + const io: *BuiltinIO.Output = &@field(this, @tagName(io_kind)); return switch (io.*) { .captured => if (comptime io_kind == .stdout) this.parentCmd().base.shell.buffered_stdout() else this.parentCmd().base.shell.buffered_stderr(), else => null, diff --git a/src/shell/ParsedShellScript.zig b/src/shell/ParsedShellScript.zig index 1dfce3de9d..66a008de9b 100644 --- a/src/shell/ParsedShellScript.zig +++ b/src/shell/ParsedShellScript.zig @@ -62,41 +62,41 @@ pub fn setQuiet(this: *ParsedShellScript, _: *JSGlobalObject, _: *JSC.CallFrame) 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", .{}); +pub fn setEnv(this: *ParsedShellScript, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { + const arguments_ = callframe.arguments_old(2); + var arguments = JSC.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments_.slice()); + const jsenv = arguments.nextEat() orelse { + return globalThis.throw("$`...`.env(): expected an object argument", .{}); }; - var object_iter = try JSC.JSPropertyIterator(.{ - .skip_empty_name = false, - .include_value = true, - }).init(globalThis, value1); - defer object_iter.deinit(); + var iter = try JSC.JSObject.DictionaryIterator.init(globalThis, jsenv.asObjectRef() orelse { + return globalThis.throw("$`...`.env(): expected an object argument", .{}); + }); + defer iter.deinit(); - var env: EnvMap = EnvMap.init(bun.default_allocator); - env.ensureTotalCapacity(object_iter.len); - - // If the env object does not include a $PATH, it must disable path lookup for argv[0] - // PATH = ""; - - while (try object_iter.next()) |key| { - const keyslice = key.toOwnedSlice(bun.default_allocator) catch bun.outOfMemory(); - var value = object_iter.value; - if (value.isUndefined()) continue; - - const value_str = try value.getZigString(globalThis); - const slice = value_str.toOwnedSlice(bun.default_allocator) catch bun.outOfMemory(); - const keyref = EnvStr.initRefCounted(keyslice); - defer keyref.deref(); - const valueref = EnvStr.initRefCounted(slice); - defer valueref.deref(); - - env.insert(keyref, valueref); + if (this.export_env == null) { + this.export_env = EnvMap.init(bun.default_allocator); } - if (this.export_env) |*previous| { - previous.deinit(); + + while (iter.next()) |entry| { + var key = try entry.key.toOwnedSlice(globalThis); + const len = @as(u32, @truncate(entry.key.getLength(globalThis))); + + var val = entry.value; + var val_slice = try val.toOwnedSlice(globalThis); + if (entry.key_allocated) key = try EnvStr.EnvKey.toOwnedSlice(key); + try this.export_env.?.put(key, val_slice); + _ = len; // autofix } - this.export_env = env; + + return .js_undefined; +} + +pub fn cancel(this: *ParsedShellScript, globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { + // This is not used directly from ParsedShellScript, cancellation is handled in the interpreter + _ = this; + _ = globalThis; + _ = callframe; return .js_undefined; } diff --git a/src/shell/Yield.zig b/src/shell/Yield.zig index ad79aab824..422705cc00 100644 --- a/src/shell/Yield.zig +++ b/src/shell/Yield.zig @@ -71,6 +71,22 @@ pub const Yield = union(enum) { return this.* == .done; } + fn getInterpreter(this: Yield) ?*Interpreter { + return switch (this) { + .script => |x| x.base.interpreter, + .stmt => |x| x.base.interpreter, + .pipeline => |x| x.base.interpreter, + .cmd => |x| x.base.interpreter, + .assigns => |x| x.base.interpreter, + .expansion => |x| x.base.interpreter, + .@"if" => |x| x.base.interpreter, + .subshell => |x| x.base.interpreter, + .cond_expr => |x| x.base.interpreter, + .on_io_writer_chunk => null, + .suspended, .failed, .done => null, + }; + } + pub fn run(this: Yield) void { if (comptime Environment.isDebug) log("Yield({s}) _dbg_catch_exec_within_exec = {d} + 1 = {d}", .{ @tagName(this), _dbg_catch_exec_within_exec, _dbg_catch_exec_within_exec + 1 }); bun.debugAssert(_dbg_catch_exec_within_exec <= MAX_DEPTH); @@ -80,48 +96,64 @@ pub const Yield = union(enum) { if (comptime Environment.isDebug) _dbg_catch_exec_within_exec -= 1; } - // A pipeline creates multiple "threads" of execution: - // - // ```bash - // cmd1 | cmd2 | cmd3 - // ``` - // - // We need to start cmd1, go back to the pipeline, start cmd2, and so - // on. - // - // This means we need to store a reference to the pipeline. And - // there can be nested pipelines, so we need a stack. var sfb = std.heap.stackFallback(@sizeOf(*Pipeline) * 4, bun.default_allocator); const alloc = sfb.get(); var pipeline_stack = std.ArrayList(*Pipeline).initCapacity(alloc, 4) catch bun.outOfMemory(); defer pipeline_stack.deinit(); + var current_yield = this; + // 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) { - .pipeline => |x| { - pipeline_stack.append(x) catch bun.outOfMemory(); - 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); - }, - .failed, .suspended, .done => { - if (drainPipelines(&pipeline_stack)) |yield| { - continue :state yield; + // re-assigning `current_yield`, so we need to be careful about state updates. + while (true) { + // Check for cancellation at the beginning of each iteration + if (current_yield.getInterpreter()) |interp| { + if (interp.is_cancelled.load(.monotonic)) { + // Begin graceful unwind + current_yield = switch (current_yield) { + .pipeline => |x| x.cancel(), + .cmd => |x| x.cancel(), + .script => |x| x.cancel(), + .stmt => |x| x.cancel(), + .assigns => |x| x.cancel(), + .expansion => |x| x.cancel(), + .@"if" => |x| x.cancel(), + .subshell => |x| x.cancel(), + .cond_expr => |x| x.cancel(), + else => current_yield, + }; + if (current_yield == .suspended or current_yield == .done or current_yield == .failed) { + return; + } + continue; } - return; - }, + } + + state: switch (current_yield) { + .pipeline => |x| { + pipeline_stack.append(x) catch bun.outOfMemory(); + current_yield = x.next(); + }, + .cmd => |x| current_yield = x.next(), + .script => |x| current_yield = x.next(), + .stmt => |x| current_yield = x.next(), + .assigns => |x| current_yield = x.next(), + .expansion => |x| current_yield = x.next(), + .@"if" => |x| current_yield = x.next(), + .subshell => |x| current_yield = x.next(), + .cond_expr => |x| current_yield = x.next(), + .on_io_writer_chunk => |x| { + const child = IOWriterChildPtr.fromAnyOpaque(x.child); + current_yield = child.onIOWriterChunk(x.written, x.err); + }, + .failed, .suspended, .done => { + if (drainPipelines(&pipeline_stack)) |yield| { + current_yield = yield; + continue; + } + return; + }, + } } } diff --git a/src/shell/builtin/basename.zig b/src/shell/builtin/basename.zig index e296f51bf5..fe56721577 100644 --- a/src/shell/builtin/basename.zig +++ b/src/shell/builtin/basename.zig @@ -20,6 +20,10 @@ pub fn start(this: *@This()) Yield { return this.bltn().done(0); } +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *@This()) void { this.buf.deinit(bun.default_allocator); //basename diff --git a/src/shell/builtin/cat.zig b/src/shell/builtin/cat.zig index 340b44a674..56ca86e76f 100644 --- a/src/shell/builtin/cat.zig +++ b/src/shell/builtin/cat.zig @@ -243,6 +243,37 @@ pub fn onIOReaderDone(this: *Cat, err: ?JSC.SystemError) Yield { pub fn deinit(_: *Cat) void {} +pub fn cancel(this: *Cat) void { + switch (this.state) { + .exec_stdin => { + // Cancel reader if needed + if (!this.state.exec_stdin.in_done) { + if (this.bltn().stdin.needsIO()) { + this.bltn().stdin.fd.removeReader(this); + } + this.state.exec_stdin.in_done = true; + } + // Cancel any pending chunks + if (this.bltn().stdout.needsIO()) |_| { + this.bltn().stdout.fd.writer.cancelChunks(this); + } + }, + .exec_filepath_args => { + var exec = &this.state.exec_filepath_args; + if (exec.reader) |r| { + r.removeReader(this); + } + exec.deinit(); + // Cancel any pending chunks + if (this.bltn().stdout.needsIO()) |_| { + this.bltn().stdout.fd.writer.cancelChunks(this); + } + }, + .idle, .waiting_write_err, .done => {}, + } + this.state = .done; +} + pub inline fn bltn(this: *Cat) *Builtin { const impl: *Builtin.Impl = @alignCast(@fieldParentPtr("cat", this)); return @fieldParentPtr("impl", impl); diff --git a/src/shell/builtin/cd.zig b/src/shell/builtin/cd.zig index 37980bfd08..07f8e5fbdc 100644 --- a/src/shell/builtin/cd.zig +++ b/src/shell/builtin/cd.zig @@ -107,6 +107,10 @@ pub inline fn bltn(this: *Cd) *Builtin { return @fieldParentPtr("impl", impl); } +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *Cd) void { log("({s}) deinit", .{@tagName(.cd)}); _ = this; diff --git a/src/shell/builtin/cp.zig b/src/shell/builtin/cp.zig index 638e3485be..f76b55ace1 100644 --- a/src/shell/builtin/cp.zig +++ b/src/shell/builtin/cp.zig @@ -50,6 +50,11 @@ const EbusyState = struct { absolute_targets: bun.StringArrayHashMapUnmanaged(void) = .{}, absolute_srcs: bun.StringArrayHashMapUnmanaged(void) = .{}, +pub fn cancel(this: *@This()) void { + // TODO: Add atomic cancellation flag for threaded execution + _ = this; +} + pub fn deinit(this: *EbusyState) void { // The tasks themselves are freed in `ignoreEbusyErrorIfPossible()` this.tasks.deinit(bun.default_allocator); diff --git a/src/shell/builtin/dirname.zig b/src/shell/builtin/dirname.zig index 169a8e88e8..f7913388c0 100644 --- a/src/shell/builtin/dirname.zig +++ b/src/shell/builtin/dirname.zig @@ -20,6 +20,10 @@ pub fn start(this: *@This()) Yield { return this.bltn().done(0); } +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *@This()) void { this.buf.deinit(bun.default_allocator); //dirname diff --git a/src/shell/builtin/echo.zig b/src/shell/builtin/echo.zig index ea6b0d8830..1236893b8f 100644 --- a/src/shell/builtin/echo.zig +++ b/src/shell/builtin/echo.zig @@ -61,6 +61,10 @@ pub fn deinit(this: *Echo) void { this.output.deinit(); } +pub fn cancel(this: *Echo) void { + _ = this; +} + pub inline fn bltn(this: *Echo) *Builtin { const impl: *Builtin.Impl = @alignCast(@fieldParentPtr("echo", this)); return @fieldParentPtr("impl", impl); diff --git a/src/shell/builtin/exit.zig b/src/shell/builtin/exit.zig index 96d47d563b..1391ac26b8 100644 --- a/src/shell/builtin/exit.zig +++ b/src/shell/builtin/exit.zig @@ -62,6 +62,10 @@ pub fn onIOWriterChunk(this: *Exit, _: usize, maybe_e: ?JSC.SystemError) Yield { return this.next(); } +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *Exit) void { _ = this; } diff --git a/src/shell/builtin/export.zig b/src/shell/builtin/export.zig index 0deb8da7a0..a28aac6b7e 100644 --- a/src/shell/builtin/export.zig +++ b/src/shell/builtin/export.zig @@ -110,6 +110,10 @@ pub fn start(this: *Export) Yield { return this.bltn().done(0); } +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *Export) void { log("({s}) deinit", .{@tagName(.@"export")}); _ = this; diff --git a/src/shell/builtin/false.zig b/src/shell/builtin/false.zig index a7d3111f2d..6a1d7e0a9b 100644 --- a/src/shell/builtin/false.zig +++ b/src/shell/builtin/false.zig @@ -2,6 +2,10 @@ pub fn start(this: *@This()) Yield { return this.bltn().done(1); } +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *@This()) void { _ = this; } diff --git a/src/shell/builtin/ls.zig b/src/shell/builtin/ls.zig index c3c3da25bd..cc1efd002e 100644 --- a/src/shell/builtin/ls.zig +++ b/src/shell/builtin/ls.zig @@ -112,6 +112,11 @@ fn next(this: *Ls) Yield { return this.bltn().done(0); } +pub fn cancel(this: *@This()) void { + // TODO: Add atomic cancellation flag for threaded execution + _ = this; +} + pub fn deinit(this: *Ls) void { this.alloc_scope.endScope(); } diff --git a/src/shell/builtin/mkdir.zig b/src/shell/builtin/mkdir.zig index 31e5dbe3db..d236e58cd5 100644 --- a/src/shell/builtin/mkdir.zig +++ b/src/shell/builtin/mkdir.zig @@ -159,6 +159,10 @@ const ShellMkdirOutputTaskVTable = struct { } }; +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *Mkdir) void { _ = this; } diff --git a/src/shell/builtin/mv.zig b/src/shell/builtin/mv.zig index 358f7097c1..0aef82e82b 100644 --- a/src/shell/builtin/mv.zig +++ b/src/shell/builtin/mv.zig @@ -368,6 +368,11 @@ pub fn batchedMoveTaskDone(this: *Mv, task: *ShellMvBatchedTask) void { } } +pub fn cancel(this: *@This()) void { + // TODO: Add atomic cancellation flag for threaded execution + _ = this; +} + pub fn deinit(this: *Mv) void { if (this.args.target_fd) |fd| fd.toOptional().close(); } diff --git a/src/shell/builtin/pwd.zig b/src/shell/builtin/pwd.zig index 734cda8623..3f58ecc746 100644 --- a/src/shell/builtin/pwd.zig +++ b/src/shell/builtin/pwd.zig @@ -68,6 +68,10 @@ pub fn onIOWriterChunk(this: *Pwd, _: usize, e: ?JSC.SystemError) Yield { return this.next(); } +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *Pwd) void { _ = this; } diff --git a/src/shell/builtin/rm.zig b/src/shell/builtin/rm.zig index 31c6268adf..c1c0530e88 100644 --- a/src/shell/builtin/rm.zig +++ b/src/shell/builtin/rm.zig @@ -320,6 +320,11 @@ pub fn onIOWriterChunk(this: *Rm, _: usize, e: ?JSC.SystemError) Yield { return this.bltn().done(1); } +pub fn cancel(this: *@This()) void { + // TODO: Add atomic cancellation flag for threaded execution + _ = this; +} + pub fn deinit(this: *Rm) void { _ = this; } diff --git a/src/shell/builtin/seq.zig b/src/shell/builtin/seq.zig index 8e55a3d530..80d849d859 100644 --- a/src/shell/builtin/seq.zig +++ b/src/shell/builtin/seq.zig @@ -122,6 +122,10 @@ pub fn onIOWriterChunk(this: *@This(), _: usize, maybe_e: ?JSC.SystemError) Yiel } } +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *@This()) void { this.buf.deinit(bun.default_allocator); //seq diff --git a/src/shell/builtin/touch.zig b/src/shell/builtin/touch.zig index 77e17cc137..c0fd13654b 100644 --- a/src/shell/builtin/touch.zig +++ b/src/shell/builtin/touch.zig @@ -21,6 +21,10 @@ pub fn format(this: *const Touch, comptime fmt: []const u8, opts: std.fmt.Format try writer.print("Touch(0x{x}, state={s})", .{ @intFromPtr(this), @tagName(this.state) }); } +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *Touch) void { log("{} deinit", .{this}); } diff --git a/src/shell/builtin/true.zig b/src/shell/builtin/true.zig index c01bffa576..d343d1a75a 100644 --- a/src/shell/builtin/true.zig +++ b/src/shell/builtin/true.zig @@ -2,6 +2,10 @@ pub fn start(this: *@This()) Yield { return this.bltn().done(0); } +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *@This()) void { _ = this; } diff --git a/src/shell/builtin/which.zig b/src/shell/builtin/which.zig index 29eba3c740..0e92a2e4c8 100644 --- a/src/shell/builtin/which.zig +++ b/src/shell/builtin/which.zig @@ -123,6 +123,10 @@ pub fn onIOWriterChunk(this: *Which, _: usize, e: ?JSC.SystemError) Yield { return this.argComplete(); } +pub fn cancel(this: *@This()) void { + _ = this; +} + pub fn deinit(this: *Which) void { log("({s}) deinit", .{@tagName(.which)}); _ = this; diff --git a/src/shell/builtin/yes.zig b/src/shell/builtin/yes.zig index bb6ed6445d..112caf6bfd 100644 --- a/src/shell/builtin/yes.zig +++ b/src/shell/builtin/yes.zig @@ -50,6 +50,20 @@ pub inline fn bltn(this: *@This()) *Builtin { pub fn deinit(_: *@This()) void {} +pub fn cancel(this: *@This()) void { + switch (this.state) { + .waiting_io => { + // Cancel any pending chunks + if (this.bltn().stdout.needsIO()) |_| { + this.bltn().stdout.fd.writer.cancelChunks(this); + } + // The concurrent task will stop when it sees the cancelled state + this.state = .done; + }, + .idle, .err, .done => {}, + } +} + pub const YesTask = struct { evtloop: JSC.EventLoopHandle, concurrent_task: JSC.EventLoopTask, @@ -67,6 +81,11 @@ pub const YesTask = struct { pub fn runFromMainThread(this: *@This()) void { const yes: *Yes = @fieldParentPtr("task", this); + // Check if we should stop + if (yes.state == .done or yes.state == .err) { + return; + } + // Manually make safeguard since this task should not be created if output does not need IO yes.bltn().stdout.enqueueFmt(yes, "{s}\n", .{yes.expletive}, .output_needs_io).run(); diff --git a/src/shell/interpreter.zig b/src/shell/interpreter.zig index 1da175db59..421b7d43af 100644 --- a/src/shell/interpreter.zig +++ b/src/shell/interpreter.zig @@ -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, @@ -300,6 +301,8 @@ pub const Interpreter = struct { __alloc_scope: if (bun.Environment.enableAllocScopes) bun.AllocationScope else void, + is_cancelled: std.atomic.Value(bool) = .{ .raw = false }, + // Here are all the state nodes: pub const State = @import("./states/Base.zig"); pub const Script = @import("./states/Script.zig"); @@ -1166,21 +1169,43 @@ 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(); + // ... existing code ... + const loop = this.event_loop.js; + const globalThis = this.globalThis; + this.this_jsvalue = .zero; + this.keep_alive.disable(); + loop.enter(); + + // Check if cancelled and reject with DOMException + if (exit_code == CANCELLED_EXIT_CODE) { + if (JSC.Codegen.JSShellInterpreter.rejectGetCached(this_jsvalue)) |reject| { + // Create DOMException with AbortError + const abort_error = globalThis.createDOMExceptionInstance(.AbortError, "The operation was aborted", .{}) 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(); + return .done; + }; + + _ = reject.call(globalThis, .js_undefined, &.{abort_error}) catch |err| globalThis.reportActiveExceptionAsUnhandled(err); + JSC.Codegen.JSShellInterpreter.resolveSetCached(this_jsvalue, globalThis, .js_undefined); + JSC.Codegen.JSShellInterpreter.rejectSetCached(this_jsvalue, globalThis, .js_undefined); + } + } else { + // Normal case - resolve with exit code + 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; @@ -1288,6 +1313,12 @@ pub const Interpreter = struct { return JSC.JSValue.jsBoolean(this.hasPendingActivity()); } + pub fn cancel(this: *ThisInterpreter, _: *JSGlobalObject, _: *JSC.CallFrame) bun.JSError!JSC.JSValue { + log("Interpreter(0x{x}) cancel()", .{@intFromPtr(this)}); + this.is_cancelled.store(true, .seq_cst); + return .js_undefined; + } + pub fn getStarted(this: *ThisInterpreter, globalThis: *JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { _ = globalThis; // autofix _ = callframe; // autofix @@ -1479,6 +1510,20 @@ pub fn StatePtrUnion(comptime TypesValue: anytype) type { unknownTag(this.tagInt()); } + /// Signals to the state node to cancel execution + 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}); } diff --git a/src/shell/states/Assigns.zig b/src/shell/states/Assigns.zig index ff2bacaa01..f343103ced 100644 --- a/src/shell/states/Assigns.zig +++ b/src/shell/states/Assigns.zig @@ -122,6 +122,13 @@ pub fn next(this: *Assigns) Yield { return this.parent.childDone(this, 0); } +pub fn cancel(this: *Assigns) Yield { + log("Assigns(0x{x}) cancel", .{@intFromPtr(this)}); + + // Report cancellation to parent + return this.parent.childDone(this, bun.shell.interpret.CANCELLED_EXIT_CODE); +} + pub fn childDone(this: *Assigns, child: ChildPtr, exit_code: ExitCode) Yield { if (child.ptr.is(Expansion)) { bun.assert(this.state == .expanding); diff --git a/src/shell/states/Async.zig b/src/shell/states/Async.zig index 5657c3deca..0a4e5f8c13 100644 --- a/src/shell/states/Async.zig +++ b/src/shell/states/Async.zig @@ -142,6 +142,23 @@ pub fn deinit(this: *Async) void { _ = this; } +pub fn cancel(this: *Async) Yield { + log("Async(0x{x}) cancel", .{@intFromPtr(this)}); + + // Cancel the child if executing + if (this.state == .exec) { + if (this.state.exec.child) |child| { + _ = child.cancel(); + } + } + + // Set state to done with cancelled exit code + this.state = .{ .done = bun.shell.interpret.CANCELLED_EXIT_CODE }; + this.enqueueSelf(); + + return .suspended; +} + pub fn actuallyDeinit(this: *Async) void { this.io.deref(); bun.destroy(this); diff --git a/src/shell/states/Binary.zig b/src/shell/states/Binary.zig index 49c4f558e8..152a44f995 100644 --- a/src/shell/states/Binary.zig +++ b/src/shell/states/Binary.zig @@ -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(0x{x}) cancel", .{@intFromPtr(this)}); + + // Cancel the currently executing child if any + if (this.currently_executing) |child| { + _ = child.cancel(); + } + + // Report cancellation to parent + return this.parent.childDone(this, bun.shell.interpret.CANCELLED_EXIT_CODE); +} + pub fn deinit(this: *Binary) void { if (this.currently_executing) |child| { child.deinit(); diff --git a/src/shell/states/Cmd.zig b/src/shell/states/Cmd.zig index c5bf72fbe2..445d53e5e5 100644 --- a/src/shell/states/Cmd.zig +++ b/src/shell/states/Cmd.zig @@ -694,6 +694,46 @@ pub fn onExit(this: *Cmd, exit_code: ExitCode) void { } } +pub fn cancel(this: *Cmd) Yield { + log("Cmd(0x{x}) cancel", .{@intFromPtr(this)}); + + // If already done, nothing to do + if (this.state == .done) { + return .suspended; + } + + // Set state to indicate cancellation + this.state = .done; + this.exit_code = bun.shell.interpret.CANCELLED_EXIT_CODE; + + // Cancel the underlying execution + switch (this.exec) { + .none => {}, + .subproc => |*subproc| { + // Try to kill the subprocess with SIGTERM + _ = subproc.child.tryKill(std.posix.SIGTERM); + }, + .bltn => |*builtin| { + // Call cancel on the builtin + builtin.cancel(); + }, + } + + // Cancel any pending IO chunks + 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); + } + } + + 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) }); diff --git a/src/shell/states/CondExpr.zig b/src/shell/states/CondExpr.zig index 33206cb722..73fb2857a5 100644 --- a/src/shell/states/CondExpr.zig +++ b/src/shell/states/CondExpr.zig @@ -210,6 +210,25 @@ fn doStat(this: *CondExpr) Yield { return .suspended; } +pub fn cancel(this: *CondExpr) Yield { + log("CondExpr(0x{x}) cancel", .{@intFromPtr(this)}); + + // Cancel any IO chunks + 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); + } + } + + // Report cancellation to parent + return this.parent.childDone(this, bun.shell.interpret.CANCELLED_EXIT_CODE); +} + pub fn deinit(this: *CondExpr) void { this.io.deinit(); for (this.args.items) |item| { diff --git a/src/shell/states/Expansion.zig b/src/shell/states/Expansion.zig index 177dce86bf..1b5d42c38b 100644 --- a/src/shell/states/Expansion.zig +++ b/src/shell/states/Expansion.zig @@ -146,11 +146,41 @@ pub fn init( expansion.current_out = std.ArrayList(u8).init(expansion.base.allocator()); } -pub fn deinit(expansion: *Expansion) void { - log("Expansion(0x{x}) deinit", .{@intFromPtr(expansion)}); - expansion.current_out.deinit(); - expansion.io.deinit(); - expansion.base.endScope(); +pub fn cancel(this: *Expansion) Yield { + log("Expansion(0x{x}) cancel", .{@intFromPtr(this)}); + + // If a command substitution is running, cancel the child Script + if (this.child_state == .cmd_subst) { + if (this.child_state.cmd_subst.cmd) |child| { + _ = child.cancel(); + } + } + + // Clean up state + if (this.current_out.items.len > 0) { + switch (this.out) { + .array_of_ptr => |buf| { + for (this.current_out.items) |item| { + _ = item; // Unused, we're cancelling + } + }, + .array_of_slice => |buf| { + _ = buf; // Unused, we're cancelling + }, + else => {}, + } + this.current_out.clearAndFree(); + } + + // Report cancellation to parent + return this.parent.childDone(this, bun.shell.interpret.CANCELLED_EXIT_CODE); +} + +pub fn deinit(this: *Expansion) void { + log("Expansion(0x{x}) deinit", .{@intFromPtr(this)}); + this.current_out.deinit(); + this.io.deinit(); + this.base.endScope(); } pub fn start(this: *Expansion) Yield { diff --git a/src/shell/states/If.zig b/src/shell/states/If.zig index 3195282563..d3181a517a 100644 --- a/src/shell/states/If.zig +++ b/src/shell/states/If.zig @@ -148,6 +148,13 @@ pub fn next(this: *If) Yield { return this.parent.childDone(this, 0); } +pub fn cancel(this: *If) Yield { + log("If(0x{x}) cancel", .{@intFromPtr(this)}); + + // Report cancellation to parent + return this.parent.childDone(this, bun.shell.interpret.CANCELLED_EXIT_CODE); +} + pub fn deinit(this: *If) void { log("{} deinit", .{this}); this.io.deref(); diff --git a/src/shell/states/Pipeline.zig b/src/shell/states/Pipeline.zig index 2ee5e7fa9a..1e236a44f2 100644 --- a/src/shell/states/Pipeline.zig +++ b/src/shell/states/Pipeline.zig @@ -260,6 +260,43 @@ 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)}); + + // If already done, nothing to do + if (this.state == .done) { + return .suspended; + } + + // Set state to done with cancelled exit code + this.state = .{ .done = .{ .exit_code = bun.shell.interpret.CANCELLED_EXIT_CODE } }; + + // 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| { + switch (cmd_or_result.*) { + .cmd => |cmd| { + // Cancel the command + _ = cmd.call("cancel", .{}, Yield); + }, + .result => { + // Already finished, nothing to do + }, + } + } + } + + return .suspended; +} + pub fn deinit(this: *Pipeline) void { // If commands was zero then we didn't allocate anything if (this.cmds == null) return; diff --git a/src/shell/states/Script.zig b/src/shell/states/Script.zig index 99fc122a6f..ca15f9ce96 100644 --- a/src/shell/states/Script.zig +++ b/src/shell/states/Script.zig @@ -91,6 +91,13 @@ 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)}); + + // Simply finish with cancelled exit code + return this.finish(bun.shell.interpret.CANCELLED_EXIT_CODE); +} + pub fn deinit(this: *Script) void { log("Script(0x{x}) deinit", .{@intFromPtr(this)}); this.io.deref(); diff --git a/src/shell/states/Stmt.zig b/src/shell/states/Stmt.zig index 6524381521..20c60d3797 100644 --- a/src/shell/states/Stmt.zig +++ b/src/shell/states/Stmt.zig @@ -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| { + _ = child.cancel(); + } + + // Report cancellation to parent + return this.parent.childDone(this, bun.shell.interpret.CANCELLED_EXIT_CODE); +} + pub fn deinit(this: *Stmt) void { log("Stmt(0x{x}) deinit", .{@intFromPtr(this)}); this.io.deinit(); diff --git a/src/shell/states/Subshell.zig b/src/shell/states/Subshell.zig index bdbb30df9d..786202cc54 100644 --- a/src/shell/states/Subshell.zig +++ b/src/shell/states/Subshell.zig @@ -170,6 +170,25 @@ 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("Subshell(0x{x}) cancel", .{@intFromPtr(this)}); + + // Cancel any IO chunks + 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); + } + } + + // Report cancellation to parent + return this.parent.childDone(this, bun.shell.interpret.CANCELLED_EXIT_CODE); +} + pub fn deinit(this: *Subshell) void { this.base.shell.deinit(); this.io.deref();