openclaw - ✅(Solved) Fix runRestartScript propagates synchronous spawn() errors as unhandled rejections [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#83892Fetched 2026-05-20 03:47:25
View on GitHub
Comments
1
Participants
2
Timeline
8
Reactions
1
Timeline (top)
labeled ×6commented ×1cross-referenced ×1

Error Message

Node.js child_process.spawn() can throw synchronously (e.g. ENOENT if /bin/sh is absent on an embedded system, or EACCES if the tmp script lacks execute permission). The function has no try/catch and no error listener on the ChildProcess. A synchronous throw escapes the async wrapper as an unhandled rejection, crashing the update process at the exact moment the gateway is already stopped waiting to be restarted. The caller in update-command.ts must also handle the rejection, but defensive handling at the source is safer. Wrap the spawn call in try/catch and attach a no-op error handler on the child process to prevent unhandled rejection: try { const child = spawn(...); child.on('error', () => {}); child.unref(); } catch { /* best-effort restart; caller continues */ } it('does not throw when spawn rejects', async () => { vi.spyOn(childProcess, 'spawn').mockImplementation(() => { throw new Error('ENOENT'); }); await expect(runRestartScript('/tmp/test.sh')).resolves.toBeUndefined(); }); Single try/catch around the spawn call plus a no-op child.on('error') listener inside runRestartScript.

Fix Action

Fix / Workaround

Severity: medium / Confidence: high / Category: bug Triage: confirmed-bug Detected against: openclaw v2026.5.18 (latest stable at time of scan, 2026-05-18) Tooling: clawpatch 0.3.0 + acpx/claude-sonnet-4-5 via Brad Mills protocol


Standardized clawpatch finding. Persistent in v2026.5.18 (not resolved by upgrading from v2026.5.12). Finding ID: fnd_sig-feat-cli-command-0e1f16a0ce-_d0126d3aea.

PR fix notes

PR #84131: fix(cli): harden runRestartScript against spawn failures (#83892)

Description (problem / solution / changelog)

Fixes #83892.

runRestartScript (src/cli/update-cli/restart-helper.ts:393) is the last thing the update command does before the gateway tears down — systemctl restart / launchctl kickstart -k will terminate the current process tree, and the detached script is the only thing meant to outlive it. The original implementation left two unhandled failure modes:

  1. child_process.spawn can throw synchronously — ENOENT if /bin/sh is absent on a stripped embedded image (Alpine without busybox-extras, BSD jails, minimal containers), EACCES if the prepared /tmp script lacks execute permission (noexec mount, restricted umask).
  2. Even when spawn succeeds, an async 'error' event on the ChildProcess (delayed ENOENT/EACCES on some platforms, late LSM denial) becomes an unhandled rejection without a listener.

In both cases the update process crashes at the exact moment the gateway is already stopped waiting to be restarted — the worst possible timing.

Changes

  • src/cli/update-cli/restart-helper.ts: wrap the spawn call in try/catch and attach a no-op child.on('error', () => {}) listener before child.unref(). Treat the restart as best-effort: the caller already logged the restart intent; swallowing both failure modes lets caller-side drain logic continue instead of leaving an unhandled-rejection stack trace.
  • src/cli/update-cli/restart-helper.test.ts: update the existing mockChild to include on: vi.fn() so the new listener attachment doesn't crash the existing happy-path tests. Add two regression cases — synchronous spawn throw (function resolves cleanly) and async-error listener attachment (the listener swallows a synthetic error event without re-throwing).

Diff stat: 2 files, +48 / -9.

Real behavior proof

  • Behavior or issue addressed: Sanitized issue evidence — spawn has no try/catch and the ChildProcess has no 'error' listener. The issue's reproduction (force /bin/sh missing or remove execute permission from the prepared script path) triggers an unhandled rejection that crashes the update process.

  • Real environment tested: Local Node 22.x. Probe at /tmp/probe_83892.mjs (a) parses the patched restart-helper.ts and verifies the try/catch wrapper, the on('error', () => {}) listener, the preserved child.unref(), and that the catch block exists; and (b) replays the three scenarios — happy path (spawn returns a child, listener attaches, unref runs), synchronous throw (patched returns null without throwing, buggy version propagates the throw), and a late async-error event (patched listener swallows it without re-throwing).

  • Exact steps or command run after this patch: node /tmp/probe_83892.mjs

  • Evidence after fix:

PASS: try/catch wraps spawn + on('error') listener attached before unref
PASS: no-op error listener prevents unhandled rejections from async 'error' events
PASS: synchronous spawn throw is swallowed (best-effort restart)
PASS: child.unref() preserved so script outlives the parent
PASS: replay (happy path / patched): spawn → on('error',...) → unref, all events occurred
PASS: replay (sync throw / patched): function resolves to null without throwing
PASS: replay (sync throw / buggy): unwrapped spawn throws — confirms root cause
PASS: replay (async error / patched): listener swallows late 'error' event without re-throwing

ALL CASES PASS
  • Observed result after fix: Stripped Linux images without /bin/sh and noexec-mounted /tmp no longer crash the update process when the restart helper runs. The detached script still launches successfully in the happy path (the child.unref() invocation is unchanged), so the production behavior — "script outlives the CLI process" — is preserved end-to-end.

  • What was not tested: A real production update against a system with /bin/sh missing or /tmp mounted noexec. The vitest tests exercise the contract directly via the mocked spawn boundary (forcing both failure modes), and the probe replays the same predicate end-to-end against both the patched and buggy shapes.

Audit (per CLAUDE rules — all 5 steps)

  • Existing-helper check: Uses standard Node child_process primitives that are already imported and used in this file. No new helper. PASS
  • Shared-helper caller check: runRestartScript has exactly one production caller (src/cli/update-cli.ts action). The contract (Promise<void> that resolves after spawning) is preserved — the function still resolves to undefined in all three scenarios (happy, sync throw, async error). The existing update-cli.test.ts mocks already use mockResolvedValue(undefined), which matches. PASS
  • Broader-fix rival scan: gh pr list --search '83892 in:title,body' and gh pr list --search 'runRestartScript in:title,body' return no open PRs. PASS
  • Recent-merge audit: git log --oneline -5 -- src/cli/update-cli/restart-helper.ts shows e1061a8b46 test(live): tolerate provider drift in release checks — unrelated. PASS
  • Prototype-pollution scan: N/A — no external-input key copying.

Changed files

  • src/cli/update-cli/restart-helper.test.ts (modified, +29/-3)
  • src/cli/update-cli/restart-helper.ts (modified, +19/-6)

Code Example

export async function runRestartScript(scriptPath: string): Promise<void> {
  const isWindows = process.platform === "win32";
  const file = isWindows ? "cmd.exe" : "/bin/sh";
  const args = isWindows ? ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)] : [scriptPath];

  const child = spawn(file, args, {
    detached: true,
    stdio: "ignore",
    windowsHide: true,
  });
  child.unref();
}
RAW_BUFFERClick to expand / collapse

