From a8437b7583084b5edd578c8256b8fba89e822cd9 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 8 Aug 2026 08:11:14 +0000 Subject: [PATCH] fix(cli): force-exit safety net for all commands, not just init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ordinary commands (org list, project list, auth status, etc.) finished their work and wrote complete output but the process never exited — lingering keep-alive sockets / a libuv refcount quirk on macOS+Bun kept the event loop referenced. The existing force-exit safety net was armed only for the init wizard. Generalize the helper (lib/init/force-exit.ts -> lib/force-exit.ts) and schedule it unconditionally in runCli's finally, after all recovery middleware has reached a terminal result. The unref'd timer only fires when a handle keeps the loop alive past a drained command, so it stays a no-op on clean exits and never arms commands whose awaited work never resolves. Fixes #1237 --- packages/cli/src/cli.ts | 9 ++--- packages/cli/src/commands/init.ts | 6 +-- packages/cli/src/lib/force-exit.ts | 20 ++++++++++ packages/cli/src/lib/init/force-exit.ts | 32 --------------- packages/cli/test/commands/init.test.ts | 11 +---- .../test/lib/{init => }/force-exit.test.ts | 40 ++++++++----------- 6 files changed, 42 insertions(+), 76 deletions(-) create mode 100644 packages/cli/src/lib/force-exit.ts delete mode 100644 packages/cli/src/lib/init/force-exit.ts rename packages/cli/test/lib/{init => }/force-exit.test.ts (66%) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 508ae271a..7a70109b1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -247,9 +247,7 @@ export async function runCli(cliArgs: string[]): Promise { "./lib/auto-auth.js" ); const { getEnvLogLevel, setLogLevel } = await import("./lib/logger.js"); - const { scheduleInitForceExitIfRequested } = await import( - "./lib/init/force-exit.js" - ); + const { scheduleForceExit } = await import("./lib/force-exit.js"); const { isTrialEligible, promptAndStartTrial } = await import( "./lib/seer-trial.js" ); @@ -726,8 +724,9 @@ export async function runCli(cliArgs: string[]): Promise { // Abort any pending version check to allow clean exit abortPendingVersionCheck(); // Runs after auto-auth, scope recovery, and command retry have reached a - // terminal result, so the init-specific macOS timer cannot interrupt them. - scheduleInitForceExitIfRequested(); + // terminal result, so the macOS/Bun force-exit timer cannot interrupt + // them. Covers every command, not just init (see #1237). + scheduleForceExit(); } // Show update notification after command completes diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 6e4ecebd6..962e5c643 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -25,7 +25,6 @@ import { looksLikePath, parseOrgProjectArg } from "../lib/arg-parsing.js"; import { buildCommand } from "../lib/command.js"; import { refreshToken } from "../lib/db/auth.js"; import { ContextError, ValidationError } from "../lib/errors.js"; -import { requestInitForceExit } from "../lib/init/force-exit.js"; import { warmOrgDetection } from "../lib/init/org-prefetch.js"; import { runWizard } from "../lib/init/wizard-runner.js"; import { validateResourceId } from "../lib/input-validation.js"; @@ -416,10 +415,7 @@ export const initCommand = buildCommand< warmOrgDetection(targetDir); } - // 6. Run the wizard. The outer CLI pipeline schedules the macOS/Bun - // force-exit safety net after recovery middleware (including auto-auth) - // has finished, so it cannot interrupt login or command retry. - requestInitForceExit(); + // 6. Run the wizard. await runWizard({ directory: targetDir, yes: flags.yes, diff --git a/packages/cli/src/lib/force-exit.ts b/packages/cli/src/lib/force-exit.ts new file mode 100644 index 000000000..a9615ff15 --- /dev/null +++ b/packages/cli/src/lib/force-exit.ts @@ -0,0 +1,20 @@ +/** + * macOS/Bun can retain lingering handles after a command has finished its + * work — keep-alive sockets, a fresh `/dev/tty` ReadStream, or a libuv + * refcount quirk — keeping the event loop referenced so the process never + * exits. This affects ordinary commands, not just the init wizard (see #1237, + * #833). + * + * Schedule a force-exit safety net once the CLI's outer recovery middleware + * has reached a terminal result. The unref'd timer only fires when another + * handle keeps the event loop alive past a drained command, so it stays a + * no-op on clean exits and never arms for commands that intentionally keep + * running (their awaited work never resolves, so this is never reached). + */ +export function scheduleForceExit(): void { + if (process.platform === "darwin" && process.env.NODE_ENV !== "test") { + setTimeout(() => { + process.exit(process.exitCode ?? 0); + }, 100).unref(); + } +} diff --git a/packages/cli/src/lib/init/force-exit.ts b/packages/cli/src/lib/init/force-exit.ts deleted file mode 100644 index 72875d349..000000000 --- a/packages/cli/src/lib/init/force-exit.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * macOS/Bun can retain the fresh `/dev/tty` handle used by the init UI after - * teardown. Track when init is about to enter the wizard, then schedule the - * existing force-exit safety net only after the CLI's outer recovery - * middleware has finished. This keeps OAuth login/retry alive while still - * covering success, cancellation, and terminal error paths. - */ - -let initForceExitRequested = false; - -/** Mark that an init run may need the macOS force-exit safety net. */ -export function requestInitForceExit(): void { - initForceExitRequested = true; -} - -/** - * Schedule the requested safety net after all CLI recovery middleware has - * completed. The unref'd timer only fires when another handle keeps the event - * loop alive, so it remains a no-op on normal exits. - */ -export function scheduleInitForceExitIfRequested(): void { - if (!initForceExitRequested) { - return; - } - initForceExitRequested = false; - - if (process.platform === "darwin" && process.env.NODE_ENV !== "test") { - setTimeout(() => { - process.exit(process.exitCode ?? 0); - }, 100).unref(); - } -} diff --git a/packages/cli/test/commands/init.test.ts b/packages/cli/test/commands/init.test.ts index 272f2c354..ac686186c 100644 --- a/packages/cli/test/commands/init.test.ts +++ b/packages/cli/test/commands/init.test.ts @@ -19,8 +19,6 @@ import { ValidationError, } from "../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as forceExitModule from "../../src/lib/init/force-exit.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as prefetchNs from "../../src/lib/init/org-prefetch.js"; import { resetPrefetch } from "../../src/lib/init/org-prefetch.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference @@ -39,7 +37,6 @@ let runWizardSpy: ReturnType; let findProjectsSpy: ReturnType; let warmSpy: ReturnType; let refreshTokenSpy: ReturnType; -let requestForceExitSpy: ReturnType; const func = (await initCommand.loader()) as unknown as ( this: { @@ -92,7 +89,6 @@ beforeEach(() => { refreshTokenSpy = vi .spyOn(authModule, "refreshToken") .mockResolvedValue({ token: "oauth-token", refreshed: false }); - requestForceExitSpy = vi.spyOn(forceExitModule, "requestInitForceExit"); }); afterEach(() => { @@ -100,8 +96,6 @@ afterEach(() => { findProjectsSpy.mockRestore(); warmSpy.mockRestore(); refreshTokenSpy.mockRestore(); - requestForceExitSpy.mockRestore(); - forceExitModule.scheduleInitForceExitIfRequested(); resetPrefetch(); }); @@ -293,11 +287,9 @@ describe("init command func", () => { expect(refreshTokenSpy).toHaveBeenCalledTimes(1); const refreshOrder = refreshTokenSpy.mock.invocationCallOrder[0]; const warmOrder = warmSpy.mock.invocationCallOrder[0]; - const forceExitOrder = requestForceExitSpy.mock.invocationCallOrder[0]; const wizardOrder = runWizardSpy.mock.invocationCallOrder[0]; expect(refreshOrder).toBeLessThan(warmOrder ?? 0); - expect(refreshOrder).toBeLessThan(forceExitOrder ?? 0); - expect(forceExitOrder).toBeLessThan(wizardOrder ?? 0); + expect(refreshOrder).toBeLessThan(wizardOrder ?? 0); }); test("propagates AuthError before background work or wizard startup", async () => { @@ -308,7 +300,6 @@ describe("init command func", () => { await expect(func.call(ctx, DEFAULT_FLAGS)).rejects.toBe(authError); expect(warmSpy).not.toHaveBeenCalled(); - expect(requestForceExitSpy).not.toHaveBeenCalled(); expect(runWizardSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/test/lib/init/force-exit.test.ts b/packages/cli/test/lib/force-exit.test.ts similarity index 66% rename from packages/cli/test/lib/init/force-exit.test.ts rename to packages/cli/test/lib/force-exit.test.ts index f5034a95f..68acb863c 100644 --- a/packages/cli/test/lib/init/force-exit.test.ts +++ b/packages/cli/test/lib/force-exit.test.ts @@ -1,8 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { - requestInitForceExit, - scheduleInitForceExitIfRequested, -} from "../../../src/lib/init/force-exit.js"; +import { scheduleForceExit } from "../../src/lib/force-exit.js"; const originalPlatform = process.platform; const originalNodeEnv = process.env.NODE_ENV; @@ -17,12 +14,9 @@ function setPlatform(platform: NodeJS.Platform): void { beforeEach(() => { setPlatform("linux"); process.env.NODE_ENV = "test"; - scheduleInitForceExitIfRequested(); }); afterEach(() => { - process.env.NODE_ENV = "test"; - scheduleInitForceExitIfRequested(); vi.restoreAllMocks(); setPlatform(originalPlatform); if (originalNodeEnv === undefined) { @@ -32,16 +26,8 @@ afterEach(() => { } }); -describe("init force-exit safety net", () => { - test("does nothing until init requests the safety net", () => { - const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); - - scheduleInitForceExitIfRequested(); - - expect(setTimeoutSpy).not.toHaveBeenCalled(); - }); - - test("schedules the macOS safety net after the outer pipeline finishes", () => { +describe("force-exit safety net", () => { + test("schedules an unref'd 100ms timer on macOS outside tests", () => { const unref = vi.fn(); const timeout = { unref } as unknown as ReturnType; const setTimeoutSpy = vi @@ -49,23 +35,29 @@ describe("init force-exit safety net", () => { .mockReturnValue(timeout); setPlatform("darwin"); process.env.NODE_ENV = "production"; - requestInitForceExit(); - scheduleInitForceExitIfRequested(); + scheduleForceExit(); expect(setTimeoutSpy).toHaveBeenCalledOnce(); expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 100); expect(unref).toHaveBeenCalledOnce(); }); - test("consumes requests without scheduling outside macOS", () => { + test("does nothing outside macOS", () => { const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); - requestInitForceExit(); + process.env.NODE_ENV = "production"; - scheduleInitForceExitIfRequested(); + scheduleForceExit(); + + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); + + test("does nothing in the test environment even on macOS", () => { + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); setPlatform("darwin"); - process.env.NODE_ENV = "production"; - scheduleInitForceExitIfRequested(); + process.env.NODE_ENV = "test"; + + scheduleForceExit(); expect(setTimeoutSpy).not.toHaveBeenCalled(); });