mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
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:
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user