Skip to content

Commit 18bb02a

Browse files
sahrizviclaude
andcommitted
fix(drivers): handle taskkill's async spawn error, and quote the last --prefix site
Fourth bot round on #1122, both findings against the previous commit. **An unhandled `error` event could take the process down.** `killTree` wrapped `spawn("taskkill", …)` in try/catch, but spawn reports a missing binary through an asynchronous `error` event rather than a throw, so the catch never ran. An unhandled `error` on a ChildProcess is fatal — meaning a Windows timeout could kill the CLI while leaving the npm install it was trying to stop still running. The killer now carries an `error` handler that falls back to killing the child directly. **A third `--prefix` site was still unquoted.** `installOptionalDriver`'s npm-missing branch built its hint from the raw directory, so a user without npm whose driver directory contains a space — a Windows profile, say — got a copy-paste command that splits on it. The test that should have caught that claimed "every printed --prefix is quoted" while only ever exercising `DriverNotInstalledError`. It is replaced by behavioural cases for each message-producing branch plus a structural check that scans the sources and fails on any `--prefix ${…}` not wrapped in `shellQuote`. Verified by adding a brand-new unquoted hint in an unrelated function: the structural check fails, which is the failure mode that let this site through twice. Tests: 188 drivers unit (was 186), 4,696 opencode. Typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
1 parent 405a5ef commit 18bb02a

2 files changed

Lines changed: 58 additions & 13 deletions

File tree

‎packages/drivers/src/resolve.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,20 @@ function killTree(child: ReturnType<typeof spawn>): void {
404404
const pid = child.pid
405405
if (pid === undefined) return
406406
if (process.platform === "win32") {
407+
// No process groups on Windows; taskkill walks the tree instead.
407408
try {
408-
// No process groups on Windows; taskkill walks the tree instead.
409-
spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" })
409+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" })
410+
// spawn reports a missing binary through an asynchronous `error` event,
411+
// not a throw, so the surrounding try/catch never sees it — and an
412+
// unhandled `error` on a ChildProcess takes the process down while npm
413+
// carries on running.
414+
killer.on("error", () => {
415+
try {
416+
child.kill()
417+
} catch {
418+
// Already gone.
419+
}
420+
})
410421
} catch {
411422
child.kill()
412423
}
@@ -580,7 +591,7 @@ async function performInstall(
580591
alreadyPresent: false,
581592
error:
582593
`npm is not available on PATH, so ${DRIVER_LABELS[driver]} cannot be installed automatically. ` +
583-
`Install Node.js, then run: npm install --prefix ${dir} ${packages.join(" ")}`,
594+
`Install Node.js, then run: npm install --prefix ${shellQuote(dir)} ${packages.join(" ")}`,
584595
}
585596
}
586597

‎packages/drivers/test/resolve-unit.test.ts‎

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -580,19 +580,53 @@ describe("shellQuote on Windows", () => {
580580
})
581581

582582
describe("manual-install hints are copy-pasteable", () => {
583-
test("every printed --prefix is quoted", () => {
584-
// The failure branch of warehouse_install_driver was the one site that
585-
// interpolated the directory raw, and it is the branch a user reads
586-
// precisely when the automatic install has just failed them.
583+
test("the npm-missing branch quotes the directory", async () => {
584+
// A third --prefix site lived here and was missed twice: the previous
585+
// version of this suite asserted "every printed --prefix is quoted" while
586+
// only ever exercising DriverNotInstalledError.
587+
process.env["ALTIMATE_DRIVER_DIR"] = path.join(tmpRoot, "My Drivers")
588+
589+
// force, because snowflake-sdk is a real workspace dependency and the
590+
// resolution check would otherwise short-circuit before npm is reached.
591+
const result = await installOptionalDriver("snowflake", {
592+
force: true,
593+
runNpm: async () => ({ code: 127, output: "npm: command not found" }),
594+
})
595+
596+
expect(result.installed).toBe(false)
597+
const prefix = /--prefix (\S+)/.exec(result.error ?? "")?.[1]
598+
expect(prefix).toBeDefined()
599+
expect(prefix!.startsWith("'") || prefix!.startsWith('"')).toBe(true)
600+
})
601+
602+
test("DriverNotInstalledError quotes the directory", () => {
587603
process.env["ALTIMATE_DRIVER_DIR"] = "/Users/John Doe/Library/drivers"
588604

589605
const err = new DriverNotInstalledError("snowflake", DRIVER_PACKAGES.snowflake, [])
590-
const prefixes = [...err.message.matchAll(/--prefix (\S+)/g)].map((m) => m[1]!)
591-
592-
expect(prefixes.length).toBeGreaterThan(0)
593-
for (const prefix of prefixes) {
594-
// An unquoted path with a space splits, and npm receives the wrong prefix.
595-
expect(prefix.startsWith("'") || prefix.startsWith('"')).toBe(true)
606+
const prefix = /--prefix (\S+)/.exec(err.message)?.[1]
607+
608+
expect(prefix).toBeDefined()
609+
expect(prefix!.startsWith("'")).toBe(true)
610+
})
611+
612+
test("no source builds a --prefix hint without shellQuote", () => {
613+
// Structural, because the behavioural tests can only cover the sites someone
614+
// remembered to write a case for. This fails when a NEW unquoted hint is
615+
// added anywhere, which is how the third site slipped through.
616+
const sources = [
617+
path.join(import.meta.dir, "..", "src", "resolve.ts"),
618+
path.join(import.meta.dir, "..", "..", "opencode", "src", "altimate", "tools", "warehouse-install-driver.ts"),
619+
path.join(import.meta.dir, "..", "..", "opencode", "src", "altimate", "tools", "warehouse-add.ts"),
620+
]
621+
622+
const offenders: string[] = []
623+
for (const file of sources) {
624+
const text = fs.readFileSync(file, "utf8")
625+
for (const match of text.matchAll(/--prefix \$\{([^}]*)\}/g)) {
626+
if (!match[1]!.includes("shellQuote")) offenders.push(`${path.basename(file)}: ${match[0]}`)
627+
}
596628
}
629+
630+
expect(offenders).toEqual([])
597631
})
598632
})

0 commit comments

Comments
 (0)