fix: await main() in cli entrypoint to prevent premature exit in Node 24.x (#1697)

This commit is contained in:
3kin0x
2026-07-07 11:05:50 +08:00
committed by GitHub
parent 766d3f8794
commit f292b057b5
9 changed files with 96 additions and 8 deletions
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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.
*/
+1 -1
View File
@@ -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.
*/
+1 -1
View File
@@ -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.
*/
+85
View File
@@ -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)
})
})
+1 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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.
*/
+1 -1
View File
@@ -1458,7 +1458,7 @@ async function run(): Promise<CommanderCommand> {
}
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);
}
+1 -1
View File
@@ -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.
*/