From 07e801abcbaa2a5d858ada12c8acddc3365b3cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Fri, 15 May 2026 00:00:31 +0200 Subject: [PATCH 1/8] fix(actors): correctly parse waitforfinish flag Resolves an issue where waitForFinishMillis could be NaN for invalid flag inputs, leading to unexpected behavior. --- src/commands/actors/push.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 927709226..d5e648955 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -190,9 +190,8 @@ export class ActorsPushCommand extends ApifyCommand { buildTag = DEFAULT_BUILD_TAG; } - const waitForFinishMillis = Number.isNaN(this.flags.waitForFinish) - ? undefined - : Number.parseInt(this.flags.waitForFinish!, 10) * 1000; + const parsedWaitForFinish = this.flags.waitForFinish ? Number.parseInt(this.flags.waitForFinish, 10) : Number.NaN; + const waitForFinishMillis = Number.isFinite(parsedWaitForFinish) ? parsedWaitForFinish * 1000 : undefined; // User can override actorId of pushing Actor. // It causes that we push Actor to this id but attributes in localConfig will remain same. From d9bf8ed636a85fe1e24b91a7490cf88510d9ea8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 18 May 2026 12:41:31 +0200 Subject: [PATCH 2/8] extract parsing function and add regression tests --- src/commands/actors/push.ts | 4 ++-- src/lib/utils.ts | 7 ++++++ test/local/lib/parse-wait-for-finish.test.ts | 23 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 test/local/lib/parse-wait-for-finish.test.ts diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index d5e648955..34b3def81 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -25,6 +25,7 @@ import { getLocalUserInfo, getLoggedClientOrThrow, outputJobLog, + parseWaitForFinishMillis, printJsonToStdout, } from '../../lib/utils.js'; @@ -190,8 +191,7 @@ export class ActorsPushCommand extends ApifyCommand { buildTag = DEFAULT_BUILD_TAG; } - const parsedWaitForFinish = this.flags.waitForFinish ? Number.parseInt(this.flags.waitForFinish, 10) : Number.NaN; - const waitForFinishMillis = Number.isFinite(parsedWaitForFinish) ? parsedWaitForFinish * 1000 : undefined; + const waitForFinishMillis = parseWaitForFinishMillis(this.flags.waitForFinish); // User can override actorId of pushing Actor. // It causes that we push Actor to this id but attributes in localConfig will remain same. diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 852d1b242..c96254b0c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -840,3 +840,10 @@ export function shellConfigFile(userHomeDirectory: string, shell: ReturnType { + it('returns undefined when flag is omitted', () => { + expect(parseWaitForFinishMillis(undefined)).toBeUndefined(); + }); + + it('returns undefined for non-numeric input', () => { + expect(parseWaitForFinishMillis('abc')).toBeUndefined(); + }); + + it('returns undefined for zero', () => { + expect(parseWaitForFinishMillis('0')).toBeUndefined(); + }); + + it('returns undefined for negative values', () => { + expect(parseWaitForFinishMillis('-5')).toBeUndefined(); + }); + + it('converts positive seconds to milliseconds', () => { + expect(parseWaitForFinishMillis('30')).toBe(30_000); + }); +}); From b1d8b3edf218de3a8a35d6d277d20afd5b7fd736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 18 May 2026 12:51:44 +0200 Subject: [PATCH 3/8] add pooling loop for push --- src/commands/actors/push.ts | 40 ++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 34b3def81..ae4d2bd29 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -6,7 +6,12 @@ import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions } from import open from 'open'; import { fetchManifest } from '@apify/actor-templates'; -import { ACTOR_JOB_STATUSES, ACTOR_SOURCE_TYPES, MAX_MULTIFILE_BYTES } from '@apify/consts'; +import { + ACTOR_JOB_STATUSES, + ACTOR_JOB_TERMINAL_STATUSES, + ACTOR_SOURCE_TYPES, + MAX_MULTIFILE_BYTES, +} from '@apify/consts'; import { createHmacSignature } from '@apify/utilities'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; @@ -358,18 +363,16 @@ Skipping push. Use --force to override.`, waitForFinish: 2, // NOTE: We need to wait some time to Apify open stream and we can create connection }); - 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({ - apifyClient, - kind: 'build', - jobId: build.id, - }); + // Forward interrupt signals (Ctrl+C, SIGTERM, SIGHUP) to a platform-side + // abort for the lifetime of log streaming AND status polling, so the + // build doesn't keep running after the user gives up waiting. + using _signalHandler = useAbortJobOnSignal({ + apifyClient, + kind: 'build', + jobId: build.id, + }); + try { await outputJobLog({ job: build, timeoutMillis: waitForFinishMillis, apifyClient }); } catch (err) { warning({ message: 'Can not get log:' }); @@ -378,6 +381,17 @@ Skipping push. Use --force to override.`, build = (await apifyClient.build(build.id).get())!; + // `outputJobLog` can return before the build is actually terminal (stream + // ended early, timeout hit). Poll so the status branches below see the + // real outcome. + if (waitForFinishMillis !== undefined) { + const deadline = Date.now() + waitForFinishMillis; + while (!ACTOR_JOB_TERMINAL_STATUSES.includes(build.status as never) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + build = (await apifyClient.build(build.id).get())!; + } + } + if (this.flags.json) { printJsonToStdout(build); return; @@ -402,9 +416,11 @@ Skipping push. Use --force to override.`, // @ts-expect-error FIX THESE TYPES 😢 } else if (build.status === ACTOR_JOB_STATUSES.READY) { warning({ message: 'Build is waiting for allocation.' }); + process.exitCode = CommandExitCodes.BuildTimedOut; // @ts-expect-error FIX THESE TYPES 😢 } else if (build.status === ACTOR_JOB_STATUSES.RUNNING) { warning({ message: 'Build is still running.' }); + process.exitCode = CommandExitCodes.BuildTimedOut; // @ts-expect-error FIX THESE TYPES 😢 } else if (build.status === ACTOR_JOB_STATUSES.ABORTED || build.status === ACTOR_JOB_STATUSES.ABORTING) { warning({ message: 'Build was aborted!' }); From 9e26c3e2c8aefb598dae1aa673807a53b1915a2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 18 May 2026 13:44:30 +0200 Subject: [PATCH 4/8] fix default pooling --- src/commands/actors/push.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index ae4d2bd29..5ad3b72f1 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -383,13 +383,12 @@ Skipping push. Use --force to override.`, // `outputJobLog` can return before the build is actually terminal (stream // ended early, timeout hit). Poll so the status branches below see the - // real outcome. - if (waitForFinishMillis !== undefined) { - const deadline = Date.now() + waitForFinishMillis; - while (!ACTOR_JOB_TERMINAL_STATUSES.includes(build.status as never) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - build = (await apifyClient.build(build.id).get())!; - } + // real outcome. With no --wait-for-finish, the flag documents "waits + // forever", so poll without a deadline. + const deadline = waitForFinishMillis === undefined ? Infinity : Date.now() + waitForFinishMillis; + while (!ACTOR_JOB_TERMINAL_STATUSES.includes(build.status as never) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + build = (await apifyClient.build(build.id).get())!; } if (this.flags.json) { From d8e957b8d5f8627c73d14afcfe4498679f71d446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 18 May 2026 13:47:11 +0200 Subject: [PATCH 5/8] fix race --- src/commands/actors/push.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 5ad3b72f1..728b662b9 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -411,6 +411,16 @@ Skipping push. Use --force to override.`, } if (build.status === ACTOR_JOB_STATUSES.SUCCEEDED) { + // Platform updates `taggedBuilds[buildTag]` asynchronously after the + // build finishes. Wait until the tag points at this build so callers + // that immediately `actor.start({ build: buildTag })` don't race it. + if (buildTag) { + while (Date.now() < deadline) { + const a = await actorClient.get(); + if (a?.taggedBuilds?.[buildTag]?.buildId === build.id) break; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } success({ message: 'Actor was deployed to Apify cloud and built there.' }); // @ts-expect-error FIX THESE TYPES 😢 } else if (build.status === ACTOR_JOB_STATUSES.READY) { From 1cac10f73f8186a5910f4175629774819ae358e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 18 May 2026 13:52:33 +0200 Subject: [PATCH 6/8] fix 0 hangs forever --- src/lib/utils.ts | 4 ++-- test/local/lib/parse-wait-for-finish.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c96254b0c..a664a5a4f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -607,7 +607,7 @@ export const outputJobLog = async ({ } }); - if (timeoutMillis) { + if (timeoutMillis !== undefined) { nodeTimeout = setTimeout(() => { stream.destroy(); resolve('timeouts'); @@ -844,6 +844,6 @@ export function shellConfigFile(userHomeDirectory: string, shell: ReturnType { expect(parseWaitForFinishMillis('abc')).toBeUndefined(); }); - it('returns undefined for zero', () => { - expect(parseWaitForFinishMillis('0')).toBeUndefined(); + it('returns 0 for zero (no wait)', () => { + expect(parseWaitForFinishMillis('0')).toBe(0); }); it('returns undefined for negative values', () => { From 1867aca2cbc36bd0036e378dc7747141daf68fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 18 May 2026 13:54:17 +0200 Subject: [PATCH 7/8] cap loop --- src/commands/actors/push.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 728b662b9..22b5b6015 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -414,8 +414,10 @@ Skipping push. Use --force to override.`, // Platform updates `taggedBuilds[buildTag]` asynchronously after the // build finishes. Wait until the tag points at this build so callers // that immediately `actor.start({ build: buildTag })` don't race it. + // Capped at 30s so an unknown platform delay can't stall push forever. if (buildTag) { - while (Date.now() < deadline) { + const tagDeadline = Math.min(deadline, Date.now() + 30_000); + while (Date.now() < tagDeadline) { const a = await actorClient.get(); if (a?.taggedBuilds?.[buildTag]?.buildId === build.id) break; await new Promise((resolve) => setTimeout(resolve, 1000)); From 5a40540509fa50efb5bba8838714a05ee801d329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 18 May 2026 14:02:56 +0200 Subject: [PATCH 8/8] fix minor findings --- src/commands/actors/push.ts | 38 +++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 22b5b6015..e9a0d9143 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -391,6 +391,32 @@ Skipping push. Use --force to override.`, build = (await apifyClient.build(build.id).get())!; } + // Platform updates `taggedBuilds[buildTag]` asynchronously after the + // build finishes. Wait until the tag points at this build so callers + // (including --json automation) that immediately + // `actor.start({ build: buildTag })` don't race it. Capped at 30s so an + // unknown platform delay can't stall push forever. + if (build.status === ACTOR_JOB_STATUSES.SUCCEEDED && buildTag) { + // 30s budget is independent of --wait-for-finish: the build is already + // done, we're only waiting on the platform to update the tag pointer. + const tagDeadline = Date.now() + 30_000; + let tagApplied = false; + while (Date.now() < tagDeadline) { + const a = await actorClient.get(); + if (a?.taggedBuilds?.[buildTag]?.buildId === build.id) { + tagApplied = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + if (!tagApplied) { + warning({ + message: `Build succeeded but tag "${buildTag}" did not update within 30s; subsequent calls referencing this tag may race.`, + }); + process.exitCode = CommandExitCodes.BuildTimedOut; + } + } + if (this.flags.json) { printJsonToStdout(build); return; @@ -411,18 +437,6 @@ Skipping push. Use --force to override.`, } if (build.status === ACTOR_JOB_STATUSES.SUCCEEDED) { - // Platform updates `taggedBuilds[buildTag]` asynchronously after the - // build finishes. Wait until the tag points at this build so callers - // that immediately `actor.start({ build: buildTag })` don't race it. - // Capped at 30s so an unknown platform delay can't stall push forever. - if (buildTag) { - const tagDeadline = Math.min(deadline, Date.now() + 30_000); - while (Date.now() < tagDeadline) { - const a = await actorClient.get(); - if (a?.taggedBuilds?.[buildTag]?.buildId === build.id) break; - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } success({ message: 'Actor was deployed to Apify cloud and built there.' }); // @ts-expect-error FIX THESE TYPES 😢 } else if (build.status === ACTOR_JOB_STATUSES.READY) {