From f292b057b59729b7aa5e06cfa2aea28a45a578c0 Mon Sep 17 00:00:00 2001 From: 3kin0x Date: Tue, 7 Jul 2026 05:05:50 +0200 Subject: [PATCH] fix: await main() in cli entrypoint to prevent premature exit in Node 24.x (#1697) --- .github/workflows/pr-checks.yml | 5 +- src/cli/handlers/templateJobs.ts | 2 +- src/daemon/main.ts | 2 +- src/daemon/workerRegistry.ts | 2 +- src/entrypoints/cli.test.ts | 85 ++++++++++++++++++++++++++++++++ src/entrypoints/cli.tsx | 2 +- src/environment-runner/main.ts | 2 +- src/main.tsx | 2 +- src/self-hosted-runner/main.ts | 2 +- 9 files changed, 96 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index c5e32e466..e3231c794 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -12,6 +12,9 @@ permissions: jobs: smoke-and-tests: runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22, "24.11.x"] steps: - name: Check out repository @@ -22,7 +25,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 22 + node-version: ${{ matrix.node-version }} - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 diff --git a/src/cli/handlers/templateJobs.ts b/src/cli/handlers/templateJobs.ts index 9426335a6..3f36099a3 100644 --- a/src/cli/handlers/templateJobs.ts +++ b/src/cli/handlers/templateJobs.ts @@ -5,7 +5,7 @@ * mirrors that behavior for the typechecker. `templatesMain` resolves * immediately; the call site then runs `process.exit(0)`, so the commands * exit quietly. The call site in entrypoints/cli.tsx does not catch errors - * (`void main()`), so a no-op is preferred over throwing. No import-time + * (`await main()`), so a no-op is preferred over throwing. No import-time * side effects. */ diff --git a/src/daemon/main.ts b/src/daemon/main.ts index 2b58347f3..e784741fb 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -4,7 +4,7 @@ * The bundler noop-stubs this specifier in current builds; this module * mirrors that behavior for the typechecker. `daemonMain` resolves * immediately, so `claude daemon` exits without supervising anything. The - * call site in entrypoints/cli.tsx does not catch errors (`void main()`), so + * call site in entrypoints/cli.tsx does not catch errors (`await main()`), so * a no-op is preferred over throwing. No import-time side effects. */ diff --git a/src/daemon/workerRegistry.ts b/src/daemon/workerRegistry.ts index 2df93fe1a..a926a34ec 100644 --- a/src/daemon/workerRegistry.ts +++ b/src/daemon/workerRegistry.ts @@ -4,7 +4,7 @@ * The bundler noop-stubs this specifier in current builds; this module * mirrors that behavior for the typechecker. `runDaemonWorker` resolves * immediately so a spawned worker process exits cleanly (code 0). The call - * site in entrypoints/cli.tsx does not catch errors (`void main()`), so a + * site in entrypoints/cli.tsx does not catch errors (`await main()`), so a * no-op is preferred over throwing. No import-time side effects. */ diff --git a/src/entrypoints/cli.test.ts b/src/entrypoints/cli.test.ts index ef878e6b0..60d0997cd 100644 --- a/src/entrypoints/cli.test.ts +++ b/src/entrypoints/cli.test.ts @@ -481,3 +481,88 @@ describe('cli.tsx — background routing behavior', () => { expect(mockCliMain).toHaveBeenCalledTimes(1) }) }) + +describe('Node 24 premature exit regression (issue #1678)', () => { + it('built CLI stays alive during initialization in interactive mode without premature exit', async () => { + const os = await import('node:os') + const path = await import('node:path') + const fs = await import('node:fs/promises') + const url = await import('node:url') + + const scriptPath = path.join(os.tmpdir(), `test-cli-startup-${Date.now()}.mjs`) + const cliUrl = url.pathToFileURL(path.resolve(import.meta.dir, '../../dist/cli.mjs')).href + let proc + + try { + await Bun.write(scriptPath, ` + // Mock TTY so the CLI thinks it's interactive and starts the TUI + process.stdout.isTTY = true; + process.stdin.isTTY = true; + process.stdin.setRawMode = () => {}; + process.env.OPENCLAUDE_DISABLE_TELEMETRY = '1'; + process.env.OPENGATEWAY_API_KEY = 'dummy'; + + // Ensure the CLI auto-runs even if the test runner disabled it globally + delete process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN; + + // Use absolute import to work from os.tmpdir() + // If the entrypoint uses void main(), this promise resolves immediately. + // If it correctly uses await main(), it stays pending while the CLI runs. + import('${cliUrl}').then(() => { + console.log('---PREMATURE_EVAL_END---'); + process.exit(0); + }); + `) + + proc = Bun.spawn(['node', scriptPath], { stdout: 'pipe' }) + const reader = proc.stdout.getReader() + + let gotOutput = false + let evaluationEndedPrematurely = false + + async function readStdout() { + while (true) { + const { done, value } = await reader.read() + if (done) break + const text = new TextDecoder().decode(value) + if (text.includes('---PREMATURE_EVAL_END---')) { + evaluationEndedPrematurely = true + } else if (text.trim().length > 0) { + gotOutput = true + } + } + } + + // Start reading without awaiting it yet + const readPromise = readStdout() + + // Wait until we get startup output or detect premature evaluation end + const start = Date.now() + while (!gotOutput && !evaluationEndedPrematurely && Date.now() - start < 5000) { + await new Promise(r => setTimeout(r, 10)) + } + + expect(gotOutput).toBe(true) + + // The critical regression window: wait 500ms *after* output. + // With void main(), Node 24 will exit during the subsequent async imports because the event loop empties, + // which allows the import() promise above to resolve and emit the signal. + await new Promise(r => setTimeout(r, 500)) + + expect(evaluationEndedPrematurely).toBe(false) + expect(proc.exitCode).toBe(null) + expect(proc.killed).toBe(false) + } finally { + if (proc && proc.exitCode === null && !proc.killed) { + proc.kill() + } + await fs.unlink(scriptPath).catch(() => {}) + } + }) + + it('cli.tsx uses top-level await for main() to prevent premature exit', async () => { + const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text() + expect(src).toMatch(/await main\(\)/) + expect(src).not.toMatch(/^\s*void main\(\)/m) + }) +}) diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 5d82f6ada..aa7c6034c 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -745,5 +745,5 @@ export async function main( // eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level if (process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN !== '1') { - void main(); + await main(); } diff --git a/src/environment-runner/main.ts b/src/environment-runner/main.ts index 45b51c083..a6cf0ee89 100644 --- a/src/environment-runner/main.ts +++ b/src/environment-runner/main.ts @@ -6,7 +6,7 @@ * mirrors that behavior for the typechecker. `environmentRunnerMain` * resolves immediately, so the command exits without registering or polling. * The call site in entrypoints/cli.tsx does not catch errors - * (`void main()`), so a no-op is preferred over throwing. No import-time + * (`await main()`), so a no-op is preferred over throwing. No import-time * side effects. */ diff --git a/src/main.tsx b/src/main.tsx index 16c48d609..8515c586f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1458,7 +1458,7 @@ async function run(): Promise { } if (reservedNameError) { // stderr+exit(1) — a throw here becomes a silent unhandled - // rejection in stream-json mode (void main() in cli.tsx). + // rejection in stream-json mode (await main() in cli.tsx). process.stderr.write(`Error: ${reservedNameError}\n`); process.exit(1); } diff --git a/src/self-hosted-runner/main.ts b/src/self-hosted-runner/main.ts index 71282c595..0dbdc3492 100644 --- a/src/self-hosted-runner/main.ts +++ b/src/self-hosted-runner/main.ts @@ -6,7 +6,7 @@ * The bundler noop-stubs this specifier in current builds; this module * mirrors that behavior for the typechecker. `selfHostedRunnerMain` resolves * immediately, so the command exits without registering or polling. The call - * site in entrypoints/cli.tsx does not catch errors (`void main()`), so a + * site in entrypoints/cli.tsx does not catch errors (`await main()`), so a * no-op is preferred over throwing. No import-time side effects. */