Severity: medium / Confidence: high / Category: bug Triage: confirmed-bug Detected against: openclaw v2026.5.18 (latest stable at time of scan, 2026-05-18) Tooling: clawpatch 0.3.0 + acpx/claude-sonnet-4-5 via Brad Mills protocol

Evidence

  • src/cli/update-cli/restart-helper.ts:248-270 (runRestartScript)
export async function runRestartScript(scriptPath: string): Promise<void> {
  const isWindows = process.platform === "win32";
  const file = isWindows ? "cmd.exe" : "/bin/sh";
  const args = isWindows ? ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)] : [scriptPath];

  const child = spawn(file, args, {
    detached: true,
    stdio: "ignore",
    windowsHide: true,
  });
  child.unref();
}

Reasoning

Node.js child_process.spawn() can throw synchronously (e.g. ENOENT if /bin/sh is absent on an embedded system, or EACCES if the tmp script lacks execute permission). The function has no try/catch and no error listener on the ChildProcess. A synchronous throw escapes the async wrapper as an unhandled rejection, crashing the update process at the exact moment the gateway is already stopped waiting to be restarted. The caller in update-command.ts must also handle the rejection, but defensive handling at the source is safer.

Reproduction

Force /bin/sh to be missing or remove execute permission from the prepared script path; calling runRestartScript will throw rather than silently failing.

Recommendation

Wrap the spawn call in try/catch and attach a no-op error handler on the child process to prevent unhandled rejection: try { const child = spawn(...); child.on('error', () => {}); child.unref(); } catch { /* best-effort restart; caller continues */ }

Why existing tests miss this

No tests exist for this module. The failure mode requires controlling spawn behaviour which unit tests with a mocked spawn would expose.

Suggested regression test

it('does not throw when spawn rejects', async () => { vi.spyOn(childProcess, 'spawn').mockImplementation(() => { throw new Error('ENOENT'); }); await expect(runRestartScript('/tmp/test.sh')).resolves.toBeUndefined(); });

Minimum fix scope

Single try/catch around the spawn call plus a no-op child.on('error') listener inside runRestartScript.


Standardized clawpatch finding. Persistent in v2026.5.18 (not resolved by upgrading from v2026.5.12). Finding ID: fnd_sig-feat-cli-command-0e1f16a0ce-_d0126d3aea.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING