From 4561bdc39a76d68eb6837afc8c85f70b63804378 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 13 Aug 2026 03:52:31 +0000 Subject: [PATCH] fix(discovery): bound WSL host-home probe with a hard timeout resolveWindowsUserProfile() ran Bun.spawnSync(cmd.exe echo %USERPROFILE%) with no timeout during startup discovery. When the WSL->Windows interop pipe is wedged, cmd.exe never returns and the synchronous spawn blocks the JS thread forever, so the TUI never paints and no log is written. Route both best-effort probes (cmd.exe and wslpath) through a shared runHostProbe() helper that spawns under a 500ms hard timeout with SIGKILL and reports a killed/non-zero exit as "host home unavailable", so discovery falls back to the Linux $HOME/~/.omp candidates instead of hanging. Fixes #8402 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/discovery/agents.ts | 45 +++++++++++++------ packages/coding-agent/test/skills.test.ts | 23 +++++++++- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7a0d0d93c..930ae2867 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -24,6 +24,7 @@ ### Fixed +- Fixed WSL2 startup hanging forever when the Windows interop pipe is wedged: the host-home probe added for #3779 ran `Bun.spawnSync(["cmd.exe", "/d", "/c", "echo", "%USERPROFILE%"])` (and `wslpath`) with no timeout, so a stuck `cmd.exe` blocked the whole startup thread before the TUI painted or any log was written. Best-effort discovery probes now run under a 500ms hard timeout (SIGKILL) and treat a killed/non-zero exit as "host home unavailable", falling back to the Linux `$HOME`/`~/.omp` candidates ([#8402](https://github.com/can1357/oh-my-pi/issues/8402)). - Refined interrupted-turn continuity prompts by omitting reasoning fragments under 60 characters, relying on native signed or encrypted thinking when available, and framing preserved text as a natural user interruption. - Fixed session-title generation regressing after prompt condensation: the telegraphic rewrite of `title-system.md` garbled small-model output (invented names, punctuation-only titles). Restored plain-sentence phrasing with a name-fidelity instruction, pinned the online title request to greedy decoding, and rejected punctuation-only titles in normalization. - Fixed Agent Control Center failing to open when an agent model override is configured as a YAML array. ([#8201](https://github.com/can1357/oh-my-pi/issues/8201)) diff --git a/packages/coding-agent/src/discovery/agents.ts b/packages/coding-agent/src/discovery/agents.ts index 1d5a6fae5..b6d3837bd 100644 --- a/packages/coding-agent/src/discovery/agents.ts +++ b/packages/coding-agent/src/discovery/agents.ts @@ -54,9 +54,33 @@ function convertWindowsPathToDefaultWslMount(windowsPath: string): string | unde return path.posix.join("/mnt", drive.toLowerCase(), ...segments); } -function resolveWithWslPath(windowsPath: string): string | undefined { +/** + * Hard cap for best-effort host-discovery probes. + * + * WSL→Windows interop can wedge indefinitely (issue #8402): a synchronous + * spawn with no timeout blocks the whole startup thread before the TUI paints + * or any log file is created. The probe result only ever augments discovery + * with an extra host-home candidate, so a few hundred milliseconds is a + * generous ceiling — past it we treat the host as unavailable. + */ +const HOST_PROBE_TIMEOUT_MS = 500; + +/** + * Run a best-effort discovery probe and return its trimmed stdout, or + * `undefined` when the command fails, produces no output, or exceeds + * {@link HOST_PROBE_TIMEOUT_MS}. On timeout the child is killed with SIGKILL so + * a wedged interop pipe cannot hang startup; the killed/non-zero exit is then + * reported as "unavailable" and discovery falls back to the Linux + * `$HOME`/`~/.omp` candidates. + */ +export function runHostProbe(cmd: string[]): string | undefined { try { - const result = Bun.spawnSync(["wslpath", "-u", windowsPath], { stdout: "pipe", stderr: "ignore" }); + const result = Bun.spawnSync(cmd, { + stdout: "pipe", + stderr: "ignore", + timeout: HOST_PROBE_TIMEOUT_MS, + killSignal: "SIGKILL", + }); if (result.exitCode !== 0) return undefined; const resolved = result.stdout.toString().trim(); return resolved.length > 0 ? resolved : undefined; @@ -65,18 +89,13 @@ function resolveWithWslPath(windowsPath: string): string | undefined { } } +function resolveWithWslPath(windowsPath: string): string | undefined { + return runHostProbe(["wslpath", "-u", windowsPath]); +} + function resolveWindowsUserProfile(): string | undefined { - try { - const result = Bun.spawnSync(["cmd.exe", "/d", "/c", "echo", "%USERPROFILE%"], { - stdout: "pipe", - stderr: "ignore", - }); - if (result.exitCode !== 0) return undefined; - const resolved = result.stdout.toString().trim(); - return resolved.length > 0 && resolved !== "%USERPROFILE%" ? resolved : undefined; - } catch { - return undefined; - } + const resolved = runHostProbe(["cmd.exe", "/d", "/c", "echo", "%USERPROFILE%"]); + return resolved && resolved !== "%USERPROFILE%" ? resolved : undefined; } /** Resolve the Windows host profile home exposed to WSL, if available. */ diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 6675f583f..23b7ca10c 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { type Skill as CapabilitySkill, skillCapability } from "@oh-my-pi/pi-coding-agent/capability/skill"; import { getCapability } from "@oh-my-pi/pi-coding-agent/discovery"; -import { getWslWindowsHomeCandidate } from "@oh-my-pi/pi-coding-agent/discovery/agents"; +import { getWslWindowsHomeCandidate, runHostProbe } from "@oh-my-pi/pi-coding-agent/discovery/agents"; import { loadSkills, loadSkillsFromDir, @@ -298,6 +298,27 @@ describe("skills", () => { expect(resolved).toBe("/mnt/c/Users/alice"); }); + it("kills a host probe that never exits instead of blocking startup (#8402)", () => { + // Integration test against real OS timer behavior: the contract is that + // runHostProbe's spawnSync `timeout` actually kills a genuinely blocked + // child. That is a native process-lifecycle effect the kernel drives, so + // fake timers cannot exercise it. The child would sleep a minute (stand-in + // for a wedged WSL->Windows interop pipe); the 500ms probe timeout must + // kill it and report "unavailable" rather than hang the calling thread. + const start = performance.now(); + const result = runHostProbe([process.execPath, "-e", "await Bun.sleep(60_000)"]); + const elapsed = performance.now() - start; + expect(result).toBeUndefined(); + // Loose bound: proves the probe returned via its own timeout, not via the + // child completing; a broken timeout would block far past this ceiling. + expect(elapsed).toBeLessThan(5_000); + }); + + it("returns trimmed stdout for a host probe that succeeds (#8402)", () => { + const result = runHostProbe([process.execPath, "-e", "process.stdout.write(' host-home ')"]); + expect(result).toBe("host-home"); + }); + it("respects an explicit enableAgentsUser: false (#2401)", async () => { const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-home-off-")); const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-cwd-off-"));