fix(ssh): name the missing unzip when Bun archive extraction cannot start (#23298)

* 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.
This commit is contained in:
Neil
2026-09-26 21:07:59 -07:00
committed by GitHub
parent 1d2c0e5879
commit 080c4ad62a
2 changed files with 61 additions and 8 deletions
@@ -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'))
+37 -8
View File
@@ -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<ProcessResult> {
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,