fix(core): SDK version check blocked every snapshot POST, hanging Cypress at 45s (PER-10514) - #2387
Conversation
… Cypress at 45s (PER-10514) `POST /percy/snapshot` does not answer the SDK until `percy.snapshot()` resolves (api.js:230-239). The first thing that generator awaited was not asset discovery — it was `checkSDKVersion()`, which calls `https://api.github.com/repos/percy/<sdk-repo>/releases` purely to print an "[SDK Update Available]" warning. That request is issued with `http.request()` and no socket timeout and no 'timeout' handler, so a peer that completes the TCP handshake and then goes silent — a corporate proxy, an egress firewall that drops rather than rejects, GitHub throttling — hangs it forever. `retries: 0` cannot rescue it because the single attempt never settles. @percy/cypress caps that POST at CY_TIMEOUT = 45000 (index.js:10, :540), so the customer sees: cy.then() timed out after waiting 45000ms. Your callback function returned a promise that never resolved. with `utils.postSnapshot` in the printed callback, intermittently, for as long as their CI network occasionally can't reach GitHub. `sdkInfoDisplayed` was also set *after* the await, so while the first check hung the flag stayed false and every subsequent snapshot in that process started its own GitHub request — one bad network moment cost the whole run, not one test. Reproduced with a TCP black-hole as HTTPS_PROXY (NO_PROXY for the local addresses): POST /percy/snapshot answers in 617ms normally and had still not answered after 70s with api.github.com unreachable. - percy.js: set the flag first and run the check detached — a snapshot never waits on it, and exactly one check is started per process. - utils.js: bound the GitHub request at 5s. Kept short because the detached socket is a live handle that would otherwise hold the event loop open at shutdown. - client/utils.js: make the `timeout` option mean something. Node's `timeout` only *emits* an event; without destroying the request, passing a timeout was a no-op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aryanku-dev
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 4 inline finding(s). Full report in the PR comment below. Verdict: Passed.
| let tooSlow = new Promise((resolve, reject) => setTimeout(() => { | ||
| reject(new Error('percy.snapshot() blocked on the SDK version check')); | ||
| }, 10000)); |
There was a problem hiding this comment.
[Medium] Leaked 10s timer in the race guard
The setTimeout handle is never captured or cleared. In the expected case percy.snapshot() wins the race and this 10s timer keeps running afterwards — a live handle that can hold the worker open for up to 10s past this spec. It won't surface as an unhandled rejection (Promise.race attaches a handler), so it fails silently as wasted CI time.
Suggestion: capture the id and clear it in a finally:
let timer;
let tooSlow = new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error('percy.snapshot() blocked on the SDK version check')), 10000);
});
try {
await expectAsync(Promise.race([percy.snapshot({ /* … */ }), tooSlow])).toBeResolved();
} finally {
clearTimeout(timer);
}Reviewer: stack-code-reviewer
| // the socket stays open for the life of the CLI process. | ||
| const githubData = await request(`https://api.github.com/repos/percy/${repoName}/releases?page=1`, { | ||
| headers: { 'User-Agent': '@percy/cli' }, | ||
| timeout: SDK_VERSION_CHECK_TIMEOUT, |
There was a problem hiding this comment.
[Medium] No regression test that this timeout is forwarded
Nothing asserts that timeout: SDK_VERSION_CHECK_TIMEOUT actually reaches the request. The existing describe('checkSDKVersion', …) specs stub http.request via mockRequests, so a real timeout could never fire there — deleting this line would keep every test green, even though it is the second of the PR's three defenses.
Suggestion: assert the option reaches the request:
it('passes a bounded timeout to the GitHub request', async () => {
ghAPI.and.returnValue([200, []]);
await checkSDKVersion('@percy/selenium-webdriver/2.2.0');
expect(http.request).toHaveBeenCalledWith(
jasmine.objectContaining({ timeout: 5000 }), jasmine.anything());
});Reviewer: stack-code-reviewer
| // accepts the connection and then goes silent, and the promise never | ||
| // settles. Destroying the request surfaces it through 'error' instead. | ||
| if (requestOptions.timeout) { | ||
| req.on('timeout', () => req.destroy(Object.assign( |
There was a problem hiding this comment.
[Low] ETIMEDOUT is not retryable — worth stating deliberately
This destroy error is tagged code: 'ETIMEDOUT', which is absent from RETRY_ERROR_CODES (packages/client/src/utils.js:117-120). Harmless today, since both callers that pass timeout (checkSDKVersion and Client#validateDomain at packages/client/src/client.js:1001-1018) also pass retries: 0. But now that timeout has teeth, a future caller combining timeout with non-zero retries gets a hard failure where a retry is the reasonable expectation, and nothing in the code says so.
Suggestion: make the decision explicit — either add 'ETIMEDOUT' to RETRY_ERROR_CODES, or add one line here noting timeout errors are intentionally not retried.
Reviewer: stack-code-reviewer
| if (!this.sdkInfoDisplayed && options.clientInfo) { | ||
| await checkSDKVersion(options.clientInfo); | ||
| this.sdkInfoDisplayed = true; | ||
| checkSDKVersion(options.clientInfo); |
There was a problem hiding this comment.
[Low] Fire-and-forget call has no defensive .catch()
This relies entirely on checkSDKVersion's internal try/catch for safety. That holds right now — a single try/catch wraps the whole body and the only await sits inside it — but any later refactor that adds code outside the try, or rethrows, reintroduces an unhandled rejection here with no test to catch it.
Suggestion: make the intent local and refactor-proof:
| checkSDKVersion(options.clientInfo); | |
| checkSDKVersion(options.clientInfo).catch(() => {}); |
Reviewer: stack-code-reviewer
Claude Code PR ReviewPR: #2387 • Head: 931b1b6 • Reviewers: stack-code-reviewer SummaryStops the informational SDK version check from gating snapshots: Review Table
Findings
Verdict: PASS — the root cause is correctly identified and all three defenses are individually sound; the two Medium items are worth fixing before merge but neither is blocking. |
Fixes PER-10514.
The report
A Cypress customer (
@percy/cypress@3.1.9,@percy/cli@1.32.6) intermittently gets, on and off for months:The 45 000 ms is ours, not theirs —
@percy/cypresshard-codesCY_TIMEOUT = 30 * 1000 * 1.5(index.js:10) and applies it to thecy.document().then({ timeout: CY_TIMEOUT }, …)that posts the snapshot (index.js:540). So the message means exactly one thing:POST localhost:5338/percy/snapshotdid not answer within 45 s.Root cause
POST /percy/snapshotdoesn't answer untilpercy.snapshot()resolves (core/src/api.js:230-239). The first thing that generator awaited was not asset discovery — it was the SDK version check (core/src/percy.js:649-653):checkSDKVersion()(core/src/utils.js:1016-1046) callshttps://api.github.com/repos/percy/<sdk-repo>/releases?page=1purely so we can log an[SDK Update Available]warning.@percy/cypressis inPACKAGE_TO_REPO, so every Cypress user takes this path.That request goes through
client/src/utils.js:228—http.request(requestOptions)with no socket timeout and no'timeout'handler. Node's default socket timeout is 0 (never). A peer that completes the TCP handshake and then goes silent — a corporate proxy, an egress firewall that drops instead of rejecting, GitHub throttling — hangs the promise forever.retries: 0can't help: the single attempt never settles, so there is nothing to retry. The SDK's ownwithRetrycan't help either — it only retries rejections.So one unreachable third party we don't own stalls snapshots indefinitely.
Aggravator in the same block:
this.sdkInfoDisplayed = truewas set after the await. While the first check hangs the flag is stillfalse, so every subsequent snapshot in that CLI process enters the branch and fires its own GitHub request. A single bad network moment costs the whole run, not one test — which is why the customer reports run-level failures rather than one flaky spec.Reproduction
Black-hole
api.github.comthe way a dropping firewall does — a TCP server that accepts the connection and never writes a byte — set it asHTTPS_PROXY,NO_PROXYthe local addresses, thenPOST /percy/snapshotwithclientInfo: '@percy/cypress/3.1.9':Identical run with
api.github.comreachable:617 ms vs >70 s. The whole difference is that one call.
The fix
packages/core/src/percy.js— setsdkInfoDisplayedbefore the call and runcheckSDKVersion()detached. A snapshot never waits on it, and exactly one check is started per CLI process.checkSDKVersionalready swallows its own errors, so nothing escapes.packages/core/src/utils.js— bound the GitHub request at 5 s. Deliberately short: now that the check is detached, its socket is a live handle that would otherwise hold the event loop open at shutdown for as long as the peer stays silent.packages/client/src/utils.js— make thetimeoutoption actually do something. Node'stimeoutoption only emits a'timeout'event; without destroying the request, passing a timeout was a no-op. Nowtimeoutdestroys the request withETIMEDOUT, which surfaces through the existing'error'path.ETIMEDOUTis not inRETRY_ERROR_CODES, so this does not change retry behaviour for any existing caller — and no existing caller passestimeout, so behaviour is unchanged unless opted into.Testing
packages/core/test/snapshot.test.js— "does not wait on the SDK version check to take a snapshot": mocksapi.github.comwith a reply that never resolves and assertspercy.snapshot()still resolves (and the snapshot still uploads). Hangs onmaster, passes here.packages/client/test/unit/request.test.js— a request withtimeoutagainst a server that accepts and never answers now rejects with "timed out after 200ms"; a normal request with a generous timeout is unaffected.yarn workspace @percy/core test --node: 104 specs, 5 failures — the same 5 pre-existingrunDoctorOnFailurefailures that fail on unmodifiedmasterin this environment (@percy/cli-doctorisn't linked locally).yarn workspace @percy/client test: 279 specs, 2 failures — the same 2 pre-existingproxyfailures that fail on unmodifiedmaster(Node-version-dependentInvalid URLmessage).Follow-ups, not in this PR
@percy/sdk-utils'srequest.postpassestimeout: 600000(sdk-utils/src/request.js:32) — dead code for the same reason, in both the node and thewindow.fetchimplementations. Worth fixing, but it changes behaviour for every SDK, so it doesn't belong here.POST /percy/snapshotstill blocks on asset discovery.api.jsalready supports?async; whether the Cypress SDK should use it is an SDK-side design question.🤖 Generated with Claude Code