From 1b33722110b67a8ab605b15e236ca8ae6836cdd7 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Sat, 5 Aug 2023 16:48:16 -0400 Subject: [PATCH 1/5] feat: CommandSignalController --- mod.test.ts | 30 ++++++++++++++++++++++++++- mod.ts | 2 +- src/command.ts | 55 ++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/mod.test.ts b/mod.test.ts index a2f8c3d..f81e57c 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -1,5 +1,12 @@ import { readAll } from "./src/deps.ts"; -import $, { build$, CommandBuilder, CommandContext, CommandHandler, CommandSignal } from "./mod.ts"; +import $, { + build$, + CommandBuilder, + CommandContext, + CommandHandler, + CommandSignal, + CommandSignalController, +} from "./mod.ts"; import { assert, assertEquals, @@ -1527,3 +1534,24 @@ Deno.test("should receive signal when listening", { ignore: Deno.build.os !== "l p.kill("SIGKILL"); assertEquals((await p).stdout, "started\nRECEIVED SIGINT\n"); }); + +Deno.test("should support setting a command signal", async () => { + const controller = new CommandSignalController(); + const commandBuilder = new CommandBuilder().signal(controller.signal).noThrow(); + const $ = build$({ commandBuilder }); + const startTime = new Date().getTime(); + + const processes = [ + $`sleep 100s`.spawn(), + $`sleep 100s`.spawn(), + $`sleep 100s`.spawn(), + ]; + + await $.sleep("5ms"); + + controller.kill(); + + await Promise.all(processes); + const endTime = new Date().getTime(); + assert(endTime - startTime < 1000); +}); diff --git a/mod.ts b/mod.ts index e4a1d9b..e908449 100644 --- a/mod.ts +++ b/mod.ts @@ -32,7 +32,7 @@ import { createPathRef, PathRef } from "./src/path.ts"; export { FsFileWrapper, PathRef } from "./src/path.ts"; export type { PathSymlinkOptions, SymlinkOptions, WalkEntry } from "./src/path.ts"; -export { CommandBuilder, CommandResult, CommandSignal } from "./src/command.ts"; +export { CommandBuilder, CommandResult, CommandSignal, CommandSignalController } from "./src/command.ts"; export type { CommandContext, CommandHandler, CommandPipeReader, CommandPipeWriter } from "./src/command_handler.ts"; export type { ConfirmOptions, diff --git a/src/command.ts b/src/command.ts index 86980f1..b96c5db 100644 --- a/src/command.ts +++ b/src/command.ts @@ -45,6 +45,7 @@ interface CommandBuilderState { printCommand: boolean; printCommandLogger: LoggerTreeBox; timeout: number | undefined; + signal: CommandSignal | undefined; } const textDecoder = new TextDecoder(); @@ -105,6 +106,7 @@ export class CommandBuilder implements PromiseLike { printCommand: false, printCommandLogger: new LoggerTreeBox(console.error), timeout: undefined, + signal: undefined, }; #getClonedState(): CommandBuilderState { @@ -124,6 +126,7 @@ export class CommandBuilder implements PromiseLike { printCommand: state.printCommand, printCommandLogger: state.printCommandLogger.createChild(), timeout: state.timeout, + signal: state.signal, }; } @@ -202,6 +205,13 @@ export class CommandBuilder implements PromiseLike { }); } + /** Sets the command signal that can be used to create */ + signal(signal: CommandSignal): CommandBuilder { + return this.#newWithState((state) => { + state.signal = signal; + }); + } + /** * Whether to capture a combined buffer of both stdout and stderr. * @@ -482,9 +492,11 @@ export class CommandChild extends Promise { * SIGKILL, SIGABRT, SIGQUIT, SIGINT, or SIGSTOP will cause the entire command * to be considered "aborted" and will return a 124 exit code, while other signals * will just be forwarded to the command. + * + * Defaults to "SIGTERM". */ - kill(signal: Deno.Signal = "SIGTERM"): void { - this.#commandSignalController?.sendSignal(signal); + kill(signal?: Deno.Signal): void { + this.#commandSignalController?.kill(signal); } stdout(): ReadableStream { @@ -557,13 +569,24 @@ export function parseAndSpawnCommand(state: CommandBuilderState) { stderrBuffer === "null" ? new NullPipeWriter() : stderrBuffer === "inherit" ? Deno.stderr : stderrBuffer, ); + const parentSignal = state.signal; + let cleanupSignalListener: (() => void) | undefined; const commandSignalController = new CommandSignalController(); + if (parentSignal != null) { + const parentSignalListener = (signal: Deno.Signal) => { + commandSignalController.kill(signal); + }; + parentSignal.addListener(parentSignalListener); + cleanupSignalListener = () => { + parentSignal.removeListener(parentSignalListener); + }; + } let timeoutId: number | undefined; let timedOut = false; if (state.timeout != null) { timeoutId = setTimeout(() => { timedOut = true; - commandSignalController.abort(); + commandSignalController.kill(); }, state.timeout); } const command = state.command; @@ -611,6 +634,7 @@ export function parseAndSpawnCommand(state: CommandBuilderState) { if (timeoutId != null) { clearTimeout(timeoutId); } + cleanupSignalListener?.(); } }, { pipedStdoutBuffer: stdoutBuffer instanceof PipedBuffer ? stdoutBuffer : undefined, @@ -844,7 +868,8 @@ function validateCommandName(command: string) { const SHELL_SIGNAL_CTOR_SYMBOL = Symbol(); -class CommandSignalController { +/** Similar to an AbortController, but for sending signals to commands. */ +export class CommandSignalController { #abortController: AbortController; #listeners: ((signal: Deno.Signal) => void)[]; #commandSignal: CommandSignal; @@ -855,15 +880,16 @@ class CommandSignalController { this.#commandSignal = new CommandSignal(SHELL_SIGNAL_CTOR_SYMBOL, this.#abortController.signal, this.#listeners); } - abort() { - this.sendSignal("SIGTERM"); - } - - get signal() { + get signal(): CommandSignal { return this.#commandSignal; } - sendSignal(signal: Deno.Signal) { + /** Send a signal to the downstream child process. Note that SIGTERM, + * SIGKILL, SIGABRT, SIGQUIT, SIGINT, or SIGSTOP will cause all the commands + * to be considered "aborted" and will return a 124 exit code, while other + * signals will just be forwarded to the commands. + */ + kill(signal: Deno.Signal = "SIGTERM") { // consider the command aborted if the signal is any one of these switch (signal) { case "SIGTERM": @@ -884,6 +910,13 @@ class CommandSignalController { } } +/** Similar to `AbortSignal`, but for `Deno.Signal`. + * + * A `CommandSignal` is considered aborted if its controller + * receives SIGTERM, SIGKILL, SIGABRT, SIGQUIT, SIGINT, or SIGSTOP. + * + * These can be created via a `CommandSignalController`. + */ export class CommandSignal { #listeners: ((signal: Deno.Signal) => void)[]; #abortSignal: AbortSignal; @@ -897,7 +930,7 @@ export class CommandSignal { this.#listeners = listeners; } - get aborted() { + get aborted(): boolean { return this.#abortSignal.aborted; } From 63f4450d5bd68357e24ff02be6356432d3876b80 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Sat, 5 Aug 2023 17:24:57 -0400 Subject: [PATCH 2/5] Going to radically refactor this. --- mod.test.ts | 31 +++++++++++++++++++++++++++++++ src/command.ts | 30 ++++++++++++++++++++---------- src/common.ts | 4 ++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/mod.test.ts b/mod.test.ts index f81e57c..97cea8e 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -1535,6 +1535,37 @@ Deno.test("should receive signal when listening", { ignore: Deno.build.os !== "l assertEquals((await p).stdout, "started\nRECEIVED SIGINT\n"); }); +Deno.test("signal listening in registered commands", async () => { + const commandBuilder = new CommandBuilder().noThrow().registerCommand("listen", (handler) => { + return new Promise((resolve) => { + function listener(signal: Deno.Signal) { + if (signal === "SIGKILL") { + resolve({ + kind: "exit", + code: 1, + }); + handler.signal.removeListener(listener); + } else { + handler.stderr.writeLine(signal); + } + } + + handler.signal.addListener(listener); + }); + }); + const $ = build$({ commandBuilder }); + + const child = $`listen`.stderr("piped").spawn(); + await $.sleep(5); // let the command start up + child.kill("SIGINT"); + child.kill("SIGBREAK"); + child.kill("SIGKILL"); + + const result = await child; + assertEquals(result.code, 1); + assertEquals(result.stderr, "SIGINT\nSIGBREAK\n"); +}); + Deno.test("should support setting a command signal", async () => { const controller = new CommandSignalController(); const commandBuilder = new CommandBuilder().signal(controller.signal).noThrow(); diff --git a/src/command.ts b/src/command.ts index b96c5db..285595e 100644 --- a/src/command.ts +++ b/src/command.ts @@ -13,7 +13,7 @@ import { sleepCommand } from "./commands/sleep.ts"; import { testCommand } from "./commands/test.ts"; import { touchCommand } from "./commands/touch.ts"; import { unsetCommand } from "./commands/unset.ts"; -import { Box, delayToMs, LoggerTreeBox } from "./common.ts"; +import { Box, delayToMs, LoggerTreeBox, ReadonlyBox } from "./common.ts"; import { Delay } from "./common.ts"; import { Buffer, colors, path, readerFromStreamReader } from "./deps.ts"; import { @@ -205,9 +205,16 @@ export class CommandBuilder implements PromiseLike { }); } - /** Sets the command signal that can be used to create */ + /** Sets the command signal that will be passed to all commands + * created with this command builder. + */ signal(signal: CommandSignal): CommandBuilder { return this.#newWithState((state) => { + if (state.signal != null) { + state.signal.addListener((signal) => { + signal.kill(signal); + }); + } state.signal = signal; }); } @@ -870,14 +877,14 @@ const SHELL_SIGNAL_CTOR_SYMBOL = Symbol(); /** Similar to an AbortController, but for sending signals to commands. */ export class CommandSignalController { - #abortController: AbortController; + #aborted: Box; #listeners: ((signal: Deno.Signal) => void)[]; #commandSignal: CommandSignal; constructor() { - this.#abortController = new AbortController(); + this.#aborted = new Box(false); this.#listeners = []; - this.#commandSignal = new CommandSignal(SHELL_SIGNAL_CTOR_SYMBOL, this.#abortController.signal, this.#listeners); + this.#commandSignal = new CommandSignal(SHELL_SIGNAL_CTOR_SYMBOL, this.#aborted, this.#listeners); } get signal(): CommandSignal { @@ -898,7 +905,7 @@ export class CommandSignalController { case "SIGQUIT": case "SIGINT": case "SIGSTOP": - this.#abortController.abort(); + this.#aborted.value = true; break; default: break; @@ -919,19 +926,22 @@ export class CommandSignalController { */ export class CommandSignal { #listeners: ((signal: Deno.Signal) => void)[]; - #abortSignal: AbortSignal; + #isAborted: ReadonlyBox; /** @internal */ - constructor(symbol: Symbol, abortSignal: AbortSignal, listeners: ((signal: Deno.Signal) => void)[]) { + constructor(symbol: Symbol, isAborted: ReadonlyBox, listeners: ((signal: Deno.Signal) => void)[]) { if (symbol !== SHELL_SIGNAL_CTOR_SYMBOL) { throw new Error("Constructing instances of CommandSignal is not permitted."); } - this.#abortSignal = abortSignal; + this.#isAborted = isAborted; this.#listeners = listeners; } + /** Returns if the command signal has ever received a SIGTERM, + * SIGKILL, SIGABRT, SIGQUIT, SIGINT, or SIGSTOP + */ get aborted(): boolean { - return this.#abortSignal.aborted; + return this.#isAborted.value; } addListener(listener: (signal: Deno.Signal) => void) { diff --git a/src/common.ts b/src/common.ts index 7ff6867..80c3078 100644 --- a/src/common.ts +++ b/src/common.ts @@ -123,6 +123,10 @@ export function resolvePath(cwd: string, arg: string) { return path.resolve(path.isAbsolute(arg) ? arg : path.join(cwd, arg)); } +export interface ReadonlyBox { + readonly value: T; +} + export class Box { constructor(public value: T) { } From fc735d62af645612ff621e9b8ecd77f3c0ac4375 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Sat, 5 Aug 2023 18:21:32 -0400 Subject: [PATCH 3/5] Improve more. --- README.md | 23 +++++++ mod.test.ts | 55 ++++++++++++---- mod.ts | 2 +- src/command.ts | 138 ++++++++++++++++++++++++----------------- src/command_handler.ts | 4 +- src/common.ts | 4 -- src/shell.ts | 8 +-- 7 files changed, 155 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index dce7a7a..4e149ed 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,29 @@ child.kill(); // or provide other signals like "SIGKILL". It uses "SIGTERM" by d await child; // Error: Aborted with exit code: 124 ``` +#### `KillSignalController` + +In some cases you might want to send signals to many commands at the same time. This is possible via a `KillSignalController`. + +```ts +import $, { KillSignalController } from "..."; + +const controller = new KillSignalController(); +const signal = controller.signal; + +const promise = Promise.all([ + $`sleep 1000s`.signal(signal), + $`sleep 2000s`.signal(signal), + $`sleep 3000s`.signal(signal), +]); + +$.sleep("1s").then(() => controller.kill("SIGKILL")); + +await promise; // throws after 1 second +``` + +Combining this with the `CommandBuilder` API and building your own `$` as shown later in the documentation, can be extremely useful for sending a `Deno.Signal` to all commands you've spawned. + ### Exporting the environment of the shell to JavaScript When executing commands in the shell, the environment will be contained to the shell and not exported to the current process. For example: diff --git a/mod.test.ts b/mod.test.ts index 97cea8e..cfcbd21 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -1,12 +1,5 @@ import { readAll } from "./src/deps.ts"; -import $, { - build$, - CommandBuilder, - CommandContext, - CommandHandler, - CommandSignal, - CommandSignalController, -} from "./mod.ts"; +import $, { build$, CommandBuilder, CommandContext, CommandHandler, KillSignal, KillSignalController } from "./mod.ts"; import { assert, assertEquals, @@ -1514,10 +1507,10 @@ Deno.test("should give nice error message when cwd directory does not exist", as Deno.test("should error creating a command signal", () => { assertThrows( () => { - new (CommandSignal as any)(); + new (KillSignal as any)(); }, Error, - "Constructing instances of CommandSignal is not permitted.", + "Constructing instances of KillSignal is not permitted.", ); }); @@ -1567,7 +1560,7 @@ Deno.test("signal listening in registered commands", async () => { }); Deno.test("should support setting a command signal", async () => { - const controller = new CommandSignalController(); + const controller = new KillSignalController(); const commandBuilder = new CommandBuilder().signal(controller.signal).noThrow(); const $ = build$({ commandBuilder }); const startTime = new Date().getTime(); @@ -1576,13 +1569,51 @@ Deno.test("should support setting a command signal", async () => { $`sleep 100s`.spawn(), $`sleep 100s`.spawn(), $`sleep 100s`.spawn(), + // this will be triggered as well because this signal + // will be linked to the parent signal + $`sleep 100s`.signal(new KillSignalController().signal), ]; + const subController = new KillSignalController(); + const p = $`sleep 100s`.signal(subController.signal).spawn(); + await $.sleep("5ms"); + subController.kill(); + + await p; + + const restPromise = Promise.all(processes); + await ensurePromiseNotResolved(restPromise); + controller.kill(); - await Promise.all(processes); + await restPromise; + const endTime = new Date().getTime(); + assert(endTime - startTime < 1000); +}); + +Deno.test("ensure KillSignalController readme example works", async () => { + const controller = new KillSignalController(); + const signal = controller.signal; + const startTime = new Date().getTime(); + + const promise = Promise.all([ + $`sleep 1000s`.signal(signal), + $`sleep 2000s`.signal(signal), + $`sleep 3000s`.signal(signal), + ]); + + $.sleep("5ms").then(() => controller.kill("SIGKILL")); + + await assertRejects(() => promise, Error, "Aborted with exit code: 124"); const endTime = new Date().getTime(); assert(endTime - startTime < 1000); }); + +function ensurePromiseNotResolved(promise: Promise) { + return new Promise((resolve, reject) => { + promise.then(() => reject(new Error("Promise was resolved"))); + setTimeout(resolve, 1); + }); +} diff --git a/mod.ts b/mod.ts index e908449..f26eeef 100644 --- a/mod.ts +++ b/mod.ts @@ -32,7 +32,7 @@ import { createPathRef, PathRef } from "./src/path.ts"; export { FsFileWrapper, PathRef } from "./src/path.ts"; export type { PathSymlinkOptions, SymlinkOptions, WalkEntry } from "./src/path.ts"; -export { CommandBuilder, CommandResult, CommandSignal, CommandSignalController } from "./src/command.ts"; +export { CommandBuilder, CommandResult, KillSignal, KillSignalController } from "./src/command.ts"; export type { CommandContext, CommandHandler, CommandPipeReader, CommandPipeWriter } from "./src/command_handler.ts"; export type { ConfirmOptions, diff --git a/src/command.ts b/src/command.ts index 285595e..e71a493 100644 --- a/src/command.ts +++ b/src/command.ts @@ -13,7 +13,7 @@ import { sleepCommand } from "./commands/sleep.ts"; import { testCommand } from "./commands/test.ts"; import { touchCommand } from "./commands/touch.ts"; import { unsetCommand } from "./commands/unset.ts"; -import { Box, delayToMs, LoggerTreeBox, ReadonlyBox } from "./common.ts"; +import { Box, delayToMs, filterEmptyRecordValues, LoggerTreeBox } from "./common.ts"; import { Delay } from "./common.ts"; import { Buffer, colors, path, readerFromStreamReader } from "./deps.ts"; import { @@ -45,7 +45,7 @@ interface CommandBuilderState { printCommand: boolean; printCommandLogger: LoggerTreeBox; timeout: number | undefined; - signal: CommandSignal | undefined; + signal: KillSignal | undefined; } const textDecoder = new TextDecoder(); @@ -208,14 +208,12 @@ export class CommandBuilder implements PromiseLike { /** Sets the command signal that will be passed to all commands * created with this command builder. */ - signal(signal: CommandSignal): CommandBuilder { + signal(killSignal: KillSignal): CommandBuilder { return this.#newWithState((state) => { if (state.signal != null) { - state.signal.addListener((signal) => { - signal.kill(signal); - }); + state.signal.linkChild(killSignal); } - state.signal = signal; + state.signal = killSignal; }); } @@ -481,18 +479,18 @@ export class CommandBuilder implements PromiseLike { export class CommandChild extends Promise { #pipedStdoutBuffer: PipedBuffer | "consumed" | undefined; #pipedStderrBuffer: PipedBuffer | "consumed" | undefined; - #commandSignalController: CommandSignalController | undefined; + #killSignalController: KillSignalController | undefined; /** @internal */ constructor(executor: (resolve: (value: CommandResult) => void, reject: (reason?: any) => void) => void, options: { pipedStdoutBuffer: PipedBuffer | undefined; pipedStderrBuffer: PipedBuffer | undefined; - commandSignalController: CommandSignalController | undefined; - } = { pipedStderrBuffer: undefined, pipedStdoutBuffer: undefined, commandSignalController: undefined }) { + killSignalController: KillSignalController | undefined; + } = { pipedStderrBuffer: undefined, pipedStdoutBuffer: undefined, killSignalController: undefined }) { super(executor); this.#pipedStdoutBuffer = options.pipedStdoutBuffer; this.#pipedStderrBuffer = options.pipedStderrBuffer; - this.#commandSignalController = options.commandSignalController; + this.#killSignalController = options.killSignalController; } /** Send a signal to the executing command's child process. Note that SIGTERM, @@ -503,7 +501,7 @@ export class CommandChild extends Promise { * Defaults to "SIGTERM". */ kill(signal?: Deno.Signal): void { - this.#commandSignalController?.kill(signal); + this.#killSignalController?.kill(signal); } stdout(): ReadableStream { @@ -578,10 +576,10 @@ export function parseAndSpawnCommand(state: CommandBuilderState) { const parentSignal = state.signal; let cleanupSignalListener: (() => void) | undefined; - const commandSignalController = new CommandSignalController(); + const killSignalController = new KillSignalController(); if (parentSignal != null) { const parentSignalListener = (signal: Deno.Signal) => { - commandSignalController.kill(signal); + killSignalController.kill(signal); }; parentSignal.addListener(parentSignalListener); cleanupSignalListener = () => { @@ -593,11 +591,11 @@ export function parseAndSpawnCommand(state: CommandBuilderState) { if (state.timeout != null) { timeoutId = setTimeout(() => { timedOut = true; - commandSignalController.kill(); + killSignalController.kill(); }, state.timeout); } const command = state.command; - const signal = commandSignalController.signal; + const signal = killSignalController.signal; return new CommandChild(async (resolve, reject) => { try { @@ -646,7 +644,7 @@ export function parseAndSpawnCommand(state: CommandBuilderState) { }, { pipedStdoutBuffer: stdoutBuffer instanceof PipedBuffer ? stdoutBuffer : undefined, pipedStderrBuffer: stderrBuffer instanceof PipedBuffer ? stderrBuffer : undefined, - commandSignalController, + killSignalController, }); function takeStdin() { @@ -875,20 +873,26 @@ function validateCommandName(command: string) { const SHELL_SIGNAL_CTOR_SYMBOL = Symbol(); +interface KillSignalState { + aborted: boolean; + listeners: ((signal: Deno.Signal) => void)[]; +} + /** Similar to an AbortController, but for sending signals to commands. */ -export class CommandSignalController { - #aborted: Box; - #listeners: ((signal: Deno.Signal) => void)[]; - #commandSignal: CommandSignal; +export class KillSignalController { + #state: KillSignalState; + #killSignal: KillSignal; constructor() { - this.#aborted = new Box(false); - this.#listeners = []; - this.#commandSignal = new CommandSignal(SHELL_SIGNAL_CTOR_SYMBOL, this.#aborted, this.#listeners); + this.#state = { + aborted: false, + listeners: [], + }; + this.#killSignal = new KillSignal(SHELL_SIGNAL_CTOR_SYMBOL, this.#state); } - get signal(): CommandSignal { - return this.#commandSignal; + get signal(): KillSignal { + return this.#killSignal; } /** Send a signal to the downstream child process. Note that SIGTERM, @@ -897,61 +901,83 @@ export class CommandSignalController { * signals will just be forwarded to the commands. */ kill(signal: Deno.Signal = "SIGTERM") { - // consider the command aborted if the signal is any one of these - switch (signal) { - case "SIGTERM": - case "SIGKILL": - case "SIGABRT": - case "SIGQUIT": - case "SIGINT": - case "SIGSTOP": - this.#aborted.value = true; - break; - default: - break; - } - - for (const listener of this.#listeners) { - listener(signal); - } + sendSignalToState(this.#state, signal); } } /** Similar to `AbortSignal`, but for `Deno.Signal`. * - * A `CommandSignal` is considered aborted if its controller + * A `KillSignal` is considered aborted if its controller * receives SIGTERM, SIGKILL, SIGABRT, SIGQUIT, SIGINT, or SIGSTOP. * - * These can be created via a `CommandSignalController`. + * These can be created via a `KillSignalController`. */ -export class CommandSignal { - #listeners: ((signal: Deno.Signal) => void)[]; - #isAborted: ReadonlyBox; +export class KillSignal { + #state: KillSignalState; /** @internal */ - constructor(symbol: Symbol, isAborted: ReadonlyBox, listeners: ((signal: Deno.Signal) => void)[]) { + constructor(symbol: Symbol, state: KillSignalState) { if (symbol !== SHELL_SIGNAL_CTOR_SYMBOL) { - throw new Error("Constructing instances of CommandSignal is not permitted."); + throw new Error("Constructing instances of KillSignal is not permitted."); } - this.#isAborted = isAborted; - this.#listeners = listeners; + this.#state = state; } /** Returns if the command signal has ever received a SIGTERM, * SIGKILL, SIGABRT, SIGQUIT, SIGINT, or SIGSTOP */ get aborted(): boolean { - return this.#isAborted.value; + return this.#state.aborted; + } + + /** + * Causes the provided kill signal to be triggered when this + * signal receives a signal. + */ + linkChild(killSignal: KillSignal): { unsubscribe(): void } { + const listener = (signal: Deno.Signal) => { + sendSignalToState(killSignal.#state, signal); + }; + this.addListener(listener); + return { + unsubscribe: () => { + this.removeListener(listener); + }, + }; } addListener(listener: (signal: Deno.Signal) => void) { - this.#listeners.push(listener); + this.#state.listeners.push(listener); } removeListener(listener: (signal: Deno.Signal) => void) { - const index = this.#listeners.indexOf(listener); + const index = this.#state.listeners.indexOf(listener); if (index >= 0) { - this.#listeners.splice(index, 1); + this.#state.listeners.splice(index, 1); } } } + +function sendSignalToState(state: KillSignalState, signal: Deno.Signal) { + if (signalCausesAbort(signal)) { + state.aborted = true; + } + for (const listener of state.listeners) { + listener(signal); + } +} + +function signalCausesAbort(signal: Deno.Signal) { + // consider the command aborted if the signal is any one of these + switch (signal) { + case "SIGTERM": + case "SIGKILL": + case "SIGABRT": + case "SIGQUIT": + case "SIGINT": + case "SIGSTOP": + return true; + default: + return false; + } +} diff --git a/src/command_handler.ts b/src/command_handler.ts index 4593524..2ad6bad 100644 --- a/src/command_handler.ts +++ b/src/command_handler.ts @@ -1,5 +1,5 @@ import { ExecuteResult } from "./result.ts"; -import type { CommandSignal } from "./command.ts"; +import type { KillSignal } from "./command.ts"; /** Used to read from stdin. */ export type CommandPipeReader = "inherit" | "null" | Deno.Reader; @@ -19,7 +19,7 @@ export interface CommandContext { get stdin(): CommandPipeReader; get stdout(): CommandPipeWriter; get stderr(): CommandPipeWriter; - get signal(): CommandSignal; + get signal(): KillSignal; } /** Handler for executing a command. */ diff --git a/src/common.ts b/src/common.ts index 80c3078..7ff6867 100644 --- a/src/common.ts +++ b/src/common.ts @@ -123,10 +123,6 @@ export function resolvePath(cwd: string, arg: string) { return path.resolve(path.isAbsolute(arg) ? arg : path.join(cwd, arg)); } -export interface ReadonlyBox { - readonly value: T; -} - export class Box { constructor(public value: T) { } diff --git a/src/shell.ts b/src/shell.ts index 382084e..99b9e66 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -1,4 +1,4 @@ -import { CommandSignal } from "./command.ts"; +import { KillSignal } from "./command.ts"; import { CommandContext, CommandHandler } from "./command_handler.ts"; import { getExecutableShebangFromPath, ShebangInfo } from "./common.ts"; import { DenoWhichRealEnvironment, fs, path, which } from "./deps.ts"; @@ -206,7 +206,7 @@ export class Context { #env: Env; #shellVars: Record; #commands: Record; - #signal: CommandSignal; + #signal: KillSignal; constructor(opts: { stdin: ShellPipeReader; @@ -215,7 +215,7 @@ export class Context { env: Env; commands: Record; shellVars: Record; - signal: CommandSignal; + signal: KillSignal; }) { this.stdin = opts.stdin; this.stdout = opts.stdout; @@ -358,7 +358,7 @@ export interface SpawnOpts { commands: Record; cwd: string; exportEnv: boolean; - signal: CommandSignal; + signal: KillSignal; } export async function spawn(list: SequentialList, opts: SpawnOpts) { From 827fec324e0561c332be6af5178f79947084d699 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Sat, 5 Aug 2023 18:22:47 -0400 Subject: [PATCH 4/5] Lint --- src/command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/command.ts b/src/command.ts index e71a493..43916eb 100644 --- a/src/command.ts +++ b/src/command.ts @@ -13,7 +13,7 @@ import { sleepCommand } from "./commands/sleep.ts"; import { testCommand } from "./commands/test.ts"; import { touchCommand } from "./commands/touch.ts"; import { unsetCommand } from "./commands/unset.ts"; -import { Box, delayToMs, filterEmptyRecordValues, LoggerTreeBox } from "./common.ts"; +import { Box, delayToMs, LoggerTreeBox } from "./common.ts"; import { Delay } from "./common.ts"; import { Buffer, colors, path, readerFromStreamReader } from "./deps.ts"; import { From 0e370a88b45a656d1ff31517a1142047b75bb2c5 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Sat, 5 Aug 2023 18:26:56 -0400 Subject: [PATCH 5/5] Update --- README.md | 4 ++-- mod.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4e149ed..46cec3a 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ const child = $`echo 1 && sleep 100 && echo 2`.spawn(); // kill the child after 1s await $.sleep("1s"); -child.kill(); // or provide other signals like "SIGKILL". It uses "SIGTERM" by default +child.kill(); // defaults to "SIGTERM" await child; // Error: Aborted with exit code: 124 ``` @@ -262,7 +262,7 @@ const promise = Promise.all([ $`sleep 3000s`.signal(signal), ]); -$.sleep("1s").then(() => controller.kill("SIGKILL")); +$.sleep("1s").then(() => controller.kill()); // defaults to "SIGTERM" await promise; // throws after 1 second ``` diff --git a/mod.test.ts b/mod.test.ts index cfcbd21..9ab9dd9 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -873,7 +873,7 @@ Deno.test("streaming api errors while streaming", async () => { } { - const child = $`echo 1 && echo 2 && sleep 0.5 && exit 1`.stdout("piped").spawn(); + const child = $`echo 1 && echo 2 && sleep 0.6 && exit 1`.stdout("piped").spawn(); const stdout = child.stdout(); const result = await $`deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable);'`