Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
44 changes: 43 additions & 1 deletion src/commands/builds/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -145,6 +146,47 @@ export class BuildsCreateCommand extends ApifyCommand<typeof BuildsCreateCommand
});

if (log) {
// While the log is streaming, forward interrupt signals to a
// platform-side abort so the build doesn't keep running after the
// user gives up waiting (Ctrl+C, SIGTERM from a parent process,
// SIGHUP from a closing terminal). The `using` binding guarantees
// the listener is removed when the block exits.
//
// `once: false` keeps the listener registered across repeated
// signals so a second Ctrl+C doesn't kill the CLI before the
// abort request finishes. The build abort API has no "gracefully"
// knob, so the first signal does the work and later signals are
// silent no-ops.
let abortAttempt = 0;

using _signalHandler = useSignalHandler({
signals: ['SIGINT', 'SIGTERM', 'SIGHUP'],
once: false,
handler: async (signal) => {
abortAttempt += 1;

if (abortAttempt > 1) {
return;
}

Comment on lines +149 to +171
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the first abort attempt, subsequent signals are ignored (abortAttempt > 1 returns) while once: false keeps intercepting them. This can leave users unable to force-terminate the CLI if aborting/streaming hangs. Consider removing/disposing the signal handler (or letting later signals fall through) after sending the abort request.

Suggested change
// While the log is streaming, forward interrupt signals to a
// platform-side abort so the build doesn't keep running after the
// user gives up waiting (Ctrl+C, SIGTERM from a parent process,
// SIGHUP from a closing terminal). The `using` binding guarantees
// the listener is removed when the block exits.
//
// `once: false` keeps the listener registered across repeated
// signals so a second Ctrl+C doesn't kill the CLI before the
// abort request finishes. The build abort API has no "gracefully"
// knob, so the first signal does the work and later signals are
// silent no-ops.
let abortAttempt = 0;
using _signalHandler = useSignalHandler({
signals: ['SIGINT', 'SIGTERM', 'SIGHUP'],
once: false,
handler: async (signal) => {
abortAttempt += 1;
if (abortAttempt > 1) {
return;
}
// While the log is streaming, forward the first interrupt signal
// to a platform-side abort so the build doesn't keep running after
// the user gives up waiting (Ctrl+C, SIGTERM from a parent
// process, SIGHUP from a closing terminal). The `using` binding
// guarantees the listener is removed when the block exits, and
// `once: true` ensures later signals fall through so the user can
// still force-terminate the CLI if aborting or log streaming hangs.
using _signalHandler = useSignalHandler({
signals: ['SIGINT', 'SIGTERM', 'SIGHUP'],
once: true,
handler: async (signal) => {

Copilot uses AI. Check for mistakes.
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) {
Expand Down
4 changes: 4 additions & 0 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
cmd: runtime.executablePath,
args: [entrypoint],
opts: { env, cwd },
forwardSignals: ['SIGINT', 'SIGTERM', 'SIGHUP'],
});
} else {
// Assert the package.json content for scripts
Expand Down Expand Up @@ -346,6 +347,7 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
args: ['run', entrypoint],
opts: { env, cwd },
overrideCommand: runtime.pmName,
forwardSignals: ['SIGINT', 'SIGTERM', 'SIGHUP'],
});
}

Expand All @@ -369,12 +371,14 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
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'],
});
}

Expand Down
60 changes: 59 additions & 1 deletion src/lib/commands/run-on-cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Comment on lines +116 to +118
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With once: false, signals are continuously intercepted. After abortAttempt > 2 this handler becomes a no-op, so further Ctrl+C presses won’t terminate the CLI even if the abort request hangs (e.g. network issues). Consider disposing the handler (or re-raising the signal to self) after a certain point so the user can still force-exit.

Copilot uses AI. Check for mistakes.

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;

Expand Down
68 changes: 59 additions & 9 deletions src/lib/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,23 +36,58 @@ 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<Result<Awaited<typeof childProcess>, Error & { cause: ExecaError }>>;
const cleanupSignalHandlers: (() => void)[] = [];

if (forwardSignals?.length) {
for (const signal of forwardSignals) {
const handler = () => {
childProcess.kill(signal);
};

process.on(signal, handler);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signal forwarding registers process.on(signal, handler) listeners but never allows the default SIGINT behavior to kick in. This means repeated Ctrl+C will keep being intercepted and the CLI can become impossible to force-terminate if the child ignores the signal. Consider registering the listener with process.once(...) (or removing it after the first forward) so a second signal falls through to Node’s default termination behavior, matching typical CLI expectations.

Suggested change
process.on(signal, handler);
process.once(signal, handler);

Copilot uses AI. Check for mistakes.
cleanupSignalHandlers.push(() => process.off(signal, handler));
}
}
Comment on lines +39 to +50
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New forwardSignals behavior isn’t covered by tests. There are existing Vitest tests for execWithLog, so it would be good to add a test that simulates a signal and asserts the child receives it (and that handlers are cleaned up).

Copilot uses AI. Check for mistakes.

try {
return (await Result.fromAsync(
childProcess.catch((execaError: ExecaError) => {
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 });
}),
)) as Result<Awaited<typeof childProcess>, Error & { cause: ExecaError }>;
} finally {
for (const cleanup of cleanupSignalHandlers) {
cleanup();
}
}
};

export interface ExecWithLogOptions {
cmd: string;
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();
Expand Down
15 changes: 4 additions & 11 deletions src/lib/hooks/useCLIVersionAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading