Files
orca/src/main/node-runtime/pinned-runtime-materializer.ts
T
Neil 401468bac7 fix(wsl): fail a first deploy fast when the Node runtime download gets no answer (#35869)
A first WSL deploy fetches the pinned Node archive, from this computer
and then from inside the distro. Behind a firewall that drops packets it
retried four times and then waited 20 s per host source, about two
minutes before failing. WSL deploys now give the desktop one 15 s attempt
with no retry on a missing response, and the host 15 s per source, so a
dead network fails in about 45 s. The host fallback stays, since the
distro's own route often succeeds when this computer's proxy refuses.
SSH keeps its timing.
2026-10-29 02:29:16 -07:00

329 lines
11 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, link, mkdir, rm } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { getMainHttpClient, type MainHttpClient } from '../network/http-client'
import {
downloadVerifiedArchive,
extractRuntimeArchive,
ReleaseMismatchError
} from './runtime-archive-download'
import { findOrcadCachePath } from './orcad-cache-path'
import { orcadNodeRuntimeExecutable } from '../../shared/orcad-artifacts'
import { nodeDistMirrorUrl } from '../../shared/node-dist-mirror'
import { configuredNodeDistMirror } from './node-dist-mirror-setting'
import {
NODE_RUNTIME_PIN,
pinnedNodeRuntimeAsset,
nodeRuntimeExecutablePath,
nodeRuntimeReleaseUrl,
type NodeRuntimeTarget
} from '../../shared/node-runtime-pin'
import { errorMessage } from '../../shared/error-message'
export type PinnedRuntimeMaterializeOptions = {
fetcher?: MainHttpClient['fetch']
signal?: AbortSignal
/** Empty disables retries. */
retryDelaysMs?: readonly number[]
/** Give up, without retrying, when no response arrives in time (a firewall that drops packets). */
connectTimeoutMs?: number
}
export type PinnedRuntimeArchive = {
label: string
url: string
archiveSha256: string
/** Defaults to 200 MiB. */
maxArchiveBytes?: number
}
export type PinnedRuntimeExecutable = PinnedRuntimeArchive & {
executableSha256: string
member: string
isWindows: boolean
/** Repairs use attempt > 0 to publish beside existing readers. */
cachePath: (attempt: number) => string
}
function archiveName(archive: PinnedRuntimeArchive): string {
return basename(new URL(archive.url).pathname)
}
/** Cache archives by pin; verify their hash on every use. */
export async function materializeVerifiedRuntimeArchive(
archive: PinnedRuntimeArchive,
cacheDir: string,
options: PinnedRuntimeMaterializeOptions
): Promise<string> {
options.signal?.throwIfAborted()
await mkdir(cacheDir, { recursive: true })
const name = archiveName(archive)
const cached = await findOrcadCachePath(
(attempt) => join(cacheDir, `${attempt ? `repair-${attempt}-` : ''}${name}`),
async (path) => (await fileSha256(path)) === archive.archiveSha256
)
if (cached.verified) {
return cached.path
}
const temporaryDir = join(cacheDir, `.download-${process.pid}-${randomUUID()}`)
await mkdir(temporaryDir, { recursive: true })
try {
const downloaded = join(temporaryDir, name)
await downloadVerifiedArchive(
archive,
downloaded,
options.fetcher ?? getMainHttpClient().fetch,
options.signal,
options.retryDelaysMs,
undefined,
options.connectTimeoutMs
).catch((error: unknown) => {
throw unreachableArchiveError(archive, cached.path, error, options.signal)
})
options.signal?.throwIfAborted()
await publishVerified(
downloaded,
cached.path,
archive.archiveSha256,
`${archive.label} archive`
)
return cached.path
} finally {
await rm(temporaryDir, { recursive: true, force: true })
}
}
/** Attribute download failures to this computer and expose the hash-verified offline cache path. */
function unreachableArchiveError(
archive: PinnedRuntimeArchive,
cachePath: string,
error: unknown,
signal: AbortSignal | undefined
): unknown {
const localFailure = error instanceof Error && 'code' in error && typeof error.code === 'string'
if (signal?.aborted || error instanceof ReleaseMismatchError || localFailure) {
return error
}
const cause = errorMessage(error)
return new DesktopDownloadUnreachableError(
`This computer couldn't download ${archive.label} from ${archive.url} (${cause}). ` +
`Orca uploads ${archive.label} to the host from this computer, so the host doesn't need ` +
`internet access, but this computer must reach that address once. Check this computer's ` +
`network connection or proxy and connect again, set a mirror in Settings > Advanced > ` +
`Network > Node.js download mirror, or download that file on another machine ` +
`and copy it to ${cachePath}`,
`${archive.url}: ${cause}`,
error
)
}
export class DesktopDownloadUnreachableError extends Error {
constructor(
message: string,
readonly reason: string,
cause: unknown
) {
super(message, { cause })
this.name = 'DesktopDownloadUnreachableError'
}
}
export async function materializeCachedRuntimeExecutable(
runtime: PinnedRuntimeExecutable,
options: PinnedRuntimeMaterializeOptions
): Promise<string> {
options.signal?.throwIfAborted()
const cached = await findOrcadCachePath(
runtime.cachePath,
async (path) => (await fileSha256(path)) === runtime.executableSha256
)
const runtimePath = cached.path
if (cached.verified) {
if (!runtime.isWindows) {
await chmod(runtimePath, 0o755)
}
return runtimePath
}
const runtimeDir = join(runtimePath, '..')
await mkdir(runtimeDir, { recursive: true })
const temporaryDir = join(runtimeDir, `.download-${process.pid}-${randomUUID()}`)
await mkdir(temporaryDir, { recursive: true })
try {
const archivePath = join(temporaryDir, archiveName(runtime))
await downloadVerifiedArchive(
runtime,
archivePath,
options.fetcher ?? getMainHttpClient().fetch,
options.signal,
options.retryDelaysMs
)
options.signal?.throwIfAborted()
const extractedDir = join(temporaryDir, 'extracted')
await mkdir(extractedDir)
const result = await extractRuntimeArchive(runtime, archivePath, extractedDir, options.signal)
options.signal?.throwIfAborted()
if (result.code !== 0) {
throw new Error(
`${runtime.label} archive extraction failed: ${result.stderr || result.stdout}`
)
}
const executable = join(extractedDir, ...runtime.member.split('/'))
await verifyFileSha256(executable, runtime.executableSha256, `${runtime.label} executable`)
if (!runtime.isWindows) {
await chmod(executable, 0o755)
}
options.signal?.throwIfAborted()
await publishVerified(
executable,
runtimePath,
runtime.executableSha256,
`cached ${runtime.label} executable`
)
return runtimePath
} finally {
await rm(temporaryDir, { recursive: true, force: true })
}
}
/** Hard-link publication preserves a concurrent winner instead of replacing its bytes. */
async function publishVerified(
source: string,
destination: string,
sha256: string,
label: string
): Promise<void> {
try {
await link(source, destination)
} catch (error) {
if ((await fileSha256(destination)) !== sha256) {
throw new Error(`${label} cache entry is unavailable or corrupted: ${destination}`, {
cause: error
})
}
}
await verifyFileSha256(destination, sha256, label)
}
function nodeRuntimeArchive(target: NodeRuntimeTarget): PinnedRuntimeArchive {
const asset = pinnedNodeRuntimeAsset(target)
return {
label: 'Node',
url: nodeRuntimeReleaseUrl(asset.source, asset.archive),
archiveSha256: asset.archiveSha256
}
}
async function withNodeDistMirror<T>(
target: NodeRuntimeTarget,
options: PinnedRuntimeMaterializeOptions,
run: (archive: PinnedRuntimeArchive, options: PinnedRuntimeMaterializeOptions) => Promise<T>
): Promise<T> {
const archive = nodeRuntimeArchive(target)
const asset = pinnedNodeRuntimeAsset(target)
// Mirrors host only the official tree; unofficial builds have one source URL.
const mirror = asset.source === 'official' ? configuredNodeDistMirror() : null
if (!mirror) {
return run(archive, options)
}
const mirrorUrl = nodeDistMirrorUrl(mirror, NODE_RUNTIME_PIN.version, asset.archive)
try {
// Do not delay official fallback with mirror retries.
return await run({ ...archive, url: mirrorUrl }, { ...options, retryDelaysMs: [] })
} catch (mirrorError) {
if (options.signal?.aborted) {
throw mirrorError
}
return run(archive, options).catch((error: unknown) => {
// Combine network failures only; cancellation/local errors retain their own identity.
if (options.signal?.aborted || !(error instanceof DesktopDownloadUnreachableError)) {
throw error
}
const mirrorReason =
mirrorError instanceof DesktopDownloadUnreachableError
? mirrorError.reason
: `${mirrorUrl}: ${errorMessage(mirrorError)}`
throw new DesktopDownloadUnreachableError(
`${errorMessage(error)} (the mirror ${mirrorReason} failed too)`,
`${mirrorReason}; ${errorReason(error)}`,
error
)
})
}
}
function errorReason(error: unknown): string {
return error instanceof DesktopDownloadUnreachableError ? error.reason : errorMessage(error)
}
export function materializeNodeRuntimeArchive(
target: NodeRuntimeTarget,
cacheRoot: string,
options: PinnedRuntimeMaterializeOptions
): Promise<string> {
return withNodeDistMirror(target, options, (archive, download) =>
materializeVerifiedRuntimeArchive(archive, nodeRuntimeArchiveDir(cacheRoot), download)
)
}
export function nodeRuntimeArchiveCachePath(target: NodeRuntimeTarget, cacheRoot: string): string {
return join(nodeRuntimeArchiveDir(cacheRoot), pinnedNodeRuntimeAsset(target).archive)
}
function nodeRuntimeArchiveDir(cacheRoot: string): string {
return join(cacheRoot, 'node', 'archives')
}
/** Content-keyed executable cache for hosts unable to extract archives, e.g. Windows SSH vault reads. */
export function materializeCachedNodeRuntime(
target: NodeRuntimeTarget,
cacheRoot: string,
options: PinnedRuntimeMaterializeOptions
): Promise<string> {
const asset = pinnedNodeRuntimeAsset(target)
const executableName = basename(orcadNodeRuntimeExecutable(target))
return withNodeDistMirror(target, options, (archive, download) =>
materializeCachedRuntimeExecutable(
{
...archive,
executableSha256: asset.executableSha256,
member: nodeRuntimeExecutablePath(target, asset.archive),
isWindows: target.startsWith('win32-'),
cachePath: (attempt) =>
join(
cacheRoot,
'node',
asset.executableSha256,
`${attempt ? `repair-${attempt}-` : ''}${executableName}`
)
},
download
)
)
}
export async function verifyFileSha256(
path: string,
expected: string,
label: string,
signal?: AbortSignal
): Promise<void> {
const actual = await fileSha256(path, signal)
signal?.throwIfAborted()
if (actual !== expected) {
throw new Error(`${label} checksum mismatch: expected ${expected}, got ${actual ?? 'missing'}`)
}
}
export async function fileSha256(path: string, signal?: AbortSignal): Promise<string | null> {
try {
const hash = createHash('sha256')
for await (const chunk of createReadStream(path, { signal })) {
hash.update(chunk)
}
return hash.digest('hex')
} catch {
return null
}
}