From 080c4ad62aa43b634db0888b8e2ebaa4eeaccf32 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:07:59 -0700 Subject: [PATCH] fix(ssh): name the missing unzip when Bun archive extraction cannot start (#23298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ssh): name the missing unzip when Bun archive extraction cannot start Extracting the downloaded Bun runtime shells out to `unzip` on POSIX hosts, which a minimal Debian/Ubuntu install does not ship. runProcess rejects a missing program with a bare `spawn unzip ENOENT`, which the caller's non-zero-exit branch never sees, so the operator got an errno instead of a remedy. Translate that one errno into a message naming the tool and the ORCA_UNZIP_BIN override. * fix(ssh): reuse the canonical absence predicate for extractor launch failures isDefinitiveAbsence is the repo's single errno allowlist for "definitively not there", and it also covers ENOTDIR — which spawn throws synchronously when a configured ORCA_UNZIP_BIN has a regular file for a parent. That case previously escaped as a bare `spawn ENOTDIR` naming no path at all. Also correct the comment: a deleted working directory is not a second source of these errnos, because runProcess leaves cwd unset and the child inherits the parent's without resolving it. Verified on macOS, Linux and Windows. --- .../orcad-bun-runtime-materializer.test.ts | 24 ++++++++++ .../ssh/orcad-bun-runtime-materializer.ts | 45 +++++++++++++++---- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/main/ssh/orcad-bun-runtime-materializer.test.ts b/src/main/ssh/orcad-bun-runtime-materializer.test.ts index 52963e98304..de9ab5d939c 100644 --- a/src/main/ssh/orcad-bun-runtime-materializer.test.ts +++ b/src/main/ssh/orcad-bun-runtime-materializer.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ORCAD_BUN_RUNTIME_FILENAME } from '../../shared/orcad-artifacts' import { ORCAD_BUN_RELEASE_ASSETS, ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' import { setMainHttpClient } from '../network/http-client' +import { runProcess } from '../../shared/child-process/run-process' import { materializeCachedOrcadBunRuntime } from './orcad-bun-runtime-materializer' const extraction = vi.hoisted(() => ({ executable: new Uint8Array(), executableName: 'bun' })) @@ -45,6 +46,7 @@ beforeEach(async () => { afterEach(async () => { vi.useRealTimers() + vi.unstubAllEnvs() setMainHttpClient(null) Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], originalAsset) await rm(cacheRoot, { recursive: true, force: true }) @@ -150,6 +152,28 @@ describe('materializeCachedOrcadBunRuntime', () => { expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) }) + // Both cases spawn for real, so the assertion is against the errno Node actually reports: + // a missing program rejects asynchronously with ENOENT, while a program path whose parent is a + // regular file throws ENOTDIR synchronously out of `spawn` itself. + it.each([ + ['absent', (): string => join(cacheRoot, 'absent-extractor')], + ['unreachable through a file', (): string => join(cacheRoot, 'plain-file', 'unzip')] + ])('names the %s extractor and the override when it cannot be launched', async (_label, path) => { + const archive = new TextEncoder().encode('unextractable archive') + ORCAD_BUN_RELEASE_ASSETS[TARGET].sha256 = sha256(archive) + await writeFile(join(cacheRoot, 'plain-file'), 'not a directory') + const { runProcess: spawnForReal } = await vi.importActual<{ + runProcess: typeof runProcess + }>('../../shared/child-process/run-process') + vi.mocked(runProcess).mockImplementationOnce(spawnForReal) + vi.stubEnv('ORCA_UNZIP_BIN', path()) + + await expect( + materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher: responseFetcher(archive) }) + ).rejects.toThrow(/install unzip, or set ORCA_UNZIP_BIN/) + expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + }) + it('cleans an aborted download before publishing any executable', async () => { const controller = new AbortController() controller.abort(new Error('deployment cancelled')) diff --git a/src/main/ssh/orcad-bun-runtime-materializer.ts b/src/main/ssh/orcad-bun-runtime-materializer.ts index 9ed65ecdb02..e52a87534eb 100644 --- a/src/main/ssh/orcad-bun-runtime-materializer.ts +++ b/src/main/ssh/orcad-bun-runtime-materializer.ts @@ -2,8 +2,9 @@ import { createHash, randomUUID } from 'node:crypto' import { createReadStream, readdirSync } from 'node:fs' import { chmod, link, mkdir, open, rm } from 'node:fs/promises' import { basename, join } from 'node:path' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess, type ProcessResult } from '../../shared/child-process/run-process' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { getZipExtractorCommand } from '../../shared/zip-extractor-command' import { getMainHttpClient, type MainHttpClient } from '../network/http-client' import { findOrcadCachePath } from './orcad-cache-path' @@ -57,13 +58,7 @@ export async function materializeCachedOrcadBunRuntime( options.signal?.throwIfAborted() const extractedDir = join(temporaryDir, 'extracted') await mkdir(extractedDir) - const command = getZipExtractorCommand(archivePath, extractedDir) - const result = await runProcess({ - program: command.file, - args: command.args, - timeoutMs: 120_000, - signal: options.signal - }) + const result = await extractArchive(archivePath, extractedDir, options.signal) options.signal?.throwIfAborted() if (result.code !== 0) { throw new Error(`Bun archive extraction failed: ${result.stderr || result.stdout}`) @@ -90,6 +85,40 @@ export async function materializeCachedOrcadBunRuntime( } } +/** + * Why the extractor needs a message of its own: `unzip` is absent from a minimal POSIX install, + * and a bare `spawn unzip ENOENT` names neither the missing tool nor the override. A misconfigured + * `ORCA_UNZIP_BIN` whose parent is a file reports ENOTDIR instead, which is the same verdict. + * + * The errno is the program path's, not the caller's: `runProcess` leaves cwd unset, so the child + * inherits the parent's without resolving it. Measured on macOS, Linux and Windows — spawn still + * succeeds from a deleted cwd, even though `process.cwd()` itself throws ENOENT there. + */ +async function extractArchive( + archivePath: string, + extractDir: string, + signal?: AbortSignal +): Promise { + const command = getZipExtractorCommand(archivePath, extractDir) + try { + return await runProcess({ + program: command.file, + args: command.args, + timeoutMs: 120_000, + signal + }) + } catch (error) { + if (isDefinitiveAbsence(error)) { + throw new Error( + `Bun archive extraction could not run ${command.file}: install ${command.label}, ` + + 'or set ORCA_UNZIP_BIN to an unzip-compatible extractor.', + { cause: error } + ) + } + throw error + } +} + async function downloadVerifiedArchive( url: string, destination: string,