Skip to content

Commit 258433c

Browse files
committed
perf(onboard): use deadlines for gateway health waits
Signed-off-by: Ho Lim <subhoya@gmail.com>
1 parent 10091a3 commit 258433c

6 files changed

Lines changed: 95 additions & 26 deletions

File tree

src/lib/onboard.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,8 @@ const {
325325
rejectUnsupportedWindowsHostOllama,
326326
shouldFrontOllamaWithProxy,
327327
}: typeof import("./onboard/local-inference-topology") = require("./onboard/local-inference-topology");
328-
const {
329-
waitForGatewayHealth,
330-
}: typeof import("./onboard/gateway-health-wait") = require("./onboard/gateway-health-wait");
328+
const { waitForGatewayHealth }: typeof import("./onboard/gateway-health-wait") =
329+
require("./onboard/gateway-health-wait");
331330
const { resolveOpenshell } = require("./adapters/openshell/resolve");
332331
const credentials: typeof import("./credentials/store") = require("./credentials/store");
333332
const {
@@ -1896,7 +1895,7 @@ async function startGatewayWithOptions(
18961895
return;
18971896
}
18981897

1899-
throw new Error("Gateway failed to start");
1898+
throw new Error(`Gateway failed within ${healthWait.count * healthWait.interval}s.`);
19001899
},
19011900
{
19021901
retries,
@@ -1912,11 +1911,9 @@ async function startGatewayWithOptions(
19121911
},
19131912
},
19141913
);
1915-
} catch {
1916-
if (exitOnFailure) {
1917-
handleFinalGatewayStartFailure({ retries, dockerUnreachable });
1918-
}
1919-
throw new Error("Gateway failed to start");
1914+
} catch (error) {
1915+
if (exitOnFailure) handleFinalGatewayStartFailure({ retries, dockerUnreachable });
1916+
throw error instanceof Error ? error : new Error(String(error));
19201917
}
19211918

19221919
console.log(" ✓ Gateway is healthy");
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { vi } from "vitest";
5+
6+
export function createVirtualClock(startMs = 1_000_000_000_000) {
7+
let currentMs = startMs;
8+
const advance = (seconds: number) => {
9+
currentMs += Math.max(0, seconds) * 1000;
10+
};
11+
return {
12+
advance,
13+
now: () => currentMs,
14+
sleeper: vi.fn(advance),
15+
};
16+
}

src/lib/onboard/docker-driver-gateway-service.test.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33

44
import { describe, expect, it, vi } from "vitest";
55

6+
import { createVirtualClock } from "./__test-helpers__/virtual-clock";
67
import {
78
getOpenShellGatewayUserServiceBinaryPaths,
89
getOpenShellGatewayUserServicePaths,
910
hasOpenShellGatewayUserService,
10-
startPackageManagedDockerDriverGateway,
11-
startOpenShellGatewayUserService,
1211
type SpawnSyncLikeResult,
12+
startOpenShellGatewayUserService,
13+
startPackageManagedDockerDriverGateway,
1314
} from "./docker-driver-gateway-service";
1415

