Skip to content

fix(cli): add timeout to downloadSandboxConfig in dashboard recovery - #2470

Closed
ericksoa wants to merge 4 commits into
mainfrom
fix/dashboard-recover-timeout
Closed

fix(cli): add timeout to downloadSandboxConfig in dashboard recovery#2470
ericksoa wants to merge 4 commits into
mainfrom
fix/dashboard-recover-timeout

Conversation

@ericksoa

@ericksoa ericksoa commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds 15s timeout to the openshell sandbox download call in downloadSandboxConfig (used by verifyDashboardChain CORS check)
  • Prevents nemoclaw status from hanging indefinitely when sandbox SSH stalls
  • Root cause of sandbox-survival, skip-permissions, and sandbox-operations E2E failures since Apr 25

Root cause

PR #2398 added verifyDashboardChain() to the nemoclaw statuscheckAndRecoverSandboxProcesses()recoverDashboardChain() path. The CORS verification calls downloadSandboxConfig() which runs openshell sandbox download (SSH into sandbox) with no timeout. The old recovery path never downloaded openclaw.json.

When sandbox SSH is slow (common in CI right after creation), the download blocks indefinitely → nemoclaw status hangs → E2E tests hit their job timeout (exit 124).

The recovery chain calls verifyDashboardChain twice (before and after recovery), so there are two unbounded SSH calls per status check.

Fix

Add timeout: 15000 (15s, matching executeSandboxCommand) to the runOpenshell call. On timeout, the download returns non-zero → downloadSandboxConfig returns null → CORS reports "could not download openclaw.json" → recovery continues without hanging.

Bisect evidence

