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
12 changes: 12 additions & 0 deletions src/commands/actors/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Args } from '../../lib/command-framework/args.js';
import { Flags } from '../../lib/command-framework/flags.js';
import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js';
import { sumFilesSizeInBytes } from '../../lib/files.js';
import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js';
import { useActorConfig } from '../../lib/hooks/useActorConfig.js';
import { error, info, link, run, success, warning } from '../../lib/outputs.js';
import { transformEnvToEnvVars } from '../../lib/secrets.js';
Expand Down Expand Up @@ -343,6 +344,17 @@ Skipping push. Use --force to override.`,
});

try {
// 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 before we poll for final status.
using _signalHandler = useAbortJobOnSignal({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

isnt this supported only on node 24+?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It gets downcompiled by tsup/down 👀

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Cursor - 2026-04-24 at 07-48-41-UTC@2x

Ignore the terminal breaking (volta doesn't forward signals LOL)

apifyClient,
kind: 'build',
jobId: build.id,
});

await outputJobLog({ job: build, timeoutMillis: waitForFinishMillis, apifyClient });
} catch (err) {
warning({ message: 'Can not get log:' });
Expand Down
12 changes: 12 additions & 0 deletions src/commands/builds/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js';
import { error, simpleLog } from '../../lib/outputs.js';
import {
getLoggedClientOrThrow,
Expand Down Expand Up @@ -145,6 +146,17 @@ 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.
using _signalHandler = useAbortJobOnSignal({
apifyClient: client,
kind: 'build',
jobId: build.id,
});

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
15 changes: 15 additions & 0 deletions src/lib/commands/run-on-cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ACTOR_JOB_STATUSES } from '@apify/consts';

import { Flags } from '../command-framework/flags.js';
import { CommandExitCodes } from '../consts.js';
import { useAbortJobOnSignal } from '../hooks/useAbortJobOnSignal.js';
import { error, 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,20 @@ 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`).
using _signalHandler = useAbortJobOnSignal({
apifyClient,
kind: 'run',
jobId: run.id,
runType: type,
silent,
});

// 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);
Comment thread
vladfrangu marked this conversation as resolved.
cleanupSignalHandlers.push(() => process.off(signal, handler));
}
}
Comment thread
vladfrangu marked this conversation as resolved.

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
130 changes: 130 additions & 0 deletions src/lib/hooks/useAbortJobOnSignal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { ApifyClient } from 'apify-client';
import chalk from 'chalk';

import { error, info } from '../outputs.js';
import { useSignalHandler } from './useSignalHandler.js';

export type UseAbortJobOnSignalInput = {
/** Logged-in client used to issue the abort request. */
apifyClient: ApifyClient;
/** Suppress status output. The listener still fires — it just stays silent. Defaults to `false`. */
silent?: boolean;
} & (
| {
/** Abort an Actor build. Builds have no graceful/force distinction. */
kind: 'build';
/** ID of the build to abort. */
jobId: string;
}
| {
/** Abort an Actor or Task run. Runs escalate: graceful on the first signal, forced on the second. */
kind: 'run';
/** ID of the run to abort. */
jobId: string;
/** Used purely for the user-visible status line (e.g. "aborting actor run ..."). */
runType: 'Actor' | 'Task';
}
);

/**
* Registers a signal handler that aborts the given build or run on the Apify
* platform, and returns a `Disposable` that removes it. Pair with the `using`
* keyword so the listener is always cleaned up when the enclosing block
* exits.
*
* Repeat signals never terminate the CLI while an abort is in flight — the
* listener stays registered for the lifetime of the `using` binding:
*
* - For `kind: 'build'`, the first signal issues the abort and subsequent
* signals are silent no-ops. The build-abort API has no "gracefully" knob.
* - For `kind: 'run'`, the first signal issues `abort({ gracefully: true })`
* with a hint that pressing Ctrl+C again forces an immediate abort. The
* second signal issues `abort({ gracefully: false })`. Third and later
* signals are silent no-ops.
*
* @example
* ```ts
* {
* using _signalHandler = useAbortJobOnSignal({
* apifyClient: client,
* kind: 'build',
* jobId: build.id,
* });
*
* await outputJobLog({ job: build, apifyClient: client });
* } // listener is removed here
* ```
*/
export function useAbortJobOnSignal(input: UseAbortJobOnSignalInput): Disposable {
const { apifyClient, silent = false } = input;

let abortAttempt = 0;

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

if (input.kind === 'build') {
if (abortAttempt > 1) {
return;
}

if (!silent) {
info({
message: chalk.gray(
`Received ${chalk.yellow(signal)}, aborting build "${chalk.yellow(input.jobId)}" on the Apify platform...`,
),
stdout: true,
});
}

try {
await apifyClient.build(input.jobId).abort();
} catch (abortErr) {
error({
message: `Failed to abort build "${input.jobId}": ${(abortErr as Error).message}`,
stdout: true,
});
}

return;
}

if (abortAttempt > 2) {
return;
}

const gracefully = abortAttempt === 1;
const runLabel = `${input.runType.toLowerCase()} run`;

if (!silent) {
if (gracefully) {
info({
message: chalk.gray(
`Received ${chalk.yellow(signal)}, gracefully aborting ${runLabel} "${chalk.yellow(input.jobId)}" 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 ${runLabel} "${chalk.yellow(input.jobId)}" immediately...`,
),
stdout: true,
});
}
}

try {
await apifyClient.run(input.jobId).abort({ gracefully });
} catch (abortErr) {
error({
message: `Failed to abort run "${input.jobId}": ${(abortErr as Error).message}`,
stdout: true,
});
}
},
});
}
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