Skip to content
Merged
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
75 changes: 69 additions & 6 deletions apps/desktop/scripts/stage-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>,
options: {
readonly attempts?: number
readonly sleep: (milliseconds: number) => Promise<void>
readonly onRetry?: (attempt: number, error: unknown) => void
},
): Promise<void> {
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<void> {
const invocation = packageManagerInvocation(process.platform, command, args)
await new Promise<void>((accept, reject) => {
Expand Down Expand Up @@ -109,12 +163,21 @@ async function materializeLinks(): Promise<void> {
async function deploy(target: string): Promise<void> {
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)
Expand Down
61 changes: 60 additions & 1 deletion apps/desktop/tests/stage-runtime.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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))
})
})
Loading