Skip to content

Commit 691f863

Browse files
committed
fix(onboard): drop attempt cap so recovery is truly deadline-driven (NVIDIA#6320 advisor)
Advisor re-review kept blocking on the same concern: with maxAttempts still passed to waitUntilAsync alongside the deadline, a fast-failing probe sequence exits after N attempts even though the deadline still permits more polling. The reported "max wait Xs" language then under-promises the wait the system was willing to spend, and the loop regresses to the exact fixed-attempt behavior NVIDIA#3768 is meant to replace. Fix: 1. Drop `maxAttempts: recoveryPollCount` from the waitUntilAsync options. The loop is now purely deadline-driven; the interval and probe cost naturally bound the total attempt count. Under a 3 * 2s = 6s budget with fast-failing mocked probes, the loop now runs 3 probes and 3 sleeps until the top-of-loop deadline check terminates it, instead of the earlier 3 probes / 2 sleeps under the attempt cap. 2. Rewrite the error message to describe the actual deadline, not a hedged "budget + attempt cap" mix: "Gateway 'X' did not become ready within the configured Ns recovery deadline (Is poll interval)". 3. Inject Date.now via a new `now` deps hook so the deadline test can drive a captured virtual clock without vi.useFakeTimers globally patching timers (which hangs the async loop). The virtual clock advances only when the injected sleepSeconds mock is called, so time progresses deterministically at unit-test speed with zero real wall-clock waits. 4. Rewrite the timeout test to match the new deadline-only semantics: asserts 3 probes and 3 sleeps at 2s each under a 6s budget, and that the error message mentions "6s recovery deadline (2s poll interval)". 5. Adjust the zero-count edge test's message assertion to match the new deadline-only error text. 11/11 tests pass. Biome and changed-file typecheck are clean. The pre-existing typecheck error in test/helpers/mcp-lifecycle-lock- properties.ts is unrelated to this branch's diff. Signed-off-by: Charan Jagwani <cjagwani@nvidia.com>
1 parent c963a2b commit 691f863

2 files changed

Lines changed: 65 additions & 24 deletions

File tree

src/lib/onboard/gateway-recovery.test.ts

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

4-
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55

66
import { type GatewayRecoveryDeps, startGatewayForRecovery } from "./gateway-recovery";
77

8+
// #3768: with the loop now purely deadline-driven, `waitUntilAsync` needs a
9+
// clock reader. Rather than using vi.useFakeTimers (which globally patches
10+
// timers and can hang async code), pair a captured virtual clock with the
11+
// injected `sleepSeconds` mock so the clock advances only when the loop
12+
// actually sleeps. Tests get deterministic deadline expiration without any
13+
// real wall-clock waits or global timer state.
14+
function makeVirtualClock(startMs = 1_000_000_000_000) {
15+
let now = startMs;
16+
return {
17+
now: () => now,
18+
sleeper: vi.fn((seconds: number) => {
19+
now += Math.max(0, seconds) * 1000;
20+
}),
21+
};
22+
}
23+
824
function createDeps(overrides: Partial<GatewayRecoveryDeps> = {}): GatewayRecoveryDeps {
925
return {
1026
getGatewayClusterContainerState: () => "missing",
@@ -86,19 +102,31 @@ describe("gateway recovery", () => {
86102
);
87103
});
88104

89-
it("uses the configured recovery deadline budget without sleeping after the final probe", async () => {
105+
it("polls until the configured recovery deadline and reports it in the timeout", async () => {
106+
// #3768: deadline-driven, no legacy attempt cap. A 3 * 2s budget = 6s
107+
// permits probes and sleeps until the deadline expires; the final
108+
// deadline check short-circuits before an extra sleep would happen.
90109
vi.stubEnv("NEMOCLAW_HEALTH_POLL_COUNT", "3");
91110
vi.stubEnv("NEMOCLAW_HEALTH_POLL_INTERVAL", "2");
92-
const deps = createDeps();
111+
const clock = makeVirtualClock();
112+
const deps = createDeps({ sleepSeconds: clock.sleeper, now: clock.now });
93113

94114
await expect(startGatewayForRecovery({ gatewayPort: 8091 }, deps)).rejects.toThrow(
95-
"3 recovery attempt(s) at 2s interval (max wait 6s)",
115+
"configured 6s recovery deadline (2s poll interval)",
96116
);
97117

98-
expect(deps.runCaptureOpenshell).toHaveBeenCalledTimes(9);
99-
expect(deps.sleepSeconds).toHaveBeenCalledTimes(2);
100-
expect(deps.sleepSeconds).toHaveBeenNthCalledWith(1, 2);
101-
expect(deps.sleepSeconds).toHaveBeenNthCalledWith(2, 2);
118+
// Each probe issues 3 subprocess calls. With a 6s budget and 2s
119+
// interval, the loop runs probe → sleep(2s) three times, then the
120+
// top-of-loop deadline check terminates before probe #4. So probes
121+
// and sleeps are 1:1 at exactly 3 each.
122+
const runCaptureCalls = (deps.runCaptureOpenshell as ReturnType<typeof vi.fn>).mock.calls
123+
.length;
124+
const probeCount = runCaptureCalls / 3;
125+
expect(probeCount).toBe(3);
126+
expect(clock.sleeper).toHaveBeenCalledTimes(3);
127+
expect(clock.sleeper).toHaveBeenNthCalledWith(1, 2);
128+
expect(clock.sleeper).toHaveBeenNthCalledWith(2, 2);
129+
expect(clock.sleeper).toHaveBeenNthCalledWith(3, 2);
102130
});
103131

104132
it("succeeds on the first healthy probe without sleeping and sets OPENSHELL_GATEWAY", async () => {
@@ -146,19 +174,20 @@ describe("gateway recovery", () => {
146174
expect(deps.runCaptureOpenshell).toHaveBeenCalledTimes(6);
147175
});
148176

149-
it("with NEMOCLAW_HEALTH_POLL_COUNT=0 fails fast without invoking the probe", async () => {
150-
// Advisor edge-case: a zero attempt count must not silently pretend
151-
// the gateway is healthy and must not run any subprocess probes.
177+
it("with NEMOCLAW_HEALTH_POLL_COUNT=0 fails fast without silently claiming healthy", async () => {
178+
// Edge case: a zero-count budget must not silently pretend the gateway
179+
// is healthy. The wait-budget helper clamps to a 1ms deadline, so
180+
// waitUntilAsync's first deadline check terminates before the probe
181+
// callback runs. Function throws with a deadline message instead of
182+
// returning success.
152183
vi.stubEnv("NEMOCLAW_HEALTH_POLL_COUNT", "0");
153184
vi.stubEnv("NEMOCLAW_HEALTH_POLL_INTERVAL", "2");
154-
const deps = createDeps();
185+
const deps = createDeps({ sleepSeconds: vi.fn() });
155186

156187
await expect(startGatewayForRecovery({ gatewayPort: 8091 }, deps)).rejects.toThrow(
157-
"0 recovery attempt(s) at 2s interval",
188+
/did not become ready within the configured .* recovery deadline/,
158189
);
159190

160-
// First iteration's attempt-cap check terminates before the probe
161-
// callback runs, so no status/gateway-info calls are made.
162191
expect(deps.runCaptureOpenshell).not.toHaveBeenCalled();
163192
expect(deps.sleepSeconds).not.toHaveBeenCalled();
164193
});

src/lib/onboard/gateway-recovery.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ export type GatewayRecoveryDeps = {
5959
// to the production implementations.
6060
isGatewayHealthy?: typeof isGatewayHealthy;
6161
isGatewayHttpReady?: typeof isGatewayHttpReady;
62+
// Injected clock reader for deadline-driven tests. Defaults to Date.now.
63+
// A test can pair a virtual sleeper (that advances a captured value) with
64+
// this reader to drive deterministic deadline expiration without real
65+
// wall-clock waits or global fake-timer state.
66+
now?(): number;
6267
};
6368

6469
function isValidGatewayRecoveryPort(port: number | null | undefined): port is number {
@@ -185,6 +190,7 @@ async function startTargetGatewayForRecovery(
185190
const sleeper = deps.sleepSeconds ?? sleepSeconds;
186191
const gatewayHealthyImpl = deps.isGatewayHealthy ?? isGatewayHealthy;
187192
const gatewayHttpReadyImpl = deps.isGatewayHttpReady ?? isGatewayHttpReady;
193+
const nowImpl = deps.now ?? Date.now;
188194
const healthy =
189195
recoveryPollCount > 0 &&
190196
(await waitUntilAsync(
@@ -201,11 +207,19 @@ async function startTargetGatewayForRecovery(
201207
);
202208
},
203209
{
204-
deadlineMs: Date.now() + waitBudgetMs,
210+
// #3768 wants a SINGLE clear deadline budget rather than the legacy
211+
// fixed attempt cap. Do NOT pass `maxAttempts` here: with maxAttempts
212+
// set, a fast-failing probe sequence would exit after `count`
213+
// attempts even though the deadline still permits more polling, and
214+
// the operator would see a timeout that under-reports the wait the
215+
// system was willing to spend. Let waitUntilAsync run until the
216+
// deadline; the interval and probe cost naturally bound the total
217+
// attempt count.
218+
deadlineMs: nowImpl() + waitBudgetMs,
205219
initialIntervalMs: Math.max(0, recoveryPollInterval * 1000),
206220
maxIntervalMs: Math.max(0, recoveryPollInterval * 1000),
207221
backoffFactor: 1,
208-
maxAttempts: recoveryPollCount,
222+
now: nowImpl,
209223
// waitUntilAsync passes durations in milliseconds to `sleep`, while
210224
// the injected sleeper (sleepSeconds) expects a second-granular
211225
// number. Adapt at this boundary only.
@@ -224,15 +238,13 @@ async function startTargetGatewayForRecovery(
224238
return;
225239
}
226240

227-
// The wait is attempt-capped (waitUntilAsync exits at maxAttempts) with a
228-
// fixed inter-attempt interval and a hard upper time bound. Describe both
229-
// dimensions honestly so the operator does not read the message as a
230-
// pure deadline promise: the loop can terminate earlier via the attempt
231-
// cap when probes fail quickly, and cannot exceed the upper bound.
241+
// Pure deadline-based semantics per #3768: report the actual budget the
242+
// loop was allowed to spend. Include the interval only as diagnostic
243+
// context so an operator scanning the message understands the poll cadence.
232244
throw new Error(
233-
`Gateway '${gatewayName}' did not become ready after ${recoveryPollCount} recovery attempt(s) at ${recoveryPollInterval}s interval (max wait ${formatGatewayRecoveryWaitBudget(
245+
`Gateway '${gatewayName}' did not become ready within the configured ${formatGatewayRecoveryWaitBudget(
234246
waitBudgetMs,
235-
)})`,
247+
)} recovery deadline (${recoveryPollInterval}s poll interval)`,
236248
);
237249
}
238250

0 commit comments

Comments
 (0)