Run Commit Result
Last good de97a00d (Apr 24 16:06) all 3 pass
Bisect 4 9fbfbaca (#2398 only) pending
Bisect 3 b804db09 (#2398 + #2408) hanging
Bisect 2 f7dff7b4 (4 commits incl #2398) hanging
Bisect 1 f41f5ec4 (7 commits incl #2398) hanging
First bad 79c8e2a9 (Apr 25 00:10) all 3 fail

Test plan

  • npx tsc -p tsconfig.src.json --noEmit passes
  • Dashboard unit tests pass (contract, health, recover)
  • Nightly E2E: sandbox-survival, skip-permissions, sandbox-operations pass

Summary by CodeRabbit

  • Bug Fixes

    • Recovery download step now enforces a 15‑second timeout to avoid hangs.
  • New Features

    • Command execution accepts a configurable timeout so operations can be limited.
  • Tests

    • Status check in end-to-end tests now enforces a 60‑second timeout and emits extra diagnostics on failure.
  • Chores

    • Added runtime diagnostic markers/logging to surface detailed progress during recovery and status checks.

The CORS verification step introduced in #2398 calls
`openshell sandbox download` (SSH into sandbox) with no timeout.
If SSH stalls — common in CI right after sandbox creation — the
entire `nemoclaw status` command hangs indefinitely, causing E2E
test timeouts (exit 124) on sandbox-survival, skip-permissions,
and sandbox-operations.

Add a 15s timeout matching executeSandboxCommand. On timeout the
download returns non-zero, downloadSandboxConfig returns null, and
CORS reports "could not download" instead of blocking forever.
@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Enforces a 15s execution timeout for the OpenClaw sandbox download, adds timestamped diagnostic markers to sandboxStatus, exposes a timeout?: number option for openshell spawns, and applies a 60s timeout and extra diagnostics in the sandbox survival E2E test. Error semantics remain unchanged.

Changes

Cohort / File(s) Summary
OpenClaw download timeout & sandbox diagnostics
src/nemoclaw.ts
Apply a 15s execution timeout to the openshell sandbox download step that retrieves /sandbox/.openclaw/openclaw.json. Add an internal _diag logger that appends timestamped markers to /tmp/nemoclaw-status-diag.log and emit markers at start/end and before/after key phases in sandboxStatus. Preserve ignoreError: true and suppressed stdio.
Openshell timeout option
src/lib/openshell.ts
Add timeout?: number to OpenshellSpawnOptions and conditionally pass timeout into spawnSyncImpl from runOpenshellCommand and captureOpenshellCommand when non-null.
E2E test: status timeout & diagnostics
test/e2e/test-sandbox-survival.sh
Run the Phase 3 nemoclaw ... status check under a 60s timeout, reset the diag log before running, and on failure emit exit code, truncated status output, and the contents (or absence) of /tmp/nemoclaw-status-diag.log before failing the test.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Fifteen seconds I gently tap,
I scribble timestamps on the map,
A timeout flag, a quieter hum,
Tests watch keenly — diagnostics come,
I nibble logs and then I nap. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(cli): add timeout to downloadSandboxConfig in dashboard recovery' directly and accurately summarizes the primary change: adding a 15-second timeout to the downloadSandboxConfig function's openshell sandbox download call to prevent indefinite hanging during dashboard recovery.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dashboard-recover-timeout

Comment @coderabbitai help to get the list of available commands and usage tips.

…mmand

The timeout option was accepted by the nemoclaw.ts runOpenshell wrapper
but never passed to spawnSync in openshell.ts — silently dropped.

Add timeout to OpenshellSpawnOptions and forward it to spawnSync in
both runOpenshellCommand and captureOpenshellCommand. This makes the
15s timeout on downloadSandboxConfig (added in the previous commit)
actually take effect, preventing nemoclaw status from hanging when
openshell sandbox download stalls.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/openshell.ts`:
- Around line 91-94: The code currently routes any spawnSync errors to
handleSpawnError(binary, args, result.error, opts), which exits on timeout;
update the logic around the spawnSync result check (where result, binary, args,
opts are used) to detect timeout-specific cases (result.error.code ===
"ETIMEDOUT" and result.status === null) before calling handleSpawnError and
instead return a recoverable failure object (e.g., success: false with a
timedOut flag and captured stdout/stderr) so callers can handle recovery; apply
the same timeout-special-case check and return behavior in the second spawnSync
error branch around the later check (the other block that currently calls
handleSpawnError).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: a41c8466-121d-4fcb-abc9-4b73b35b545a

📥 Commits

Reviewing files that changed from the base of the PR and between ba471df and a6ae606.

📒 Files selected for processing (1)
  • src/lib/openshell.ts

Comment thread src/lib/openshell.ts
Comment on lines +91 to 94
...(opts.timeout != null && { timeout: opts.timeout }),
});
if (result.error) {
return handleSpawnError(binary, args, result.error, opts);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In Node.js child_process.spawnSync, when a timeout is hit, what fields are populated in the return object (error, status, signal), and is error.codeset toETIMEDOUT?

💡 Result:

When child_process.spawnSync hits a timeout, it kills the child process with the killSignal (default 'SIGTERM'), waits for it to exit, and returns an object with: - error: populated with an Error instance where error.code === 'ETIMEDOUT' - status: null (since terminated by signal) - signal: the kill signal used (e.g., 'SIGTERM') To detect timeout: check if (result.error && result.error.code === 'ETIMEDOUT').

Citations:


🏁 Script executed:

# First, check if the file exists and get its size
wc -l src/lib/openshell.ts

Repository: NVIDIA/NemoClaw

Length of output: 84


🏁 Script executed:

# Read the relevant sections around lines 91-94 and 116-119
sed -n '85,105p' src/lib/openshell.ts

Repository: NVIDIA/NemoClaw

Length of output: 756


🏁 Script executed:

# Also read the section around line 116-119
sed -n '110,130p' src/lib/openshell.ts

Repository: NVIDIA/NemoClaw

Length of output: 704


🏁 Script executed:

# Find and read the handleSpawnError function
rg -A 10 "function handleSpawnError" src/lib/openshell.ts

Repository: NVIDIA/NemoClaw

Length of output: 376


🏁 Script executed:

# Check git history to see if this is new timeout handling
git log --oneline -n 20 -- src/lib/openshell.ts

Repository: NVIDIA/NemoClaw

Length of output: 142


🏁 Script executed:

# Search for any timeout-related tests or comments
rg -i "timeout" src/lib/openshell.ts

Repository: NVIDIA/NemoClaw

Length of output: 199


🏁 Script executed:

# Look for any tests related to openshell or spawn errors
fd -e "test\.(ts|js)" -o -e "spec\.(ts|js)" | xargs rg -l "openshell|spawnSync" 2>/dev/null | head -5

Repository: NVIDIA/NemoClaw

Length of output: 299


🏁 Script executed:

# Check the actual commit that added timeout handling
git show a6ae606 --stat

Repository: NVIDIA/NemoClaw

Length of output: 35863


🏁 Script executed:

# Look at the full diff of the timeout commit
git show a6ae606

Repository: NVIDIA/NemoClaw

Length of output: 50375


🏁 Script executed:

# Check if there are timeout-related tests
cat src/lib/openshell.test.ts | grep -A 10 -B 2 "timeout\|ETIMEDOUT"

Repository: NVIDIA/NemoClaw

Length of output: 41


🏁 Script executed:

# Also check the full test file to understand test coverage
wc -l src/lib/openshell.test.ts
head -50 src/lib/openshell.test.ts

Repository: NVIDIA/NemoClaw

Length of output: 1301


🏁 Script executed:

# Read the entire test file to check for timeout tests
cat src/lib/openshell.test.ts

Repository: NVIDIA/NemoClaw

Length of output: 4468


Timeout errors still cause hard exit instead of returning failed status for recovery.

When spawnSync times out, it populates result.error with code === "ETIMEDOUT" and status === null. Currently, lines 93 and 118 route all result.error cases to handleSpawnError(), which exits with status 1. This bypasses any recovery flow the caller might implement.

The timeout parameter was added to prevent nemoclaw status from hanging indefinitely, but the current code exits immediately on timeout instead of allowing the caller to handle it. Add a timeout-specific check before calling handleSpawnError():

Suggested fix
+function isSpawnTimeoutError(error: Error): boolean {
+  return (error as NodeJS.ErrnoException).code === "ETIMEDOUT";
+}
+
 export function runOpenshellCommand(
   binary: string,
   args: string[],
   opts: RunOpenshellOptions = {},
 ): SpawnSyncReturns<string> {
@@
   });
   if (result.error) {
+    if (isSpawnTimeoutError(result.error)) {
+      const timedOutResult: SpawnSyncReturns<string> = {
+        ...result,
+        error: undefined,
+        status: result.status ?? 124,
+      };
+      if (timedOutResult.status !== 0 && !opts.ignoreError) {
+        (opts.errorLine ?? console.error)(
+          `  Command timed out: openshell ${args.join(" ")}`,
+        );
+        return (opts.exit ?? ((code) => process.exit(code)))(timedOutResult.status);
+      }
+      return timedOutResult;
+    }
     return handleSpawnError(binary, args, result.error, opts);
   }
@@
 export function captureOpenshellCommand(
   binary: string,
   args: string[],
   opts: CaptureOpenshellOptions = {},
 ): CaptureOpenshellResult {
@@
   });
   if (result.error) {
+    if (isSpawnTimeoutError(result.error)) {
+      return {
+        status: result.status ?? 124,
+        output: `${result.stdout || ""}${opts.ignoreError ? "" : result.stderr || ""}`.trim(),
+      };
+    }
     return handleSpawnError(binary, args, result.error, opts);
   }

Also applies to: 116-119

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/openshell.ts` around lines 91 - 94, The code currently routes any
spawnSync errors to handleSpawnError(binary, args, result.error, opts), which
exits on timeout; update the logic around the spawnSync result check (where
result, binary, args, opts are used) to detect timeout-specific cases
(result.error.code === "ETIMEDOUT" and result.status === null) before calling
handleSpawnError and instead return a recoverable failure object (e.g., success:
false with a timedOut flag and captured stdout/stderr) so callers can handle
recovery; apply the same timeout-special-case check and return behavior in the
second spawnSync error branch around the later check (the other block that
currently calls handleSpawnError).

Temporary instrumentation — will be removed once we identify which
openshell call hangs in the E2E environment.
Write diag markers to /tmp/nemoclaw-status-diag.log (survives subshell
capture). Add 60s timeout to the nemoclaw status call in test-sandbox-survival
so the test prints the diag log and fails fast instead of waiting for the
job timeout.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/nemoclaw.ts (1)

1639-1642: Diagnostic helper looks reasonable; consider cleanup in test harness.

The implementation is appropriately defensive (empty catch prevents diagnostics from breaking the main flow). Note that this log file will accumulate entries across invocations. The test already handles this by resetting the file before each run (rm -f /tmp/nemoclaw-status-diag.log), which is the right approach for E2E diagnostics.

For production, you may eventually want to either:

  • Truncate/rotate on sandboxStatus entry, or
  • Gate behind a NEMOCLAW_DEBUG env var

Low priority since this is diagnostic instrumentation for debugging the specific hang issue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nemoclaw.ts` around lines 1639 - 1642, The _diag diagnostic currently
always appends to /tmp/nemoclaw-status-diag.log; update it to avoid unbounded
growth by gating writes behind an environment flag and/or truncating on key
events: modify function _diag to check process.env.NEMOCLAW_DEBUG (only append
when truthy) and preserve the defensive empty catch, and additionally implement
logic inside _diag (or a small helper called by it) to truncate the log file the
first time a "sandboxStatus" message is logged (or rotate/rename existing file)
so tests can still rm -f but production won’t grow unbounded; keep references to
the same function name (_diag) and preserve the timestamped line format.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/e2e/test-sandbox-survival.sh`:
- Around line 285-293: The snippet uses the external timeout binary directly
(timeout 60 ...) which is not portable on macOS; update the call to use the
previously-detected timeout wrapper (the TIMEOUT_STATUS/TIMEOUT_CMD pattern used
later) or introduce a small helper (e.g., get_timeout_cmd or TIMEOUT_CMD
variable) that selects between timeout and gtimeout and then invoke it with the
60s argument (e.g., "$TIMEOUT_CMD" 60 nemoclaw "$SANDBOX_NAME" status) so the
same portability check logic as lines that set TIMEOUT_STATUS/TIMEOUT_CMD is
reused.

---

Nitpick comments:
In `@src/nemoclaw.ts`:
- Around line 1639-1642: The _diag diagnostic currently always appends to
/tmp/nemoclaw-status-diag.log; update it to avoid unbounded growth by gating
writes behind an environment flag and/or truncating on key events: modify
function _diag to check process.env.NEMOCLAW_DEBUG (only append when truthy) and
preserve the defensive empty catch, and additionally implement logic inside
_diag (or a small helper called by it) to truncate the log file the first time a
"sandboxStatus" message is logged (or rotate/rename existing file) so tests can
still rm -f but production won’t grow unbounded; keep references to the same
function name (_diag) and preserve the timestamped line format.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 84348731-a217-4e41-b188-af7e9f41b618

📥 Commits

Reviewing files that changed from the base of the PR and between 5df75c6 and 25679a0.

📒 Files selected for processing (2)
  • src/nemoclaw.ts
  • test/e2e/test-sandbox-survival.sh

Comment on lines +285 to 293
rm -f /tmp/nemoclaw-status-diag.log
if status_output=$(timeout 60 nemoclaw "$SANDBOX_NAME" status 2>&1); then
pass "nemoclaw $SANDBOX_NAME status exits 0"
else
echo "[diag] nemoclaw status exit code: $?"
echo "[diag] status output: ${status_output:0:500}"
echo "[diag] diag log:"
cat /tmp/nemoclaw-status-diag.log 2>/dev/null || echo "(no diag log)"
fail "nemoclaw $SANDBOX_NAME status failed: ${status_output:0:200}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Portability issue: timeout command used without availability check.

Line 286 uses timeout directly, but on macOS the command is gtimeout (from coreutils). Later in this same file (lines 534-536), there's a proper portability check:

command -v timeout >/dev/null 2>&1 && TIMEOUT_STATUS="timeout 120"
command -v gtimeout >/dev/null 2>&1 && TIMEOUT_STATUS="gtimeout 120"

Consider applying the same pattern here for consistency:

Proposed fix for timeout portability
 # 3d: nemoclaw status works
 rm -f /tmp/nemoclaw-status-diag.log
-if status_output=$(timeout 60 nemoclaw "$SANDBOX_NAME" status 2>&1); then
+TIMEOUT_CMD=""
+command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD="timeout 60"
+command -v gtimeout >/dev/null 2>&1 && TIMEOUT_CMD="gtimeout 60"
+if status_output=$($TIMEOUT_CMD nemoclaw "$SANDBOX_NAME" status 2>&1); then
   pass "nemoclaw $SANDBOX_NAME status exits 0"

Alternatively, extract a helper function since this pattern is used multiple times.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/e2e/test-sandbox-survival.sh` around lines 285 - 293, The snippet uses
the external timeout binary directly (timeout 60 ...) which is not portable on
macOS; update the call to use the previously-detected timeout wrapper (the
TIMEOUT_STATUS/TIMEOUT_CMD pattern used later) or introduce a small helper
(e.g., get_timeout_cmd or TIMEOUT_CMD variable) that selects between timeout and
gtimeout and then invoke it with the 60s argument (e.g., "$TIMEOUT_CMD" 60
nemoclaw "$SANDBOX_NAME" status) so the same portability check logic as lines
that set TIMEOUT_STATUS/TIMEOUT_CMD is reused.

@ericksoa

Copy link
Copy Markdown
Contributor Author

Superseded by #2471 (revert of #2398) and #2472 (sandbox safety fixes). Closing.

@ericksoa ericksoa closed this Apr 25, 2026
@wscurran wscurran added the bug-fix PR fixes a bug or regression label Jun 8, 2026
@cv
cv deleted the fix/dashboard-recover-timeout branch June 28, 2026 00:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug-fix PR fixes a bug or regression

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants