diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e8e1e5..a865b2bf 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 + 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. + ## [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: 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..d4ba7fbb 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -512,20 +512,27 @@ 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(); + }), // 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..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(); } @@ -204,6 +205,58 @@ describe("SshProcessMonitor", () => { expect(pids).toContain(888); }); + it("resets backoff when port changes in log file", async () => { + vi.useFakeTimers(); + + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 11111 ->", + }); + vi.mocked(find).mockResolvedValue([]); + + const pollInterval = 100; + const logger = createMockLogger(); + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + discoveryPollIntervalMs: pollInterval, + maxDiscoveryBackoffMs: 10_000, + logger, + }); + + const pids: Array = []; + monitor.onPidChange((pid) => pids.push(pid)); + + // 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( + "No process found listening on port 11111", + ); + + // Change port, simulates VS Code reconnection after sleep/wake + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 22222 ->", + }); + + // Trigger next iteration: detects port change, resets backoff, no pid + await vi.advanceTimersByTimeAsync(1); + expect(logger.debug).toHaveBeenCalledWith( + "SSH port changed in log file: 11111 -> 22222", + ); + + // Process becomes available + vi.mocked(find).mockResolvedValue([ + { pid: 555, ppid: 1, name: "ssh", cmd: "ssh" }, + ]); + + // 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 () => { vol.fromJSON({ "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":