Skip to content

Commit 4cf1279

Browse files
committed
fix(wait): honor the shared deadline for queued members and hint only resumable runs
1 parent 5e05fd5 commit 4cf1279

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
@@ -3030,6 +3030,7 @@ describe('runTestWaitMany', () => {
30303030
return { body: terminalRun('run_ok', 'passed') };
30313031
});
30323032
const out: string[] = [];
3033+
const errs: string[] = [];
30333034
const rejection = await runTestWaitMany(
30343035
{
30353036
profile: 'default',
@@ -3039,7 +3040,12 @@ describe('runTestWaitMany', () => {
30393040
timeoutSeconds: 30,
30403041
maxConcurrency: 2,
30413042
},
3042-
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
3043+
{
3044+
credentialsPath,
3045+
fetchImpl,
3046+
stdout: line => out.push(line),
3047+
stderr: line => errs.push(line),
3048+
},
30433049
).catch((error: unknown) => error);
30443050
expect(rejection).toMatchObject({ exitCode: 7 });
30453051
const payload = JSON.parse(out.join('')) as {
@@ -3048,6 +3054,36 @@ describe('runTestWaitMany', () => {
30483054
// The failing member did not abort the pool: the passed verdict survived.
30493055
expect(payload.results[0]).toMatchObject({ runId: 'run_ok', status: 'passed' });
30503056
expect(payload.results[1]!.status).toBe('error:NOT_FOUND');
3057+
// The re-attach hint names the errored member (resumable) but NOT the
3058+
// already-terminal passed one.
3059+
const hint = errs.find(line => line.includes('Re-attach with:'));
3060+
expect(hint).toContain('run_gone');
3061+
expect(hint).not.toContain('run_ok');
3062+
});
3063+
3064+
it('members dequeued after the shared deadline are not granted extra poll time', async () => {
3065+
const { credentialsPath } = makeCreds();
3066+
let fetches = 0;
3067+
const fetchImpl = makeFetch(() => {
3068+
fetches += 1;
3069+
return { body: terminalRun('run_any', 'passed') };
3070+
});
3071+
// timeoutSeconds 0: the shared deadline is already in the past when the
3072+
// pool starts, so every member must resolve to timeout WITHOUT polling
3073+
// (previously each dequeued member was granted a fresh 1s minimum).
3074+
const rejection = await runTestWaitMany(
3075+
{
3076+
profile: 'default',
3077+
output: 'json',
3078+
debug: false,
3079+
runIds: ['run_a', 'run_b', 'run_c'],
3080+
timeoutSeconds: 0,
3081+
maxConcurrency: 1,
3082+
},
3083+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: () => undefined },
3084+
).catch((error: unknown) => error);
3085+
expect(rejection).toMatchObject({ exitCode: 7 });
3086+
expect(fetches).toBe(0);
30513087
});
30523088

30533089
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
@@ -5250,6 +5250,11 @@ export async function runTestWaitMany(
52505250
| { kind: 'error'; code: string; exitCode: number };
52515251

52525252
const pollOne = async (runId: string): Promise<WaitOutcome> => {
5253+
// A member dequeued AFTER the shared deadline has passed must not be
5254+
// granted a fresh minimum poll window (with --max-concurrency 1 that
5255+
// would extend the invocation by ~1s per queued run past --timeout).
5256+
const remainingSeconds = Math.ceil((deadlineMs - Date.now()) / 1000);
5257+
if (remainingSeconds <= 0) return { kind: 'timeout' };
52535258
const resolveAlternate = makeBackendWaitFallback({
52545259
client,
52555260
resolveTestId: run => run.testId,
@@ -5258,7 +5263,7 @@ export async function runTestWaitMany(
52585263
});
52595264
try {
52605265
const run = await pollRunUntilTerminal(client, runId, {
5261-
timeoutSeconds: Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000)),
5266+
timeoutSeconds: remainingSeconds,
52625267
sleep: deps.sleep,
52635268
onTransition: opts.verbose ? (msg: string) => stderrFn(`[verbose] ${msg}`) : undefined,
52645269
onTick: (run, elapsedMs) => {
@@ -5302,14 +5307,27 @@ export async function runTestWaitMany(
53025307
} catch (fanOutErr) {
53035308
if (fanOutErr instanceof RequestTimeoutError) {
53045309
// Same contract as the batch pollers: leave stdout parseable before
5305-
// exiting 7 — every dispatched id is re-attachable in one command.
5310+
// exiting 7. Members that already settled keep their real status; only
5311+
// the still-unfinished ids are marked running and named in the hint
5312+
// (re-attaching to an already-terminal run would be a wasted command).
53065313
ticker.finalize('Multi-run wait — request timed out');
53075314
const partial = {
5308-
results: opts.runIds.map(runId => ({ runId, status: 'running' })),
5315+
results: opts.runIds.map((runId): CliMultiWaitResult => {
5316+
const outcome = outcomes.get(runId);
5317+
if (outcome === undefined) return { runId, status: 'running' };
5318+
if (outcome.kind === 'timeout') return { runId, status: 'timeout' };
5319+
if (outcome.kind === 'error') return { runId, status: `error:${outcome.code}` };
5320+
return { runId, status: outcome.run.status, testId: outcome.run.testId };
5321+
}),
53095322
summary: { total: opts.runIds.length },
53105323
};
5311-
out.print(partial, () => partial.results.map(r => `${r.runId} running`).join('\n'));
5312-
stderrFn(`Re-attach with: testsprite test wait ${opts.runIds.join(' ')}`);
5324+
out.print(partial, () => partial.results.map(r => `${r.runId} ${r.status}`).join('\n'));
5325+
const unfinished = partial.results
5326+
.filter(r => r.status === 'running' || r.status === 'timeout')
5327+
.map(r => r.runId);
5328+
if (unfinished.length > 0) {
5329+
stderrFn(`Re-attach with: testsprite test wait ${unfinished.join(' ')}`);
5330+
}
53135331
}
53145332
throw fanOutErr;
53155333
}
@@ -5337,9 +5355,14 @@ export async function runTestWaitMany(
53375355
].join('\n'),
53385356
);
53395357

5340-
const timedOutIds = results.filter(r => r.status === 'timeout').map(r => r.runId);
5341-
if (timedOutIds.length > 0) {
5342-
stderrFn(`Re-attach with: testsprite test wait ${timedOutIds.join(' ')}`);
5358+
// Every member that did not reach a terminal verdict is re-attachable:
5359+
// timeouts (still running server-side) and poll errors (e.g. a transient
5360+
// transport failure) both belong in the hint; terminal runs do not.
5361+
const unfinishedIds = results
5362+
.filter(r => r.status === 'timeout' || r.status.startsWith('error:'))
5363+
.map(r => r.runId);
5364+
if (unfinishedIds.length > 0) {
5365+
stderrFn(`Re-attach with: testsprite test wait ${unfinishedIds.join(' ')}`);
53435366
}
53445367

53455368
// Worst-status exit: auth escalates (a rejected key fails every member the
@@ -8156,8 +8179,9 @@ export function createTestCommand(deps: TestDeps = {}): Command {
81568179
' 0 passed\n' +
81578180
' 1 failed / blocked / cancelled\n' +
81588181
' 3 auth error\n' +
8159-
' 4 run not found\n' +
8160-
' 7 timeout — resume with: testsprite test wait <run-id...>\n' +
8182+
' 4 run not found (single run-id; with several ids a per-member poll error\n' +
8183+
' is recorded as error:<CODE> in its row and folded into exit 7)\n' +
8184+
' 7 timeout or per-member poll error — resume with: testsprite test wait <run-id...>\n' +
81618185
' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
81628186
'\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>',
81638187
)

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

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

0 commit comments

Comments
 (0)