From a042b32d2f017f0e83309f8e36b22cd2c750ff2b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 14 Feb 2026 10:46:07 -0800 Subject: [PATCH] fix: Docker installation keeps hanging on MacOS (#12972) * Onboarding: avoid stdin resume after wizard finish * Changelog: remove Docker hang entry from PR * Terminal: make stdin resume behavior explicit at call sites * CI: rerun format check * Onboarding: restore terminal before cancel exit * test(onboard): align restoreTerminalState expectation * chore(format): align onboarding restore test with updated oxfmt config * chore(format): enforce updated oxfmt on restore test * chore(format): apply updated oxfmt spacing to restore test * fix: avoid stdin resume after onboarding (#12972) (thanks @vincentkoc) --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 2 +- src/commands/onboard-interactive.e2e.test.ts | 8 ++- src/commands/onboard-interactive.test.ts | 72 ++++++++++++++++++++ src/commands/onboard-interactive.ts | 10 ++- src/runtime.ts | 2 +- src/terminal/restore.test.ts | 19 +++++- src/terminal/restore.ts | 23 ++++++- src/wizard/onboarding.finalize.ts | 2 +- 8 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 src/commands/onboard-interactive.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cea0af080f..7907774478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,7 +115,7 @@ Docs: https://docs.openclaw.ai - OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into `pi` `auth.json` so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e. - Auth/OpenAI Codex: share OAuth login handling across onboarding and `models auth login --provider openai-codex`, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20. - Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng. -- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck. +- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly (including Docker TTY installs that would otherwise hang). (#12972) Thanks @vincentkoc. - Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k. - macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR. - Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr. diff --git a/src/commands/onboard-interactive.e2e.test.ts b/src/commands/onboard-interactive.e2e.test.ts index 654edd540a..a1b4c9420d 100644 --- a/src/commands/onboard-interactive.e2e.test.ts +++ b/src/commands/onboard-interactive.e2e.test.ts @@ -46,7 +46,9 @@ describe("runInteractiveOnboarding", () => { await runInteractiveOnboarding({} as never, runtime); expect(runtime.exit).toHaveBeenCalledWith(1); - expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish"); + expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish", { + resumeStdin: false, + }); }); it("rethrows non-cancel errors", async () => { @@ -56,6 +58,8 @@ describe("runInteractiveOnboarding", () => { await expect(runInteractiveOnboarding({} as never, runtime)).rejects.toThrow("boom"); expect(runtime.exit).not.toHaveBeenCalled(); - expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish"); + expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish", { + resumeStdin: false, + }); }); }); diff --git a/src/commands/onboard-interactive.test.ts b/src/commands/onboard-interactive.test.ts new file mode 100644 index 0000000000..f4eb06070f --- /dev/null +++ b/src/commands/onboard-interactive.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; +import { WizardCancelledError } from "../wizard/prompts.js"; +import { runInteractiveOnboarding } from "./onboard-interactive.js"; + +const mocks = vi.hoisted(() => ({ + createClackPrompter: vi.fn(() => ({ id: "prompter" })), + runOnboardingWizard: vi.fn(async () => {}), + restoreTerminalState: vi.fn(), +})); + +vi.mock("../wizard/clack-prompter.js", () => ({ + createClackPrompter: mocks.createClackPrompter, +})); + +vi.mock("../wizard/onboarding.js", () => ({ + runOnboardingWizard: mocks.runOnboardingWizard, +})); + +vi.mock("../terminal/restore.js", () => ({ + restoreTerminalState: mocks.restoreTerminalState, +})); + +function makeRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn() as unknown as RuntimeEnv["exit"], + }; +} + +describe("runInteractiveOnboarding", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("restores terminal state without resuming stdin on success", async () => { + const runtime = makeRuntime(); + + await runInteractiveOnboarding({} as never, runtime); + + expect(mocks.runOnboardingWizard).toHaveBeenCalledOnce(); + expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish", { + resumeStdin: false, + }); + }); + + it("restores terminal state without resuming stdin on cancel", async () => { + const exitError = new Error("exit"); + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw exitError; + }) as unknown as RuntimeEnv["exit"], + }; + mocks.runOnboardingWizard.mockRejectedValueOnce(new WizardCancelledError("cancelled")); + + await expect(runInteractiveOnboarding({} as never, runtime)).rejects.toBe(exitError); + + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish", { + resumeStdin: false, + }); + const restoreOrder = + mocks.restoreTerminalState.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER; + const exitOrder = + (runtime.exit as unknown as ReturnType).mock.invocationCallOrder[0] ?? + Number.MAX_SAFE_INTEGER; + expect(restoreOrder).toBeLessThan(exitOrder); + }); +}); diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index a02d066b9d..4041a11764 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -11,15 +11,21 @@ export async function runInteractiveOnboarding( runtime: RuntimeEnv = defaultRuntime, ) { const prompter = createClackPrompter(); + let exitCode: number | null = null; try { await runOnboardingWizard(opts, runtime, prompter); } catch (err) { if (err instanceof WizardCancelledError) { - runtime.exit(1); + // Best practice: cancellation is not a successful completion. + exitCode = 1; return; } throw err; } finally { - restoreTerminalState("onboarding finish"); + // Keep stdin paused so non-daemon runs can exit cleanly (e.g. Docker setup). + restoreTerminalState("onboarding finish", { resumeStdin: false }); + if (exitCode !== null) { + runtime.exit(exitCode); + } } } diff --git a/src/runtime.ts b/src/runtime.ts index 1c6fcec30f..6cd0850e5c 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -31,7 +31,7 @@ export const defaultRuntime: RuntimeEnv = { console.error(...args); }, exit: (code) => { - restoreTerminalState("runtime exit"); + restoreTerminalState("runtime exit", { resumeStdin: false }); process.exit(code); throw new Error("unreachable"); // satisfies tests when mocked }, diff --git a/src/terminal/restore.test.ts b/src/terminal/restore.test.ts index 4b0b0d16c3..ea5e65681a 100644 --- a/src/terminal/restore.test.ts +++ b/src/terminal/restore.test.ts @@ -30,7 +30,7 @@ describe("restoreTerminalState", () => { (process.stdin as { isPaused?: () => boolean }).isPaused = originalIsPaused; }); - it("does not resume paused stdin while restoring raw mode", () => { + it("does not resume paused stdin by default", () => { const setRawMode = vi.fn(); const resume = vi.fn(); const isPaused = vi.fn(() => true); @@ -46,4 +46,21 @@ describe("restoreTerminalState", () => { expect(setRawMode).toHaveBeenCalledWith(false); expect(resume).not.toHaveBeenCalled(); }); + + it("resumes paused stdin when resumeStdin is true", () => { + const setRawMode = vi.fn(); + const resume = vi.fn(); + const isPaused = vi.fn(() => true); + + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); + (process.stdin as { setRawMode?: (mode: boolean) => void }).setRawMode = setRawMode; + (process.stdin as { resume?: () => void }).resume = resume; + (process.stdin as { isPaused?: () => boolean }).isPaused = isPaused; + + restoreTerminalState("test", { resumeStdin: true }); + + expect(setRawMode).toHaveBeenCalledWith(false); + expect(resume).toHaveBeenCalledOnce(); + }); }); diff --git a/src/terminal/restore.ts b/src/terminal/restore.ts index c718c5932f..8494c44775 100644 --- a/src/terminal/restore.ts +++ b/src/terminal/restore.ts @@ -2,6 +2,16 @@ import { clearActiveProgressLine } from "./progress-line.js"; const RESET_SEQUENCE = "\x1b[0m\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l"; +type RestoreTerminalStateOptions = { + /** + * Resumes paused stdin after restoring terminal mode. + * Keep this off when the process should exit immediately after cleanup. + * + * Default: false (safer for "cleanup then exit" call sites). + */ + resumeStdin?: boolean; +}; + function reportRestoreFailure(scope: string, err: unknown, reason?: string): void { const suffix = reason ? ` (${reason})` : ""; const message = `[terminal] restore ${scope} failed${suffix}: ${String(err)}`; @@ -12,7 +22,11 @@ function reportRestoreFailure(scope: string, err: unknown, reason?: string): voi } } -export function restoreTerminalState(reason?: string): void { +export function restoreTerminalState( + reason?: string, + options: RestoreTerminalStateOptions = {}, +): void { + const resumeStdin = options.resumeStdin ?? false; try { clearActiveProgressLine(); } catch (err) { @@ -26,6 +40,13 @@ export function restoreTerminalState(reason?: string): void { } catch (err) { reportRestoreFailure("raw mode", err, reason); } + if (resumeStdin && typeof stdin.isPaused === "function" && stdin.isPaused()) { + try { + stdin.resume(); + } catch (err) { + reportRestoreFailure("stdin resume", err, reason); + } + } } if (process.stdout.isTTY) { diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index ca454cc01f..b5840e45aa 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -329,7 +329,7 @@ export async function finalizeOnboardingWizard( }); if (hatchChoice === "tui") { - restoreTerminalState("pre-onboarding tui"); + restoreTerminalState("pre-onboarding tui", { resumeStdin: true }); await runTui({ url: links.wsUrl, token: settings.authMode === "token" ? settings.gatewayToken : undefined,