Skip to content

fix(onboard): add gateway start timeout and increase health poll window (#1830) - #2006

Merged
ericksoa merged 4 commits into
mainfrom
fix/gateway-startup-retry-and-health-poll-1830
Apr 17, 2026
Merged

fix(onboard): add gateway start timeout and increase health poll window (#1830)#2006
ericksoa merged 4 commits into
mainfrom
fix/gateway-startup-retry-and-health-poll-1830

Conversation

@yanyunl1991

@yanyunl1991 yanyunl1991 commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

fix(onboard): add gateway start timeout and increase health poll window (#1830)

Summary

Two defensive improvements to gateway startup: (1) add a hard timeout to streamGatewayStart to prevent NemoClaw from hanging indefinitely if the openshell gateway start process never exits, and (2) increase the post-start health poll window from 10s to 60s to give the application-layer connectivity check (gRPC,
TLS, port mapping) more time after the container reports healthy.

Related Issue

Fixes #1830

Changes

  • Add a 600s configurable hard timeout (NEMOCLAW_GATEWAY_START_TIMEOUT) to streamGatewayStart() that kills the child process if it hasn't exited. Covers edge cases where Docker daemon is unresponsive or k3s enters a restart loop that never terminates.
  • Increase x86 post-start health poll defaults from 5×2s=10s to 12×5s=60s. Gateway startup involves two health-check layers: Layer 1 (Docker container healthcheck, handled inside openshell gateway start) confirms k3s/pods/TLS are ready; Layer 2 (NemoClaw's post-start poll via openshell status + gateway info)
    confirms the host-side CLI can connect over gRPC. The previous 10s window was tight for Layer 2 propagation. ARM64 defaults (30×10s=300s) are unchanged.
  • All poll values remain overridable via NEMOCLAW_HEALTH_POLL_COUNT and NEMOCLAW_HEALTH_POLL_INTERVAL.

Type of Change

  • Code change (feature, bug fix, or refactor)
  • Code change with doc updates
  • Doc only (prose changes, no code sample modifications)
  • Doc only (includes code sample changes)

Verification

  • npx prek run --all-files passes
  • npm test passes
  • Tests added or updated for new or changed behavior
  • No secrets, API keys, or credentials committed
  • Docs updated for user-facing behavior changes
  • make docs builds without warnings (doc changes only)
  • Doc pages follow the style guide (doc changes only)
  • New doc pages include SPDX header and frontmatter (new pages only)

AI Disclosure

  • AI-assisted — tool: Claude Code

Root Cause Analysis

Full analysis of the #1830 failure scenario, based on reading OpenShell source code (crates/openshell-bootstrap/src/runtime.rs, lib.rs, docker.rs):

Two-Layer Health Checking Architecture

Layer 1 — Container health (inside OpenShell): wait_for_gateway_ready() polls Docker container HEALTHCHECK status for up to 360s (180×2s). The healthcheck script verifies DNS, k8s API, StatefulSet readiness, TLS secrets, and NodePort connectivity. Docker HEALTHCHECK config: interval=5s, timeout=5s, start-period=20s, retries=60 — container stays starting for up to 320s before becoming unhealthy.

Layer 2 — Application connectivity (inside NemoClaw): Post-start poll checks openshell status (gRPC connectivity) and openshell gateway info (metadata). Container HEALTHY ≠ CLI connected — gRPC init, TLS handshake, and port mapping propagation may need additional seconds.

Why the 15+ minute hang occurs on slow environments

On slow environments (macOS + Docker Desktop, first-run image pulls):

  1. openshell gateway start creates container + volume
  2. k3s initialization exceeds Docker's 320s healthcheck window → container becomes unhealthy
  3. OpenShell's wait_for_gateway_ready() sees unhealthy → returns error
  4. OpenShell's auto-cleanup (destroy_gateway_resources(), added in fix: macOS local inference DNS resolution + oMLX provider #464) deletes container + volume + network + image
  5. NemoClaw's post-start poll finds empty environment → fails
  6. NemoClaw's destroyGateway() is a no-op (OpenShell already cleaned up)
  7. Retry starts from scratch — must re-pull images, recreate volume, re-init k3s
  8. 3 attempts × ~350s each = 15+ minutes

The cache loss happens inside OpenShell's auto-cleanup, not in NemoClaw's destroyGateway(). The root constraint is Docker's 320s healthcheck window being insufficient for slow first-run environments.

What this PR fixes vs. what requires OpenShell changes

Issue Root cause Fixed here?
fix(onboard): add gateway start timeout and increase health poll window (#1830)
Cache destroyed on retry OpenShell auto-cleanup deletes volume on fresh deploy failure. The 320s Docker healthcheck window and 360s OpenShell poll are reasonable for most environments — the issue only occurs when first-run image pulls + k3s init exceed this window on slow systems. No — happens inside OpenShell
before NemoClaw runs
streamGatewayStart hangs forever No timeout if openshell process never exits Yes — 600s hard timeout
Layer 2 poll too short 10s may not cover gRPC/TLS propagation Yes — increased to 60s

Reproduction

Reproduced on Ubuntu 24.04 with network throttling (200kbit). Confirmed via docker ps that container was health: starting during openshell gateway start but gone after it returned non-zero — OpenShell's auto-cleanup removed it before NemoClaw's post-start poll ran.

Verification of fix

Tested on Ubuntu 24.04 (clean install, no cached images):

  [2/8] Starting OpenShell gateway                                                                                                                                                                                                                                                                                           
  Using pinned OpenShell gateway image: ghcr.io/nvidia/openshell/cluster:0.0.26
  Starting gateway cluster...                                                                                                                                                                                                                                                                                                
  Still starting gateway cluster... (5s elapsed)
  ...                                                                                                                                                                                                                                                                                                                        
  Still starting OpenShell gateway pod... (200s elapsed)
  Waiting for gateway health...                                                                                                                                                                                                                                                                                              
  Waiting for gateway health...
  ✓ Gateway is healthy

Gateway started successfully. Layer 2 poll passed within 2 iterations (~10s).

Summary by CodeRabbit

  • Bug Fixes
    • Added a hard timeout to gateway startup to prevent indefinite waits, terminate stalled launches, and ensure failures are reported.
    • Adjusted gateway health-check polling defaults for more reliable readiness detection across different platforms.

Signed-off-by: Yanyun Liao yanyunl@nvidia.com

…ow (#1830)

Two defensive improvements to gateway startup:

1. streamGatewayStart fallback timeout: Add a 600s hard timeout
   (configurable via NEMOCLAW_GATEWAY_START_TIMEOUT) that kills the
   openshell gateway start child process if it never exits. Prevents
   NemoClaw from hanging indefinitely when Docker daemon is
   unresponsive or k3s enters a restart loop.

2. Increase post-start health poll defaults from 5×2s=10s to 12×5s=60s
   on x86. After openshell gateway start returns (container healthy at
   the Docker layer), the application-layer connectivity check (gRPC,
   TLS handshake, port mapping) may need additional time. 60s provides
   a reasonable buffer. ARM64 defaults (30×10s=300s) unchanged. All
   values remain overridable via NEMOCLAW_HEALTH_POLL_COUNT and
   NEMOCLAW_HEALTH_POLL_INTERVAL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Yanyun Liao <yanyunl@nvidia.com>
@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f817a1b8-d90b-4cef-ad6b-de3f1b61f90d

📥 Commits

Reviewing files that changed from the base of the PR and between 9a7b255 and be53c8a.

📒 Files selected for processing (1)
  • src/lib/onboard.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/onboard.ts

📝 Walkthrough

Walkthrough

Added a configurable hard timeout to the gateway start stream and lengthened the default post-start health polling parameters in src/lib/onboard.ts to prevent indefinite hangs and extend application-layer health probing.

Changes

Cohort / File(s) Summary
Gateway start & health polling
src/lib/onboard.ts
Added NEMOCLAW_GATEWAY_START_TIMEOUT (default 600s) in streamGatewayStart() that appends a timeout message, sends SIGTERM to the spawned OpenShell child, schedules a forced SIGKILL after 10s if needed, and makes the close handler report status: 1 for timeout-triggered kills. Clears timeout on child "error" and "close". Increased defaults for post-start health polling: NEMOCLAW_HEALTH_POLL_COUNT changed from isArm64 ? 30 : 5isArm64 ? 30 : 12 and NEMOCLAW_HEALTH_POLL_INTERVAL from isArm64 ? 10 : 2isArm64 ? 10 : 5 (application-layer probe).

Sequence Diagram(s)

sequenceDiagram
  participant Nemoclaw as Nemoclaw (parent)
  participant OpenShell as OpenShell CLI (child)
  participant Gateway as Gateway Cluster (k3s/Docker)

  Nemoclaw->>OpenShell: spawn gateway start
  Note right of OpenShell: streamGatewayStart() streams logs and starts hard timeout
  OpenShell->>Gateway: init/start cluster
  Gateway-->>OpenShell: progress / readiness updates
  Nemoclaw->>Gateway: periodic health checks (NEMOCLAW_HEALTH_POLL_*)
  alt start exceeds timeout
    Nemoclaw->>OpenShell: send SIGTERM (timeout)
    Nemoclaw->>OpenShell: schedule SIGKILL fallback (10s)
    OpenShell-->>Nemoclaw: close with status:1 (timeout-triggered)
  else start completes
    OpenShell-->>Nemoclaw: close with exit code
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇
I counted seconds, held my paw,
A gentle TERM then KILL if raw—
Longer polls to watch the pod,
No endless waits, no wasted nod.
Hop, hop—onboard beats the clock.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR partially addresses Issue #1830 requirements: adds hard timeout to streamGatewayStart and increases health poll defaults; however, omits the critical fix to gate destroyGateway() on container health state to prevent destroying containers still in 'starting' state. Implement logic to check container health status before calling destroyGateway() on retry failures—only destroy when container is confirmed unhealthy or not running, not while in 'starting' state.
✅ 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 directly reflects the two main changes: adding a gateway start timeout and increasing health poll window parameters for post-start connectivity checks.
Out of Scope Changes check ✅ Passed All changes are directly related to Issue #1830: timeout implementation and health poll parameter adjustments. No unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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/gateway-startup-retry-and-health-poll-1830

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

@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: 2

🤖 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/onboard.ts`:
- Around line 2613-2615: The envInt calls for health polling can return 0 which
disables the loop; clamp both results to a minimum of 1 by wrapping the
envInt(...) calls in Math.max(1, envInt(...)) (or equivalent) so healthPollCount
and healthPollInterval are never 0; update the assignments to healthPollCount
and healthPollInterval (the variables used in the for loop starting at the for
(let i = 0; i < healthPollCount; i++) {) accordingly.
- Around line 387-391: The timeout handler currently calls child.kill("SIGTERM")
and immediately calls finish(), which can resolve before the child actually
exits; change the handler to send SIGTERM then wait for the child process to
emit its exit/close event (or await a short grace-period fallback and then
escalate to SIGKILL) before calling finish(), using the existing child reference
and the finish/settled guard; ensure killTimer is still cleared and any
escalation to SIGKILL is logged, and reuse/detect destroyGateway() semantics so
the promise only resolves once the child has truly terminated (use
child.on("exit"/"close") or a Promise that races a grace timeout and then
force-kills).
🪄 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: Pro Plus

Run ID: a2ee58c5-989d-4108-8a05-56e3bb04ea72

📥 Commits

Reviewing files that changed from the base of the PR and between e7dc62a and 2490607.

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

Comment thread src/lib/onboard.ts
Comment thread src/lib/onboard.ts
yanyunl1991 and others added 2 commits April 17, 2026 15:46
…tions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Yanyun Liao <yanyunl@nvidia.com>
…#1830)

On timeout, send SIGTERM and let the `close` event resolve the promise
so the child has actually exited before the caller proceeds to retry.
If SIGTERM is ignored, escalate to SIGKILL after 10s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Yanyun Liao <yanyunl@nvidia.com>

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/onboard.ts (1)

2616-2652: ⚠️ Potential issue | 🟠 Major

Retry still destroys gateways that may only be slow-starting.

At Line 2640 every exhausted Layer 2 poll path throws the same generic failure, and Lines 2650-2652 destroy the gateway on any retriable failure. That still tears down a container that is merely late to finish app-layer bring-up, which loses the k3s image cache this flow is trying to preserve. Please gate destroyGateway() on an explicit container state/health check and skip destroy while the gateway is still running in a transient starting state.

♻️ Duplicate comments (1)
src/lib/onboard.ts (1)

2623-2624: ⚠️ Potential issue | 🟡 Minor

Clamp health-poll overrides to at least 1.

envInt() allows 0, so NEMOCLAW_HEALTH_POLL_COUNT=0 skips the loop entirely and fails immediately. NEMOCLAW_HEALTH_POLL_INTERVAL=0 also collapses retries into effectively back-to-back probes.

Suggested fix
-        const healthPollCount = envInt("NEMOCLAW_HEALTH_POLL_COUNT", isArm64 ? 30 : 12);
-        const healthPollInterval = envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", isArm64 ? 10 : 5);
+        const healthPollCount = Math.max(
+          1,
+          envInt("NEMOCLAW_HEALTH_POLL_COUNT", isArm64 ? 30 : 12),
+        );
+        const healthPollInterval = Math.max(
+          1,
+          envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", isArm64 ? 10 : 5),
+        );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/onboard.ts` around lines 2623 - 2624, envInt currently permits zero
which allows health polling to be skipped or interval to be zero; clamp the
computed values for healthPollCount and healthPollInterval to a minimum of 1
after calling envInt (i.e., compute healthPollCount = Math.max(1,
envInt("NEMOCLAW_HEALTH_POLL_COUNT", isArm64 ? 30 : 12)) and healthPollInterval
= Math.max(1, envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", isArm64 ? 10 : 5))) so the
loop always runs at least once and intervals are never zero; update any
references to healthPollCount and healthPollInterval accordingly.
🧹 Nitpick comments (1)
src/lib/onboard.ts (1)

2537-2686: Split startGatewayWithOptions() before adding more startup paths.

This function is already handling reuse detection, cleanup, retry orchestration, timeout logging, and Layer 2 probing in one block. Extracting the retry/poll path into helpers would make these new failure modes much easier to reason about.

As per coding guidelines, "Limit cyclomatic complexity to 20 in JavaScript/TypeScript files, with target of 15".

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

In `@src/lib/onboard.ts` around lines 2537 - 2686, startGatewayWithOptions is too
large/complex; extract the retry and Layer-2 health-poll logic into helper
functions to reduce cyclomatic complexity. Specifically, move the pRetry wrapper
and its inner async block (the call to streamGatewayStart + the health polling
loop that calls runCaptureOpenshell and isGatewayHealthy) into a new function
(e.g., attemptGatewayStart or startGatewayWithRetry) and factor the inner
polling into a separate helper (e.g., waitForGatewayHealth) that returns
success/throws on failure; keep reuse detection, cleanup
(ssh-keygen/known_hosts), and env setup in startGatewayWithOptions and call the
new helpers (referencing startGatewayWithOptions, streamGatewayStart, pRetry,
waitForGatewayHealth, and isGatewayHealthy) so the main function’s complexity
drops below the threshold.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/lib/onboard.ts`:
- Around line 2623-2624: envInt currently permits zero which allows health
polling to be skipped or interval to be zero; clamp the computed values for
healthPollCount and healthPollInterval to a minimum of 1 after calling envInt
(i.e., compute healthPollCount = Math.max(1,
envInt("NEMOCLAW_HEALTH_POLL_COUNT", isArm64 ? 30 : 12)) and healthPollInterval
= Math.max(1, envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", isArm64 ? 10 : 5))) so the
loop always runs at least once and intervals are never zero; update any
references to healthPollCount and healthPollInterval accordingly.

---

Nitpick comments:
In `@src/lib/onboard.ts`:
- Around line 2537-2686: startGatewayWithOptions is too large/complex; extract
the retry and Layer-2 health-poll logic into helper functions to reduce
cyclomatic complexity. Specifically, move the pRetry wrapper and its inner async
block (the call to streamGatewayStart + the health polling loop that calls
runCaptureOpenshell and isGatewayHealthy) into a new function (e.g.,
attemptGatewayStart or startGatewayWithRetry) and factor the inner polling into
a separate helper (e.g., waitForGatewayHealth) that returns success/throws on
failure; keep reuse detection, cleanup (ssh-keygen/known_hosts), and env setup
in startGatewayWithOptions and call the new helpers (referencing
startGatewayWithOptions, streamGatewayStart, pRetry, waitForGatewayHealth, and
isGatewayHealthy) so the main function’s complexity drops below the threshold.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 247ffc72-7761-4bb8-b588-7a3c81aff80f

📥 Commits

Reviewing files that changed from the base of the PR and between 2490607 and 9a7b255.

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

@yanyunl1991

Copy link
Copy Markdown
Contributor Author

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)

src/lib/onboard.ts (1)> 2616-2652: ⚠️ Potential issue | 🟠 Major

Retry still destroys gateways that may only be slow-starting.
At Line 2640 every exhausted Layer 2 poll path throws the same generic failure, and Lines 2650-2652 destroy the gateway on any retriable failure. That still tears down a container that is merely late to finish app-layer bring-up, which loses the k3s image cache this flow is trying to preserve. Please gate destroyGateway() on an explicit container state/health check and skip destroy while the gateway is still running in a transient starting state.

♻️ Duplicate comments (1)

src/lib/onboard.ts (1)> 2623-2624: ⚠️ Potential issue | 🟡 Minor

Clamp health-poll overrides to at least 1.
envInt() allows 0, so NEMOCLAW_HEALTH_POLL_COUNT=0 skips the loop entirely and fails immediately. NEMOCLAW_HEALTH_POLL_INTERVAL=0 also collapses retries into effectively back-to-back probes.

Suggested fix

-        const healthPollCount = envInt("NEMOCLAW_HEALTH_POLL_COUNT", isArm64 ? 30 : 12);
-        const healthPollInterval = envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", isArm64 ? 10 : 5);
+        const healthPollCount = Math.max(
+          1,
+          envInt("NEMOCLAW_HEALTH_POLL_COUNT", isArm64 ? 30 : 12),
+        );
+        const healthPollInterval = Math.max(
+          1,
+          envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", isArm64 ? 10 : 5),
+        );

🤖 Prompt for AI Agents

Verify each finding against the current code and only fix it if needed.

In `@src/lib/onboard.ts` around lines 2623 - 2624, envInt currently permits zero
which allows health polling to be skipped or interval to be zero; clamp the
computed values for healthPollCount and healthPollInterval to a minimum of 1
after calling envInt (i.e., compute healthPollCount = Math.max(1,
envInt("NEMOCLAW_HEALTH_POLL_COUNT", isArm64 ? 30 : 12)) and healthPollInterval
= Math.max(1, envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", isArm64 ? 10 : 5))) so the
loop always runs at least once and intervals are never zero; update any
references to healthPollCount and healthPollInterval accordingly.

🧹 Nitpick comments (1)

src/lib/onboard.ts (1)> 2537-2686: Split startGatewayWithOptions() before adding more startup paths.

This function is already handling reuse detection, cleanup, retry orchestration, timeout logging, and Layer 2 probing in one block. Extracting the retry/poll path into helpers would make these new failure modes much easier to reason about.
As per coding guidelines, "Limit cyclomatic complexity to 20 in JavaScript/TypeScript files, with target of 15".

🤖 Prompt for AI Agents

Verify each finding against the current code and only fix it if needed.

In `@src/lib/onboard.ts` around lines 2537 - 2686, startGatewayWithOptions is too
large/complex; extract the retry and Layer-2 health-poll logic into helper
functions to reduce cyclomatic complexity. Specifically, move the pRetry wrapper
and its inner async block (the call to streamGatewayStart + the health polling
loop that calls runCaptureOpenshell and isGatewayHealthy) into a new function
(e.g., attemptGatewayStart or startGatewayWithRetry) and factor the inner
polling into a separate helper (e.g., waitForGatewayHealth) that returns
success/throws on failure; keep reuse detection, cleanup
(ssh-keygen/known_hosts), and env setup in startGatewayWithOptions and call the
new helpers (referencing startGatewayWithOptions, streamGatewayStart, pRetry,
waitForGatewayHealth, and isGatewayHealthy) so the main function’s complexity
drops below the threshold.

🤖 Prompt for all review comments with AI agents

Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/lib/onboard.ts`:
- Around line 2623-2624: envInt currently permits zero which allows health
polling to be skipped or interval to be zero; clamp the computed values for
healthPollCount and healthPollInterval to a minimum of 1 after calling envInt
(i.e., compute healthPollCount = Math.max(1,
envInt("NEMOCLAW_HEALTH_POLL_COUNT", isArm64 ? 30 : 12)) and healthPollInterval
= Math.max(1, envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", isArm64 ? 10 : 5))) so the
loop always runs at least once and intervals are never zero; update any
references to healthPollCount and healthPollInterval accordingly.

---

Nitpick comments:
In `@src/lib/onboard.ts`:
- Around line 2537-2686: startGatewayWithOptions is too large/complex; extract
the retry and Layer-2 health-poll logic into helper functions to reduce
cyclomatic complexity. Specifically, move the pRetry wrapper and its inner async
block (the call to streamGatewayStart + the health polling loop that calls
runCaptureOpenshell and isGatewayHealthy) into a new function (e.g.,
attemptGatewayStart or startGatewayWithRetry) and factor the inner polling into
a separate helper (e.g., waitForGatewayHealth) that returns success/throws on
failure; keep reuse detection, cleanup (ssh-keygen/known_hosts), and env setup
in startGatewayWithOptions and call the new helpers (referencing
startGatewayWithOptions, streamGatewayStart, pRetry, waitForGatewayHealth, and
isGatewayHealthy) so the main function’s complexity drops below the threshold.

ℹ️ Review info

As detailed in the Root Cause Analysis section of the PR description, this scenario does not occur with current OpenShell (0.0.26+):

When openshell gateway start returns non-zero, OpenShell's auto-cleanup (destroy_gateway_resources(), added in OpenShell #464) has already removed the container + volume + network + image before NemoClaw's post-start poll runs. NemoClaw's destroyGateway() is effectively a no-op — there is nothing left to
destroy.

The Layer 2 poll only runs after openshell gateway start returns exit 0 (container HEALTHY at Layer 1). If Layer 2 times out after a successful Layer 1, the container is healthy at the Docker level but the gRPC/CLI connection failed — destroying and retrying is the correct action in this case.

See the "What happens when openshell gateway start fails (exit non-zero)" section in the PR description for the full trace with source code references.

@cv
cv requested a review from ericksoa April 17, 2026 08:51

@ericksoa ericksoa 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.

LGTM — well-scoped defensive fix with correct timeout escalation (SIGTERM → grace → SIGKILL), proper timer cleanup, and thorough root cause analysis.

Suggestion for a follow-up PR: Consider clamping envInt results for NEMOCLAW_HEALTH_POLL_COUNT and NEMOCLAW_HEALTH_POLL_INTERVAL to a minimum of 1 (e.g. Math.max(1, envInt(...))). Setting either to 0 via env var currently skips the poll loop or collapses retries. Pre-existing issue, not introduced here, but worth hardening.

@ericksoa
ericksoa merged commit 946c52b into main Apr 17, 2026
14 checks passed
@wscurran wscurran added the bug-fix PR fixes a bug or regression label Jun 8, 2026
@wscurran wscurran added the NV QA Bugs found by the NVIDIA QA Team label Jun 26, 2026
@cv
cv deleted the fix/gateway-startup-retry-and-health-poll-1830 branch June 28, 2026 00:21
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 NV QA Bugs found by the NVIDIA QA Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[all platform]Gateway startup retry destroys cached images and health poll window is too short, causing 10+ minute onboard hangs

3 participants