Skip to content
Merged
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
67 changes: 67 additions & 0 deletions src/lib/onboard/forward-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions src/lib/onboard/forward-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down