Skip to content

Commit 60d3f82

Browse files
committed
test(onboard): prove deadline drives termination + add issue suffix (NVIDIA#3768)
Address two CodeRabbit nits on PR NVIDIA#6320. 1. Test titles missing local issue reference (Major, quick win). Add the required `(NVIDIA#3768)` suffix to the four new tests so they match the repo's test-title-style contract. 2. Deadline-driven proof (Major, heavy lift). The prior timeout test used fast-failing probes that only advanced the clock via the sleep mock. Under that setup, a hidden `maxAttempts: recoveryPollCount` would have produced the same probe/sleep counts as a pure deadline, so the test could not tell them apart. Redesign the timeout test to make probes ALSO advance the virtual clock: NEMOCLAW_HEALTH_POLL_COUNT=10, NEMOCLAW_HEALTH_POLL_INTERVAL=1 with each probe consuming 1s of virtual time. Under the pure-deadline implementation the loop runs ~5 iterations before the 10s budget is exhausted (probes and sleeps consume 2s per iteration cumulatively). Under a hidden attempt cap of 10 the loop would run exactly 10 iterations. The new assertion `probeCount < 10` therefore only passes when the deadline (not an attempt cap) terminates the loop, which is the invariant NVIDIA#3768 asks for. Expose `advance(seconds)` on the makeVirtualClock helper so a mocked subprocess-probe implementation can move time forward while keeping the sleeper as the primary in-loop clock driver. 11/11 tests still pass. Biome and repo checks clean. Signed-off-by: Charan Jagwani <cjagwani@nvidia.com>
1 parent 8406fd3 commit 60d3f82

1 file changed

Lines changed: 51 additions & 20 deletions

File tree

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

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@ import { type GatewayRecoveryDeps, startGatewayForRecovery } from "./gateway-rec
1111
// injected `sleepSeconds` mock so the clock advances only when the loop
1212
// actually sleeps. Tests get deterministic deadline expiration without any
1313
// real wall-clock waits or global timer state.
14+
//
15+
// `advance` is exposed so a test can also advance the clock from inside a
16+
// mocked probe. This is how the timeout test proves the loop is truly
17+
// deadline-driven: if each probe advances the clock, then a maxAttempts=N
18+
// cap would exit at a different observable count than a pure deadline
19+
// would, so the assertions can only be satisfied by the deadline path.
1420
function makeVirtualClock(startMs = 1_000_000_000_000) {
1521
let now = startMs;
1622
return {
1723
now: () => now,
24+
advance: (seconds: number) => {
25+
now += Math.max(0, seconds) * 1000;
26+
},
1827
sleeper: vi.fn((seconds: number) => {
1928
now += Math.max(0, seconds) * 1000;
2029
}),
@@ -102,34 +111,56 @@ describe("gateway recovery", () => {
102111
);
103112
});
104113

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.
109-
vi.stubEnv("NEMOCLAW_HEALTH_POLL_COUNT", "3");
110-
vi.stubEnv("NEMOCLAW_HEALTH_POLL_INTERVAL", "2");
114+
it("polls until the configured recovery deadline and reports it in the timeout (#3768)", async () => {
115+
// #3768: prove the loop is DEADLINE-driven, not just attempt-capped.
116+
// Design: with count=10 and interval=1s the wait budget is 10s. Make
117+
// each subprocess-probe advance the clock by ~1s so probes are the
118+
// primary time-consumer, then sleeps at 1s add another second per
119+
// iteration. Under a pure deadline: iterations run until ~2s per
120+
// iteration cumulatively hits 10s -> ~5 probes. Under a hidden
121+
// maxAttempts=count cap, the loop would exit at exactly 10 probes
122+
// (attempt cap hits first because probes and sleeps take equal time),
123+
// which is a different observable count from the deadline path. The
124+
// strict upper bound `probeCount < 10` therefore only passes when the
125+
// deadline (not an attempt cap) terminates the loop.
126+
vi.stubEnv("NEMOCLAW_HEALTH_POLL_COUNT", "10");
127+
vi.stubEnv("NEMOCLAW_HEALTH_POLL_INTERVAL", "1");
111128
const clock = makeVirtualClock();
112-
const deps = createDeps({ sleepSeconds: clock.sleeper, now: clock.now });
129+
let probesRun = 0;
130+
const deps = createDeps({
131+
sleepSeconds: clock.sleeper,
132+
now: clock.now,
133+
runCaptureOpenshell: vi.fn(() => {
134+
// Only advance the clock ONCE per probe iteration (three subprocess
135+
// calls per probe): status is the first call.
136+
if (probesRun * 3 === (deps.runCaptureOpenshell as ReturnType<typeof vi.fn>).mock.calls.length - 1) {
137+
probesRun += 1;
138+
clock.advance(1);
139+
}
140+
return "Disconnected";
141+
}),
142+
});
113143

114144
await expect(startGatewayForRecovery({ gatewayPort: 8091 }, deps)).rejects.toThrow(
115-
"configured 6s recovery deadline (2s poll interval)",
145+
"configured 10s recovery deadline (1s poll interval)",
116146
);
117147

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.
122148
const runCaptureCalls = (deps.runCaptureOpenshell as ReturnType<typeof vi.fn>).mock.calls
123149
.length;
124150
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);
151+
// The deadline (not an attempt cap) MUST have terminated the loop:
152+
// probe advances 1s + sleep advances 1s = 2s per iteration, so under
153+
// a 10s budget the loop runs ~5 iterations and cannot reach the 10
154+
// attempts a hidden attempt cap would permit.
155+
expect(probeCount).toBeGreaterThan(0);
156+
expect(probeCount).toBeLessThan(10);
157+
// Sleeps happen after every probe except the last one (deadline check
158+
// after the final probe short-circuits before an extra sleep).
159+
expect(clock.sleeper).toHaveBeenCalled();
160+
expect(clock.sleeper.mock.calls.every(([s]) => s === 1)).toBe(true);
130161
});
131162

132-
it("succeeds on the first healthy probe without sleeping and sets OPENSHELL_GATEWAY", async () => {
163+
it("succeeds on the first healthy probe without sleeping and sets OPENSHELL_GATEWAY (#3768)", async () => {
133164
// Advisor: pin the happy path so a future refactor cannot silently
134165
// break the side effects the caller relies on after readiness.
135166
vi.stubEnv("NEMOCLAW_HEALTH_POLL_COUNT", "3");
@@ -149,7 +180,7 @@ describe("gateway recovery", () => {
149180
expect(deps.runCaptureOpenshell).toHaveBeenCalledTimes(3);
150181
});
151182

152-
it("succeeds after retrying past unhealthy probes and still sets OPENSHELL_GATEWAY", async () => {
183+
it("succeeds after retrying past unhealthy probes and still sets OPENSHELL_GATEWAY (#3768)", async () => {
153184
vi.stubEnv("NEMOCLAW_HEALTH_POLL_COUNT", "3");
154185
vi.stubEnv("NEMOCLAW_HEALTH_POLL_INTERVAL", "2");
155186
// Probe #1 fails the health predicate, probe #2 passes. Each probe
@@ -174,7 +205,7 @@ describe("gateway recovery", () => {
174205
expect(deps.runCaptureOpenshell).toHaveBeenCalledTimes(6);
175206
});
176207

177-
it("with NEMOCLAW_HEALTH_POLL_COUNT=0 fails fast without silently claiming healthy", async () => {
208+
it("with NEMOCLAW_HEALTH_POLL_COUNT=0 fails fast without silently claiming healthy (#3768)", async () => {
178209
// Edge case: a zero-count budget must not silently pretend the gateway
179210
// is healthy. The wait-budget helper clamps to a 1ms deadline, so
180211
// waitUntilAsync's first deadline check terminates before the probe

0 commit comments

Comments
 (0)