From a3fdc98ed7b72af30d2f05f43ec84586e9eda09d Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:34:36 +0800 Subject: [PATCH] fix(cli): prevent silent zero-exit when the version-check fetch stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every zen command awaits checkNewVersion() in a preAction hook, which fetches the npm registry with AbortSignal.timeout(2000). If the connection stalls in a way that strands the fetch promise with no active handle, nothing keeps the event loop alive (AbortSignal.timeout's timer is unref'd), so node exits with code 0 before the command's action runs — the CLI reports success having done nothing. On CI runners this struck roughly 1 in 100-300 invocations, which made most Build-and-Test runs fail in a random packages/cli test with baffling "output file missing after successful CLI run" assertions (observed on PRs and on main alike; root-caused and fix verified over several instrumented runs on the throwaway diagnostic PR #2766). Fix: race the fetch against a ref'd, always-settling timer that is cleared afterward. The timer keeps the event loop alive and guarantees the await settles into the existing catch. A noop catch on the losing fetch promise avoids unhandled rejections in long-lived modes (watch, proxy, studio). Also: runCli in the CLI test harness now captures and prints the child's stdout/stderr when a command fails, so future failures of this kind are self-diagnosing instead of bare assertion errors. Co-Authored-By: Claude Fable 5 --- packages/cli/src/utils/version-utils.ts | 18 +++++++++++++++++- packages/cli/test/utils.ts | 11 ++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/version-utils.ts b/packages/cli/src/utils/version-utils.ts index e9749e878..25a257633 100644 --- a/packages/cli/src/utils/version-utils.ts +++ b/packages/cli/src/utils/version-utils.ts @@ -20,11 +20,27 @@ export function getVersion() { export async function checkNewVersion() { const currVersion = getVersion(); let latestVersion: string; + // race against a ref'd, always-settling timer: if the fetch's connection dies in a + // way that strands its promise (leaving no active handle), the pending `await` would + // otherwise drain the event loop and silently exit the process with code 0 mid-command; + // the timer both keeps the loop alive and guarantees this await settles + let timer: NodeJS.Timeout | undefined; try { - latestVersion = await getLatestVersion(); + const fetchPromise = getLatestVersion(); + // if the timer wins the race, a later settlement of the fetch must not surface + // as an unhandled rejection + fetchPromise.catch(() => {}); + latestVersion = await Promise.race([ + fetchPromise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('version check timed out')), CHECK_VERSION_TIMEOUT + 1000); + }), + ]); } catch { // noop return; + } finally { + clearTimeout(timer); } if (latestVersion && currVersion && semver.gt(latestVersion, currVersion)) { diff --git a/packages/cli/test/utils.ts b/packages/cli/test/utils.ts index 85d85559b..799d16838 100644 --- a/packages/cli/test/utils.ts +++ b/packages/cli/test/utils.ts @@ -117,5 +117,14 @@ export async function createProject( export function runCli(command: string, cwd: string) { const cli = path.join(__dirname, '../dist/index.mjs'); - execSync(`node ${cli} ${command}`, { cwd }); + try { + return execSync(`node ${cli} ${command}`, { cwd, encoding: 'utf8' }); + } catch (err: any) { + // surface the child's output in the test log — without this, CLI failures show up + // as bare assertion errors with no clue about what the CLI actually did + console.error(`[runCli] "${command}" failed in ${cwd} (status=${err.status}, signal=${err.signal})`); + console.error(`[runCli] stdout:\n${err.stdout}`); + console.error(`[runCli] stderr:\n${err.stderr}`); + throw err; + } }