Skip to content

Commit 0d0d2bb

Browse files
committed
fix(wait): honor the shared deadline for queued members and hint only resumable runs
1 parent 91a4641 commit 0d0d2bb

3 files changed

Lines changed: 74 additions & 13 deletions

File tree

src/commands/test.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3112,6 +3112,7 @@ describe('runTestWaitMany', () => {
31123112
return { body: terminalRun('run_ok', 'passed') };
31133113
});
31143114
const out: string[] = [];
3115+
const errs: string[] = [];
31153116
const rejection = await runTestWaitMany(
31163117
{
31173118
profile: 'default',
@@ -3121,7 +3122,12 @@ describe('runTestWaitMany', () => {
31213122
timeoutSeconds: 30,
31223123
maxConcurrency: 2,
31233124
},
3124-
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
3125+
{
3126+
credentialsPath,
3127+
fetchImpl,
3128+
stdout: line => out.push(line),
3129+
stderr: line => errs.push(line),
3130+
},
31253131
).catch((error: unknown) => error);
31263132
expect(rejection).toMatchObject({ exitCode: 7 });
31273133
const payload = JSON.parse(out.join('')) as {
@@ -3130,6 +3136,36 @@ describe('runTestWaitMany', () => {
31303136
// The failing member did not abort the pool: the passed verdict survived.
31313137
expect(payload.results[0]).toMatchObject({ runId: 'run_ok', status: 'passed' });
31323138
expect(payload.results[1]!.status).toBe('error:NOT_FOUND');
3139+
// The re-attach hint names the errored member (resumable) but NOT the
3140+
// already-terminal passed one.
3141+
const hint = errs.find(line => line.includes('Re-attach with:'));
3142+
expect(hint).toContain('run_gone');
3143+
expect(hint).not.toContain('run_ok');
3144+
});
3145+
3146+
it('members dequeued after the shared deadline are not granted extra poll time', async () => {
3147+
const { credentialsPath } = makeCreds();
3148+
let fetches = 0;
3149+
const fetchImpl = makeFetch(() => {
3150+
fetches += 1;
3151+
return { body: terminalRun('run_any', 'passed') };
3152+
});
3153+
// timeoutSeconds 0: the shared deadline is already in the past when the
3154+
// pool starts, so every member must resolve to timeout WITHOUT polling
3155+
// (previously each dequeued member was granted a fresh 1s minimum).
3156+
const rejection = await runTestWaitMany(
3157+
{
3158+
profile: 'default',
3159+
output: 'json',
3160+
debug: false,
3161+
runIds: ['run_a', 'run_b', 'run_c'],
3162+
timeoutSeconds: 0,
3163+
maxConcurrency: 1,
3164+
},
3165+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: () => undefined },
3166+
).catch((error: unknown) => error);
3167+
expect(rejection).toMatchObject({ exitCode: 7 });
3168+
expect(fetches).toBe(0);
31333169
});
31343170

31353171
it('an auth error escalates the exit code to 3', async () => {

src/commands/test.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5396,6 +5396,11 @@ export async function runTestWaitMany(
53965396
| { kind: 'error'; code: string; exitCode: number };
53975397

53985398
const pollOne = async (runId: string): Promise<WaitOutcome> => {
5399+
// A member dequeued AFTER the shared deadline has passed must not be
5400+
// granted a fresh minimum poll window (with --max-concurrency 1 that
5401+
// would extend the invocation by ~1s per queued run past --timeout).
5402+
const remainingSeconds = Math.ceil((deadlineMs - Date.now()) / 1000);
5403+
if (remainingSeconds <= 0) return { kind: 'timeout' };
53995404
const resolveAlternate = makeBackendWaitFallback({
54005405
client,
54015406
resolveTestId: run => run.testId,
@@ -5404,7 +5409,7 @@ export async function runTestWaitMany(
54045409
});
54055410
try {
54065411
const run = await pollRunUntilTerminal(client, runId, {
5407-
timeoutSeconds: Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000)),
5412+
timeoutSeconds: remainingSeconds,
54085413
sleep: deps.sleep,
54095414
onTransition: opts.verbose ? (msg: string) => stderrFn(`[verbose] ${msg}`) : undefined,
54105415
onTick: (run, elapsedMs) => {
@@ -5448,14 +5453,27 @@ export async function runTestWaitMany(
54485453
} catch (fanOutErr) {
54495454
if (fanOutErr instanceof RequestTimeoutError) {
54505455
// Same contract as the batch pollers: leave stdout parseable before
5451-
// exiting 7 — every dispatched id is re-attachable in one command.
5456+
// exiting 7. Members that already settled keep their real status; only
5457+
// the still-unfinished ids are marked running and named in the hint
5458+
// (re-attaching to an already-terminal run would be a wasted command).
54525459
ticker.finalize('Multi-run wait — request timed out');
54535460
const partial = {
5454-
results: opts.runIds.map(runId => ({ runId, status: 'running' })),
5461+
results: opts.runIds.map((runId): CliMultiWaitResult => {
5462+
const outcome = outcomes.get(runId);
5463+
if (outcome === undefined) return { runId, status: 'running' };
5464+
if (outcome.kind === 'timeout') return { runId, status: 'timeout' };
5465+
if (outcome.kind === 'error') return { runId, status: `error:${outcome.code}` };
5466+
return { runId, status: outcome.run.status, testId: outcome.run.testId };
5467+
}),
54555468
summary: { total: opts.runIds.length },
54565469
};
5457-
out.print(partial, () => partial.results.map(r => `${r.runId} running`).join('\n'));
5458-
stderrFn(`Re-attach with: testsprite test wait ${opts.runIds.join(' ')}`);
5470+
out.print(partial, () => partial.results.map(r => `${r.runId} ${r.status}`).join('\n'));
5471+
const unfinished = partial.results
5472+
.filter(r => r.status === 'running' || r.status === 'timeout')
5473+
.map(r => r.runId);
5474+
if (unfinished.length > 0) {
5475+
stderrFn(`Re-attach with: testsprite test wait ${unfinished.join(' ')}`);
5476+
}
54595477
}
54605478
throw fanOutErr;
54615479
}
@@ -5483,9 +5501,14 @@ export async function runTestWaitMany(
54835501
].join('\n'),
54845502
);
54855503

5486-
const timedOutIds = results.filter(r => r.status === 'timeout').map(r => r.runId);
5487-
if (timedOutIds.length > 0) {
5488-
stderrFn(`Re-attach with: testsprite test wait ${timedOutIds.join(' ')}`);
5504+
// Every member that did not reach a terminal verdict is re-attachable:
5505+
// timeouts (still running server-side) and poll errors (e.g. a transient
5506+
// transport failure) both belong in the hint; terminal runs do not.
5507+
const unfinishedIds = results
5508+
.filter(r => r.status === 'timeout' || r.status.startsWith('error:'))
5509+
.map(r => r.runId);
5510+
if (unfinishedIds.length > 0) {
5511+
stderrFn(`Re-attach with: testsprite test wait ${unfinishedIds.join(' ')}`);
54895512
}
54905513

54915514
// Worst-status exit: auth escalates (a rejected key fails every member the
@@ -8333,8 +8356,9 @@ export function createTestCommand(deps: TestDeps = {}): Command {
83338356
' 0 passed\n' +
83348357
' 1 failed / blocked / cancelled\n' +
83358358
' 3 auth error\n' +
8336-
' 4 run not found\n' +
8337-
' 7 timeout — resume with: testsprite test wait <run-id...>\n' +
8359+
' 4 run not found (single run-id; with several ids a per-member poll error\n' +
8360+
' is recorded as error:<CODE> in its row and folded into exit 7)\n' +
8361+
' 7 timeout or per-member poll error — resume with: testsprite test wait <run-id...>\n' +
83388362
' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
83398363
'\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>',
83408364
)

test/__snapshots__/help.snapshot.test.ts.snap

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,9 @@ Commands:
262262
0 passed
263263
1 failed / blocked / cancelled
264264
3 auth error
265-
4 run not found
266-
7 timeout — resume with: testsprite test wait <run-id...>
265+
4 run not found (single run-id; with several ids a per-member poll error
266+
is recorded as error:<CODE> in its row and folded into exit 7)
267+
7 timeout or per-member poll error — resume with: testsprite test wait <run-id...>
267268
10 transport/network failure (UNAVAILABLE) — retry the command
268269
269270
On failure/blocked/cancelled, run: testsprite test artifact get <run-id>

0 commit comments

Comments
 (0)