mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
fix: await main() in cli entrypoint to prevent premature exit in Node 24.x (#1697)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user