From f1135dd4ca6c32440c0b52bb33f3785ede4bcdf6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:22:24 -0700 Subject: [PATCH] fix(ssh): say when this computer can't download Node for a managed server, and where an offline copy goes (#30911) --- .../pinned-runtime-materializer-node.test.ts | 71 +++++++++++++++++++ .../pinned-runtime-materializer.ts | 36 +++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/main/node-runtime/pinned-runtime-materializer-node.test.ts b/src/main/node-runtime/pinned-runtime-materializer-node.test.ts index 230890c930a..440072ece77 100644 --- a/src/main/node-runtime/pinned-runtime-materializer-node.test.ts +++ b/src/main/node-runtime/pinned-runtime-materializer-node.test.ts @@ -151,3 +151,74 @@ describe('pinned runtime archive request ownership', () => { expect(await readdir(join(cacheRoot, 'node', 'archives'))).toEqual([]) }) }) + +// The desktop downloads Node and uploads it, so an offline SSH host is fine but an offline desktop +// used to see only Chromium's bare `net::ERR_CONNECTION_REFUSED` under "couldn't start its server". +describe('a pinned runtime archive this computer cannot download', () => { + it('says this computer could not reach the download, and where an offline copy goes', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const cacheRoot = join(root, 'cache') + const fetcher = vi.fn(async () => { + throw new Error('net::ERR_CONNECTION_REFUSED') + }) + let settled = false + const failure = materializeNodeRuntimeArchive(TARGET, cacheRoot, { fetcher }).catch( + (error: unknown) => error + ) + void failure.finally(() => (settled = true)) + // Real file I/O runs between the retry pauses, so yield to it before skipping each pause. + for (let turn = 0; !settled && turn < 1_000; turn += 1) { + await new Promise((resolve) => setImmediate(resolve)) + vi.advanceTimersByTime(10_000) + } + const error = await failure + expect(fetcher).toHaveBeenCalledTimes(4) + const offlinePath = join(cacheRoot, 'node', 'archives', originalAsset.archive) + + expect(error).toBeInstanceOf(Error) + const message = error instanceof Error ? error.message : '' + expect(message).toContain("This computer couldn't download Node from https://") + expect(message).toContain(`/v${NODE_RUNTIME_PIN.version}/${originalAsset.archive}`) + expect(message).toContain('(net::ERR_CONNECTION_REFUSED)') + expect(message).toContain("the host doesn't need internet access") + expect(message).toContain(`copy it to ${offlinePath}`) + expect(error instanceof Error ? error.cause : null).toMatchObject({ + message: 'net::ERR_CONNECTION_REFUSED' + }) + } finally { + vi.useRealTimers() + } + }) + + it.skipIf(process.platform === 'win32')( + 'uses a copy placed at that path without downloading', + async () => { + const archive = await nodeDistArchiveFixture(new TextEncoder().encode('node')) + Object.assign(NODE_RUNTIME_ASSETS[TARGET], { archiveSha256: sha256(archive) }) + const cacheRoot = join(root, 'cache') + const offlinePath = join(cacheRoot, 'node', 'archives', originalAsset.archive) + await mkdir(join(offlinePath, '..'), { recursive: true }) + await writeFile(offlinePath, archive) + const fetcher = vi.fn(async () => { + throw new Error('net::ERR_CONNECTION_REFUSED') + }) + + expect(await materializeNodeRuntimeArchive(TARGET, cacheRoot, { fetcher })).toBe(offlinePath) + expect(fetcher).not.toHaveBeenCalled() + } + ) + + it('keeps a local filesystem failure as it is', async () => { + const cacheRoot = join(root, 'cache') + const archiveRoot = join(cacheRoot, 'node', 'archives') + const fetcher = vi.fn(async () => { + const stage = (await readdir(archiveRoot)).find((name) => name.startsWith('.download-')) + await mkdir(join(archiveRoot, stage ?? 'missing', originalAsset.archive)) + return new Response(new ReadableStream(), { status: 200 }) + }) + await expect( + materializeNodeRuntimeArchive(TARGET, cacheRoot, { fetcher }) + ).rejects.toMatchObject({ code: 'EEXIST' }) + }) +}) diff --git a/src/main/node-runtime/pinned-runtime-materializer.ts b/src/main/node-runtime/pinned-runtime-materializer.ts index d8836f5f629..3a94e584001 100644 --- a/src/main/node-runtime/pinned-runtime-materializer.ts +++ b/src/main/node-runtime/pinned-runtime-materializer.ts @@ -4,7 +4,11 @@ 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 } from './runtime-archive-download' +import { + downloadVerifiedArchive, + extractRuntimeArchive, + ReleaseMismatchError +} from './runtime-archive-download' import { findOrcadCachePath } from './orcad-cache-path' import { orcadNodeRuntimeExecutable } from '../../shared/orcad-artifacts' import { @@ -66,7 +70,9 @@ export async function materializeVerifiedRuntimeArchive( downloaded, options.fetcher ?? getMainHttpClient().fetch, options.signal - ) + ).catch((error: unknown) => { + throw unreachableArchiveError(archive, cached.path, error, options.signal) + }) options.signal?.throwIfAborted() await publishVerified( downloaded, @@ -80,6 +86,32 @@ export async function materializeVerifiedRuntimeArchive( } } +/** + * Why: the bare network error (e.g. net::ERR_CONNECTION_REFUSED) reads as the host failing, but + * only this computer downloads the archive. Its cache path is the offline way in, since a file + * placed there with the pinned hash is used without any download. + */ +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 = error instanceof Error ? error.message : String(error) + return new Error( + `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, or download that file on another machine ` + + `and copy it to ${cachePath}`, + { cause: error } + ) +} + export async function materializeCachedRuntimeExecutable( runtime: PinnedRuntimeExecutable, options: PinnedRuntimeMaterializeOptions