From 645867e9dc256b9157353394ec2141334c8b99e3 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 17 Mar 2026 16:32:00 +0300 Subject: [PATCH 1/5] feat: detect SSH port changes, reconnect WebSockets on PID change Add port-change detection to SshProcessMonitor so backoff resets when VS Code writes a new port after reconnection. Extract label formatter re-registration into a shared helper and trigger it (along with WebSocket reconnection) when the SSH process PID changes. Also makes the CLI version mismatch prompt modal. --- src/api/coderApi.ts | 17 +++++ src/core/cliManager.ts | 1 + src/remote/remote.ts | 25 ++++--- src/remote/sshProcess.ts | 47 ++++++++++--- test/unit/remote/sshProcess.test.ts | 103 ++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 19 deletions(-) diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 70a0c5cc..da410f6e 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -154,6 +154,23 @@ export class CoderApi extends Api implements vscode.Disposable { this.setCredentials(host, this.getSessionToken()); }; + /** + * Force-reconnect all sockets in CONNECTED state, which may be in a TCP + * half-open state after sleep/wake. Sockets in other states (AWAITING_RETRY, + * DISCONNECTED) are left alone as they already have their own retry logic. + */ + reconnectAllConnected(reason: string): void { + const stale = [...this.reconnectingSockets].filter( + (s) => s.state === ConnectionState.CONNECTED, + ); + if (stale.length > 0) { + this.output.info(`Reconnecting ${stale.length} WebSocket(s): ${reason}`); + for (const socket of stale) { + socket.reconnect(); + } + } + } + /** * Permanently dispose all WebSocket connections. * This clears handlers and prevents reconnection. diff --git a/src/core/cliManager.ts b/src/core/cliManager.ts index 060f001e..7f054d98 100644 --- a/src/core/cliManager.ts +++ b/src/core/cliManager.ts @@ -232,6 +232,7 @@ export class CliManager { ): Promise { const choice = await vscodeProposed.window.showErrorMessage( `${reason}. Run version ${version} anyway?`, + { modal: true, useCustom: true }, "Run", ); return choice === "Run"; diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 395a9da1..6a6f8c57 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -512,20 +512,29 @@ export class Remote { this.commands.workspaceLogPath = sshMonitor.getLogFilePath(); + const reregisterLabelFormatter = () => { + labelFormatterDisposable.dispose(); + labelFormatterDisposable = this.registerLabelFormatter( + remoteAuthority, + workspace.owner_name, + workspace.name, + agent.name, + ); + }; + disposables.push( sshMonitor.onLogFilePathChange((newPath) => { this.commands.workspaceLogPath = newPath; }), + // Re-register label formatter when SSH process reconnects after sleep/wake + sshMonitor.onPidChange(() => { + reregisterLabelFormatter(); + // Reconnect WebSockets that may be in TCP half-open state + workspaceClient.reconnectAllConnected("SSH process changed"); + }), // Register the label formatter again because SSH overrides it! vscode.extensions.onDidChange(() => { - // Dispose previous label formatter - labelFormatterDisposable.dispose(); - labelFormatterDisposable = this.registerLabelFormatter( - remoteAuthority, - workspace.owner_name, - workspace.name, - agent.name, - ); + reregisterLabelFormatter(); }), ...(await this.createAgentMetadataStatusBar(agent, workspaceClient)), ); diff --git a/src/remote/sshProcess.ts b/src/remote/sshProcess.ts index 4b444599..c53c31a8 100644 --- a/src/remote/sshProcess.ts +++ b/src/remote/sshProcess.ts @@ -279,6 +279,7 @@ export class SshProcessMonitor implements vscode.Disposable { this.options; let attempt = 0; let currentBackoff = discoveryPollIntervalMs; + let lastFoundPort: number | undefined; while (!this.disposed) { attempt++; @@ -289,9 +290,25 @@ export class SshProcessMonitor implements vscode.Disposable { ); } - const pidByPort = await this.findSshProcessByPort(); - if (pidByPort !== undefined) { - this.setCurrentPid(pidByPort); + const { pid, port } = await this.findSshProcessByPort(); + + // Track port changes to reset backoff after VS Code reconnection + const portChanged = + lastFoundPort !== undefined && + port !== undefined && + port !== lastFoundPort; + if (portChanged) { + logger.debug( + `SSH port changed in log file: ${lastFoundPort} -> ${port}`, + ); + currentBackoff = discoveryPollIntervalMs; + } + if (port !== undefined) { + lastFoundPort = port; + } + + if (pid !== undefined) { + this.setCurrentPid(pid); this.startMonitoring(); return; } @@ -305,7 +322,10 @@ export class SshProcessMonitor implements vscode.Disposable { * Finds SSH process by parsing the Remote SSH extension's log to get the port. * This is more accurate as each VS Code window has a unique port. */ - private async findSshProcessByPort(): Promise { + private async findSshProcessByPort(): Promise<{ + pid?: number; + port?: number; + }> { const { codeLogDir, remoteSshExtensionId, logger } = this.options; try { @@ -315,27 +335,31 @@ export class SshProcessMonitor implements vscode.Disposable { logger, ); if (!logPath) { - return undefined; + logger.debug("No Remote SSH log file found"); + return {}; } const logContent = await fs.readFile(logPath, "utf8"); - this.options.logger.debug(`Read Remote SSH log file:`, logPath); + logger.debug(`Read Remote SSH log file:`, logPath); const port = findPort(logContent); if (!port) { - return undefined; + logger.debug(`No SSH port found in log file: ${logPath}`); + return {}; } - this.options.logger.debug(`Found SSH port ${port} in log file`); + + logger.debug(`Found SSH port ${port} in log file`); const processes = await find("port", port); if (processes.length === 0) { - return undefined; + logger.debug(`No process found listening on port ${port}`); + return { port }; } - return processes[0].pid; + return { pid: processes[0].pid, port }; } catch (error) { logger.debug("SSH process search failed", error); - return undefined; + return {}; } } @@ -579,6 +603,7 @@ async function findRemoteSshLogPath( if (outputDirs.length > 0) { const outputPath = path.join(logsParentDir, outputDirs[0]); + logger.debug(`Using Remote SSH log directory: ${outputPath}`); const remoteSshLog = await findSshLogInDir(outputPath); if (remoteSshLog) { return remoteSshLog; diff --git a/test/unit/remote/sshProcess.test.ts b/test/unit/remote/sshProcess.test.ts index 4047b50e..102fc6e7 100644 --- a/test/unit/remote/sshProcess.test.ts +++ b/test/unit/remote/sshProcess.test.ts @@ -204,6 +204,109 @@ describe("SshProcessMonitor", () => { expect(pids).toContain(888); }); + it("resets backoff when port changes in log file", async () => { + // Start with port 11111, no process on it + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 11111 ->", + }); + + // No process found initially, then process found after port change + vi.mocked(find).mockResolvedValue([]); + + const logger = createMockLogger(); + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + discoveryPollIntervalMs: 10, + maxDiscoveryBackoffMs: 5000, + logger, + }); + + // Wait for several search attempts with backoff growing + await new Promise((r) => setTimeout(r, 80)); + + // Now change the port in the log — simulates VS Code reconnection + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 22222 ->", + }); + + // After port change, make find return a process + vi.mocked(find).mockResolvedValue([ + { pid: 555, ppid: 1, name: "ssh", cmd: "ssh" }, + ]); + + // The process should be found quickly since backoff resets on port change + const pid = await waitForEvent(monitor.onPidChange, 2000); + expect(pid).toBe(555); + + // Verify port change was logged + expect(logger.debug).toHaveBeenCalledWith( + "SSH port changed in log file: 11111 -> 22222", + ); + }); + + it("logs when port found but no process listening", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + }); + + vi.mocked(find).mockResolvedValue([]); + + const logger = createMockLogger(); + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + logger, + }); + + // Wait for at least one search attempt + await new Promise((r) => setTimeout(r, 30)); + monitor.dispose(); + + expect(logger.debug).toHaveBeenCalledWith( + "No process found listening on port 12345", + ); + }); + + it("detects port change across search attempts", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 11111 ->", + }); + + // First attempt: port 11111 with no process + // After port changes: port 22222 with process + let callCount = 0; + vi.mocked(find).mockImplementation(() => { + callCount++; + if (callCount >= 3) { + return Promise.resolve([ + { pid: 777, ppid: 1, name: "ssh", cmd: "ssh" }, + ]); + } + return Promise.resolve([]); + }); + + const logger = createMockLogger(); + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + logger, + }); + + // Wait for first attempts with port 11111 + await new Promise((r) => setTimeout(r, 30)); + + // Change port + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 22222 ->", + }); + + const pid = await waitForEvent(monitor.onPidChange, 2000); + expect(pid).toBe(777); + }); + it("does not fire event when same process is found after stale check", async () => { vol.fromJSON({ "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": From fbbd19fb63f959bd91da84d8e3382b2b553d9cd4 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 17 Mar 2026 19:12:09 +0300 Subject: [PATCH 2/5] fix: improve SSH process discovery and sleep/wake recovery Bump find-process to 2.1.1 so process discovery uses ss -> netstat -> lsof on Linux and netstat -> lsof on macOS. Previously, systems without netstat could not resolve the SSH PID, breaking network info display and log viewing. --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- pnpm-lock.yaml | 18 ++++++++++++------ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e8e1e5..25c737de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Fixed + +- SSH connections now recover faster after laptop sleep/wake by detecting port changes, + re-registering the label formatter, and refreshing WebSockets automatically. +- SSH process discovery now uses `ss` -> `netstat` -> `lsof` on Linux + and `netstat` -> `lsof` on macOS, fixing systems where `netstat` was unavailable + and the SSH PID could not be resolved, which broke network info display and log viewing. + ## [v1.14.1-pre](https://github.com/coder/vscode-coder/releases/tag/v1.14.1-pre) 2026-03-16 ### Added diff --git a/package.json b/package.json index c866d1ea..a78b2b5b 100644 --- a/package.json +++ b/package.json @@ -507,7 +507,7 @@ "axios": "1.13.6", "date-fns": "catalog:", "eventsource": "^4.1.0", - "find-process": "^2.1.0", + "find-process": "^2.1.1", "jsonc-parser": "^3.3.1", "openpgp": "^6.3.0", "pretty-bytes": "^7.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a1a5a92..bd4f4a16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,8 +77,8 @@ importers: specifier: ^4.1.0 version: 4.1.0 find-process: - specifier: ^2.1.0 - version: 2.1.0 + specifier: ^2.1.1 + version: 2.1.1 jsonc-parser: specifier: ^3.3.1 version: 3.3.1 @@ -2002,6 +2002,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + comment-parser@1.4.1: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} @@ -2493,8 +2497,8 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-process@2.1.0: - resolution: {integrity: sha512-uf1F6mFDAltKaVHf4/zlXstW/k01qPwv+OB6bMRnuIycO6dTmQo+sUW1Uj/xJzGmmVarYW4ZM7TBBbBy6f/y0g==} + find-process@2.1.1: + resolution: {integrity: sha512-SrQDx3QhlmHM90iqn9rdjCQcw/T+WlpOkHFsjoRgB+zTpDfltNA1VSNYeYELwhUTJy12UFxqjWhmhOrJc+o4sA==} hasBin: true find-up@5.0.0: @@ -6195,6 +6199,8 @@ snapshots: commander@12.1.0: {} + commander@14.0.3: {} + comment-parser@1.4.1: {} compare-versions@6.1.1: {} @@ -6778,10 +6784,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 - find-process@2.1.0: + find-process@2.1.1: dependencies: chalk: 4.1.2 - commander: 12.1.0 + commander: 14.0.3 loglevel: 1.9.2 find-up@5.0.0: From ec63c8a91ce13d5a581e06d5fe42e856c6eaca43 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 18 Mar 2026 16:10:50 +0300 Subject: [PATCH 3/5] Add unit tests for reconnectAllConnected --- test/unit/api/coderApi.test.ts | 38 ++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts index f4377aae..9c31b2e3 100644 --- a/test/unit/api/coderApi.test.ts +++ b/test/unit/api/coderApi.test.ts @@ -795,6 +795,44 @@ describe("CoderApi", () => { }); }); + describe("reconnectAllConnected", () => { + const tick = () => new Promise((resolve) => setImmediate(resolve)); + + it("reconnects sockets in CONNECTED state", async () => { + const { sockets } = setupAutoOpeningWebSocket(); + api = createApi(CODER_URL, AXIOS_TOKEN); + await api.watchAgentMetadata(AGENT_ID); + await tick(); + + api.reconnectAllConnected("sleep/wake recovery"); + await tick(); + + expect(sockets[0].close).toHaveBeenCalledWith( + 1000, + "Replacing connection", + ); + expect(sockets).toHaveLength(2); + }); + + it.each([ + ["AWAITING_RETRY", 1006, "Abnormal closure"], + ["DISCONNECTED", 1002, "Protocol error"], + ])("does not reconnect %s sockets", async (_state, code, reason) => { + const { sockets, handlers } = setupAutoOpeningWebSocket(); + api = createApi(CODER_URL, AXIOS_TOKEN); + await api.watchAgentMetadata(AGENT_ID); + await tick(); + + handlers["close"]?.({ code, reason }); + await tick(); + + api.reconnectAllConnected("sleep/wake recovery"); + await tick(); + + expect(sockets).toHaveLength(1); + }); + }); + describe("Configuration Change Reconnection", () => { const tick = () => new Promise((resolve) => setImmediate(resolve)); From 48d7b3eec7e881d1e7a56e6710d4dfdf9c6285ad Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 18 Mar 2026 23:38:54 +0300 Subject: [PATCH 4/5] fix: remove eager WebSocket reconnect on SSH PID change Server-side heartbeat (15s ping) already handles dead connections. WebSockets use a separate HTTPS path from the SSH tunnel, so PID changes don't reliably indicate WebSocket issues. --- CHANGELOG.md | 4 ++-- src/api/coderApi.ts | 17 --------------- src/remote/remote.ts | 2 -- test/unit/api/coderApi.test.ts | 38 ---------------------------------- 4 files changed, 2 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25c737de..a865b2bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ ### Fixed -- SSH connections now recover faster after laptop sleep/wake by detecting port changes, - re-registering the label formatter, and refreshing WebSockets automatically. +- SSH connections now recover faster after laptop sleep/wake by detecting port changes + and re-registering the label formatter. - SSH process discovery now uses `ss` -> `netstat` -> `lsof` on Linux and `netstat` -> `lsof` on macOS, fixing systems where `netstat` was unavailable and the SSH PID could not be resolved, which broke network info display and log viewing. diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index da410f6e..70a0c5cc 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -154,23 +154,6 @@ export class CoderApi extends Api implements vscode.Disposable { this.setCredentials(host, this.getSessionToken()); }; - /** - * Force-reconnect all sockets in CONNECTED state, which may be in a TCP - * half-open state after sleep/wake. Sockets in other states (AWAITING_RETRY, - * DISCONNECTED) are left alone as they already have their own retry logic. - */ - reconnectAllConnected(reason: string): void { - const stale = [...this.reconnectingSockets].filter( - (s) => s.state === ConnectionState.CONNECTED, - ); - if (stale.length > 0) { - this.output.info(`Reconnecting ${stale.length} WebSocket(s): ${reason}`); - for (const socket of stale) { - socket.reconnect(); - } - } - } - /** * Permanently dispose all WebSocket connections. * This clears handlers and prevents reconnection. diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 6a6f8c57..d4ba7fbb 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -529,8 +529,6 @@ export class Remote { // Re-register label formatter when SSH process reconnects after sleep/wake sshMonitor.onPidChange(() => { reregisterLabelFormatter(); - // Reconnect WebSockets that may be in TCP half-open state - workspaceClient.reconnectAllConnected("SSH process changed"); }), // Register the label formatter again because SSH overrides it! vscode.extensions.onDidChange(() => { diff --git a/test/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts index 9c31b2e3..f4377aae 100644 --- a/test/unit/api/coderApi.test.ts +++ b/test/unit/api/coderApi.test.ts @@ -795,44 +795,6 @@ describe("CoderApi", () => { }); }); - describe("reconnectAllConnected", () => { - const tick = () => new Promise((resolve) => setImmediate(resolve)); - - it("reconnects sockets in CONNECTED state", async () => { - const { sockets } = setupAutoOpeningWebSocket(); - api = createApi(CODER_URL, AXIOS_TOKEN); - await api.watchAgentMetadata(AGENT_ID); - await tick(); - - api.reconnectAllConnected("sleep/wake recovery"); - await tick(); - - expect(sockets[0].close).toHaveBeenCalledWith( - 1000, - "Replacing connection", - ); - expect(sockets).toHaveLength(2); - }); - - it.each([ - ["AWAITING_RETRY", 1006, "Abnormal closure"], - ["DISCONNECTED", 1002, "Protocol error"], - ])("does not reconnect %s sockets", async (_state, code, reason) => { - const { sockets, handlers } = setupAutoOpeningWebSocket(); - api = createApi(CODER_URL, AXIOS_TOKEN); - await api.watchAgentMetadata(AGENT_ID); - await tick(); - - handlers["close"]?.({ code, reason }); - await tick(); - - api.reconnectAllConnected("sleep/wake recovery"); - await tick(); - - expect(sockets).toHaveLength(1); - }); - }); - describe("Configuration Change Reconnection", () => { const tick = () => new Promise((resolve) => setImmediate(resolve)); From 4e84603c3382efaa058bc664c90905d6453fb6b4 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 19 Mar 2026 01:54:49 +0300 Subject: [PATCH 5/5] test: consolidate port-change tests into single deterministic test Replace 3 timing-dependent real-timer tests with one fake-timer test that covers port-change detection, debug logging, and backoff reset. --- test/unit/remote/sshProcess.test.ts | 102 +++++++--------------------- 1 file changed, 26 insertions(+), 76 deletions(-) diff --git a/test/unit/remote/sshProcess.test.ts b/test/unit/remote/sshProcess.test.ts index 102fc6e7..8b401fd0 100644 --- a/test/unit/remote/sshProcess.test.ts +++ b/test/unit/remote/sshProcess.test.ts @@ -36,6 +36,7 @@ describe("SshProcessMonitor", () => { }); afterEach(() => { + vi.useRealTimers(); for (const m of activeMonitors) { m.dispose(); } @@ -205,106 +206,55 @@ describe("SshProcessMonitor", () => { }); it("resets backoff when port changes in log file", async () => { - // Start with port 11111, no process on it + vi.useFakeTimers(); + vol.fromJSON({ "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": "-> socksPort 11111 ->", }); - - // No process found initially, then process found after port change vi.mocked(find).mockResolvedValue([]); + const pollInterval = 100; const logger = createMockLogger(); const monitor = createMonitor({ codeLogDir: "/logs/window1", - discoveryPollIntervalMs: 10, - maxDiscoveryBackoffMs: 5000, + discoveryPollIntervalMs: pollInterval, + maxDiscoveryBackoffMs: 10_000, logger, }); - // Wait for several search attempts with backoff growing - await new Promise((r) => setTimeout(r, 80)); - - // Now change the port in the log — simulates VS Code reconnection - vol.fromJSON({ - "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": - "-> socksPort 22222 ->", - }); - - // After port change, make find return a process - vi.mocked(find).mockResolvedValue([ - { pid: 555, ppid: 1, name: "ssh", cmd: "ssh" }, - ]); - - // The process should be found quickly since backoff resets on port change - const pid = await waitForEvent(monitor.onPidChange, 2000); - expect(pid).toBe(555); + const pids: Array = []; + monitor.onPidChange((pid) => pids.push(pid)); - // Verify port change was logged + // Backoff doubles each iteration: 100, 200, 400, 800, 1600, ... + // Total after 5 iterations = pollInterval * (2^5 - 1) = 3100ms + const fiveIterationsMs = pollInterval * (2 ** 5 - 1); + await vi.advanceTimersByTimeAsync(fiveIterationsMs - 1); expect(logger.debug).toHaveBeenCalledWith( - "SSH port changed in log file: 11111 -> 22222", + "No process found listening on port 11111", ); - }); - it("logs when port found but no process listening", async () => { + // Change port, simulates VS Code reconnection after sleep/wake vol.fromJSON({ "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": - "-> socksPort 12345 ->", - }); - - vi.mocked(find).mockResolvedValue([]); - - const logger = createMockLogger(); - const monitor = createMonitor({ - codeLogDir: "/logs/window1", - logger, + "-> socksPort 22222 ->", }); - // Wait for at least one search attempt - await new Promise((r) => setTimeout(r, 30)); - monitor.dispose(); - + // Trigger next iteration: detects port change, resets backoff, no pid + await vi.advanceTimersByTimeAsync(1); expect(logger.debug).toHaveBeenCalledWith( - "No process found listening on port 12345", + "SSH port changed in log file: 11111 -> 22222", ); - }); - - it("detects port change across search attempts", async () => { - vol.fromJSON({ - "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": - "-> socksPort 11111 ->", - }); - - // First attempt: port 11111 with no process - // After port changes: port 22222 with process - let callCount = 0; - vi.mocked(find).mockImplementation(() => { - callCount++; - if (callCount >= 3) { - return Promise.resolve([ - { pid: 777, ppid: 1, name: "ssh", cmd: "ssh" }, - ]); - } - return Promise.resolve([]); - }); - const logger = createMockLogger(); - const monitor = createMonitor({ - codeLogDir: "/logs/window1", - logger, - }); - - // Wait for first attempts with port 11111 - await new Promise((r) => setTimeout(r, 30)); - - // Change port - vol.fromJSON({ - "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": - "-> socksPort 22222 ->", - }); + // Process becomes available + vi.mocked(find).mockResolvedValue([ + { pid: 555, ppid: 1, name: "ssh", cmd: "ssh" }, + ]); - const pid = await waitForEvent(monitor.onPidChange, 2000); - expect(pid).toBe(777); + // With reset backoff, process found within 2 poll intervals. + // Without reset, backoff would be pollInterval * 2^5 = 3200ms. + await vi.advanceTimersByTimeAsync(pollInterval * 2); + expect(pids).toContain(555); }); it("does not fire event when same process is found after stale check", async () => {