Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cli-build/src/finalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export const finalize = command('finalize', {
// rely on the parallel nonce to cause the API to return the current running build for the nonce
let { data: build } = await percy.client.createBuild({ cliStartTime: percy.cliStartTime });
try {
// Wait for snapshot counts to stabilise across all shards before finalizing.
// Without this guard, percy build:finalize can race against snapshot-level
// upload/finalize requests that are still in-flight on other shards.
await percy.client.waitForBuildReadyToFinalize(build.id);
await percy.client.finalizeBuild(build.id, { all: true });
} catch (error) {
exit(1, 'Percy build failed during finalize', error.message);
Expand Down
76 changes: 76 additions & 0 deletions packages/cli-build/test/finalize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ import { finalize } from '@percy/cli-build';
describe('percy build:finalize', () => {
beforeEach(async () => {
await setupTest();
// Keep the readiness-gate fast in all tests by default
process.env.PERCY_FINALIZE_QUIET_WINDOW_MS = '0';
process.env.PERCY_FINALIZE_INTERVAL_MS = '10';
});

afterEach(() => {
delete process.env.PERCY_PARALLEL_TOTAL;
delete process.env.PERCY_ENABLE;
delete process.env.PERCY_FINALIZE_QUIET_WINDOW_MS;
delete process.env.PERCY_FINALIZE_INTERVAL_MS;
delete process.env.PERCY_FINALIZE_TIMEOUT_MS;
});

it('does nothing and logs when percy is not enabled', async () => {
Expand Down Expand Up @@ -61,4 +67,74 @@ describe('percy build:finalize', () => {

expect(logger.stderr).toEqual(['[percy] Error: Percy build failed during finalize']);
});

describe('readiness gate before all-shards finalize', () => {
beforeEach(() => {
process.env.PERCY_TOKEN = '<<PERCY_TOKEN>>';
process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' });
});

it('polls build until snapshot count stabilizes before finalizing', async () => {
// Use a 50ms quiet window so the gate actually waits for stability.
process.env.PERCY_FINALIZE_QUIET_WINDOW_MS = '50';

// Simulate two shards still uploading: count grows 2 → 5, then stays at 5
api
.reply('/builds/123', () => [200, {
data: { id: '123', attributes: { 'build-number': 1, 'web-url': 'https://percy.io/test/test/123', 'total-snapshots': 2 } }
}])
.reply('/builds/123', () => [200, {
data: { id: '123', attributes: { 'build-number': 1, 'web-url': 'https://percy.io/test/test/123', 'total-snapshots': 5 } }
}]);

await finalize();

// Polled at least twice: once for 2-snaps, once for 5-snaps (count changed → reset window)
expect(api.requests['/builds/123'].length).toBeGreaterThanOrEqual(2);
expect(api.requests['/builds/123/finalize?all-shards=true']).toBeDefined();

expect(logger.stderr).toEqual([]);
expect(logger.stdout).toEqual([
'[percy] Finalizing parallel build...',
'[percy] Finalized build #1: https://percy.io/test/test/123'
]);
});

it('finalizes immediately when snapshot count is already stable', async () => {
// Default /builds/123 reply returns total-snapshots: 0 (stable from first poll)
await finalize();

expect(api.requests['/builds/123']).toBeDefined();
expect(api.requests['/builds/123/finalize?all-shards=true']).toBeDefined();
});

it('rejects and logs when readiness check times out', async () => {
// Set tiny timeout so it expires before the quiet window is satisfied
process.env.PERCY_FINALIZE_TIMEOUT_MS = '50';
process.env.PERCY_FINALIZE_QUIET_WINDOW_MS = '200';

// Count never changes, but quiet window (200ms) > timeout (50ms) — times out.
// The timeout error bubbles into the catch block which calls exit(1, ...).
await expectAsync(finalize()).toBeRejected();

expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy] Error: Percy build failed during finalize'
]));
});

it('finalizes successfully despite an intermittent readiness check poll failure', async () => {
// Queue two replies: a 500 error followed by a stable 200.
// The client's internal retry logic will consume the 500 on its first attempt
// and succeed with the 200 on the retry, so getBuild resolves successfully.
api
.reply('/builds/123', () => [500, { errors: [{ detail: 'server error' }] }])
.reply('/builds/123', () => [200, {
data: { id: '123', attributes: { 'build-number': 1, 'web-url': 'https://percy.io/test/test/123', 'total-snapshots': 0 } }
}]);

await finalize();

expect(api.requests['/builds/123/finalize?all-shards=true']).toBeDefined();
});
});
});
46 changes: 46 additions & 0 deletions packages/client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,52 @@ export class PercyClient {
return this.post(`builds/${buildId}/finalize?${qs}`, {}, { identifier: 'build.finalze' });
}

// Waits until the build's total-snapshots count has been stable for a quiet
// window before the all-shards finalize call. This avoids a race where
// snapshot-level upload/finalize requests from other parallel shards are
// still in-flight when percy build:finalize is triggered by the CI
// orchestrator. Configurable via environment variables:
// PERCY_FINALIZE_QUIET_WINDOW_MS – ms the count must be unchanged (default 5000)
// PERCY_FINALIZE_TIMEOUT_MS – overall wait ceiling (default 10 min)
// PERCY_FINALIZE_INTERVAL_MS – poll interval (default 1000)
async waitForBuildReadyToFinalize(buildId, {
quietMs = parseInt(process.env.PERCY_FINALIZE_QUIET_WINDOW_MS ?? '5000'),
timeout = parseInt(process.env.PERCY_FINALIZE_TIMEOUT_MS ?? String(10 * 60 * 1000)),
interval = parseInt(process.env.PERCY_FINALIZE_INTERVAL_MS ?? '1000')
} = {}) {
validateId('build', buildId);
this.log.debug(`Waiting for build ${buildId} to be ready to finalize...`);

let start = Date.now();
let lastSnapshotCount = null;
let stableSince = null;

while (Date.now() - start < timeout) {
try {
let { data } = await this.getBuild(buildId);
let snapshotCount = data?.attributes?.['total-snapshots'] ?? 0;

if (snapshotCount !== lastSnapshotCount) {
// Count changed — reset the stability window
lastSnapshotCount = snapshotCount;
stableSince = Date.now();
} else if (stableSince != null && Date.now() - stableSince >= quietMs) {
// Count has been stable for the full quiet window — safe to finalize
this.log.debug(`Build ${buildId} ready to finalize with ${snapshotCount} snapshots`);
return;
}
} catch (error) {
this.log.warn(`Build readiness check failed: ${error.message}`);
lastSnapshotCount = null;
stableSince = null;
}

await waitForTimeout(interval);
}

throw new Error(`Timed out waiting for build ${buildId} to be ready to finalize`);
}

// Retrieves build data by id. Requires a read access token.
async getBuild(buildId) {
validateId('build', buildId);
Expand Down
12 changes: 12 additions & 0 deletions packages/client/test/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ export const api = {
}
}],

'/builds/123': () => [200, {
data: {
id: '123',
attributes: {
'build-number': 1,
'web-url': 'https://percy.io/test/test/123',
'total-snapshots': 0,
state: 'pending'
}
}
}],

'/builds/123/snapshots': ({ body }) => [201, {
data: {
id: '4567',
Expand Down
Loading