Skip to content

[Hackathon] --wait polling turns into a tight request loop when a long-poll read comes back without being held (run poll + plan generation) #481

Closed

Description

@cipoklean

Verified against (commit SHA or released version)

commit 988c035 (main, v0.14.0)

Discord Username / User ID

949225590181994556

What does this improvement do?

What's broken

pollRunUntilTerminal (src/lib/poll.ts), which every --wait path goes through (test run --wait, test run --all --wait, test rerun --wait, test wait, testlist run, test flaky, test create --run --wait), re-issues the GET /runs/{id}?waitSeconds=N read immediately after every non-terminal answer:

// Long-poll path: server already waited up to waitSeconds for us;
// loop immediately (no extra client-side sleep).
if (!useBackoff) {
  if (Date.now() >= deadlineMs) throw new TimeoutError(...);
  continue;
}

That is only safe if the server really held the read. The loop never checks. When a non-terminal answer comes back early, nothing paces the next request, and the CLI sends back-to-back GETs for the whole --timeout (up to 3600 s). This happens when a server or proxy accepts but ignores waitSeconds, when an intermediary answers early, or when the backend only holds some statuses. The code only switches to the paced backoff mode on a 400 VALIDATION_ERROR, so a server that silently ignores the parameter never triggers the fallback.

The plan-generation ladder (src/lib/plan-poll.ts, test plan generate) does the same in its active-status branch (if (!useLongPoll) await paceBeforeNextRead(); continue;). Its own comment says the server only enters the waitSeconds hold for some statuses. So the "server already waited" assumption already depends on server behaviour the client doesn't check.

Impact:

  • A request flood against the API from every waiting CLI. A --all --wait batch multiplies it by --max-concurrency.
  • The flood trips the per-minute throttle. poll.ts rethrows RATE_LIMITED as soon as http.ts's 3 attempts are used up, so a healthy run's wait ends with exit 11 even though the test is fine.

Repro (no API key, no network)

// node repro.mjs (after npm run build)
import { pollRunUntilTerminal } from './dist/lib/poll.js';
let calls = 0;
const client = { async getRun(id) { calls++; return { runId: id, testId: 't', status: 'running' }; } };
try { await pollRunUntilTerminal(client, 'r1', { timeoutSeconds: 2 }); } catch (e) { console.log(e.name); }
console.log('getRun calls in 2s:', calls);

Actual (main @ 988c035): TimeoutError / getRun calls in 2s: 6728387. Against a real server, that is one request per round trip for the whole timeout.

Expected: a non-terminal answer that came back without being held is followed by the same pacing the fallback mode already uses (2 s), so at most one read per interval.

Details / implementation notes

Measure how long each long-poll read was held:

  • A read held for at least the fallback pace (BACKOFF_SCHEDULE_MS[0] = 2 s in poll.ts, SETTLED_POLL_DELAY_MS = 2 s in plan-poll.ts) is re-issued at once, as today.
  • A faster read sleeps only the remainder, clamped to the --timeout deadline.

Behaviour against a server that holds reads is unchanged: no extra sleep, no extra latency. No new dependencies and no new requests. Vitest coverage uses a fake Date so the test is deterministic.

I have a fix with tests ready on a branch and will open the PR once this is accepted.

Confirmations

  • I have searched existing issues and this is not a duplicate.
  • I have provided my Discord identity above for reward coordination.

Activity

  1. zeshi-du commented on Oct 20, 2026

    @zeshi-du
    Contributor

    Thanks @cipoklean. Accepted as a hardening fix: the service holds reads for up to waitSeconds, so it isn't live today, but a proxy answering early would hit your loop, so a client-side floor is worth having.

    Scope:

    • Change src/lib/poll.ts only (long-poll branch); leave plan-poll.ts out, plan generation is being removed.
    • Measure how long each read was held; if a non-terminal answer returns faster than the 2s fallback pace, sleep only the remainder, clamped to the --timeout deadline. Held reads behave as today.
    • No extra requests or dependencies; no changes to exit codes, retryAfterSeconds or the VALIDATION_ERROR fallback.

    Tests in src/lib/poll.spec.ts with a fake clock (your sleep spy must advance it; existing tests inject a no-op sleep): one failing without the change (instant-return client, bounded call count), one showing a held read gets no added sleep, one for deadline clamping. Keep the diff minimal; leave test.ts call sites alone. Claim it with /assign.

  2. added
    acceptedTriaged and accepted — a PR for this will be reviewed
    and removed
    needs-triageNew issue/PR awaiting first triage
    on Oct 20, 2026
  3. cipoklean commented on Oct 20, 2026

    @cipoklean
    ContributorAuthor

    /assign

  4. testsprite-hob commented on Oct 20, 2026

    @testsprite-hob

    Assigned to @cipoklean. Thanks for taking this on. (1 more slot available.)

    Your open assigned issues (2/3):

  5. added a commit that references this issue on Nov 9, 2026
    2ff4960
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

acceptedTriaged and accepted — a PR for this will be reviewedhackathonCLI hackathon submissionsin-progressAssigned and actively being worked on

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions