Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
"./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"
);
Expand Down Expand Up @@ -726,8 +724,9 @@ export async function runCli(cliArgs: string[]): Promise<void> {
// 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
Expand Down
6 changes: 1 addition & 5 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/lib/force-exit.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
32 changes: 0 additions & 32 deletions packages/cli/src/lib/init/force-exit.ts

This file was deleted.

11 changes: 1 addition & 10 deletions packages/cli/test/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,7 +37,6 @@ let runWizardSpy: ReturnType<typeof spyOn>;
let findProjectsSpy: ReturnType<typeof spyOn>;
let warmSpy: ReturnType<typeof spyOn>;
let refreshTokenSpy: ReturnType<typeof spyOn>;
let requestForceExitSpy: ReturnType<typeof spyOn>;

const func = (await initCommand.loader()) as unknown as (
this: {
Expand Down Expand Up @@ -92,16 +89,13 @@ beforeEach(() => {
refreshTokenSpy = vi
.spyOn(authModule, "refreshToken")
.mockResolvedValue({ token: "oauth-token", refreshed: false });
requestForceExitSpy = vi.spyOn(forceExitModule, "requestInitForceExit");
});

afterEach(() => {
runWizardSpy.mockRestore();
findProjectsSpy.mockRestore();
warmSpy.mockRestore();
refreshTokenSpy.mockRestore();
requestForceExitSpy.mockRestore();
forceExitModule.scheduleInitForceExitIfRequested();
resetPrefetch();
});

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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();
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) {
Expand All @@ -32,40 +26,38 @@ 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<typeof setTimeout>;
const setTimeoutSpy = vi
.spyOn(globalThis, "setTimeout")
.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();
});
Expand Down
Loading