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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 12 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/core/cliManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export class CliManager {
): Promise<boolean> {
const choice = await vscodeProposed.window.showErrorMessage(
`${reason}. Run version ${version} anyway?`,
{ modal: true, useCustom: true },
"Run",
);
return choice === "Run";
Expand Down
23 changes: 15 additions & 8 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
);
Expand Down
47 changes: 36 additions & 11 deletions src/remote/sshProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand All @@ -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;
}
Expand All @@ -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<number | undefined> {
private async findSshProcessByPort(): Promise<{
pid?: number;
port?: number;
}> {
const { codeLogDir, remoteSshExtensionId, logger } = this.options;

try {
Expand All @@ -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 {};
}
}

Expand Down Expand Up @@ -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;
Expand Down
53 changes: 53 additions & 0 deletions test/unit/remote/sshProcess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe("SshProcessMonitor", () => {
});

afterEach(() => {
vi.useRealTimers();
for (const m of activeMonitors) {
m.dispose();
}
Expand Down Expand Up @@ -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<number | undefined> = [];
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":
Expand Down