diff --git a/src/lib/onboard/forward-start.test.ts b/src/lib/onboard/forward-start.test.ts index 89d6e21343c..128da36fe13 100644 --- a/src/lib/onboard/forward-start.test.ts +++ b/src/lib/onboard/forward-start.test.ts @@ -391,6 +391,59 @@ describe("runDetachedForwardStartWithDiagnostics", () => { } }); + it("confirms a mux-delegated forward via the live-port probe when ssh exits under ControlMaster (#6099)", () => { + // Under `Host *` / `ControlMaster auto` ssh config, the spawned ssh client + // hands the -L forward to the ControlMaster mux daemon and exits; openshell + // 0.0.72+ reports the exit as a startup failure even though the mux daemon + // holds the listener and the dashboard is serving. + const fetchList = vi.fn().mockReturnValue(forwardListWith([])); + const spawn = vi.fn().mockImplementation(({ stderr }: { stderr: number }) => { + fs.writeSync( + stderr, + "Error: × ssh process started but local forward listener was not reachable\n" + + " ╰─▶ ssh exited before local forward listener opened on 127.0.0.1:18789\n", + ); + return { pid: 781 }; + }); + const sleep = vi.fn(); + const isPortListening = vi.fn().mockReturnValue(true); + + const result = runDetachedForwardStartWithDiagnostics( + spawn, + fetchList, + { port: 18789, sandboxName: "my-sandbox" }, + { overallTimeoutMs: 10_000, pollIntervalMs: 10, sleepMs: sleep, isPortListening }, + ); + + expect(result.ok).toBe(true); + expect(result.reason).toBe("ok-port-live"); + expect(result.pid).toBe(781); + expect(isPortListening).toHaveBeenCalledWith(18789); + }); + + it("keeps waiting (then times out) when ssh exits under ControlMaster but the port is not live (#6099)", () => { + // A genuinely failed ssh (auth error, refused connection) produces the same + // exit diagnostic without a live listener — the fallback must not confirm. + const fetchList = vi.fn().mockReturnValue(forwardListWith([])); + const spawn = vi.fn().mockImplementation(({ stderr }: { stderr: number }) => { + fs.writeSync(stderr, "ssh exited before local forward listener opened on 127.0.0.1:18789\n"); + return { pid: 782 }; + }); + const sleep = vi.fn(); + const isPortListening = vi.fn().mockReturnValue(false); + + const result = runDetachedForwardStartWithDiagnostics( + spawn, + fetchList, + { port: 18789, sandboxName: "my-sandbox" }, + { overallTimeoutMs: 30, pollIntervalMs: 10, sleepMs: sleep, isPortListening }, + ); + + expect(result.ok).toBe(false); + expect(result.reason).toBe("timeout"); + expect(isPortListening).toHaveBeenCalled(); + }); + it("keeps waiting (then times out) when openshell reports untracked but the port is not live", () => { const fetchList = vi.fn().mockReturnValue(forwardListWith([])); const spawn = vi.fn().mockImplementation(({ stderr }: { stderr: number }) => { @@ -533,6 +586,20 @@ describe("looksLikeUntrackedForward", () => { expect(looksLikeUntrackedForward("forward may be running but is not tracked")).toBe(true); }); + it("matches openshell 0.0.72's mux-delegated ssh exit error (#6099)", () => { + expect( + looksLikeUntrackedForward( + "Error: × ssh process started but local forward listener was not reachable\n" + + " ╰─▶ ssh exited before local forward listener opened on 127.0.0.1:18789", + ), + ).toBe(true); + expect( + looksLikeUntrackedForward( + "ssh exited before local forward listener opened on 127.0.0.1:8642", + ), + ).toBe(true); + }); + it("returns false for unrelated diagnostics", () => { expect(looksLikeUntrackedForward("forward did not appear in list within 180000ms")).toBe(false); expect(looksLikeUntrackedForward("")).toBe(false); diff --git a/src/lib/onboard/forward-start.ts b/src/lib/onboard/forward-start.ts index b99ec07532a..1b868a68d06 100644 --- a/src/lib/onboard/forward-start.ts +++ b/src/lib/onboard/forward-start.ts @@ -77,10 +77,16 @@ export function looksLikeForwardPortConflict(diagnostic: string): boolean { * established the tunnel but could not discover/track the backgrounded * process — so the forward is running yet never appears in `openshell forward * list`. Observed on macOS hosts backed by Colima, where the remote bind - * completes *after* openshell's one-shot discovery probe. See GitHub #6099. + * completes *after* openshell's one-shot discovery probe, and on hosts whose + * ssh config applies `ControlMaster auto` to the sandbox host: the spawned + * ssh client delegates the -L forward to the ControlMaster mux daemon and + * exits, which openshell 0.0.72+ reports as "ssh exited before local forward + * listener opened" even though the mux daemon holds the listener and serves + * traffic. Confirmation still requires the live-port probe, so a genuinely + * failed ssh (closed port) keeps timing out as before. See GitHub #6099. */ export function looksLikeUntrackedForward(diagnostic: string): boolean { - return /could not discover backgrounded ssh process|forward may be running but is not tracked/i.test( + return /could not discover backgrounded ssh process|forward may be running but is not tracked|ssh exited before local forward listener opened|local forward listener was not reachable/i.test( diagnostic, ); }