1516
const STATUS_CONNECTED = `
@@ -254,6 +255,7 @@ describe("docker-driver-gateway-service", () => {
254255

255256
it("uses the package-managed service only after endpoint, metadata, and gRPC health are ready", async () => {
256257
const events: string[] = [];
258+
const clock = createVirtualClock();
257259
let registerCount = 0;
258260
const registerDockerDriverGatewayEndpoint = vi.fn(() => {
259261
events.push("register");
@@ -268,14 +270,18 @@ describe("docker-driver-gateway-service", () => {
268270
gatewayName: "nemoclaw",
269271
hasOpenShellGatewayUserService: () => true,
270272
healthPollCount: 3,
271-
healthPollInterval: 0,
273+
healthPollInterval: 1,
272274
isDockerDriverGatewayReady: async () => {
273275
events.push("ready");
274276
return true;
275277
},
278+
now: clock.now,
276279
registerDockerDriverGatewayEndpoint,
277280
runCaptureOpenshell: (args) => (args[0] === "status" ? STATUS_CONNECTED : GATEWAY_INFO),
278-
sleepSeconds: () => events.push("sleep"),
281+
sleepSeconds: (seconds) => {
282+
events.push("sleep");
283+
clock.advance(seconds);
284+
},
279285
skipSandboxBridgeReachability: false,
280286
startOpenShellGatewayUserService: () => ({
281287
attempted: true,
@@ -318,6 +324,7 @@ describe("docker-driver-gateway-service", () => {
318324

319325
it("keeps standalone runtime breadcrumbs when service health never becomes ready", async () => {
320326
const clearDockerDriverGatewayRuntimeFiles = vi.fn();
327+
const clock = createVirtualClock();
321328

322329
await expect(
323330
startPackageManagedDockerDriverGateway({
@@ -326,9 +333,12 @@ describe("docker-driver-gateway-service", () => {
326333
gatewayName: "nemoclaw",
327334
hasOpenShellGatewayUserService: () => true,
328335
healthPollCount: 1,
336+
healthPollInterval: 1,
329337
isDockerDriverGatewayReady: async () => false,
338+
now: clock.now,
330339
registerDockerDriverGatewayEndpoint: () => true,
331340
runCaptureOpenshell: (args) => (args[0] === "status" ? STATUS_CONNECTED : GATEWAY_INFO),
341+
sleepSeconds: clock.advance,
332342
skipSandboxBridgeReachability: false,
333343
startOpenShellGatewayUserService: () => ({
334344
attempted: true,
@@ -337,7 +347,7 @@ describe("docker-driver-gateway-service", () => {
337347
}),
338348
verifySandboxBridgeGatewayReachableOrExit: vi.fn(),
339349
}),
340-
).rejects.toThrow("did not become healthy");
350+
).rejects.toThrow("configured 1s health deadline");
341351

342352
expect(clearDockerDriverGatewayRuntimeFiles).not.toHaveBeenCalled();
343353
});

src/lib/onboard/docker-driver-gateway-service.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { spawnSync, type SpawnSyncOptions } from "node:child_process";
4+
import { type SpawnSyncOptions, spawnSync } from "node:child_process";
55
import fs from "node:fs";
66
import path from "node:path";
77

88
import { sleepSeconds, waitUntilAsync } from "../core/wait";
99
import { isGatewayHealthy } from "../state/gateway";
1010
import { envInt } from "./env";
11+
import { formatGatewayHealthWaitBudget, getGatewayHealthWaitBudgetMs } from "./gateway-health-wait";
1112
import { isDockerDriverGatewayHttpReady } from "./gateway-http-readiness";
1213

1314
export const OPENSHELL_GATEWAY_USER_SERVICE = "openshell-gateway";
@@ -49,6 +50,7 @@ export interface PackageManagedDockerDriverGatewayOptions {
4950
healthPollCount?: number;
5051
healthPollInterval?: number;
5152
isDockerDriverGatewayReady?: () => Promise<boolean>;
53+
now?: () => number;
5254
registerDockerDriverGatewayEndpoint: () => boolean;
5355
runCaptureOpenshell: (args: string[], opts?: { ignoreError?: boolean }) => string;
5456
sleepSeconds?: (seconds: number) => void;
@@ -291,6 +293,7 @@ export async function startPackageManagedDockerDriverGateway({
291293
healthPollCount,
292294
healthPollInterval,
293295
isDockerDriverGatewayReady = isDockerDriverGatewayHttpReady,
296+
now = Date.now,
294297
registerDockerDriverGatewayEndpoint,
295298
runCaptureOpenshell,
296299
sleepSeconds: sleepSecondsImpl = sleepSeconds,
@@ -324,6 +327,7 @@ export async function startPackageManagedDockerDriverGateway({
324327
const pollCount = healthPollCount ?? envInt("NEMOCLAW_HEALTH_POLL_COUNT", 30);
325328
const pollInterval = healthPollInterval ?? envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", 2);
326329
const pollIntervalMs = Math.max(0, pollInterval * 1000);
330+
const waitBudgetMs = getGatewayHealthWaitBudgetMs(pollCount, pollInterval);
327331
const healthy =
328332
pollCount > 0 &&
329333
(await waitUntilAsync(
@@ -339,10 +343,11 @@ export async function startPackageManagedDockerDriverGateway({
339343
);
340344
},
341345
{
346+
deadlineMs: now() + waitBudgetMs,
342347
initialIntervalMs: pollIntervalMs,
343348
maxIntervalMs: pollIntervalMs,
344349
backoffFactor: 1,
345-
maxAttempts: pollCount,
350+
now,
346351
sleep: (ms) => sleepSecondsImpl(ms / 1000),
347352
},
348353
));
@@ -355,7 +360,10 @@ export async function startPackageManagedDockerDriverGateway({
355360
return true;
356361
}
357362

358-
const message = "OpenShell gateway user service started but did not become healthy.";
363+
const message = `OpenShell gateway user service started but did not become healthy within the configured ${formatGatewayHealthWaitBudget(
364+
pollCount,
365+
pollInterval,
366+
)} health deadline.`;
359367
console.error(` ${message}`);
360368
console.error(" Check: systemctl --user status openshell-gateway");
361369
if (exitOnFailure) process.exit(1);

src/lib/onboard/gateway-health-wait.test.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { beforeEach, describe, expect, it, vi } from "vitest";
55

6+
import { createVirtualClock } from "./__test-helpers__/virtual-clock";
67
import { type GatewayHealthWaitOptions, waitForGatewayHealth } from "./gateway-health-wait";
78

89
function buildOptions(overrides: Partial<GatewayHealthWaitOptions> = {}): GatewayHealthWaitOptions {
@@ -49,16 +50,19 @@ describe("waitForGatewayHealth", () => {
4950
});
5051

5152
it("returns false when HTTP readiness never follows healthy metadata", async () => {
53+
const clock = createVirtualClock();
5254
const options = buildOptions({
5355
healthPollCount: 2,
5456
isGatewayHttpReady: vi.fn(async () => false),
57+
now: clock.now,
58+
sleepSeconds: clock.sleeper,
5559
});
5660

5761
await expect(waitForGatewayHealth(options)).resolves.toBe(false);
5862

5963
expect(options.isGatewayHealthy).toHaveBeenCalledTimes(2);
6064
expect(options.isGatewayHttpReady).toHaveBeenCalledTimes(2);
61-
expect(options.sleepSeconds).toHaveBeenCalledTimes(1);
65+
expect(options.sleepSeconds).toHaveBeenCalledTimes(2);
6266
});
6367

6468
it("force-refreshes metadata after bootstrap secret repair", async () => {
@@ -85,19 +89,27 @@ describe("waitForGatewayHealth", () => {
8589
expect(options.attachGatewayMetadataIfNeeded).toHaveBeenCalledWith();
8690
});
8791

88-
it("stops after healthPollCount attempts without sleeping after the final failed probe", async () => {
92+
it("polls until the configured health deadline instead of stopping at the count cap (#3768)", async () => {
93+
const clock = createVirtualClock();
94+
const isGatewayHealthy = vi.fn(() => {
95+
clock.advance(1);
96+
return false;
97+
});
8998
const options = buildOptions({
90-
healthPollCount: 3,
91-
isGatewayHealthy: vi.fn(() => false),
99+
healthPollCount: 10,
100+
healthPollIntervalSeconds: 1,
101+
isGatewayHealthy,
102+
now: clock.now,
103+
sleepSeconds: clock.sleeper,
92104
});
93105

94106
await expect(waitForGatewayHealth(options)).resolves.toBe(false);
95107

96-
expect(options.isGatewayHealthy).toHaveBeenCalledTimes(3);
108+
expect(isGatewayHealthy).toHaveBeenCalled();
109+
expect(isGatewayHealthy.mock.calls.length).toBeLessThan(10);
97110
expect(options.isGatewayHttpReady).not.toHaveBeenCalled();
98-
expect(options.sleepSeconds).toHaveBeenCalledTimes(2);
99-
expect(options.sleepSeconds).toHaveBeenNthCalledWith(1, 2);
100-
expect(options.sleepSeconds).toHaveBeenNthCalledWith(2, 2);
111+
expect(clock.sleeper).toHaveBeenCalled();
112+
expect(clock.sleeper.mock.calls.every(([seconds]) => seconds === 1)).toBe(true);
101113
});
102114

103115
it("returns false without probing when healthPollCount is zero", async () => {

src/lib/onboard/gateway-health-wait.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,29 @@ export interface GatewayHealthWaitOptions {
1616
repairGatewayBootstrapSecrets: () => { repaired: boolean };
1717
runCaptureOpenshell: RunCaptureOpenshell;
1818
sleepSeconds: (seconds: number) => void;
19+
now?: () => number;
20+
}
21+
22+
export function getGatewayHealthWaitBudgetMs(
23+
healthPollCount: number,
24+
healthPollIntervalSeconds: number,
25+
): number {
26+
const normalizedCount = Number.isFinite(healthPollCount) ? Math.max(0, healthPollCount) : 0;
27+
const normalizedIntervalSeconds = Number.isFinite(healthPollIntervalSeconds)
28+
? Math.max(0, healthPollIntervalSeconds)
29+
: 0;
30+
return normalizedCount <= 0 ? 0 : Math.max(1, normalizedCount * normalizedIntervalSeconds * 1000);
31+
}
32+
33+
export function formatGatewayHealthWaitBudget(
34+
healthPollCount: number,
35+
healthPollIntervalSeconds: number,
36+
): string {
37+
const budgetMs = getGatewayHealthWaitBudgetMs(healthPollCount, healthPollIntervalSeconds);
38+
if (budgetMs <= 0) return "0s";
39+
if (budgetMs < 1000) return `${Math.ceil(budgetMs)}ms`;
40+
const seconds = budgetMs / 1000;
41+
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`;
1942
}
2043

2144
export async function waitForGatewayHealth({
@@ -29,8 +52,10 @@ export async function waitForGatewayHealth({
2952
repairGatewayBootstrapSecrets,
3053
runCaptureOpenshell,
3154
sleepSeconds,
55+
now = Date.now,
3256
}: GatewayHealthWaitOptions): Promise<boolean> {
3357
const healthPollIntervalMs = Math.max(0, healthPollIntervalSeconds * 1000);
58+
const waitBudgetMs = getGatewayHealthWaitBudgetMs(healthPollCount, healthPollIntervalSeconds);
3459
return (
3560
healthPollCount > 0 &&
3661
(await waitUntilAsync(
@@ -50,10 +75,11 @@ export async function waitForGatewayHealth({
5075
return isGatewayHealthy(status, namedInfo, currentInfo) && (await isGatewayHttpReady());
5176
},
5277
{
78+
deadlineMs: now() + waitBudgetMs,
5379
initialIntervalMs: healthPollIntervalMs,
5480
maxIntervalMs: healthPollIntervalMs,
5581
backoffFactor: 1,
56-
maxAttempts: healthPollCount,
82+
now,
5783
sleep: (ms) => sleepSeconds(ms / 1000),
5884
},
5985
))

0 commit comments

Comments
 (0)