-
Notifications
You must be signed in to change notification settings - Fork 2.9k
fix: prevent terminal corruption during background bun install #2369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" | ||
|
|
||
| import * as loggerModule from "../../shared/logger" | ||
| import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide" | ||
| import { resetConfigContext } from "./config-context" | ||
| import { runBunInstallWithDetails } from "./bun-install" | ||
|
|
||
| function createProc( | ||
| exitCode: number, | ||
| output?: { stdout?: string; stderr?: string } | ||
| ): ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide> { | ||
| return { | ||
| exited: Promise.resolve(exitCode), | ||
| exitCode, | ||
| stdout: output?.stdout ? new Blob([output.stdout]).stream() : undefined, | ||
| stderr: output?.stderr ? new Blob([output.stderr]).stream() : undefined, | ||
| kill: () => {}, | ||
| } satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide> | ||
| } | ||
|
|
||
| describe("runBunInstallWithDetails", () => { | ||
| beforeEach(() => { | ||
| process.env.OPENCODE_CONFIG_DIR = "/test/opencode" | ||
| resetConfigContext() | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| resetConfigContext() | ||
| delete process.env.OPENCODE_CONFIG_DIR | ||
| }) | ||
|
|
||
| it("inherits install output by default", async () => { | ||
| // given | ||
| const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) | ||
|
|
||
| try { | ||
| // when | ||
| const result = await runBunInstallWithDetails() | ||
|
|
||
| // then | ||
| expect(result).toEqual({ success: true }) | ||
| const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide> | ||
| expect(options.stdout).toBe("inherit") | ||
| expect(options.stderr).toBe("inherit") | ||
| } finally { | ||
| spawnSpy.mockRestore() | ||
| } | ||
| }) | ||
|
|
||
| it("pipes install output when requested", async () => { | ||
| // given | ||
| const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) | ||
|
|
||
| try { | ||
| // when | ||
| const result = await runBunInstallWithDetails({ outputMode: "pipe" }) | ||
|
|
||
| // then | ||
| expect(result).toEqual({ success: true }) | ||
| const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide> | ||
| expect(options.stdout).toBe("pipe") | ||
| expect(options.stderr).toBe("pipe") | ||
| } finally { | ||
| spawnSpy.mockRestore() | ||
| } | ||
| }) | ||
|
|
||
| it("logs captured output when piped install fails", async () => { | ||
| // given | ||
| const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue( | ||
| createProc(1, { | ||
| stdout: "resolved 10 packages", | ||
| stderr: "network error", | ||
| }) | ||
| ) | ||
| const logSpy = spyOn(loggerModule, "log").mockImplementation(() => {}) | ||
|
|
||
| try { | ||
| // when | ||
| const result = await runBunInstallWithDetails({ outputMode: "pipe" }) | ||
|
|
||
| // then | ||
| expect(result).toEqual({ | ||
| success: false, | ||
| error: "bun install failed with exit code 1", | ||
| }) | ||
| expect(logSpy).toHaveBeenCalledWith("[bun-install] Captured output from failed bun install", { | ||
| stdout: "resolved 10 packages", | ||
| stderr: "network error", | ||
| }) | ||
| } finally { | ||
| logSpy.mockRestore() | ||
| spawnSpy.mockRestore() | ||
| } | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,30 @@ | ||
| import { getConfigDir } from "./config-context" | ||
| import { log } from "../../shared/logger" | ||
| import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" | ||
|
|
||
| const BUN_INSTALL_TIMEOUT_SECONDS = 60 | ||
| const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000 | ||
|
|
||
| type BunInstallOutputMode = "inherit" | "pipe" | ||
|
|
||
| interface RunBunInstallOptions { | ||
| outputMode?: BunInstallOutputMode | ||
| } | ||
|
|
||
| interface BunInstallOutput { | ||
| stdout: string | ||
| stderr: string | ||
| } | ||
|
|
||
| declare function setTimeout(callback: () => void, delay?: number): number | ||
| declare function clearTimeout(timeout: number): void | ||
|
|
||
| type ProcessOutputStream = ReturnType<typeof spawnWithWindowsHide>["stdout"] | ||
|
|
||
| declare const Bun: { | ||
| readableStreamToText(stream: NonNullable<ProcessOutputStream>): Promise<string> | ||
| } | ||
|
|
||
| export interface BunInstallResult { | ||
| success: boolean | ||
| timedOut?: boolean | ||
|
|
@@ -15,36 +36,77 @@ export async function runBunInstall(): Promise<boolean> { | |
| return result.success | ||
| } | ||
|
|
||
| export async function runBunInstallWithDetails(): Promise<BunInstallResult> { | ||
| function readProcessOutput(stream: ProcessOutputStream): Promise<string> { | ||
| if (!stream) { | ||
| return Promise.resolve("") | ||
| } | ||
|
|
||
| return Bun.readableStreamToText(stream) | ||
| } | ||
|
|
||
| function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: BunInstallOutput): void { | ||
| if (outputMode !== "pipe") { | ||
| return | ||
| } | ||
|
|
||
| const stdout = output.stdout.trim() | ||
| const stderr = output.stderr.trim() | ||
| if (!stdout && !stderr) { | ||
| return | ||
| } | ||
|
|
||
| log("[bun-install] Captured output from failed bun install", { | ||
| stdout, | ||
| stderr, | ||
| }) | ||
| } | ||
|
|
||
| export async function runBunInstallWithDetails(options?: RunBunInstallOptions): Promise<BunInstallResult> { | ||
| const outputMode = options?.outputMode ?? "inherit" | ||
|
|
||
| try { | ||
| const proc = spawnWithWindowsHide(["bun", "install"], { | ||
| cwd: getConfigDir(), | ||
| stdout: "inherit", | ||
| stderr: "inherit", | ||
| stdout: outputMode, | ||
| stderr: outputMode, | ||
| }) | ||
|
|
||
| let timeoutId: ReturnType<typeof setTimeout> | ||
| const outputPromise = Promise.all([readProcessOutput(proc.stdout), readProcessOutput(proc.stderr)]).then( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Add a Prompt for AI agents |
||
| ([stdout, stderr]) => ({ stdout, stderr }) | ||
| ) | ||
|
|
||
| let timeoutId: ReturnType<typeof setTimeout> | undefined | ||
| const timeoutPromise = new Promise<"timeout">((resolve) => { | ||
| timeoutId = setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS) | ||
| }) | ||
| const exitPromise = proc.exited.then(() => "completed" as const) | ||
| const result = await Promise.race([exitPromise, timeoutPromise]) | ||
| clearTimeout(timeoutId!) | ||
| if (timeoutId) { | ||
| clearTimeout(timeoutId) | ||
| } | ||
|
|
||
| if (result === "timeout") { | ||
| try { | ||
| proc.kill() | ||
| } catch { | ||
| /* intentionally empty - process may have already exited */ | ||
| } | ||
|
|
||
| await proc.exited | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Awaiting process exit and output on timeout can cause indefinite hangs on Windows due to orphaned child processes holding pipes open. Prompt for AI agents |
||
| logCapturedOutputOnFailure(outputMode, await outputPromise) | ||
|
|
||
| return { | ||
| success: false, | ||
| timedOut: true, | ||
| error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${getConfigDir()} && bun i`, | ||
| } | ||
| } | ||
|
|
||
| const output = await outputPromise | ||
|
|
||
| if (proc.exitCode !== 0) { | ||
| logCapturedOutputOnFailure(outputMode, output) | ||
|
|
||
| return { | ||
| success: false, | ||
| error: `bun install failed with exit code ${proc.exitCode}`, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Unconditionally deleting
process.env.OPENCODE_CONFIG_DIRcan corrupt the test environment if it was previously set by a developer or another test suite.Without restoring the original environment variable, this test pollutes the environment and can cause flaky, hard-to-debug failures in other concurrent tests that rely on
OPENCODE_CONFIG_DIR(as seen safely handled ininstall.test.ts).Prompt for AI agents