Skip to content

Commit 3a618ff

Browse files
committed
fix(wait): honor the shared deadline for queued members and hint only resumable runs
1 parent dce194c commit 3a618ff

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
@@ -2791,6 +2791,7 @@ describe('runTestWaitMany', () => {
27912791
return { body: terminalRun('run_ok', 'passed') };
27922792
});
27932793
const out: string[] = [];
2794+
const errs: string[] = [];
27942795
const rejection = await runTestWaitMany(
27952796
{
27962797
profile: 'default',
@@ -2800,7 +2801,12 @@ describe('runTestWaitMany', () => {
28002801
timeoutSeconds: 30,
28012802
maxConcurrency: 2,
28022803
},
2803-
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2804+
{
2805+
credentialsPath,
2806+
fetchImpl,
2807+
stdout: line => out.push(line),
2808+
stderr: line => errs.push(line),
2809+
},
28042810
).catch((error: unknown) => error);
28052811
expect(rejection).toMatchObject({ exitCode: 7 });
28062812
const payload = JSON.parse(out.join('')) as {
@@ -2809,6 +2815,36 @@ describe('runTestWaitMany', () => {
28092815
// The failing member did not abort the pool: the passed verdict survived.
28102816
expect(payload.results[0]).toMatchObject({ runId: 'run_ok', status: 'passed' });
28112817
expect(payload.results[1]!.status).toBe('error:NOT_FOUND');
2818+
// The re-attach hint names the errored member (resumable) but NOT the
2819+
// already-terminal passed one.
2820+
const hint = errs.find(line => line.includes('Re-attach with:'));
2821+
expect(hint).toContain('run_gone');
2822+
expect(hint).not.toContain('run_ok');
2823+
});
2824+
2825+
it('members dequeued after the shared deadline are not granted extra poll time', async () => {
2826+
const { credentialsPath } = makeCreds();
2827+
let fetches = 0;
2828+
const fetchImpl = makeFetch(() => {
2829+
fetches += 1;
2830+
return { body: terminalRun('run_any', 'passed') };
2831+
});
2832+
// timeoutSeconds 0: the shared deadline is already in the past when the
2833+
// pool starts, so every member must resolve to timeout WITHOUT polling
2834+
// (previously each dequeued member was granted a fresh 1s minimum).
2835+
const rejection = await runTestWaitMany(
2836+
{
2837+
profile: 'default',
2838+
output: 'json',
2839+
debug: false,
2840+
runIds: ['run_a', 'run_b', 'run_c'],
2841+
timeoutSeconds: 0,
2842+
maxConcurrency: 1,
2843+
},
2844+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: () => undefined },
2845+
).catch((error: unknown) => error);
2846+
expect(rejection).toMatchObject({ exitCode: 7 });
2847+
expect(fetches).toBe(0);
28122848
});
28132849

28142850
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
@@ -4984,6 +4984,11 @@ export async function runTestWaitMany(
49844984
| { kind: 'error'; code: string; exitCode: number };
49854985

49864986
const pollOne = async (runId: string): Promise<WaitOutcome> => {
4987+
// A member dequeued AFTER the shared deadline has passed must not be
4988+
// granted a fresh minimum poll window (with --max-concurrency 1 that
4989+
// would extend the invocation by ~1s per queued run past --timeout).
4990+
const remainingSeconds = Math.ceil((deadlineMs - Date.now()) / 1000);
4991+
if (remainingSeconds <= 0) return { kind: 'timeout' };
49874992
const resolveAlternate = makeBackendWaitFallback({
49884993
client,
49894994
resolveTestId: run => run.testId,
@@ -4992,7 +4997,7 @@ export async function runTestWaitMany(
49924997
});
49934998
try {
49944999
const run = await pollRunUntilTerminal(client, runId, {
4995-
timeoutSeconds: Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000)),
5000+
timeoutSeconds: remainingSeconds,
49965001
sleep: deps.sleep,
49975002
onTransition: opts.verbose ? (msg: string) => stderrFn(`[verbose] ${msg}`) : undefined,
49985003
onTick: (run, elapsedMs) => {
@@ -5036,14 +5041,27 @@ export async function runTestWaitMany(
50365041
} catch (fanOutErr) {
50375042
if (fanOutErr instanceof RequestTimeoutError) {
50385043
// Same contract as the batch pollers: leave stdout parseable before
5039-
// exiting 7 — every dispatched id is re-attachable in one command.
5044+
// exiting 7. Members that already settled keep their real status; only
5045+
// the still-unfinished ids are marked running and named in the hint
5046+
// (re-attaching to an already-terminal run would be a wasted command).
50405047
ticker.finalize('Multi-run wait — request timed out');
50415048
const partial = {
5042-
results: opts.runIds.map(runId => ({ runId, status: 'running' })),
5049+
results: opts.runIds.map((runId): CliMultiWaitResult => {
5050+
const outcome = outcomes.get(runId);
5051+
if (outcome === undefined) return { runId, status: 'running' };
5052+
if (outcome.kind === 'timeout') return { runId, status: 'timeout' };
5053+
if (outcome.kind === 'error') return { runId, status: `error:${outcome.code}` };
5054+
return { runId, status: outcome.run.status, testId: outcome.run.testId };
5055+
}),
50435056
summary: { total: opts.runIds.length },
50445057
};
5045-
out.print(partial, () => partial.results.map(r => `${r.runId} running`).join('\n'));
5046-
stderrFn(`Re-attach with: testsprite test wait ${opts.runIds.join(' ')}`);
5058+
out.print(partial, () => partial.results.map(r => `${r.runId} ${r.status}`).join('\n'));
5059+
const unfinished = partial.results
5060+
.filter(r => r.status === 'running' || r.status === 'timeout')
5061+
.map(r => r.runId);
5062+
if (unfinished.length > 0) {
5063+
stderrFn(`Re-attach with: testsprite test wait ${unfinished.join(' ')}`);
5064+
}
50475065
}
50485066
throw fanOutErr;
50495067
}
@@ -5071,9 +5089,14 @@ export async function runTestWaitMany(
50715089
].join('\n'),
50725090
);
50735091

5074-
const timedOutIds = results.filter(r => r.status === 'timeout').map(r => r.runId);
5075-
if (timedOutIds.length > 0) {
5076-
stderrFn(`Re-attach with: testsprite test wait ${timedOutIds.join(' ')}`);
5092+
// Every member that did not reach a terminal verdict is re-attachable:
5093+
// timeouts (still running server-side) and poll errors (e.g. a transient
5094+
// transport failure) both belong in the hint; terminal runs do not.
5095+
const unfinishedIds = results
5096+
.filter(r => r.status === 'timeout' || r.status.startsWith('error:'))
5097+
.map(r => r.runId);
5098+
if (unfinishedIds.length > 0) {
5099+
stderrFn(`Re-attach with: testsprite test wait ${unfinishedIds.join(' ')}`);
50775100
}
50785101

50795102
// Worst-status exit: auth escalates (a rejected key fails every member the
@@ -7708,8 +7731,9 @@ export function createTestCommand(deps: TestDeps = {}): Command {
77087731
' 0 passed\n' +
77097732
' 1 failed / blocked / cancelled\n' +
77107733
' 3 auth error\n' +
7711-
' 4 run not found\n' +
7712-
' 7 timeout — resume with: testsprite test wait <run-id...>\n' +
7734+
' 4 run not found (single run-id; with several ids a per-member poll error\n' +
7735+
' is recorded as error:<CODE> in its row and folded into exit 7)\n' +
7736+
' 7 timeout or per-member poll error — resume with: testsprite test wait <run-id...>\n' +
77137737
' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
77147738
'\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>',
77157739
)

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,9 @@ Commands:
248248
0 passed
249249
1 failed / blocked / cancelled
250250
3 auth error
251-
4 run not found
252-
7 timeout — resume with: testsprite test wait <run-id...>
251+
4 run not found (single run-id; with several ids a per-member poll error
252+
is recorded as error:<CODE> in its row and folded into exit 7)
253+
7 timeout or per-member poll error — resume with: testsprite test wait <run-id...>
253254
10 transport/network failure (UNAVAILABLE) — retry the command
254255
255256
On failure/blocked/cancelled, run: testsprite test artifact get <run-id>

0 commit comments

Comments
 (0)