Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions src/lib/actions/sandbox/process-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,16 +310,26 @@ function ensureSandboxPortForward(sandboxName: string): boolean {
* The in-sandbox gateway and the host-side forward are independent
* dimensions: the forward can die (host SSH session dropped, list shows
* STATUS=dead) while the gateway keeps listening on 127.0.0.1:<port>.
*
* Also falls back to a local TCP/HTTP probe of 127.0.0.1:<port> when
* `forward list` would classify the entry as not-running. openshell's
* STATUS column lags real state — it can show "dead" for an entry that
* is still serving traffic, or hide an entry whose SSH session was just
* recycled (#3334). Trusting the column verbatim made every `connect`
* print a "missing or dead" preamble followed by a "Failed to
* re-establish" line even though the forward worked.
*/
function isSandboxForwardHealthy(sandboxName: string): SandboxForwardHealth {
const port = String(resolveSandboxDashboardPort(sandboxName));
const port = resolveSandboxDashboardPort(sandboxName);
const result = captureOpenshell(["forward", "list"], {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
});
if (!result || isCommandTimeout(result) || result.status !== 0) return null;
const entries = parseForwardList(result.output) as SandboxForwardListEntry[];
return classifySandboxForwardHealth(entries, sandboxName, port);
return classifyForwardHealthWithReachability(entries, sandboxName, String(port), () =>
isLocalForwardReachable(port),
);
}

export function classifySandboxForwardHealth(
Expand All @@ -333,6 +343,50 @@ export function classifySandboxForwardHealth(
return match.status === "running";
}

/**
* Like {@link classifySandboxForwardHealth} but accepts a reachability
* callback that probes whether the local forwarded port actually answers.
* When the entry-based classification would return `false`, the
* reachability check overrides it: a port that answers is healthy
* regardless of what `forward list` reports. The "occupied" verdict is
* preserved — we never silently take over a forward owned by another
* sandbox, even if that forward happens to be reachable.
*/
export function classifyForwardHealthWithReachability(
entries: SandboxForwardListEntry[],
sandboxName: string,
port: string,
isReachable: () => boolean,
): Exclude<SandboxForwardHealth, null> {
const verdict = classifySandboxForwardHealth(entries, sandboxName, port);
if (verdict !== false) return verdict;
return isReachable() ? true : false;
}
Comment on lines +361 to +364

@coderabbitai coderabbitai Bot May 12, 2026

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 | 🟠 Major | ⚡ Quick win

Guard reachability callback errors to keep recovery path non-throwing

If isReachable() throws, this function propagates and can abort connect/recover instead of returning a health verdict. Falling back to false preserves the existing recovery behavior.

Suggested patch
 export function classifyForwardHealthWithReachability(
   entries: SandboxForwardListEntry[],
   sandboxName: string,
   port: string,
   isReachable: () => boolean,
 ): Exclude<SandboxForwardHealth, null> {
   const verdict = classifySandboxForwardHealth(entries, sandboxName, port);
   if (verdict !== false) return verdict;
-  return isReachable() ? true : false;
+  try {
+    return isReachable() ? true : false;
+  } catch {
+    return false;
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/actions/sandbox/process-recovery.ts` around lines 361 - 364, The call
to isReachable() in the tail of the function can throw and bubble up, aborting
recovery; wrap the isReachable() invocation in a try/catch so any exception is
caught and the function returns false instead of throwing. Locate the block
where verdict is computed (using classifySandboxForwardHealth(entries,
sandboxName, port)) and replace the direct return of isReachable() with a
guarded call: try { return await isReachable(); } catch { return false; } (or
the synchronous equivalent) so errors from isReachable do not propagate.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Holding off on this. isReachable is typed () => boolean and has two concrete bindings in this PR:

  • Production: () => isLocalForwardReachable(port), which uses spawnSync and explicitly returns false on every error path (if (result.error) return false). It cannot throw for any normal runtime condition; only programmer-bug argv shapes would, and those would fail at parse/build time.
  • Tests: inline lambdas like () => true, () => false, and () => { probed = true; return false; }. None throw.

Per the project's coding guidance — "Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees. Only validate at system boundaries" — wrapping the call in try/catch would be defensive code at a non-boundary point. The boundary check already lives inside isLocalForwardReachable where the actual I/O happens.

If a future change wires a throwing callback into this classifier, that's a contract break we'd want to surface loudly rather than swallow.

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.

@jason-ma-nv, that's a well-reasoned position — the error boundary is already at isLocalForwardReachable, and surfacing a contract break loudly is the right call. Withdrawing the suggestion.


✏️ Learnings added
Learnt from: jason-ma-nv
Repo: NVIDIA/NemoClaw PR: 3385
File: src/lib/actions/sandbox/process-recovery.ts:361-364
Timestamp: 2026-05-12T06:25:43.759Z
Learning: In the NemoClaw repository, the project coding guidance (CLAUDE.md) explicitly discourages adding defensive error handling, fallbacks, or validation for scenarios that cannot realistically occur. The principle is: trust internal code and framework guarantees, and only validate at system boundaries. Wrapping internal helpers in try/catch when there is no realistic throwing path is considered unnecessary defensive code and should be avoided during code review.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


/**
* Synchronous reachability check for a local port. Used to override a
* negative `openshell forward list` verdict when the forward is actually
* still serving traffic — see {@link classifyForwardHealthWithReachability}.
* Returns false on any error so the existing recovery path stays intact
* when Node can't probe (e.g., restrictive sandbox).
*/
function isLocalForwardReachable(port: number): boolean {
const script =
"const net=require('node:net');" +
`const s=net.createConnection({host:'127.0.0.1',port:${port}});` +
"s.setTimeout(1000);" +
"s.on('connect',()=>{s.destroy();process.exit(0)});" +
"s.on('error',()=>process.exit(1));" +
"s.on('timeout',()=>{s.destroy();process.exit(1)});";
const result = spawnSync(process.execPath, ["-e", script], {
encoding: "utf-8",
stdio: ["ignore", "ignore", "ignore"],
timeout: 2000,
});
if (result.error) return false;
return result.status === 0;
}

/**
* Detect and recover from a sandbox that survived a gateway restart but
* whose OpenClaw processes are not running. Also re-establishes the
Expand Down
62 changes: 62 additions & 0 deletions test/process-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import { describe, expect, it } from "vitest";

import {
classifyForwardHealthWithReachability,
classifySandboxForwardHealth,
resolveSandboxDashboardPort,
type SandboxForwardListEntry,
} from "../dist/lib/actions/sandbox/process-recovery.js";

describe("resolveSandboxDashboardPort", () => {
Expand Down Expand Up @@ -81,3 +83,63 @@ describe("classifySandboxForwardHealth", () => {
).toBe(false);
});
});

describe("classifyForwardHealthWithReachability", () => {
// Regression coverage for #3334: `openshell forward list` STATUS can lag the
// real state of the forward. When it shows a non-running entry but the
// local port still answers, the forward is functionally healthy and the
// probe must not trigger spurious "missing or dead" + "Failed to
// re-establish" log pairs.
it("treats a non-running entry as healthy when the local port answers", () => {
// Covers both branches that produce `false` from the underlying classifier:
// a missing entry, and an entry whose status is anything but "running".
const inputs: SandboxForwardListEntry[][] = [
[],
[{ sandboxName: "beta", port: "18790", status: "dead" }],
];
for (const entries of inputs) {
expect(
classifyForwardHealthWithReachability(entries, "beta", "18790", () => true),
).toBe(true);
}
});

it("returns false when forward list says dead and the port does not answer", () => {
expect(
classifyForwardHealthWithReachability(
[{ sandboxName: "beta", port: "18790", status: "dead" }],
"beta",
"18790",
() => false,
),
).toBe(false);
});

it("returns true without probing when forward list already reports running", () => {
let probed = false;
const result = classifyForwardHealthWithReachability(
[{ sandboxName: "beta", port: "18790", status: "running" }],
"beta",
"18790",
() => {
probed = true;
return false;
},
);
expect(result).toBe(true);
expect(probed).toBe(false);
});

it("returns occupied even when the port answers if another sandbox owns it", () => {
// Reachability says yes, but the entry belongs to a different sandbox —
// we must not silently take over someone else's forward.
expect(
classifyForwardHealthWithReachability(
[{ sandboxName: "alpha", port: "18790", status: "running" }],
"beta",
"18790",
() => true,
),
).toBe("occupied");
});
});
10 changes: 9 additions & 1 deletion test/recover-port-forward.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import { execTimeout, testTimeoutOptions } from "./helpers/timeouts";

const tmpFixtures: string[] = [];

// Each fixture grabs a unique high port. Sharing port 18789 across tests
// collides with real nemoclaw installs on the developer's machine: the
// post-#3334 reachability probe sees the real forward answering and
// (correctly) classifies the dead-list entry as healthy, skipping recovery.
// Seed the base with the worker PID so parallel vitest workers (if ever
// enabled for this file) can't reuse the same ports across processes.
let nextFixturePort = 47000 + (process.pid % 10000);

afterEach(() => {
for (const dir of tmpFixtures.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
Expand All @@ -33,7 +41,7 @@ function setupFixture(opts: {
port?: string;
}): Fixture {
const sandboxName = opts.sandboxName;
const port = opts.port ?? "18789";
const port = opts.port ?? String(nextFixturePort++);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-recover-"));
tmpFixtures.push(tmpDir);
const homeLocalBin = path.join(tmpDir, ".local", "bin");
Expand Down
Loading