-
Notifications
You must be signed in to change notification settings - Fork 61
fix(core): SDK version check blocked every snapshot POST, hanging Cypress at 45s (PER-10514) #2387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -646,10 +646,18 @@ export class Percy { | |||||
| let server; | ||||||
|
|
||||||
| try { | ||||||
| // Check SDK version | ||||||
| // Check SDK version. This generator runs inside the POST /percy/snapshot | ||||||
| // request handler (see api.js), so anything awaited here delays the HTTP | ||||||
| // response the SDK is blocked on. checkSDKVersion reaches out to | ||||||
| // api.github.com — an unrelated third party that a corporate proxy, | ||||||
| // egress firewall or GitHub throttling can leave hanging — so it must | ||||||
| // never gate a snapshot. Set the flag first so exactly one check is ever | ||||||
| // started (it used to be set after the await, which meant every snapshot | ||||||
| // posted during a slow check started its own), and run it detached: | ||||||
| // checkSDKVersion swallows its own errors and only logs. | ||||||
| if (!this.sdkInfoDisplayed && options.clientInfo) { | ||||||
| await checkSDKVersion(options.clientInfo); | ||||||
| this.sdkInfoDisplayed = true; | ||||||
| checkSDKVersion(options.clientInfo); | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] Fire-and-forget call has no defensive This relies entirely on Suggestion: make the intent local and refactor-proof:
Suggested change
Reviewer: stack-code-reviewer |
||||||
| } | ||||||
| if ('serve' in options) { | ||||||
| // create and start a static server | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -978,6 +978,12 @@ export async function* maybeScrollToBottom(page, discovery) { | |
| } | ||
| } | ||
|
|
||
| // How long the (purely informational) SDK version check may spend talking to | ||
| // api.github.com before it gives up. Kept short deliberately: the check now runs | ||
| // detached, so its socket is a live handle that would otherwise hold the event | ||
| // loop open at shutdown for as long as the peer stays silent. | ||
| const SDK_VERSION_CHECK_TIMEOUT = 5000; | ||
|
|
||
| // Package to GitHub repo mapping | ||
| const PACKAGE_TO_REPO = { | ||
| '@percy/selenium-webdriver': 'percy-selenium-js', | ||
|
|
@@ -1022,9 +1028,13 @@ export async function checkSDKVersion(clientInfo) { | |
| return; | ||
| } | ||
|
|
||
| // Fetch latest version from GitHub releases | ||
| // Fetch latest version from GitHub releases. api.github.com is a third | ||
| // party we don't control and networks routinely black-hole it (proxies, | ||
| // egress firewalls, rate limiting), so this is bounded — without a timeout | ||
| // 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, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] No regression test that this timeout is forwarded Nothing asserts that 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 |
||
| retries: 0 | ||
| }); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| import { sha256hash, base64encode } from '@percy/client/utils'; | ||
| import { logger, api, setupTest, createTestServer, dedent } from './helpers/index.js'; | ||
| import { logger, api, setupTest, createTestServer, dedent, mockRequests } from './helpers/index.js'; | ||
| import { waitFor } from '@percy/core/utils'; | ||
| import Percy from '@percy/core'; | ||
| import { handleSyncJob } from '../src/snapshot.js'; | ||
|
|
@@ -48,6 +48,34 @@ describe('Snapshot', () => { | |
| expect(() => percy.snapshot({})).toThrowError('Not running'); | ||
| }); | ||
|
|
||
| // PER-10514: POST /percy/snapshot does not answer until percy.snapshot() | ||
| // resolves, so anything the generator awaits stalls the SDK. The version | ||
| // check talks to api.github.com — a third party that proxies and egress | ||
| // firewalls routinely leave hanging — and it must never gate a snapshot. | ||
| it('does not wait on the SDK version check to take a snapshot', async () => { | ||
| let ghAPI = await mockRequests('https://api.github.com'); | ||
| // accept the request and never answer, the way a black-holing proxy does | ||
| ghAPI.and.returnValue(new Promise(() => {})); | ||
|
|
||
| let tooSlow = new Promise((resolve, reject) => setTimeout(() => { | ||
| reject(new Error('percy.snapshot() blocked on the SDK version check')); | ||
| }, 10000)); | ||
|
Comment on lines
+60
to
+62
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] Leaked 10s timer in the race guard The Suggestion: capture the id and clear it in a 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 |
||
|
|
||
| await expectAsync(Promise.race([ | ||
| percy.snapshot({ | ||
| name: 'test snapshot', | ||
| url: 'http://localhost:8000', | ||
| domSnapshot: testDOM, | ||
| clientInfo: '@percy/cypress/3.1.9' | ||
| }), | ||
| tooSlow | ||
| ])).toBeResolved(); | ||
|
|
||
| await percy.idle(); | ||
| expect(api.requests['/builds/123/snapshots'][0].body.data.attributes.name) | ||
| .toEqual('test snapshot'); | ||
| }); | ||
|
|
||
| it('errors when missing a url', () => { | ||
| expect(() => percy.snapshot({ name: 'test snapshot' })) | ||
| .toThrowError('Missing required URL for snapshot'); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Low]
ETIMEDOUTis not retryable — worth stating deliberatelyThis destroy error is tagged
code: 'ETIMEDOUT', which is absent fromRETRY_ERROR_CODES(packages/client/src/utils.js:117-120). Harmless today, since both callers that passtimeout(checkSDKVersionandClient#validateDomainatpackages/client/src/client.js:1001-1018) also passretries: 0. But now thattimeouthas teeth, a future caller combiningtimeoutwith non-zeroretriesgets 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'toRETRY_ERROR_CODES, or add one line here noting timeout errors are intentionally not retried.Reviewer: stack-code-reviewer