Skip to content

Commit 9516e9f

Browse files
committed
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.
1 parent 0a7c58d commit 9516e9f

File tree

5 files changed

+176
-19
lines changed

5 files changed

+176
-19
lines changed

src/api/coderApi.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,25 @@ export class CoderApi extends Api implements vscode.Disposable {
154154
this.setCredentials(host, this.getSessionToken());
155155
};
156156

157+
/**
158+
* Force-reconnect all sockets in CONNECTED state, which may be in a TCP
159+
* half-open state after sleep/wake. Sockets in other states (AWAITING_RETRY,
160+
* DISCONNECTED) are left alone as they already have their own retry logic.
161+
*/
162+
reconnectAllConnected(reason: string): void {
163+
const stale = [...this.reconnectingSockets].filter(
164+
(s) => s.state === ConnectionState.CONNECTED,
165+
);
166+
if (stale.length > 0) {
167+
this.output.info(
168+
`Reconnecting ${stale.length} WebSocket(s): ${reason}`,
169+
);
170+
for (const socket of stale) {
171+
socket.reconnect();
172+
}
173+
}
174+
}
175+
157176
/**
158177
* Permanently dispose all WebSocket connections.
159178
* This clears handlers and prevents reconnection.

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: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -512,20 +512,29 @@ 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+
// Reconnect WebSockets that may be in TCP half-open state
533+
workspaceClient.reconnectAllConnected("SSH process changed");
534+
}),
519535
// Register the label formatter again because SSH overrides it!
520536
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-
);
537+
reregisterLabelFormatter();
529538
}),
530539
...(await this.createAgentMetadataStatusBar(agent, workspaceClient)),
531540
);

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: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,109 @@ describe("SshProcessMonitor", () => {
204204
expect(pids).toContain(888);
205205
});
206206

207+
it("resets backoff when port changes in log file", async () => {
208+
// Start with port 11111, no process on it
209+
vol.fromJSON({
210+
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":
211+
"-> socksPort 11111 ->",
212+
});
213+
214+
// No process found initially, then process found after port change
215+
vi.mocked(find).mockResolvedValue([]);
216+
217+
const logger = createMockLogger();
218+
const monitor = createMonitor({
219+
codeLogDir: "/logs/window1",
220+
discoveryPollIntervalMs: 10,
221+
maxDiscoveryBackoffMs: 5000,
222+
logger,
223+
});
224+
225+
// Wait for several search attempts with backoff growing
226+
await new Promise((r) => setTimeout(r, 80));
227+
228+
// Now change the port in the log — simulates VS Code reconnection
229+
vol.fromJSON({
230+
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":
231+
"-> socksPort 22222 ->",
232+
});
233+
234+
// After port change, make find return a process
235+
vi.mocked(find).mockResolvedValue([
236+
{ pid: 555, ppid: 1, name: "ssh", cmd: "ssh" },
237+
]);
238+
239+
// The process should be found quickly since backoff resets on port change
240+
const pid = await waitForEvent(monitor.onPidChange, 2000);
241+
expect(pid).toBe(555);
242+
243+
// Verify port change was logged
244+
expect(logger.debug).toHaveBeenCalledWith(
245+
"SSH port changed in log file: 11111 -> 22222",
246+
);
247+
});
248+
249+
it("logs when port found but no process listening", async () => {
250+
vol.fromJSON({
251+
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":
252+
"-> socksPort 12345 ->",
253+
});
254+
255+
vi.mocked(find).mockResolvedValue([]);
256+
257+
const logger = createMockLogger();
258+
const monitor = createMonitor({
259+
codeLogDir: "/logs/window1",
260+
logger,
261+
});
262+
263+
// Wait for at least one search attempt
264+
await new Promise((r) => setTimeout(r, 30));
265+
monitor.dispose();
266+
267+
expect(logger.debug).toHaveBeenCalledWith(
268+
"No process found listening on port 12345",
269+
);
270+
});
271+
272+
it("detects port change across search attempts", async () => {
273+
vol.fromJSON({
274+
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":
275+
"-> socksPort 11111 ->",
276+
});
277+
278+
// First attempt: port 11111 with no process
279+
// After port changes: port 22222 with process
280+
let callCount = 0;
281+
vi.mocked(find).mockImplementation(() => {
282+
callCount++;
283+
if (callCount >= 3) {
284+
return Promise.resolve([
285+
{ pid: 777, ppid: 1, name: "ssh", cmd: "ssh" },
286+
]);
287+
}
288+
return Promise.resolve([]);
289+
});
290+
291+
const logger = createMockLogger();
292+
const monitor = createMonitor({
293+
codeLogDir: "/logs/window1",
294+
logger,
295+
});
296+
297+
// Wait for first attempts with port 11111
298+
await new Promise((r) => setTimeout(r, 30));
299+
300+
// Change port
301+
vol.fromJSON({
302+
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":
303+
"-> socksPort 22222 ->",
304+
});
305+
306+
const pid = await waitForEvent(monitor.onPidChange, 2000);
307+
expect(pid).toBe(777);
308+
});
309+
207310
it("does not fire event when same process is found after stale check", async () => {
208311
vol.fromJSON({
209312
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":

0 commit comments

Comments
 (0)