Skip to content

Commit ef4d375

Browse files
vladfranguCopilot
andauthored
feat: forward user signals to builds/runs (#1088)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent a0943ac commit ef4d375

12 files changed

Lines changed: 397 additions & 22 deletions

File tree

eslint.config.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ export default [
5555
// Not ideal, but we still use any for simplicity
5656
'@typescript-eslint/no-explicit-any': 'off',
5757

58+
// Allow underscore-prefixed variables (including `using _name = ...`
59+
// bindings kept alive solely for their Symbol.dispose effect) in
60+
// addition to the base config's underscore-prefixed args exemption.
61+
'@typescript-eslint/no-unused-vars': [
62+
'error',
63+
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', ignoreRestSiblings: true },
64+
],
65+
5866
// '@typescript-eslint/array-type': 'error',
5967
// '@typescript-eslint/no-empty-object-type': 'off',
6068
},

src/commands/actors/push.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Args } from '../../lib/command-framework/args.js';
1414
import { Flags } from '../../lib/command-framework/flags.js';
1515
import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js';
1616
import { sumFilesSizeInBytes } from '../../lib/files.js';
17+
import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js';
1718
import { useActorConfig } from '../../lib/hooks/useActorConfig.js';
1819
import { error, info, link, run, success, warning } from '../../lib/outputs.js';
1920
import { transformEnvToEnvVars } from '../../lib/secrets.js';
@@ -362,6 +363,17 @@ Skipping push. Use --force to override.`,
362363
});
363364

364365
try {
366+
// While the log is streaming, forward interrupt signals to a
367+
// platform-side abort so the build doesn't keep running after the
368+
// user gives up waiting (Ctrl+C, SIGTERM from a parent process,
369+
// SIGHUP from a closing terminal). The `using` binding guarantees
370+
// the listener is removed before we poll for final status.
371+
using _signalHandler = useAbortJobOnSignal({
372+
apifyClient,
373+
kind: 'build',
374+
jobId: build.id,
375+
});
376+
365377
await outputJobLog({ job: build, timeoutMillis: waitForFinishMillis, apifyClient });
366378
} catch (err) {
367379
warning({ message: 'Can not get log:' });

src/commands/builds/create.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
44
import { Args } from '../../lib/command-framework/args.js';
55
import { Flags } from '../../lib/command-framework/flags.js';
66
import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js';
7+
import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js';
78
import { error, simpleLog } from '../../lib/outputs.js';
89
import {
910
getLoggedClientOrThrow,
@@ -158,6 +159,17 @@ export class BuildsCreateCommand extends ApifyCommand<typeof BuildsCreateCommand
158159
});
159160

160161
if (log) {
162+
// While the log is streaming, forward interrupt signals to a
163+
// platform-side abort so the build doesn't keep running after the
164+
// user gives up waiting (Ctrl+C, SIGTERM from a parent process,
165+
// SIGHUP from a closing terminal). The `using` binding guarantees
166+
// the listener is removed when the block exits.
167+
using _signalHandler = useAbortJobOnSignal({
168+
apifyClient: client,
169+
kind: 'build',
170+
jobId: build.id,
171+
});
172+
161173
try {
162174
await outputJobLog({ job: build, apifyClient: client });
163175
} catch (err) {

src/commands/run.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getInputOverride } from '../lib/commands/resolve-input.js';
1616
import {
1717
CommandExitCodes,
1818
DEFAULT_LOCAL_STORAGE_DIR,
19+
INTERRUPT_SIGNALS,
1920
LEGACY_LOCAL_STORAGE_DIR,
2021
MINIMUM_SUPPORTED_PYTHON_VERSION,
2122
SUPPORTED_NODEJS_VERSION,
@@ -338,6 +339,7 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
338339
cmd: runtime.executablePath,
339340
args: [entrypoint],
340341
opts: { env, cwd },
342+
forwardSignals: INTERRUPT_SIGNALS,
341343
});
342344
} else {
343345
// Assert the package.json content for scripts
@@ -369,6 +371,7 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
369371
args: ['run', entrypoint],
370372
opts: { env, cwd },
371373
overrideCommand: runtime.pmName,
374+
forwardSignals: INTERRUPT_SIGNALS,
372375
});
373376
}
374377

@@ -392,12 +395,14 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
392395
cmd: runtime.executablePath,
393396
args: ['-m', entrypoint],
394397
opts: { env, cwd },
398+
forwardSignals: INTERRUPT_SIGNALS,
395399
});
396400
} else {
397401
await execWithLog({
398402
cmd: runtime.executablePath,
399403
args: [entrypoint],
400404
opts: { env, cwd },
405+
forwardSignals: INTERRUPT_SIGNALS,
401406
});
402407
}
403408

src/lib/commands/run-on-cloud.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ACTOR_JOB_STATUSES } from '@apify/consts';
77

88
import { Flags } from '../command-framework/flags.js';
99
import { CommandExitCodes } from '../consts.js';
10+
import { useAbortJobOnSignal } from '../hooks/useAbortJobOnSignal.js';
1011
import { error, run as runLog, success, warning } from '../outputs.js';
1112
import { outputJobLog } from '../utils.js';
1213
import { resolveInput } from './resolve-input.js';
@@ -90,6 +91,20 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options:
9091
throw err;
9192
}
9293

94+
// From this point on the run exists on the platform. Forward interrupt
95+
// signals to a platform-side abort so the run does not keep burning
96+
// compute units after the user gives up waiting locally (Ctrl+C, SIGTERM
97+
// from a parent process, SIGHUP from a closing terminal). The `using`
98+
// binding removes the listener when this generator finishes or is
99+
// terminated by the consumer (e.g. `break` out of `for await`).
100+
using _signalHandler = useAbortJobOnSignal({
101+
apifyClient,
102+
kind: 'run',
103+
jobId: run.id,
104+
runType: type,
105+
silent,
106+
});
107+
93108
// Return the started run right away
94109
yield run;
95110

src/lib/consts.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export const EMPTY_LOCAL_CONFIG = {
2323

2424
export const CHECK_VERSION_EVERY_MILLIS = 24 * 60 * 60 * 1000; // Once a day
2525

26+
// Signals representing user-initiated interruption that long-running commands
27+
// should react to (aborting platform jobs, forwarding to local subprocesses).
28+
export const INTERRUPT_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
29+
2630
export const GLOBAL_CONFIGS_FOLDER = () => {
2731
const base = join(homedir(), '.apify');
2832

src/lib/exec.ts

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,25 @@ import { normalizeExecutablePath } from './hooks/runtimes/utils.js';
55
import { error, run } from './outputs.js';
66
import { cliDebugPrint } from './utils/cliDebugPrint.js';
77

8-
const spawnPromised = async (cmd: string, args: string[], opts: Options) => {
8+
interface SpawnPromisedInternalOptions {
9+
/**
10+
* Signals that should be forwarded from the parent process to the spawned
11+
* child. When the CLI receives one of these signals it is re-sent to the
12+
* child so it can shut down cleanly instead of being orphaned when the CLI
13+
* exits.
14+
*/
15+
forwardSignals?: NodeJS.Signals[];
16+
}
17+
18+
const spawnPromised = async (
19+
cmd: string,
20+
args: string[],
21+
opts: Options,
22+
{ forwardSignals }: SpawnPromisedInternalOptions = {},
23+
) => {
924
const escapedCommand = normalizeExecutablePath(cmd);
1025

11-
cliDebugPrint('spawnPromised', { escapedCommand, args, opts });
26+
cliDebugPrint('spawnPromised', { escapedCommand, args, opts, forwardSignals });
1227

1328
const childProcess = execa(escapedCommand, args, {
1429
shell: true,
@@ -21,23 +36,58 @@ const spawnPromised = async (cmd: string, args: string[], opts: Options) => {
2136
verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
2237
});
2338

24-
return Result.fromAsync(
25-
childProcess.catch((execaError: ExecaError) => {
26-
throw new Error(`${cmd} exited with code ${execaError.exitCode}`, { cause: execaError });
27-
}),
28-
) as Promise<Result<Awaited<typeof childProcess>, Error & { cause: ExecaError }>>;
39+
const cleanupSignalHandlers: (() => void)[] = [];
40+
41+
if (forwardSignals?.length) {
42+
for (const signal of forwardSignals) {
43+
const handler = () => {
44+
childProcess.kill(signal);
45+
};
46+
47+
process.on(signal, handler);
48+
cleanupSignalHandlers.push(() => process.off(signal, handler));
49+
}
50+
}
51+
52+
try {
53+
return (await Result.fromAsync(
54+
childProcess.catch((execaError: ExecaError) => {
55+
let message;
56+
57+
if (execaError.exitCode != null) {
58+
message = `${cmd} exited with code ${execaError.exitCode}`;
59+
} else if (execaError.signal) {
60+
message = `${cmd} exited due to signal ${execaError.signal}`;
61+
} else {
62+
message = execaError.shortMessage;
63+
}
64+
65+
throw new Error(message, { cause: execaError });
66+
}),
67+
)) as Result<Awaited<typeof childProcess>, Error & { cause: ExecaError }>;
68+
} finally {
69+
for (const cleanup of cleanupSignalHandlers) {
70+
cleanup();
71+
}
72+
}
2973
};
3074

3175
export interface ExecWithLogOptions {
3276
cmd: string;
3377
args?: string[];
3478
opts?: Options;
3579
overrideCommand?: string;
80+
/**
81+
* Signals to forward from the parent process to the spawned child. Use this
82+
* for long-running children (e.g. user scripts) so pressing Ctrl+C on the
83+
* CLI does not leave the child running in the background.
84+
*/
85+
forwardSignals?: NodeJS.Signals[];
3686
}
3787

38-
export async function execWithLog({ cmd, args = [], opts = {}, overrideCommand }: ExecWithLogOptions) {
88+
export async function execWithLog({ cmd, args = [], opts = {}, overrideCommand, forwardSignals }: ExecWithLogOptions) {
3989
run({ message: `${overrideCommand || cmd} ${args.join(' ')}` });
40-
const result = await spawnPromised(cmd, args, opts);
90+
const result = await spawnPromised(cmd, args, opts, { forwardSignals });
4191

4292
if (result.isErr()) {
4393
const err = result.unwrapErr();
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { ApifyClient } from 'apify-client';
2+
import chalk from 'chalk';
3+
4+
import { INTERRUPT_SIGNALS } from '../consts.js';
5+
import { error, info } from '../outputs.js';
6+
import { useSignalHandler } from './useSignalHandler.js';
7+
8+
export type UseAbortJobOnSignalInput = {
9+
/** Logged-in client used to issue the abort request. */
10+
apifyClient: ApifyClient;
11+
/** Suppress status output. The listener still fires — it just stays silent. Defaults to `false`. */
12+
silent?: boolean;
13+
} & (
14+
| {
15+
/** Abort an Actor build. Builds have no graceful/force distinction. */
16+
kind: 'build';
17+
/** ID of the build to abort. */
18+
jobId: string;
19+
}
20+
| {
21+
/** Abort an Actor or Task run. Runs escalate: graceful on the first signal, forced on the second. */
22+
kind: 'run';
23+
/** ID of the run to abort. */
24+
jobId: string;
25+
/** Used purely for the user-visible status line (e.g. "aborting actor run ..."). */
26+
runType: 'Actor' | 'Task';
27+
}
28+
);
29+
30+
/**
31+
* Registers a signal handler that aborts the given build or run on the Apify
32+
* platform, and returns a `Disposable` that removes it. Pair with the `using`
33+
* keyword so the listener is always cleaned up when the enclosing block
34+
* exits.
35+
*
36+
* Repeat signals never terminate the CLI while an abort is in flight — the
37+
* listener stays registered for the lifetime of the `using` binding:
38+
*
39+
* - For `kind: 'build'`, the first signal issues the abort and subsequent
40+
* signals are silent no-ops. The build-abort API has no "gracefully" knob.
41+
* - For `kind: 'run'`, the first signal issues `abort({ gracefully: true })`
42+
* with a hint that pressing Ctrl+C again forces an immediate abort. The
43+
* second signal issues `abort({ gracefully: false })`. Third and later
44+
* signals are silent no-ops.
45+
*
46+
* @example
47+
* ```ts
48+
* {
49+
* using _signalHandler = useAbortJobOnSignal({
50+
* apifyClient: client,
51+
* kind: 'build',
52+
* jobId: build.id,
53+
* });
54+
*
55+
* await outputJobLog({ job: build, apifyClient: client });
56+
* } // listener is removed here
57+
* ```
58+
*/
59+
export function useAbortJobOnSignal(input: UseAbortJobOnSignalInput): Disposable {
60+
const { apifyClient, silent = false } = input;
61+
62+
let abortAttempt = 0;
63+
64+
return useSignalHandler({
65+
signals: INTERRUPT_SIGNALS,
66+
once: false,
67+
handler: async (signal) => {
68+
abortAttempt += 1;
69+
70+
if (input.kind === 'build') {
71+
if (abortAttempt > 1) {
72+
return;
73+
}
74+
75+
if (!silent) {
76+
info({
77+
message: chalk.gray(
78+
`Received ${chalk.yellow(signal)}, aborting build "${chalk.yellow(input.jobId)}" on the Apify platform...`,
79+
),
80+
stdout: true,
81+
});
82+
}
83+
84+
try {
85+
await apifyClient.build(input.jobId).abort();
86+
} catch (abortErr) {
87+
error({
88+
message: `Failed to abort build "${input.jobId}": ${(abortErr as Error).message}`,
89+
stdout: true,
90+
});
91+
}
92+
93+
return;
94+
}
95+
96+
if (abortAttempt > 2) {
97+
return;
98+
}
99+
100+
const gracefully = abortAttempt === 1;
101+
const runLabel = `${input.runType.toLowerCase()} run`;
102+
103+
if (!silent) {
104+
const message = gracefully
105+
? `Received ${chalk.yellow(signal)}, gracefully aborting ${runLabel} "${chalk.yellow(input.jobId)}" on the Apify platform... ${chalk.dim('(press Ctrl+C again to abort immediately)')}`
106+
: `Received ${chalk.yellow(signal)} again, aborting ${runLabel} "${chalk.yellow(input.jobId)}" immediately...`;
107+
108+
info({ message: chalk.gray(message), stdout: true });
109+
}
110+
111+
try {
112+
await apifyClient.run(input.jobId).abort({ gracefully });
113+
} catch (abortErr) {
114+
error({
115+
message: `Failed to abort run "${input.jobId}": ${(abortErr as Error).message}`,
116+
stdout: true,
117+
});
118+
}
119+
},
120+
});
121+
}

src/lib/hooks/useCLIVersionAssets.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,10 @@ export async function useCLIVersionAssets(version: string) {
9191
const requiresBaseline = isInstalledOnBaseline();
9292

9393
const assets = body.assets.filter((asset) => {
94-
const [
95-
//
96-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
97-
_cliEntrypoint,
98-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
99-
_version,
100-
assetOs,
101-
assetArch,
102-
assetBaselineOrMusl,
103-
assetBaseline,
104-
] = asset.name.replace(versionWithoutV, 'version').replace('.exe', '').split('-');
94+
const [_cliEntrypoint, _version, assetOs, assetArch, assetBaselineOrMusl, assetBaseline] = asset.name
95+
.replace(versionWithoutV, 'version')
96+
.replace('.exe', '')
97+
.split('-');
10598

10699
if (assetOs !== metadata.platform) {
107100
return false;

0 commit comments

Comments
 (0)