diff --git a/apps/desktop/scripts/stage-runtime.ts b/apps/desktop/scripts/stage-runtime.ts index 90297663..37a8e39a 100644 --- a/apps/desktop/scripts/stage-runtime.ts +++ b/apps/desktop/scripts/stage-runtime.ts @@ -57,6 +57,60 @@ export function deployTargetArgument(workspaceRoot: string, target: string): str return relative(workspaceRoot, target) } +/** How many times the deploy is attempted before the build gives up. */ +export const DEPLOY_ATTEMPTS = 3 + +/** + * Back-off before a retry, in milliseconds, indexed by the retry number. + * + * `pnpm deploy --legacy` re-resolves from the registry and ignores the + * lockfile, so a package published in a partially-propagated state fails the + * build even though every pin here is installable. That happened with + * `@tanstack/react-query@5.102.3`, whose `query-core` peer of the same version + * was not yet resolvable — the desktop packaging gate went red for a package + * nothing in the shipped runtime uses. + * + * A deterministic failure still fails; it just costs the sum of these waits + * first. That is the trade: about half a minute added to a genuinely broken + * build, against a release gate that no longer turns red because npm was + * mid-publish. + * @param retry - 1 for the first retry, 2 for the second. + * @returns Milliseconds to wait before that retry. + */ +export function deployRetryDelayMs(retry: number): number { + return retry <= 1 ? 5_000 : 20_000 +} + +/** + * Run an operation, retrying a failure up to {@link DEPLOY_ATTEMPTS} times. + * + * `sleep` and `onRetry` are injected so the policy is testable without a real + * wait or a real registry. + * @param operation - Receives the 1-based attempt number. + * @param options - Injected clock and retry reporter. + * @returns Nothing; the last failure is rethrown when every attempt fails. + */ +export async function withDeployRetries( + operation: (attempt: number) => Promise, + options: { + readonly attempts?: number + readonly sleep: (milliseconds: number) => Promise + readonly onRetry?: (attempt: number, error: unknown) => void + }, +): Promise { + const attempts = options.attempts ?? DEPLOY_ATTEMPTS + for (let attempt = 1; ; attempt += 1) { + try { + await operation(attempt) + return + } catch (error) { + if (attempt >= attempts) throw error + options.onRetry?.(attempt, error) + await options.sleep(deployRetryDelayMs(attempt)) + } + } +} + async function run(command: string, args: readonly string[]): Promise { const invocation = packageManagerInvocation(process.platform, command, args) await new Promise((accept, reject) => { @@ -109,12 +163,21 @@ async function materializeLinks(): Promise { async function deploy(target: string): Promise { const savedWorkspaceState = existsSync(workspaceState) ? await readFile(workspaceState) : undefined try { - await run('pnpm', [ - '--config.verify-deps-before-run=false', '--filter', deployPackage, 'deploy', '--legacy', '--prod', - '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', - '--config.allow-unused-patches=true', - deployTargetArgument(repositoryRoot, target), - ]) + await withDeployRetries( + () => run('pnpm', [ + '--config.verify-deps-before-run=false', '--filter', deployPackage, 'deploy', '--legacy', '--prod', + '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', + '--config.allow-unused-patches=true', + deployTargetArgument(repositoryRoot, target), + ]), + { + sleep: (milliseconds) => new Promise(resolve => { setTimeout(resolve, milliseconds) }), + onRetry: (attempt, error) => { + const reason = error instanceof Error ? error.message : String(error) + console.warn(`desktop runtime staging attempt ${attempt} failed, retrying: ${reason}`) + }, + }, + ) } finally { if (savedWorkspaceState === undefined) await rm(workspaceState, { force: true }) else await writeFile(workspaceState, savedWorkspaceState) diff --git a/apps/desktop/tests/stage-runtime.spec.ts b/apps/desktop/tests/stage-runtime.spec.ts index 49d14651..6c637bd2 100644 --- a/apps/desktop/tests/stage-runtime.spec.ts +++ b/apps/desktop/tests/stage-runtime.spec.ts @@ -1,6 +1,12 @@ import { isAbsolute, join } from 'node:path' import { describe, expect, it } from 'vitest' -import { deployTargetArgument, packageManagerInvocation } from '../scripts/stage-runtime' +import { + DEPLOY_ATTEMPTS, + deployRetryDelayMs, + deployTargetArgument, + packageManagerInvocation, + withDeployRetries, +} from '../scripts/stage-runtime' describe('package manager invocation', () => { it('leaves non-Windows invocations untouched', () => { @@ -50,3 +56,56 @@ describe('deploy target argument', () => { expect(deployTargetArgument('/repo', target)).not.toBe(target) }) }) + +describe('deploy retries', () => { + function recorder() { + const waits: number[] = [] + return { waits, sleep: async (milliseconds: number) => { waits.push(milliseconds) } } + } + + it('does not retry a first-attempt success', async () => { + const { waits, sleep } = recorder() + let calls = 0 + + await withDeployRetries(async () => { calls += 1 }, { sleep }) + + expect(calls).toBe(1) + expect(waits).toEqual([]) + }) + + it('retries a transient failure and resolves', async () => { + const { waits, sleep } = recorder() + const retried: number[] = [] + + await withDeployRetries( + async (attempt) => { if (attempt < 3) throw new Error('ERR_PNPM_NO_MATCHING_VERSION') }, + { sleep, onRetry: (attempt) => retried.push(attempt) }, + ) + + expect(retried).toEqual([1, 2]) + expect(waits).toEqual([5_000, 20_000]) + }) + + // A deterministic failure must still fail the build, and must surface its own + // error rather than a wrapper that hides which command broke. + it('rethrows the last failure once the attempts run out', async () => { + const { waits, sleep } = recorder() + let calls = 0 + + await expect(withDeployRetries( + async () => { calls += 1; throw new Error(`attempt ${String(calls)} failed`) }, + { sleep }, + )).rejects.toThrow('attempt 3 failed') + + expect(calls).toBe(DEPLOY_ATTEMPTS) + expect(waits).toHaveLength(DEPLOY_ATTEMPTS - 1) + }) + + // Ordering alone would accept a 0ms/1ms backoff, which retries faster than a + // registry propagates and turns one retry into three failures in a row. + it('waits five seconds before the first retry and twenty before the second', () => { + expect(deployRetryDelayMs(1)).toBe(5_000) + expect(deployRetryDelayMs(2)).toBe(20_000) + expect(deployRetryDelayMs(2)).toBeGreaterThan(deployRetryDelayMs(1)) + }) +})