Skip to content

fix(cli): call process.exit(1) in root help fast path error handler (#97793)#97807

Merged
vincentkoc merged 1 commit into
openclaw:mainfrom
maweibin:fix/cli-root-help-exit-97793
Jun 29, 2026
Merged

fix(cli): call process.exit(1) in root help fast path error handler (#97793)#97807
vincentkoc merged 1 commit into
openclaw:mainfrom
maweibin:fix/cli-root-help-exit-97793

Conversation

@maweibin

@maweibin maweibin commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #97793.

What Problem This Solves

The error handler in tryHandleRootHelpFastPath sets process.exitCode = 1 but does not call process.exit(1). Since the fast path skips the main runtime teardown, any dangling async handles (open connections, timers, promises) keep the Node event loop alive, hanging the CLI indefinitely instead of returning to the terminal. The same pattern exists in tryHandleRootVersionFastPath.

Why This Change Was Made

  • src/entry.ts (tryHandleRootHelpFastPath): Changed process.exitCode = 1 to process.exit(1) so the process terminates immediately after logging the error, regardless of any dangling handles.
  • src/entry.version-fast-path.ts (tryHandleRootVersionFastPath): Changed the default error handler from process.exit(1) to exit(1) — using the local exit variable that resolves to deps.exit ?? process.exit. This preserves the existing injection seam so that tests and embedded callers that supply exit can control shutdown behavior. The success path already routes through the same injected exit hook; the error path now matches.

User Impact

CLI no longer hangs when openclaw --help or openclaw --version hits an error during the fast path with open async handles. The exit code remains 1.

Evidence

Behavior addressed: Error handlers in root help and root version fast paths only set exitCode / bypassed the injected exit seam, risking CLI hangs from dangling async handles.

Real environment tested: Node 24.13.1, local OpenClaw checkout at /home/0668000787/open-source-pr/openclaw/openclaw.

Exact steps or command run after this patch:

# Child-process proof: dangling HTTP server + default error handler → process.exit(1)
cat > /tmp/proof-default-exit.mjs << 'SCRIPT'
import http from 'node:http';
import { tryHandleRootHelpFastPath } from '/home/0668000787/open-source-pr/openclaw/openclaw/src/entry.js';

await new Promise((resolve) => {
  const server = http.createServer((_req, res) => { res.end('ok'); });
  server.listen(0, () => {
    console.error("[proof] dangling server on", server.address().port);
    resolve(undefined);
  });
});

await tryHandleRootHelpFastPath(['node', 'openclaw', '--help'], {
  loadRootHelpRenderOptionsForConfigSensitivePlugins: async () => {
    throw new Error('TEST: config load failed');
  },
});

console.error('[proof] UNEXPECTED: reached end without process.exit(1)');
process.exitCode = 99;
SCRIPT

echo "=== PROOF RUN START ==="
timeout 8 node --import tsx /tmp/proof-default-exit.mjs 2>&1
echo "EXIT_CODE: $?"
echo "=== PROOF RUN END ==="

Evidence after fix:

=== PROOF RUN START ===
[proof] dangling server on 44653
[openclaw] Failed to display help: Error: TEST: config load failed
    at loadRootHelpRenderOptionsForConfigSensitivePlugins (file:///tmp/proof-default-exit.mjs:14:11)
    at tryHandleRootHelpFastPath (/home/0668000787/open-source-pr/openclaw/openclaw/src/entry.ts:168:39)
    at file:///tmp/proof-default-exit.mjs:12:7
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
EXIT_CODE: 1
=== PROOF RUN END ===

Key observations from the terminal output:

  • Dangling HTTP server is active on port 44653 — this open handle would keep the event loop alive without process.exit()
  • Default error handler fires without any onError injection — [openclaw] Failed to display help: Error: TEST: config load failed
  • Stack trace confirms the code path: proof-default-exit.mjs:12entry.ts:168handleErrorprocess.exit(1)
  • process.exit(1) terminates immediately despite the open HTTP server — exit code 1, not timeout 124
  • [proof] UNEXPECTED message never appears — process was killed by process.exit(1) before reaching it
# Version fast-path injection proof: resolveVersion rejects → injected exit(1)
node --import tsx -e "
import { tryHandleRootVersionFastPath } from './src/entry.version-fast-path.js';
let capturedCode = undefined;
const handled = tryHandleRootVersionFastPath(['node', 'openclaw', '--version'], {
  exit: (code) => { capturedCode = code; },
  resolveVersion: async () => { throw new Error('TEST: version resolution failed'); },
});
await new Promise(r => setTimeout(r, 100));
console.log(JSON.stringify({ scenario: 'resolveVersion rejects', handled, capturedCode }));
"
[openclaw] Failed to resolve version: Error: TEST: version resolution failed
    at resolveVersion (file:///.../[eval1]:8:39)
    at tryHandleRootVersionFastPath (/.../src/entry.version-fast-path.ts:46:3)
{"scenario":"resolveVersion rejects","handled":true,"capturedCode":1}

Before fix: process.exit(1) called directly, bypassing the injected exit dependency.
After fix: exit(1) uses the local exit variable (deps.exit ?? process.exit), honoring the injection seam.

node scripts/run-vitest.mjs src/entry.version-fast-path.test.ts --run
 Test Files  1 passed (1)
      Tests  3 passed (3)
  • call exit(1) via injected exit hook when resolveVersion rejects — NEW
  • call injected onError when provided and resolveVersion rejects — NEW
  • prints version output and skips host handling when container-targeted — existing
node scripts/run-vitest.mjs src/entry.test.ts src/entry.root-help-fast-path.test.ts --run
 Test Files  2 passed (2)
      Tests  25 passed (25)
node scripts/run-oxlint.mjs src/entry.version-fast-path.ts src/entry.version-fast-path.test.ts
(passed, exit 0)

Observed result after fix: Root-help fast-path default error handler calls process.exit(1) which terminates the process immediately even with active async handles (open HTTP server, port 44653). The version fast-path error handler routes through the injected exit dependency, matching the success path, so tests and embedded callers can observe and control exit behavior on both paths.

What was not tested: Live E2E with a real openclaw --help CLI command hitting the error path (requires corrupting the config-sensitive plugins module at runtime). The fix is verified through a child-process scenario that exercises the DEFAULT error handler (no onError injection) with a dangling HTTP server as the active handle that would otherwise keep the event loop alive.

Regression Test Plan

  • node scripts/run-vitest.mjs src/entry.version-fast-path.test.ts --run — Test Files 1 passed (1), Tests 3 passed (3)
  • node scripts/run-vitest.mjs src/entry.test.ts --run — Test Files 1 passed (1), Tests 21 passed (21)
  • node scripts/run-vitest.mjs src/entry.root-help-fast-path.test.ts --run — Test Files 1 passed (1), Tests 4 passed (4)
  • node scripts/run-oxlint.mjs src/entry.version-fast-path.ts src/entry.version-fast-path.test.ts — exit 0

Root Cause

In tryHandleRootVersionFastPath, the default onError handler called process.exit(1) directly instead of routing through the local exit variable (deps.exit ?? process.exit). The success path already uses exit(0) preserving the injection seam, but the error path bypassed it. In tryHandleRootHelpFastPath, the error handler only set process.exitCode = 1 without calling process.exit(); when dangling async handles exist, the Node event loop stays open and the CLI hangs indefinitely.

AI Assistance

  • AI-assisted: Yes
  • Model used: Claude Opus 4.8 (1M context)
  • Co-Authored-By: Already in commit message

@clawsweeper

clawsweeper Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed June 29, 2026, 11:42 AM ET / 15:42 UTC.

Summary
The PR changes root help and root version fast-path failure handlers to terminate through process/injected exit paths and adds root-version rejection-path tests.

PR surface: Source 0, Tests +41. Total +41 across 3 files.

Reproducibility: yes. Current-main source shows the default fast-path failure handlers only set process.exitCode, and the PR body gives a high-confidence child-process proof shape with an active HTTP server for the after-fix behavior.

Review metrics: 1 noteworthy metric.

  • Fast-path failure handlers: 2 changed, 0 exitCode-only default failure handlers left in PR head. Root help and root version both bypass normal CLI teardown, so maintainers should see that the sibling invariant is covered.

Root-cause cluster
Relationship: fixed_by_candidate
Canonical: #97793
Summary: This PR is the active candidate fix for the open root-help fast-path hang report; the runtime ExitError PR and root-help performance PR are adjacent but distinct.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🐚 platinum hermit
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • none.

Next step before merge

  • No repair job is needed; the PR has no concrete code findings and should proceed through normal maintainer review and landing gates.

Security
Cleared: Cleared: the diff changes only CLI termination behavior and focused tests, with no dependency, workflow, secret, package, or supply-chain surface.

Review details

Best possible solution:

Land the narrow fast-path exit fix after normal maintainer review, then let the linked issue close after merge.

Do we have a high-confidence way to reproduce the issue?

Yes. Current-main source shows the default fast-path failure handlers only set process.exitCode, and the PR body gives a high-confidence child-process proof shape with an active HTTP server for the after-fix behavior.

Is this the best way to solve the issue?

Yes. The fix belongs at the fast-path error boundary: root help should terminate on default failure, while root version should use its existing injected exit seam instead of adding a broader runtime dependency.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 1052652a7168.

Label changes

Label justifications:

  • P2: The PR addresses a bounded CLI hang/availability bug in root help and version startup fast paths with limited blast radius.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): Sufficient terminal proof is in the PR body: it shows the patched root-help default failure path exiting despite an active HTTP server, plus version injected-exit output and focused test results.
  • proof: sufficient: Contributor real behavior proof is sufficient. Sufficient terminal proof is in the PR body: it shows the patched root-help default failure path exiting despite an active HTTP server, plus version injected-exit output and focused test results.
Evidence reviewed

PR surface:

Source 0, Tests +41. Total +41 across 3 files.

View PR surface stats
Area Files Added Removed Net
Source 2 2 2 0
Tests 1 41 0 +41
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 3 43 2 +41

What I checked:

  • Repository policy read: Root AGENTS.md was read fully; no scoped AGENTS.md directly owns src/entry.ts or src/entry.version-fast-path.ts, so root PR-review and source/history policy applies. (AGENTS.md:1, 1052652a7168)
  • Current root-help failure behavior: Current main's root-help fast path default error handler logs the failure and only sets process.exitCode = 1 before returning handled. (src/entry.ts:155, 1052652a7168)
  • Current root-version failure behavior: Current main's root-version fast path has an injected exit seam for success, but its default rejection handler only sets process.exitCode = 1. (src/entry.version-fast-path.ts:27, 1052652a7168)
  • Fast path bypasses normal cleanup: runMainOrRootHelp returns immediately when root help is handled, while normal run-main cleanup is in a later finally block, so the fast-path error boundary is the right fix location. (src/entry.ts:271, 1052652a7168)
  • Normal CLI cleanup is outside the fast path: The normal CLI path tears down runtime hooks, proxy, harnesses, memory managers, and stdin in run-main's finally block, which root-help fast-path handling does not reach. (src/cli/run-main.ts:1089, 1052652a7168)
  • PR source change: The PR head changes the root-help default failure handler to process.exit(1). (src/entry.ts:162, 2cfba462f519)

Likely related people:

  • Vincent Koc: Authored the root-version fast path and the root-help fast-path bootstrap work where exitCode-only failure branches appear in history. (role: introduced behavior and feature-history owner; confidence: high; commits: 153adc4c8f14, 38da2d076c8a, bcee8c61e27c; files: src/entry.ts, src/entry.version-fast-path.ts)
  • Josh Avant: Authored recent root-help live-config work that the current fast path consults before rendering dynamic help. (role: recent adjacent CLI startup contributor; confidence: medium; commits: eb6dd2c65d11; files: src/entry.ts, src/cli/root-help-live-config.ts, src/cli/run-main.ts)
  • Anson_H: Recently expanded precomputed command-help startup behavior and tests around src/entry.ts and launcher e2e coverage. (role: recent adjacent fast-path contributor; confidence: medium; commits: 3895c9341bed; files: src/entry.ts, src/entry.test.ts, test/openclaw-launcher.e2e.test.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. labels Jun 29, 2026
…or handlers (openclaw#97793)

The error handler in tryHandleRootHelpFastPath only set process.exitCode=1
without calling process.exit(). When dangling async handles exist (timers,
connections, promises), the Node event loop stays open and the CLI hangs
indefinitely instead of returning to the terminal.

In tryHandleRootVersionFastPath, the default onError handler called
process.exit(1) directly, bypassing the injected exit seam that the
success path already uses. Changed to exit(1) (deps.exit ?? process.exit)
so tests and embedded callers can observe and control exit on failure.

Added rejection-path tests for version fast path: injected exit called
on resolveVersion rejection, and caller-supplied onError honored.

Fixes openclaw#97793.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maweibin
maweibin force-pushed the fix/cli-root-help-exit-97793 branch from 638268f to 2cfba46 Compare June 29, 2026 14:58
@maweibin

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@maweibin

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jun 29, 2026
@vincentkoc
vincentkoc merged commit 84cd3aa into openclaw:main Jun 29, 2026
149 of 156 checks passed
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jun 30, 2026
…or handlers (openclaw#97793) (openclaw#97807)

The error handler in tryHandleRootHelpFastPath only set process.exitCode=1
without calling process.exit(). When dangling async handles exist (timers,
connections, promises), the Node event loop stays open and the CLI hangs
indefinitely instead of returning to the terminal.

In tryHandleRootVersionFastPath, the default onError handler called
process.exit(1) directly, bypassing the injected exit seam that the
success path already uses. Changed to exit(1) (deps.exit ?? process.exit)
so tests and embedded callers can observe and control exit on failure.

Added rejection-path tests for version fast path: injected exit called
on resolveVersion rejection, and caller-supplied onError honored.

Fixes openclaw#97793.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QiuYuang pushed a commit to QiuYuang/openclaw that referenced this pull request Jul 1, 2026
…or handlers (openclaw#97793) (openclaw#97807)

The error handler in tryHandleRootHelpFastPath only set process.exitCode=1
without calling process.exit(). When dangling async handles exist (timers,
connections, promises), the Node event loop stays open and the CLI hangs
indefinitely instead of returning to the terminal.

In tryHandleRootVersionFastPath, the default onError handler called
process.exit(1) directly, bypassing the injected exit seam that the
success path already uses. Changed to exit(1) (deps.exit ?? process.exit)
so tests and embedded callers can observe and control exit on failure.

Added rejection-path tests for version fast path: injected exit called
on resolveVersion rejection, and caller-supplied onError honored.

Fixes openclaw#97793.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chenyangjun-xy pushed a commit to chenyangjun-xy/openclaw that referenced this pull request Jul 1, 2026
…or handlers (openclaw#97793) (openclaw#97807)

The error handler in tryHandleRootHelpFastPath only set process.exitCode=1
without calling process.exit(). When dangling async handles exist (timers,
connections, promises), the Node event loop stays open and the CLI hangs
indefinitely instead of returning to the terminal.

In tryHandleRootVersionFastPath, the default onError handler called
process.exit(1) directly, bypassing the injected exit seam that the
success path already uses. Changed to exit(1) (deps.exit ?? process.exit)
so tests and embedded callers can observe and control exit on failure.

Added rejection-path tests for version fast path: injected exit called
on resolveVersion rejection, and caller-supplied onError honored.

Fixes openclaw#97793.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chenyangjun-xy pushed a commit to chenyangjun-xy/openclaw that referenced this pull request Jul 3, 2026
…or handlers (openclaw#97793) (openclaw#97807)

The error handler in tryHandleRootHelpFastPath only set process.exitCode=1
without calling process.exit(). When dangling async handles exist (timers,
connections, promises), the Node event loop stays open and the CLI hangs
indefinitely instead of returning to the terminal.

In tryHandleRootVersionFastPath, the default onError handler called
process.exit(1) directly, bypassing the injected exit seam that the
success path already uses. Changed to exit(1) (deps.exit ?? process.exit)
so tests and embedded callers can observe and control exit on failure.

Added rejection-path tests for version fast path: injected exit called
on resolveVersion rejection, and caller-supplied onError honored.

Fixes openclaw#97793.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rorqualx pushed a commit to Rorqualx/cortex that referenced this pull request Jul 5, 2026
…or handlers (openclaw#97793) (openclaw#97807)

The error handler in tryHandleRootHelpFastPath only set process.exitCode=1
without calling process.exit(). When dangling async handles exist (timers,
connections, promises), the Node event loop stays open and the CLI hangs
indefinitely instead of returning to the terminal.

In tryHandleRootVersionFastPath, the default onError handler called
process.exit(1) directly, bypassing the injected exit seam that the
success path already uses. Changed to exit(1) (deps.exit ?? process.exit)
so tests and embedded callers can observe and control exit on failure.

Added rejection-path tests for version fast path: injected exit called
on resolveVersion rejection, and caller-supplied onError honored.

Fixes openclaw#97793.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 84cd3aa)
Rorqualx pushed a commit to Rorqualx/cortex that referenced this pull request Jul 8, 2026
…or handlers (openclaw#97793) (openclaw#97807)

The error handler in tryHandleRootHelpFastPath only set process.exitCode=1
without calling process.exit(). When dangling async handles exist (timers,
connections, promises), the Node event loop stays open and the CLI hangs
indefinitely instead of returning to the terminal.

In tryHandleRootVersionFastPath, the default onError handler called
process.exit(1) directly, bypassing the injected exit seam that the
success path already uses. Changed to exit(1) (deps.exit ?? process.exit)
so tests and embedded callers can observe and control exit on failure.

Added rejection-path tests for version fast path: injected exit called
on resolveVersion rejection, and caller-supplied onError honored.

Fixes openclaw#97793.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 84cd3aa)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: XS status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: tryHandleRootHelpFastPath sets process.exitCode but fails to terminate process, risking CLI hangs

2 participants