From d66321c60082d2f72402e0490de67055c5a83881 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Wed, 26 Aug 2026 13:15:25 +0000 Subject: [PATCH 1/2] fix(cli): close undici global dispatcher on exit Ordinary commands left Node's global fetch (undici) keep-alive sockets pooled after finishing their work, keeping the event loop referenced so the process lingered on macOS (#1237). #1396 added a force-exit timer as a safety net; this addresses the root cause by closing the global dispatcher in runCli's finally, releasing the pooled sockets so the loop drains on its own. The timer stays as a backstop for handles the close can't reach. Refs #1237 --- packages/cli/src/cli.ts | 10 +++-- packages/cli/src/lib/close-dispatcher.ts | 20 +++++++++ .../cli/test/lib/close-dispatcher.test.ts | 42 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/lib/close-dispatcher.ts create mode 100644 packages/cli/test/lib/close-dispatcher.test.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7258ca0b4..caace2bc8 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,9 +641,12 @@ 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). + // Release undici's pooled keep-alive sockets so the event loop can drain + // on its own — the root-cause fix for the process-hang (see #1237). + await closeGlobalDispatcher(); + // Backstop for any handle the dispatcher close can't reach (a libuv + // refcount quirk on macOS). The unref'd timer only fires if the loop is + // still referenced after a drained command, so it's a no-op otherwise. scheduleForceExit(); } diff --git a/packages/cli/src/lib/close-dispatcher.ts b/packages/cli/src/lib/close-dispatcher.ts new file mode 100644 index 000000000..16dc77871 --- /dev/null +++ b/packages/cli/src/lib/close-dispatcher.ts @@ -0,0 +1,20 @@ +/** + * 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). + * + * Closing 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. + */ +const GLOBAL_DISPATCHER = Symbol.for("undici.globalDispatcher.1"); + +type ClosableDispatcher = { close?: () => Promise }; + +export function closeGlobalDispatcher(): Promise { + const dispatcher = (globalThis as Record)[ + GLOBAL_DISPATCHER + ] as ClosableDispatcher | undefined; + + return dispatcher?.close?.() ?? Promise.resolve(); +} 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..8afa930ca --- /dev/null +++ b/packages/cli/test/lib/close-dispatcher.test.ts @@ -0,0 +1,42 @@ +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("closes the global dispatcher when one is registered", async () => { + let closed = false; + global[GLOBAL_DISPATCHER] = { + close: () => { + closed = true; + return Promise.resolve(); + }, + }; + + await closeGlobalDispatcher(); + + expect(closed).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 close method", async () => { + global[GLOBAL_DISPATCHER] = {}; + + await expect(closeGlobalDispatcher()).resolves.toBeUndefined(); + }); +}); From 3a2b750a2147f40b939ef2c5b3da352b41aebb93 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Wed, 26 Aug 2026 13:36:47 +0000 Subject: [PATCH 2/2] fix(cli): harden dispatcher teardown against hang and rejection Bot review flagged that an unguarded `await closeGlobalDispatcher()` in runCli's finally could reject (masking the command result, forcing exit 1) or hang (never arming the force-exit backstop). - Arm scheduleForceExit() before the await so the backstop fires regardless of teardown outcome. - Make closeGlobalDispatcher never reject (try/catch) and use destroy() instead of close() so it aborts in-flight requests immediately rather than waiting for them. Refs #1237 --- packages/cli/src/cli.ts | 11 +++++----- packages/cli/src/lib/close-dispatcher.ts | 22 ++++++++++++++----- .../cli/test/lib/close-dispatcher.test.ts | 20 ++++++++++++----- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index caace2bc8..9c899dc82 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -641,13 +641,14 @@ export async function runCli(cliArgs: string[]): Promise { } finally { // Abort any pending version check to allow clean exit abortPendingVersionCheck(); + // 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 for the process-hang (see #1237). + // on its own — the root-cause fix. Never rejects (see close-dispatcher.ts). await closeGlobalDispatcher(); - // Backstop for any handle the dispatcher close can't reach (a libuv - // refcount quirk on macOS). The unref'd timer only fires if the loop is - // still referenced after a drained command, so it's a no-op otherwise. - scheduleForceExit(); } // Show update notification after command completes diff --git a/packages/cli/src/lib/close-dispatcher.ts b/packages/cli/src/lib/close-dispatcher.ts index 16dc77871..9c04ddd88 100644 --- a/packages/cli/src/lib/close-dispatcher.ts +++ b/packages/cli/src/lib/close-dispatcher.ts @@ -3,18 +3,28 @@ * a command finishes its work. Those sockets keep the event loop referenced, * so the process lingers instead of exiting on its own (see #1237). * - * Closing 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. + * 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 = { close?: () => Promise }; +type ClosableDispatcher = { destroy?: () => Promise }; -export function closeGlobalDispatcher(): Promise { +export async function closeGlobalDispatcher(): Promise { const dispatcher = (globalThis as Record)[ GLOBAL_DISPATCHER ] as ClosableDispatcher | undefined; - return dispatcher?.close?.() ?? Promise.resolve(); + 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 index 8afa930ca..36ae4ce81 100644 --- a/packages/cli/test/lib/close-dispatcher.test.ts +++ b/packages/cli/test/lib/close-dispatcher.test.ts @@ -14,18 +14,18 @@ afterEach(() => { }); describe("closeGlobalDispatcher", () => { - test("closes the global dispatcher when one is registered", async () => { - let closed = false; + test("destroys the global dispatcher when one is registered", async () => { + let destroyed = false; global[GLOBAL_DISPATCHER] = { - close: () => { - closed = true; + destroy: () => { + destroyed = true; return Promise.resolve(); }, }; await closeGlobalDispatcher(); - expect(closed).toBe(true); + expect(destroyed).toBe(true); }); test("resolves without throwing when no dispatcher is registered", async () => { @@ -34,9 +34,17 @@ describe("closeGlobalDispatcher", () => { await expect(closeGlobalDispatcher()).resolves.toBeUndefined(); }); - test("resolves when the dispatcher has no close method", async () => { + 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(); + }); });