diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7258ca0b4..9c899dc82 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -246,6 +246,7 @@ export async function runCli(cliArgs: string[]): Promise { const { recoverWithAutoLogin } = await import("./lib/auto-auth.js"); const { getEnvLogLevel, setLogLevel } = await import("./lib/logger.js"); const { scheduleForceExit } = await import("./lib/force-exit.js"); + const { closeGlobalDispatcher } = await import("./lib/close-dispatcher.js"); const { isTrialEligible, promptAndStartTrial } = await import( "./lib/seer-trial.js" ); @@ -640,10 +641,14 @@ export async function runCli(cliArgs: string[]): Promise { } finally { // 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 macOS/Bun force-exit timer cannot interrupt - // them. Covers every command, not just init (see #1237). + // Arm the backstop first so it fires regardless of what the dispatcher + // teardown does. The unref'd timer only triggers if the loop is still + // referenced after a drained command, so it's a no-op on clean exits + // (a libuv refcount quirk on macOS keeps it worthwhile — see #1237). scheduleForceExit(); + // Release undici's pooled keep-alive sockets so the event loop can drain + // on its own — the root-cause fix. Never rejects (see close-dispatcher.ts). + await closeGlobalDispatcher(); } // Show update notification after command completes diff --git a/packages/cli/src/lib/close-dispatcher.ts b/packages/cli/src/lib/close-dispatcher.ts new file mode 100644 index 000000000..9c04ddd88 --- /dev/null +++ b/packages/cli/src/lib/close-dispatcher.ts @@ -0,0 +1,30 @@ +/** + * Node's global `fetch` (undici) keeps a pool of keep-alive sockets open after + * a command finishes its work. Those sockets keep the event loop referenced, + * so the process lingers instead of exiting on its own (see #1237). + * + * Destroying the global dispatcher releases the pooled sockets, letting the + * loop drain naturally. This is the root-cause complement to the force-exit + * timer, which stays armed as a last-resort backstop. + * + * `destroy()` aborts in-flight requests and returns immediately rather than + * waiting for them to settle, so it can't hang the exit path. The call runs in + * a `finally` after the command has already produced its result, so it must + * never reject — a shutdown error here would otherwise mask the command's + * outcome and skip the backstop timer. + */ +const GLOBAL_DISPATCHER = Symbol.for("undici.globalDispatcher.1"); + +type ClosableDispatcher = { destroy?: () => Promise }; + +export async function closeGlobalDispatcher(): Promise { + const dispatcher = (globalThis as Record)[ + GLOBAL_DISPATCHER + ] as ClosableDispatcher | undefined; + + try { + await dispatcher?.destroy?.(); + } catch { + // Socket teardown errors are irrelevant once we're on the way out. + } +} diff --git a/packages/cli/test/lib/close-dispatcher.test.ts b/packages/cli/test/lib/close-dispatcher.test.ts new file mode 100644 index 000000000..36ae4ce81 --- /dev/null +++ b/packages/cli/test/lib/close-dispatcher.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { closeGlobalDispatcher } from "../../src/lib/close-dispatcher.js"; + +const GLOBAL_DISPATCHER = Symbol.for("undici.globalDispatcher.1"); +const global = globalThis as Record; +const original = global[GLOBAL_DISPATCHER]; + +afterEach(() => { + if (original === undefined) { + delete global[GLOBAL_DISPATCHER]; + } else { + global[GLOBAL_DISPATCHER] = original; + } +}); + +describe("closeGlobalDispatcher", () => { + test("destroys the global dispatcher when one is registered", async () => { + let destroyed = false; + global[GLOBAL_DISPATCHER] = { + destroy: () => { + destroyed = true; + return Promise.resolve(); + }, + }; + + await closeGlobalDispatcher(); + + expect(destroyed).toBe(true); + }); + + test("resolves without throwing when no dispatcher is registered", async () => { + delete global[GLOBAL_DISPATCHER]; + + await expect(closeGlobalDispatcher()).resolves.toBeUndefined(); + }); + + test("resolves when the dispatcher has no destroy method", async () => { + global[GLOBAL_DISPATCHER] = {}; + + await expect(closeGlobalDispatcher()).resolves.toBeUndefined(); + }); + + test("swallows a rejection from destroy so the exit path is never disrupted", async () => { + global[GLOBAL_DISPATCHER] = { + destroy: () => Promise.reject(new Error("socket teardown failed")), + }; + + await expect(closeGlobalDispatcher()).resolves.toBeUndefined(); + }); +});