From fe46397c98805e654d841d1788d4a9f5b75965e5 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 20 Apr 2026 01:19:17 +0300 Subject: [PATCH 1/4] feat: forward user signals to builds/runs --- eslint.config.mjs | 8 ++ src/commands/builds/create.ts | 29 ++++++- src/commands/run.ts | 4 + src/lib/exec.ts | 58 +++++++++++-- src/lib/hooks/useCLIVersionAssets.ts | 15 +--- src/lib/hooks/useSignalHandler.ts | 124 +++++++++++++++++++++++++++ tsconfig.json | 2 +- 7 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 src/lib/hooks/useSignalHandler.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 5da613825..d80564ca4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -55,6 +55,14 @@ export default [ // Not ideal, but we still use any for simplicity '@typescript-eslint/no-explicit-any': 'off', + // Allow underscore-prefixed variables (including `using _name = ...` + // bindings kept alive solely for their Symbol.dispose effect) in + // addition to the base config's underscore-prefixed args exemption. + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', ignoreRestSiblings: true }, + ], + // '@typescript-eslint/array-type': 'error', // '@typescript-eslint/no-empty-object-type': 'off', }, diff --git a/src/commands/builds/create.ts b/src/commands/builds/create.ts index 58c69d10f..b28441567 100644 --- a/src/commands/builds/create.ts +++ b/src/commands/builds/create.ts @@ -4,7 +4,8 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; -import { error, simpleLog } from '../../lib/outputs.js'; +import { useSignalHandler } from '../../lib/hooks/useSignalHandler.js'; +import { error, info, simpleLog } from '../../lib/outputs.js'; import { getLoggedClientOrThrow, objectGroupBy, @@ -145,6 +146,32 @@ export class BuildsCreateCommand extends ApifyCommand { + info({ + message: chalk.gray( + `Received ${chalk.yellow(signal)}, aborting build "${chalk.yellow(build.id)}" on the Apify platform...`, + ), + stdout: true, + }); + + try { + await client.build(build.id).abort(); + } catch (abortErr) { + error({ + message: `Failed to abort build "${build.id}": ${(abortErr as Error).message}`, + stdout: true, + }); + } + }, + }); + try { await outputJobLog({ job: build, apifyClient: client }); } catch (err) { diff --git a/src/commands/run.ts b/src/commands/run.ts index 917f9593f..273634da4 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -315,6 +315,7 @@ export class RunCommand extends ApifyCommand { cmd: runtime.executablePath, args: [entrypoint], opts: { env, cwd }, + forwardSignals: ['SIGINT', 'SIGTERM', 'SIGHUP'], }); } else { // Assert the package.json content for scripts @@ -346,6 +347,7 @@ export class RunCommand extends ApifyCommand { args: ['run', entrypoint], opts: { env, cwd }, overrideCommand: runtime.pmName, + forwardSignals: ['SIGINT', 'SIGTERM', 'SIGHUP'], }); } @@ -369,12 +371,14 @@ export class RunCommand extends ApifyCommand { cmd: runtime.executablePath, args: ['-m', entrypoint], opts: { env, cwd }, + forwardSignals: ['SIGINT', 'SIGTERM', 'SIGHUP'], }); } else { await execWithLog({ cmd: runtime.executablePath, args: [entrypoint], opts: { env, cwd }, + forwardSignals: ['SIGINT', 'SIGTERM', 'SIGHUP'], }); } diff --git a/src/lib/exec.ts b/src/lib/exec.ts index 1ddf61289..bd00ade91 100644 --- a/src/lib/exec.ts +++ b/src/lib/exec.ts @@ -5,10 +5,25 @@ import { normalizeExecutablePath } from './hooks/runtimes/utils.js'; import { error, run } from './outputs.js'; import { cliDebugPrint } from './utils/cliDebugPrint.js'; -const spawnPromised = async (cmd: string, args: string[], opts: Options) => { +interface SpawnPromisedInternalOptions { + /** + * Signals that should be forwarded from the parent process to the spawned + * child. When the CLI receives one of these signals it is re-sent to the + * child so it can shut down cleanly instead of being orphaned when the CLI + * exits. + */ + forwardSignals?: NodeJS.Signals[]; +} + +const spawnPromised = async ( + cmd: string, + args: string[], + opts: Options, + { forwardSignals }: SpawnPromisedInternalOptions = {}, +) => { const escapedCommand = normalizeExecutablePath(cmd); - cliDebugPrint('spawnPromised', { escapedCommand, args, opts }); + cliDebugPrint('spawnPromised', { escapedCommand, args, opts, forwardSignals }); const childProcess = execa(escapedCommand, args, { shell: true, @@ -21,11 +36,30 @@ const spawnPromised = async (cmd: string, args: string[], opts: Options) => { verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined, }); - return Result.fromAsync( - childProcess.catch((execaError: ExecaError) => { - throw new Error(`${cmd} exited with code ${execaError.exitCode}`, { cause: execaError }); - }), - ) as Promise, Error & { cause: ExecaError }>>; + const cleanupSignalHandlers: (() => void)[] = []; + + if (forwardSignals?.length) { + for (const signal of forwardSignals) { + const handler = () => { + childProcess.kill(signal); + }; + + process.on(signal, handler); + cleanupSignalHandlers.push(() => process.off(signal, handler)); + } + } + + try { + return (await Result.fromAsync( + childProcess.catch((execaError: ExecaError) => { + throw new Error(`${cmd} exited with code ${execaError.exitCode}`, { cause: execaError }); + }), + )) as Result, Error & { cause: ExecaError }>; + } finally { + for (const cleanup of cleanupSignalHandlers) { + cleanup(); + } + } }; export interface ExecWithLogOptions { @@ -33,11 +67,17 @@ export interface ExecWithLogOptions { args?: string[]; opts?: Options; overrideCommand?: string; + /** + * Signals to forward from the parent process to the spawned child. Use this + * for long-running children (e.g. user scripts) so pressing Ctrl+C on the + * CLI does not leave the child running in the background. + */ + forwardSignals?: NodeJS.Signals[]; } -export async function execWithLog({ cmd, args = [], opts = {}, overrideCommand }: ExecWithLogOptions) { +export async function execWithLog({ cmd, args = [], opts = {}, overrideCommand, forwardSignals }: ExecWithLogOptions) { run({ message: `${overrideCommand || cmd} ${args.join(' ')}` }); - const result = await spawnPromised(cmd, args, opts); + const result = await spawnPromised(cmd, args, opts, { forwardSignals }); if (result.isErr()) { const err = result.unwrapErr(); diff --git a/src/lib/hooks/useCLIVersionAssets.ts b/src/lib/hooks/useCLIVersionAssets.ts index 1b667514c..85482142b 100644 --- a/src/lib/hooks/useCLIVersionAssets.ts +++ b/src/lib/hooks/useCLIVersionAssets.ts @@ -91,17 +91,10 @@ export async function useCLIVersionAssets(version: string) { const requiresBaseline = isInstalledOnBaseline(); const assets = body.assets.filter((asset) => { - const [ - // - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _cliEntrypoint, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _version, - assetOs, - assetArch, - assetBaselineOrMusl, - assetBaseline, - ] = asset.name.replace(versionWithoutV, 'version').replace('.exe', '').split('-'); + const [_cliEntrypoint, _version, assetOs, assetArch, assetBaselineOrMusl, assetBaseline] = asset.name + .replace(versionWithoutV, 'version') + .replace('.exe', '') + .split('-'); if (assetOs !== metadata.platform) { return false; diff --git a/src/lib/hooks/useSignalHandler.ts b/src/lib/hooks/useSignalHandler.ts new file mode 100644 index 000000000..22c545ba5 --- /dev/null +++ b/src/lib/hooks/useSignalHandler.ts @@ -0,0 +1,124 @@ +import { cliDebugPrint } from '../utils/cliDebugPrint.js'; + +// NOTE: we intentionally use the bare `process` global here instead of +// `import process from 'node:process'`. Vitest's SSR transformer wraps the +// imported default in an object that does not expose EventEmitter methods +// (`.on`, `.off`), so signal registration would silently fail under tests. +// The bare global resolves to the real Node process in both production and +// Vitest's environment. + +export interface UseSignalHandlerInput { + /** + * Signals to listen for. Typical values are `['SIGINT', 'SIGTERM', 'SIGHUP']`. + */ + signals: NodeJS.Signals[]; + /** + * Invoked the first time any of the registered signals fires. The handler + * is unregistered immediately before being called so a second signal falls + * back to whatever listener is active at that point (by default, Node's + * built-in termination behavior). The returned promise, if any, is not + * awaited — the caller is responsible for holding the process open long + * enough for its work to complete (e.g. via the guarded async body). + */ + handler: (signal: NodeJS.Signals) => void | Promise; + /** + * Before the handler runs, erase the current terminal line and reset all + * ANSI styles. This wipes the `^C` that the terminal driver echoes on + * SIGINT, and recovers color state when upstream output (e.g. a streamed + * job log) was interrupted mid-escape-sequence. Only runs when stderr is + * a TTY, so it is safe to leave on when output is piped or redirected. + * + * Defaults to `true`. + */ + cleanTerminalLine?: boolean; +} + +/** + * Registers a one-off signal handler for the given signals and returns a + * `Disposable` that removes it. Pair with the `using` keyword so the listener + * is always cleaned up when the enclosing block exits — whether the guarded + * code finishes normally, throws, or returns early. + * + * The handler fires at most once. Useful for commands that start work on the + * Apify platform and want to clean up (e.g. abort a build or a run) when the + * user interrupts the CLI with Ctrl+C. + * + * @example + * ```ts + * { + * using _signalHandler = useSignalHandler({ + * signals: ['SIGINT', 'SIGTERM', 'SIGHUP'], + * handler: () => client.build(buildId).abort().catch(() => {}), + * }); + * + * await outputJobLog({ job: build, apifyClient: client }); + * } // listener is removed here + * ``` + */ +// `\r` - move the cursor to the start of the line (over any `^C` the +// terminal driver just echoed). +// `\x1b[2K` - erase the entire current line. +// `\x1b[0m` - reset all ANSI styles, in case a streamed log was interrupted +// mid-escape-sequence and left the terminal colored. +const TERMINAL_LINE_RESET = '\r\x1b[2K\x1b[0m'; + +export function useSignalHandler({ signals, handler, cleanTerminalLine = true }: UseSignalHandlerInput): Disposable { + let fired = false; + + const wrapped = (signal: NodeJS.Signals) => { + if (fired) { + return; + } + + fired = true; + + // Remove listeners before invoking the handler so a second signal + // received while the handler is still running uses default behavior + // (i.e. terminates the process), giving users an escape hatch. + for (const s of signals) { + process.off(s, wrapped); + } + + // Synchronously wipe the terminal line before the handler prints + // anything, so its output is not mixed with the echoed `^C` or + // stranded ANSI styles. stderr is unbuffered on TTYs, which matters + // here because we want the reset to hit the terminal immediately. + if (cleanTerminalLine && process.stderr.isTTY) { + process.stderr.write(TERMINAL_LINE_RESET); + } + + cliDebugPrint('useSignalHandler', { event: 'fired', signal }); + + // Intentionally fire-and-forget: the caller decides whether to block + // on the handler's work via their own control flow. + void (async () => { + try { + await handler(signal); + } catch (err) { + cliDebugPrint('useSignalHandler', { event: 'handler-threw', signal, err }); + } + })(); + }; + + for (const signal of signals) { + process.on(signal, wrapped); + } + + cliDebugPrint('useSignalHandler', { event: 'registered', signals }); + + return { + [Symbol.dispose]() { + if (fired) { + return; + } + + fired = true; + + for (const signal of signals) { + process.off(signal, wrapped); + } + + cliDebugPrint('useSignalHandler', { event: 'disposed', signals }); + }, + }; +} diff --git a/tsconfig.json b/tsconfig.json index cbcd2e8fc..4ea527b9a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "resolveJsonModule": true, - "lib": ["ES2022"], + "lib": ["ES2022", "ESNext.Disposable"], "allowJs": true, "rootDir": "src", // For now we need this :( From 5738d9c7888f033037bfb5b18d4405e311689960 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 20 Apr 2026 01:35:23 +0300 Subject: [PATCH 2/4] feat: abort runs with ctrl+c, gracefully then forcefully --- src/commands/builds/create.ts | 15 ++++++ src/lib/commands/run-on-cloud.ts | 60 ++++++++++++++++++++++- src/lib/hooks/useSignalHandler.ts | 81 +++++++++++++++++++++---------- 3 files changed, 129 insertions(+), 27 deletions(-) diff --git a/src/commands/builds/create.ts b/src/commands/builds/create.ts index b28441567..1779a3bde 100644 --- a/src/commands/builds/create.ts +++ b/src/commands/builds/create.ts @@ -151,9 +151,24 @@ export class BuildsCreateCommand extends ApifyCommand { + abortAttempt += 1; + + if (abortAttempt > 1) { + return; + } + info({ message: chalk.gray( `Received ${chalk.yellow(signal)}, aborting build "${chalk.yellow(build.id)}" on the Apify platform...`, diff --git a/src/lib/commands/run-on-cloud.ts b/src/lib/commands/run-on-cloud.ts index f93372010..148d953c3 100644 --- a/src/lib/commands/run-on-cloud.ts +++ b/src/lib/commands/run-on-cloud.ts @@ -7,7 +7,8 @@ import { ACTOR_JOB_STATUSES } from '@apify/consts'; import { Flags } from '../command-framework/flags.js'; import { CommandExitCodes } from '../consts.js'; -import { error, run as runLog, success, warning } from '../outputs.js'; +import { useSignalHandler } from '../hooks/useSignalHandler.js'; +import { error, info, run as runLog, success, warning } from '../outputs.js'; import { outputJobLog } from '../utils.js'; import { resolveInput } from './resolve-input.js'; @@ -90,6 +91,63 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: throw err; } + // From this point on the run exists on the platform. Forward interrupt + // signals to a platform-side abort so the run does not keep burning + // compute units after the user gives up waiting locally (Ctrl+C, SIGTERM + // from a parent process, SIGHUP from a closing terminal). The `using` + // binding removes the listener when this generator finishes or is + // terminated by the consumer (e.g. `break` out of `for await`). + // + // `once: false` keeps the listener registered across repeated signals so + // the default Node behavior (terminate the process) is suppressed while + // the abort is in flight. We escalate across attempts: + // 1st signal → graceful abort, with a hint the user can press again + // 2nd signal → immediate abort + // 3rd+ → silent no-op, so frantic Ctrl+C doesn't kill the CLI + // before the abort request finishes. + let abortAttempt = 0; + + using _signalHandler = useSignalHandler({ + signals: ['SIGINT', 'SIGTERM', 'SIGHUP'], + once: false, + handler: async (signal) => { + abortAttempt += 1; + + if (abortAttempt > 2) { + return; + } + + const gracefully = abortAttempt === 1; + + if (!silent) { + if (gracefully) { + info({ + message: chalk.gray( + `Received ${chalk.yellow(signal)}, gracefully aborting ${type.toLowerCase()} run "${chalk.yellow(run.id)}" on the Apify platform... ${chalk.dim('(press Ctrl+C again to abort immediately)')}`, + ), + stdout: true, + }); + } else { + info({ + message: chalk.gray( + `Received ${chalk.yellow(signal)} again, aborting ${type.toLowerCase()} run "${chalk.yellow(run.id)}" immediately...`, + ), + stdout: true, + }); + } + } + + try { + await apifyClient.run(run.id).abort({ gracefully }); + } catch (abortErr) { + error({ + message: `Failed to abort run "${run.id}": ${(abortErr as Error).message}`, + stdout: true, + }); + } + }, + }); + // Return the started run right away yield run; diff --git a/src/lib/hooks/useSignalHandler.ts b/src/lib/hooks/useSignalHandler.ts index 22c545ba5..7aedb7e24 100644 --- a/src/lib/hooks/useSignalHandler.ts +++ b/src/lib/hooks/useSignalHandler.ts @@ -13,12 +13,15 @@ export interface UseSignalHandlerInput { */ signals: NodeJS.Signals[]; /** - * Invoked the first time any of the registered signals fires. The handler - * is unregistered immediately before being called so a second signal falls - * back to whatever listener is active at that point (by default, Node's - * built-in termination behavior). The returned promise, if any, is not - * awaited — the caller is responsible for holding the process open long - * enough for its work to complete (e.g. via the guarded async body). + * Invoked each time one of the registered signals fires, until the + * returned `Disposable` is disposed. When `once` is `true` (the default), + * the handler fires at most once and the listener is removed immediately + * before it runs, so a follow-up signal falls through to whatever is + * installed next (typically Node's default, which terminates the process). + * + * The returned promise, if any, is not awaited — the caller is responsible + * for holding the process open long enough for the handler's work to + * complete (e.g. via the guarded async body). */ handler: (signal: NodeJS.Signals) => void | Promise; /** @@ -31,17 +34,34 @@ export interface UseSignalHandlerInput { * Defaults to `true`. */ cleanTerminalLine?: boolean; + /** + * If `true` (default), the handler runs at most once: after it fires, the + * listener is removed so a second signal falls through to Node's default + * behavior, which terminates the process. This is the right choice when + * there is nothing else to escalate and the user's second Ctrl+C is an + * explicit "just quit already". + * + * Set to `false` to keep the listener active after the handler fires so + * the handler can escalate across repeated signals — for example, issuing + * a graceful abort on the first Ctrl+C and an immediate abort on the + * second — without the process being killed while the work is still in + * flight. The caller is responsible for tracking invocation state (e.g. + * a counter in the handler's closure) and for eventually disposing of the + * hook so signals stop being intercepted. + */ + once?: boolean; } /** - * Registers a one-off signal handler for the given signals and returns a - * `Disposable` that removes it. Pair with the `using` keyword so the listener - * is always cleaned up when the enclosing block exits — whether the guarded - * code finishes normally, throws, or returns early. + * Registers a signal handler for the given signals and returns a `Disposable` + * that removes it. Pair with the `using` keyword so the listener is always + * cleaned up when the enclosing block exits — whether the guarded code + * finishes normally, throws, or returns early. * - * The handler fires at most once. Useful for commands that start work on the - * Apify platform and want to clean up (e.g. abort a build or a run) when the - * user interrupts the CLI with Ctrl+C. + * Useful for commands that start work on the Apify platform and want to clean + * up (e.g. abort a build or a run) when the user interrupts the CLI with + * Ctrl+C. See {@link UseSignalHandlerInput.once} for the single-shot vs. + * escalating modes. * * @example * ```ts @@ -62,21 +82,30 @@ export interface UseSignalHandlerInput { // mid-escape-sequence and left the terminal colored. const TERMINAL_LINE_RESET = '\r\x1b[2K\x1b[0m'; -export function useSignalHandler({ signals, handler, cleanTerminalLine = true }: UseSignalHandlerInput): Disposable { - let fired = false; +export function useSignalHandler({ + signals, + handler, + cleanTerminalLine = true, + once = true, +}: UseSignalHandlerInput): Disposable { + let disposed = false; const wrapped = (signal: NodeJS.Signals) => { - if (fired) { + if (disposed) { return; } - fired = true; + // In `once` mode, remove listeners before invoking the handler so a + // second signal received while the handler is still running uses + // default behavior (i.e. terminates the process), giving users an + // escape hatch. In persistent mode, the listener stays so the handler + // can react to every signal until the hook is explicitly disposed. + if (once) { + disposed = true; - // Remove listeners before invoking the handler so a second signal - // received while the handler is still running uses default behavior - // (i.e. terminates the process), giving users an escape hatch. - for (const s of signals) { - process.off(s, wrapped); + for (const s of signals) { + process.off(s, wrapped); + } } // Synchronously wipe the terminal line before the handler prints @@ -87,7 +116,7 @@ export function useSignalHandler({ signals, handler, cleanTerminalLine = true }: process.stderr.write(TERMINAL_LINE_RESET); } - cliDebugPrint('useSignalHandler', { event: 'fired', signal }); + cliDebugPrint('useSignalHandler', { event: 'fired', signal, once }); // Intentionally fire-and-forget: the caller decides whether to block // on the handler's work via their own control flow. @@ -104,15 +133,15 @@ export function useSignalHandler({ signals, handler, cleanTerminalLine = true }: process.on(signal, wrapped); } - cliDebugPrint('useSignalHandler', { event: 'registered', signals }); + cliDebugPrint('useSignalHandler', { event: 'registered', signals, once }); return { [Symbol.dispose]() { - if (fired) { + if (disposed) { return; } - fired = true; + disposed = true; for (const signal of signals) { process.off(signal, wrapped); From 5b3e6c469fcfa75719446fe6011e1d7c8e670d96 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 20 Apr 2026 01:46:46 +0300 Subject: [PATCH 3/4] chore: update error message Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/lib/exec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/exec.ts b/src/lib/exec.ts index bd00ade91..453cfdcfa 100644 --- a/src/lib/exec.ts +++ b/src/lib/exec.ts @@ -52,7 +52,13 @@ const spawnPromised = async ( try { return (await Result.fromAsync( childProcess.catch((execaError: ExecaError) => { - throw new Error(`${cmd} exited with code ${execaError.exitCode}`, { cause: execaError }); + const message = execaError.exitCode != null + ? `${cmd} exited with code ${execaError.exitCode}` + : execaError.signal + ? `${cmd} exited due to signal ${execaError.signal}` + : execaError.shortMessage; + + throw new Error(message, { cause: execaError }); }), )) as Result, Error & { cause: ExecaError }>; } finally { From 5c7952056bceb83d94eb1ac52514ec97c6e08adc Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 20 Apr 2026 01:48:50 +0300 Subject: [PATCH 4/4] fmt: ty copilot --- src/lib/exec.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/lib/exec.ts b/src/lib/exec.ts index 453cfdcfa..fb5865c58 100644 --- a/src/lib/exec.ts +++ b/src/lib/exec.ts @@ -52,11 +52,15 @@ const spawnPromised = async ( try { return (await Result.fromAsync( childProcess.catch((execaError: ExecaError) => { - const message = execaError.exitCode != null - ? `${cmd} exited with code ${execaError.exitCode}` - : execaError.signal - ? `${cmd} exited due to signal ${execaError.signal}` - : execaError.shortMessage; + let message; + + if (execaError.exitCode != null) { + message = `${cmd} exited with code ${execaError.exitCode}`; + } else if (execaError.signal) { + message = `${cmd} exited due to signal ${execaError.signal}`; + } else { + message = execaError.shortMessage; + } throw new Error(message, { cause: execaError }); }),