Skip to content

Commit abf2652

Browse files
authored
fix: improve SSH process discovery and sleep/wake recovery (#846)
- Detect SSH port changes in the Remote SSH log to reset discovery backoff after sleep/wake. - Re-register the label formatter when the SSH PID changes. - Bump `find-process` to 2.1.1: uses `ss` -> `netstat` -> `lsof` on Linux, `netstat` -> `lsof` on macOS, fixing systems where `netstat` was unavailable and the SSH PID could not be resolved (broke network info display and log viewing). Closes #845
1 parent 7f147ab commit abf2652

File tree

7 files changed

+126
-26
lines changed

7 files changed

+126
-26
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## Unreleased
44

5+
### Fixed
6+
7+
- SSH connections now recover faster after laptop sleep/wake by detecting port changes
8+
and re-registering the label formatter.
9+
- SSH process discovery now uses `ss` -> `netstat` -> `lsof` on Linux
10+
and `netstat` -> `lsof` on macOS, fixing systems where `netstat` was unavailable
11+
and the SSH PID could not be resolved, which broke network info display and log viewing.
12+
513
## [v1.14.1-pre](https://github.com/coder/vscode-coder/releases/tag/v1.14.1-pre) 2026-03-16
614

715
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@
507507
"axios": "1.13.6",
508508
"date-fns": "catalog:",
509509
"eventsource": "^4.1.0",
510-
"find-process": "^2.1.0",
510+
"find-process": "^2.1.1",
511511
"jsonc-parser": "^3.3.1",
512512
"openpgp": "^6.3.0",
513513
"pretty-bytes": "^7.1.0",

pnpm-lock.yaml

Lines changed: 12 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/cliManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ export class CliManager {
232232
): Promise<boolean> {
233233
const choice = await vscodeProposed.window.showErrorMessage(
234234
`${reason}. Run version ${version} anyway?`,
235+
{ modal: true, useCustom: true },
235236
"Run",
236237
);
237238
return choice === "Run";

src/remote/remote.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -512,20 +512,27 @@ export class Remote {
512512

513513
this.commands.workspaceLogPath = sshMonitor.getLogFilePath();
514514

515+
const reregisterLabelFormatter = () => {
516+
labelFormatterDisposable.dispose();
517+
labelFormatterDisposable = this.registerLabelFormatter(
518+
remoteAuthority,
519+
workspace.owner_name,
520+
workspace.name,
521+
agent.name,
522+
);
523+
};
524+
515525
disposables.push(
516526
sshMonitor.onLogFilePathChange((newPath) => {
517527
this.commands.workspaceLogPath = newPath;
518528
}),
529+
// Re-register label formatter when SSH process reconnects after sleep/wake
530+
sshMonitor.onPidChange(() => {
531+
reregisterLabelFormatter();
532+
}),
519533
// Register the label formatter again because SSH overrides it!
520534
vscode.extensions.onDidChange(() => {
521-
// Dispose previous label formatter
522-
labelFormatterDisposable.dispose();
523-
labelFormatterDisposable = this.registerLabelFormatter(
524-
remoteAuthority,
525-
workspace.owner_name,
526-
workspace.name,
527-
agent.name,
528-
);
535+
reregisterLabelFormatter();
529536
}),
530537
...(await this.createAgentMetadataStatusBar(agent, workspaceClient)),
531538
);

src/remote/sshProcess.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export class SshProcessMonitor implements vscode.Disposable {
279279
this.options;
280280
let attempt = 0;
281281
let currentBackoff = discoveryPollIntervalMs;
282+
let lastFoundPort: number | undefined;
282283

283284
while (!this.disposed) {
284285
attempt++;
@@ -289,9 +290,25 @@ export class SshProcessMonitor implements vscode.Disposable {
289290
);
290291
}
291292

292-
const pidByPort = await this.findSshProcessByPort();
293-
if (pidByPort !== undefined) {
294-
this.setCurrentPid(pidByPort);
293+
const { pid, port } = await this.findSshProcessByPort();
294+
295+
// Track port changes to reset backoff after VS Code reconnection
296+
const portChanged =
297+
lastFoundPort !== undefined &&
298+
port !== undefined &&
299+
port !== lastFoundPort;
300+
if (portChanged) {
301+
logger.debug(
302+
`SSH port changed in log file: ${lastFoundPort} -> ${port}`,
303+
);
304+
currentBackoff = discoveryPollIntervalMs;
305+
}
306+
if (port !== undefined) {
307+
lastFoundPort = port;
308+
}
309+
310+
if (pid !== undefined) {
311+
this.setCurrentPid(pid);
295312
this.startMonitoring();
296313
return;
297314
}
@@ -305,7 +322,10 @@ export class SshProcessMonitor implements vscode.Disposable {
305322
* Finds SSH process by parsing the Remote SSH extension's log to get the port.
306323
* This is more accurate as each VS Code window has a unique port.
307324
*/
308-
private async findSshProcessByPort(): Promise<number | undefined> {
325+
private async findSshProcessByPort(): Promise<{
326+
pid?: number;
327+
port?: number;
328+
}> {
309329
const { codeLogDir, remoteSshExtensionId, logger } = this.options;
310330

311331
try {
@@ -315,27 +335,31 @@ export class SshProcessMonitor implements vscode.Disposable {
315335
logger,
316336
);
317337
if (!logPath) {
318-
return undefined;
338+
logger.debug("No Remote SSH log file found");
339+
return {};
319340
}
320341

321342
const logContent = await fs.readFile(logPath, "utf8");
322-
this.options.logger.debug(`Read Remote SSH log file:`, logPath);
343+
logger.debug(`Read Remote SSH log file:`, logPath);
323344

324345
const port = findPort(logContent);
325346
if (!port) {
326-
return undefined;
347+
logger.debug(`No SSH port found in log file: ${logPath}`);
348+
return {};
327349
}
328-
this.options.logger.debug(`Found SSH port ${port} in log file`);
350+
351+
logger.debug(`Found SSH port ${port} in log file`);
329352

330353
const processes = await find("port", port);
331354
if (processes.length === 0) {
332-
return undefined;
355+
logger.debug(`No process found listening on port ${port}`);
356+
return { port };
333357
}
334358

335-
return processes[0].pid;
359+
return { pid: processes[0].pid, port };
336360
} catch (error) {
337361
logger.debug("SSH process search failed", error);
338-
return undefined;
362+
return {};
339363
}
340364
}
341365

@@ -579,6 +603,7 @@ async function findRemoteSshLogPath(
579603

580604
if (outputDirs.length > 0) {
581605
const outputPath = path.join(logsParentDir, outputDirs[0]);
606+
logger.debug(`Using Remote SSH log directory: ${outputPath}`);
582607
const remoteSshLog = await findSshLogInDir(outputPath);
583608
if (remoteSshLog) {
584609
return remoteSshLog;

test/unit/remote/sshProcess.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe("SshProcessMonitor", () => {
3636
});
3737

3838
afterEach(() => {
39+
vi.useRealTimers();
3940
for (const m of activeMonitors) {
4041
m.dispose();
4142
}
@@ -204,6 +205,58 @@ describe("SshProcessMonitor", () => {
204205
expect(pids).toContain(888);
205206
});
206207

208+
it("resets backoff when port changes in log file", async () => {
209+
vi.useFakeTimers();
210+
211+
vol.fromJSON({
212+
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":
213+
"-> socksPort 11111 ->",
214+
});
215+
vi.mocked(find).mockResolvedValue([]);
216+
217+
const pollInterval = 100;
218+
const logger = createMockLogger();
219+
const monitor = createMonitor({
220+
codeLogDir: "/logs/window1",
221+
discoveryPollIntervalMs: pollInterval,
222+
maxDiscoveryBackoffMs: 10_000,
223+
logger,
224+
});
225+
226+
const pids: Array<number | undefined> = [];
227+
monitor.onPidChange((pid) => pids.push(pid));
228+
229+
// Backoff doubles each iteration: 100, 200, 400, 800, 1600, ...
230+
// Total after 5 iterations = pollInterval * (2^5 - 1) = 3100ms
231+
const fiveIterationsMs = pollInterval * (2 ** 5 - 1);
232+
await vi.advanceTimersByTimeAsync(fiveIterationsMs - 1);
233+
expect(logger.debug).toHaveBeenCalledWith(
234+
"No process found listening on port 11111",
235+
);
236+
237+
// Change port, simulates VS Code reconnection after sleep/wake
238+
vol.fromJSON({
239+
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":
240+
"-> socksPort 22222 ->",
241+
});
242+
243+
// Trigger next iteration: detects port change, resets backoff, no pid
244+
await vi.advanceTimersByTimeAsync(1);
245+
expect(logger.debug).toHaveBeenCalledWith(
246+
"SSH port changed in log file: 11111 -> 22222",
247+
);
248+
249+
// Process becomes available
250+
vi.mocked(find).mockResolvedValue([
251+
{ pid: 555, ppid: 1, name: "ssh", cmd: "ssh" },
252+
]);
253+
254+
// With reset backoff, process found within 2 poll intervals.
255+
// Without reset, backoff would be pollInterval * 2^5 = 3200ms.
256+
await vi.advanceTimersByTimeAsync(pollInterval * 2);
257+
expect(pids).toContain(555);
258+
});
259+
207260
it("does not fire event when same process is found after stale check", async () => {
208261
vol.fromJSON({
209262
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":

0 commit comments

Comments
 (0)