From c48c39fe1e4abc550bec03712c35f28fe5f9d0e8 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 1 Dec 2025 15:05:53 +0300 Subject: [PATCH 1/5] Extract SSH process discovery and network status display into a dedicated SshProcessMonitor class. Add centralized Remote SSH extension detection to better support Cursor, Windsurf, and other VS Code forks. Key changes: - Extract SSH monitoring logic from remote.ts into sshProcess.ts - Add sshExtension.ts to detect installed Remote SSH extension - Use createRequire instead of private module._load API - Fix port detection to find most recent port (handles reconnects) - Add Cursor's "Socks port:" log format to port regex --- src/extension.ts | 30 +-- src/remote/remote.ts | 232 ++++-------------- src/remote/sshExtension.ts | 25 ++ src/remote/sshProcess.ts | 487 +++++++++++++++++++++++++++++++++++++ src/util.ts | 26 +- 5 files changed, 579 insertions(+), 221 deletions(-) create mode 100644 src/remote/sshExtension.ts create mode 100644 src/remote/sshProcess.ts diff --git a/src/extension.ts b/src/extension.ts index 9751b0f7..974cbe7d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,7 +2,8 @@ import axios, { isAxiosError } from "axios"; import { getErrorMessage } from "coder/site/src/api/errors"; -import * as module from "module"; +import { createRequire } from "node:module"; +import * as path from "node:path"; import * as vscode from "vscode"; import { errToStr } from "./api/api-helper"; @@ -14,6 +15,7 @@ import { AuthAction } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; import { maybeAskUrl } from "./promptUtils"; import { Remote } from "./remote/remote"; +import { getRemoteSshExtension } from "./remote/sshExtension"; import { toSafeHost } from "./util"; import { WorkspaceProvider, @@ -33,30 +35,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Cursor and VSCode are covered by ms remote, and the only other is windsurf for now // Means that vscodium is not supported by this for now - const remoteSSHExtension = - vscode.extensions.getExtension("jeanp413.open-remote-ssh") || - vscode.extensions.getExtension("codeium.windsurf-remote-openssh") || - vscode.extensions.getExtension("anysphere.remote-ssh") || - vscode.extensions.getExtension("ms-vscode-remote.remote-ssh") || - vscode.extensions.getExtension("google.antigravity-remote-openssh"); + const remoteSshExtension = getRemoteSshExtension(); let vscodeProposed: typeof vscode = vscode; - if (!remoteSSHExtension) { + if (remoteSshExtension) { + const extensionRequire = createRequire( + path.join(remoteSshExtension.extensionPath, "package.json"), + ); + vscodeProposed = extensionRequire("vscode"); + } else { vscode.window.showErrorMessage( "Remote SSH extension not found, this may not work as expected.\n" + // NB should we link to documentation or marketplace? "Please install your choice of Remote SSH extension from the VS Code Marketplace.", ); - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vscodeProposed = (module as any)._load( - "vscode", - { - filename: remoteSSHExtension.extensionPath, - }, - false, - ); } const serviceContainer = new ServiceContainer(ctx, vscodeProposed); @@ -366,11 +359,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // after the Coder extension is installed, instead of throwing a fatal error // (this would require the user to uninstall the Coder extension and // reinstall after installing the remote SSH extension, which is annoying) - if (remoteSSHExtension && vscodeProposed.env.remoteAuthority) { + if (remoteSshExtension && vscodeProposed.env.remoteAuthority) { try { const details = await remote.setup( vscodeProposed.env.remoteAuthority, isFirstConnect, + remoteSshExtension.id, ); if (details) { ctx.subscriptions.push(details); diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 1edf351c..1883486b 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -4,12 +4,10 @@ import { type Workspace, type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; -import find from "find-process"; import * as jsonc from "jsonc-parser"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import prettyBytes from "pretty-bytes"; import * as semver from "semver"; import * as vscode from "vscode"; @@ -36,12 +34,12 @@ import { AuthorityPrefix, escapeCommandArg, expandPath, - findPort, parseRemoteAuthority, } from "../util"; import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; import { SSHConfig, type SSHValues, mergeSSHConfigValues } from "./sshConfig"; +import { SshProcessMonitor } from "./sshProcess"; import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; import { WorkspaceStateMachine } from "./workspaceStateMachine"; @@ -109,6 +107,7 @@ export class Remote { public async setup( remoteAuthority: string, firstConnect: boolean, + remoteSshExtensionId: string, ): Promise { const parts = parseRemoteAuthority(remoteAuthority); if (!parts) { @@ -148,7 +147,7 @@ export class Remote { ]); if (result.type === "login") { - return this.setup(remoteAuthority, firstConnect); + return this.setup(remoteAuthority, firstConnect, remoteSshExtensionId); } else if (!result.userChoice) { // User declined to log in. await this.closeRemote(); @@ -156,7 +155,7 @@ export class Remote { } else { // Log in then try again. await this.commands.login({ url: baseUrlRaw, label: parts.label }); - return this.setup(remoteAuthority, firstConnect); + return this.setup(remoteAuthority, firstConnect, remoteSshExtensionId); } }; @@ -485,30 +484,26 @@ export class Remote { throw error; } - // TODO: This needs to be reworked; it fails to pick up reconnects. - this.findSSHProcessID().then(async (pid) => { - if (!pid) { - // TODO: Show an error here! - return; - } - disposables.push(this.showNetworkUpdates(pid)); - if (logDir) { - const logFiles = await fs.readdir(logDir); - const logFileName = logFiles - .reverse() - .find( - (file) => file === `${pid}.log` || file.endsWith(`-${pid}.log`), - ); - this.commands.workspaceLogPath = logFileName - ? path.join(logDir, logFileName) - : undefined; - } else { - this.commands.workspaceLogPath = undefined; - } + // Monitor SSH process and display network status + const sshMonitor = SshProcessMonitor.start({ + sshHost: parts.host, + networkInfoPath: this.pathResolver.getNetworkInfoPath(), + proxyLogDir: logDir || undefined, + logger: this.logger, + codeLogDir: this.pathResolver.getCodeLogDir(), + remoteSshExtensionId, }); + disposables.push(sshMonitor); + + this.commands.workspaceLogPath = sshMonitor.getLogFilePath(); - // Register the label formatter again because SSH overrides it! disposables.push( + sshMonitor.onLogFilePathChange((newPath) => { + this.commands.workspaceLogPath = newPath; + }), + // Watch for logDir configuration changes + this.watchLogDirSetting(logDir, featureSet), + // Register the label formatter again because SSH overrides it! vscode.extensions.onDidChange(() => { // Dispose previous label formatter labelFormatterDisposable.dispose(); @@ -741,172 +736,27 @@ export class Remote { return ` ${args.join(" ")}`; } - // showNetworkUpdates finds the SSH process ID that is being used by this - // workspace and reads the file being created by the Coder CLI. - private showNetworkUpdates(sshPid: number): vscode.Disposable { - const networkStatus = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 1000, - ); - const networkInfoFile = path.join( - this.pathResolver.getNetworkInfoPath(), - `${sshPid}.json`, - ); - - const updateStatus = (network: { - p2p: boolean; - latency: number; - preferred_derp: string; - derp_latency: { [key: string]: number }; - upload_bytes_sec: number; - download_bytes_sec: number; - using_coder_connect: boolean; - }) => { - let statusText = "$(globe) "; - - // Coder Connect doesn't populate any other stats - if (network.using_coder_connect) { - networkStatus.text = statusText + "Coder Connect "; - networkStatus.tooltip = "You're connected using Coder Connect."; - networkStatus.show(); - return; - } - - if (network.p2p) { - statusText += "Direct "; - networkStatus.tooltip = "You're connected peer-to-peer ✨."; - } else { - statusText += network.preferred_derp + " "; - networkStatus.tooltip = - "You're connected through a relay 🕵.\nWe'll switch over to peer-to-peer when available."; - } - networkStatus.tooltip += - "\n\nDownload ↓ " + - prettyBytes(network.download_bytes_sec, { - bits: true, - }) + - "/s • Upload ↑ " + - prettyBytes(network.upload_bytes_sec, { - bits: true, - }) + - "/s\n"; - - if (!network.p2p) { - const derpLatency = network.derp_latency[network.preferred_derp]; - - networkStatus.tooltip += `You ↔ ${derpLatency.toFixed(2)}ms ↔ ${network.preferred_derp} ↔ ${(network.latency - derpLatency).toFixed(2)}ms ↔ Workspace`; - - let first = true; - Object.keys(network.derp_latency).forEach((region) => { - if (region === network.preferred_derp) { - return; - } - if (first) { - networkStatus.tooltip += `\n\nOther regions:`; - first = false; - } - networkStatus.tooltip += `\n${region}: ${Math.round(network.derp_latency[region] * 100) / 100}ms`; - }); - } - - statusText += "(" + network.latency.toFixed(2) + "ms)"; - networkStatus.text = statusText; - networkStatus.show(); - }; - let disposed = false; - const periodicRefresh = () => { - if (disposed) { - return; - } - fs.readFile(networkInfoFile, "utf8") - .then((content) => { - return JSON.parse(content); - }) - .then((parsed) => { - try { - updateStatus(parsed); - } catch { - // Ignore - } - }) - .catch(() => { - // TODO: Log a failure here! - }) - .finally(() => { - // This matches the write interval of `coder vscodessh`. - setTimeout(periodicRefresh, 3000); - }); - }; - periodicRefresh(); - - return { - dispose: () => { - disposed = true; - networkStatus.dispose(); - }, - }; - } - - // findSSHProcessID returns the currently active SSH process ID that is - // powering the remote SSH connection. - private async findSSHProcessID(timeout = 15000): Promise { - const search = async (logPath: string): Promise => { - // This searches for the socksPort that Remote SSH is connecting to. We do - // this to find the SSH process that is powering this connection. That SSH - // process will be logging network information periodically to a file. - const text = await fs.readFile(logPath, "utf8"); - const port = findPort(text); - if (!port) { - return; - } - const processes = await find("port", port); - if (processes.length < 1) { - return; - } - const process = processes[0]; - return process.pid; - }; - const start = Date.now(); - const loop = async (): Promise => { - if (Date.now() - start > timeout) { - return undefined; - } - // Loop until we find the remote SSH log for this window. - const filePath = await this.getRemoteSSHLogPath(); - if (!filePath) { - return new Promise((resolve) => setTimeout(() => resolve(loop()), 500)); - } - // Then we search the remote SSH log until we find the port. - const result = await search(filePath); - if (!result) { - return new Promise((resolve) => setTimeout(() => resolve(loop()), 500)); + private watchLogDirSetting( + currentLogDir: string, + featureSet: FeatureSet, + ): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("coder.proxyLogDirectory")) { + const newLogDir = this.getLogDir(featureSet); + if (newLogDir !== currentLogDir) { + vscode.window + .showInformationMessage( + "Log directory configuration changed. Reload window to apply.", + "Reload", + ) + .then((action) => { + if (action === "Reload") { + vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + }); + } } - return result; - }; - return loop(); - } - - /** - * Returns the log path for the "Remote - SSH" output panel. There is no VS - * Code API to get the contents of an output panel. We use this to get the - * active port so we can display network information. - */ - private async getRemoteSSHLogPath(): Promise { - const upperDir = path.dirname(this.pathResolver.getCodeLogDir()); - // Node returns these directories sorted already! - const dirs = await fs.readdir(upperDir); - const latestOutput = dirs - .reverse() - .filter((dir) => dir.startsWith("output_logging_")); - if (latestOutput.length === 0) { - return undefined; - } - const dir = await fs.readdir(path.join(upperDir, latestOutput[0])); - const remoteSSH = dir.filter((file) => file.indexOf("Remote - SSH") !== -1); - if (remoteSSH.length === 0) { - return undefined; - } - return path.join(upperDir, latestOutput[0], remoteSSH[0]); + }); } /** diff --git a/src/remote/sshExtension.ts b/src/remote/sshExtension.ts new file mode 100644 index 00000000..70ed849d --- /dev/null +++ b/src/remote/sshExtension.ts @@ -0,0 +1,25 @@ +import * as vscode from "vscode"; + +export const REMOTE_SSH_EXTENSION_IDS = [ + "jeanp413.open-remote-ssh", + "codeium.windsurf-remote-openssh", + "anysphere.remote-ssh", + "ms-vscode-remote.remote-ssh", + "google.antigravity-remote-openssh", +] as const; + +export type RemoteSshExtensionId = (typeof REMOTE_SSH_EXTENSION_IDS)[number]; + +type RemoteSshExtension = vscode.Extension & { + id: RemoteSshExtensionId; +}; + +export function getRemoteSshExtension(): RemoteSshExtension | undefined { + for (const id of REMOTE_SSH_EXTENSION_IDS) { + const extension = vscode.extensions.getExtension(id); + if (extension) { + return extension as RemoteSshExtension; + } + } + return undefined; +} diff --git a/src/remote/sshProcess.ts b/src/remote/sshProcess.ts new file mode 100644 index 00000000..abeefdaf --- /dev/null +++ b/src/remote/sshProcess.ts @@ -0,0 +1,487 @@ +import find from "find-process"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import prettyBytes from "pretty-bytes"; +import * as vscode from "vscode"; + +import { type Logger } from "../logging/logger"; +import { findPort } from "../util"; + +/** + * Network information from the Coder CLI. + */ +export interface NetworkInfo { + p2p: boolean; + latency: number; + preferred_derp: string; + derp_latency: { [key: string]: number }; + upload_bytes_sec: number; + download_bytes_sec: number; + using_coder_connect: boolean; +} + +/** + * Process information from find-process. + */ +interface ProcessInfo { + pid: number; + name: string; + cmd: string; +} + +/** + * Options for creating an SshProcessMonitor. + */ +export interface SshProcessMonitorOptions { + sshHost: string; + networkInfoPath: string; + proxyLogDir?: string; + logger: Logger; + pollInterval?: number; + networkPollInterval?: number; + // For port-based SSH process discovery + codeLogDir: string; + remoteSshExtensionId: string; +} + +/** + * Checks if a process is an actual SSH process (not a shell wrapper). + * Filters out processes like "sh -c ... | ssh ..." where ssh appears mid-command. + */ +function isActualSshProcess(p: ProcessInfo): boolean { + // Process name is exactly "ssh" or "ssh.exe" + if (p.name === "ssh" || p.name === "ssh.exe") { + return true; + } + // Command starts with ssh binary (not piped through shell) + if (/^(ssh|ssh\.exe)\s/i.test(p.cmd)) { + return true; + } + // Command starts with full path to ssh (Unix or Windows) + // e.g., "/usr/bin/ssh " or "C:\Program Files\OpenSSH\ssh.exe " + if (/^[\w/\\:.-]+([/\\])ssh(\.exe)?\s/i.test(p.cmd)) { + return true; + } + return false; +} + +/** + * Finds the Remote SSH extension's log file path. + */ +async function findRemoteSshLogPath( + codeLogDir: string, + extensionId: string, +): Promise { + const logsParentDir = path.dirname(codeLogDir); + + // Try extension-specific folder (for VS Code clones like Cursor, Windsurf) + try { + const extensionLogDir = path.join(logsParentDir, extensionId); + // Node returns these directories sorted already! + const files = await fs.readdir(extensionLogDir); + files.reverse(); + + const remoteSsh = files.find((file) => file.includes("Remote - SSH")); + if (remoteSsh) { + return path.join(extensionLogDir, remoteSsh); + } + } catch { + // Extension-specific folder doesn't exist, try fallback + } + + try { + // Node returns these directories sorted already! + const dirs = await fs.readdir(logsParentDir); + dirs.reverse(); + const outputDirs = dirs.filter((d) => d.startsWith("output_logging_")); + + if (outputDirs.length > 0) { + const outputPath = path.join(logsParentDir, outputDirs[0]); + const files = await fs.readdir(outputPath); + const remoteSSHLog = files.find((f) => f.includes("Remote - SSH")); + if (remoteSSHLog) { + return path.join(outputPath, remoteSSHLog); + } + } + } catch { + // output_logging folder doesn't exist + } + + return undefined; +} + +/** + * Monitors the SSH process for a Coder workspace connection and displays + * network status in the VS Code status bar. + */ +export class SshProcessMonitor implements vscode.Disposable { + private readonly statusBarItem: vscode.StatusBarItem; + private readonly options: Required< + SshProcessMonitorOptions & { proxyLogDir: string | undefined } + >; + + private readonly _onLogFilePathChange = new vscode.EventEmitter< + string | undefined + >(); + private readonly _onPidChange = new vscode.EventEmitter(); + + /** + * Event fired when the log file path changes (e.g., after reconnecting to a new process). + */ + public readonly onLogFilePathChange = this._onLogFilePathChange.event; + + /** + * Event fired when the SSH process PID changes (e.g., after reconnecting). + */ + public readonly onPidChange = this._onPidChange.event; + + private disposed = false; + private currentPid: number | undefined; + private logFilePath: string | undefined; + private pendingTimeout: NodeJS.Timeout | undefined; + + private constructor(options: SshProcessMonitorOptions) { + this.options = { + ...options, + proxyLogDir: options.proxyLogDir, + pollInterval: options.pollInterval ?? 1000, + networkPollInterval: options.networkPollInterval ?? 3000, + }; + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 1000, + ); + } + + /** + * Creates and starts an SSH process monitor. + * Begins searching for the SSH process in the background. + */ + static start(options: SshProcessMonitorOptions): SshProcessMonitor { + const monitor = new SshProcessMonitor(options); + monitor.searchForProcess().catch((err) => { + options.logger.error("Error in SSH process monitor", err); + }); + return monitor; + } + + /** + * Returns the path to the log file for this connection, or undefined if not found. + */ + getLogFilePath(): string | undefined { + return this.logFilePath; + } + + /** + * Cleans up resources and stops monitoring. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + if (this.pendingTimeout) { + clearTimeout(this.pendingTimeout); + this.pendingTimeout = undefined; + } + this.statusBarItem.dispose(); + this._onLogFilePathChange.dispose(); + this._onPidChange.dispose(); + } + + /** + * Delays for the specified duration. Returns early if disposed. + */ + private async delay(ms: number): Promise { + if (this.disposed) { + return; + } + await new Promise((resolve) => { + this.pendingTimeout = setTimeout(() => { + this.pendingTimeout = undefined; + resolve(); + }, ms); + }); + } + + /** + * Searches for the SSH process indefinitely until found or disposed. + * Tries port-based discovery first (more accurate), falls back to hostname-based. + * When found, starts monitoring. + */ + private async searchForProcess(): Promise { + const { pollInterval, logger, sshHost } = this.options; + let attempt = 0; + + while (!this.disposed) { + attempt++; + + if (attempt % 5 === 0) { + logger.debug( + `SSH process search attempt ${attempt} for host: ${sshHost}`, + ); + } + + // Try port-based discovery first (unique per VS Code window) + const pidByPort = await this.findSshProcessByPort(); + if (pidByPort !== undefined) { + logger.info(`Found SSH process by port (PID: ${pidByPort})`); + this.setCurrentPid(pidByPort); + this.startMonitoring(); + return; + } + + // Fall back to hostname-based search + // const pidByHost = await this.findSshProcessByHost(); + // if (pidByHost !== undefined) { + // logger.info(`Found SSH process by hostname (PID: ${pidByHost})`); + // this.setCurrentPid(pidByHost); + // this.startMonitoring(); + // return; + // } + + await this.delay(pollInterval); + } + } + + /** + * 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 { + const { codeLogDir, remoteSshExtensionId, logger } = this.options; + + try { + const logPath = await findRemoteSshLogPath( + codeLogDir, + remoteSshExtensionId, + ); + if (!logPath) { + return undefined; + } + + const logContent = await fs.readFile(logPath, "utf8"); + this.options.logger.debug(`Read Remote SSH log file: ${logPath}`); + const port = findPort(logContent); + if (!port) { + return undefined; + } + this.options.logger.debug(`Found SSH port ${port} in log file`); + + const processes = await find("port", port); + if (processes.length === 0) { + return undefined; + } + + return processes[0].pid; + } catch (error) { + logger.debug(`Port-based SSH process search failed: ${error}`); + return undefined; + } + } + + /** + * Attempts to find an SSH process by hostname. + * Less accurate than port-based as multiple windows may connect to same host. + * Returns the PID if found, undefined otherwise. + */ + private async findSshProcessByHost(): Promise { + const { sshHost, logger } = this.options; + + try { + // Find all processes with "ssh" in name + const processes = await find("name", "ssh"); + const matches = processes.filter( + (p) => p.cmd.includes(sshHost) && isActualSshProcess(p), + ); + + if (matches.length === 0) { + return undefined; + } + + const preferred = matches.find( + (p) => p.name === "ssh" || p.name === "ssh.exe", + ); + if (preferred) { + return preferred.pid; + } + + if (matches.length > 1) { + logger.warn( + `Found ${matches.length} SSH processes for host, using first`, + ); + } + + return matches[0].pid; + } catch (error) { + logger.debug(`Error searching for SSH process: ${error}`); + return undefined; + } + } + + /** + * Updates the current PID and fires change events. + */ + private setCurrentPid(pid: number): void { + const previousPid = this.currentPid; + this.currentPid = pid; + + if (previousPid !== undefined && previousPid !== pid) { + this.options.logger.info( + `SSH process changed from ${previousPid} to ${pid}`, + ); + this.logFilePath = undefined; + this._onLogFilePathChange.fire(undefined); + this._onPidChange.fire(pid); + } else if (previousPid === undefined) { + this.options.logger.info(`SSH connection established (PID: ${pid})`); + this._onPidChange.fire(pid); + } + } + + /** + * Starts monitoring tasks after finding the SSH process. + */ + private startMonitoring(): void { + if (this.disposed || this.currentPid === undefined) { + return; + } + this.searchForLogFile(); + this.monitorNetwork(); + } + + /** + * Searches for the log file for the current PID. + * Polls until found or PID changes. + */ + private async searchForLogFile(): Promise { + const { proxyLogDir: logDir, logger, pollInterval } = this.options; + if (!logDir) { + return; + } + + const targetPid = this.currentPid; + while (!this.disposed && this.currentPid === targetPid) { + try { + const logFiles = await fs.readdir(logDir); + logFiles.reverse(); + const logFileName = logFiles.find( + (file) => + file === `${targetPid}.log` || file.endsWith(`-${targetPid}.log`), + ); + + if (logFileName) { + const foundPath = path.join(logDir, logFileName); + if (foundPath !== this.logFilePath) { + this.logFilePath = foundPath; + logger.info(`Log file found: ${this.logFilePath}`); + this._onLogFilePathChange.fire(this.logFilePath); + } + return; + } + } catch { + logger.debug(`Could not read log directory: ${logDir}`); + } + + await this.delay(pollInterval); + } + } + + /** + * Monitors network info and updates the status bar. + * Checks file mtime to detect stale connections and trigger reconnection search. + */ + private async monitorNetwork(): Promise { + const { networkInfoPath, networkPollInterval, logger } = this.options; + const staleThreshold = networkPollInterval * 5; + + while (!this.disposed && this.currentPid !== undefined) { + const networkInfoFile = path.join( + networkInfoPath, + `${this.currentPid}.json`, + ); + + try { + const stats = await fs.stat(networkInfoFile); + const ageMs = Date.now() - stats.mtime.getTime(); + + if (ageMs > staleThreshold) { + logger.info( + `Network info stale (${Math.round(ageMs / 1000)}s old), searching for new SSH process`, + ); + this.currentPid = undefined; + this.statusBarItem.hide(); + this._onPidChange.fire(undefined); + this.searchForProcess().catch((err) => { + logger.error("Error restarting SSH process search", err); + }); + return; + } + + const content = await fs.readFile(networkInfoFile, "utf8"); + const network = JSON.parse(content) as NetworkInfo; + this.updateStatusBar(network); + } catch (error) { + logger.debug( + `Failed to read network info: ${(error as Error).message}`, + ); + } + + await this.delay(networkPollInterval); + } + } + + /** + * Updates the status bar with network information. + */ + private updateStatusBar(network: NetworkInfo): void { + let statusText = "$(globe) "; + + // Coder Connect doesn't populate any other stats + if (network.using_coder_connect) { + this.statusBarItem.text = statusText + "Coder Connect "; + this.statusBarItem.tooltip = "You're connected using Coder Connect."; + this.statusBarItem.show(); + return; + } + + if (network.p2p) { + statusText += "Direct "; + this.statusBarItem.tooltip = "You're connected peer-to-peer ✨."; + } else { + statusText += network.preferred_derp + " "; + this.statusBarItem.tooltip = + "You're connected through a relay 🕵.\nWe'll switch over to peer-to-peer when available."; + } + + let tooltip = this.statusBarItem.tooltip; + tooltip += + "\n\nDownload ↓ " + + prettyBytes(network.download_bytes_sec, { bits: true }) + + "/s • Upload ↑ " + + prettyBytes(network.upload_bytes_sec, { bits: true }) + + "/s\n"; + + if (!network.p2p) { + const derpLatency = network.derp_latency[network.preferred_derp]; + tooltip += `You ↔ ${derpLatency.toFixed(2)}ms ↔ ${network.preferred_derp} ↔ ${(network.latency - derpLatency).toFixed(2)}ms ↔ Workspace`; + + let first = true; + for (const region of Object.keys(network.derp_latency)) { + if (region === network.preferred_derp) { + continue; + } + if (first) { + tooltip += `\n\nOther regions:`; + first = false; + } + tooltip += `\n${region}: ${Math.round(network.derp_latency[region] * 100) / 100}ms`; + } + } + + this.statusBarItem.tooltip = tooltip; + statusText += "(" + network.latency.toFixed(2) + "ms)"; + this.statusBarItem.text = statusText; + this.statusBarItem.show(); + } +} diff --git a/src/util.ts b/src/util.ts index 21785cf6..56090826 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,5 @@ -import * as os from "os"; -import url from "url"; +import * as os from "node:os"; +import url from "node:url"; export interface AuthorityParts { agent: string | undefined; @@ -13,27 +13,29 @@ export interface AuthorityParts { // they should be handled by this extension. export const AuthorityPrefix = "coder-vscode"; +// Regex patterns to find the SSH port from Remote SSH extension logs. // `ms-vscode-remote.remote-ssh`: `-> socksPort ->` -// `codeium.windsurf-remote-openssh`, `jeanp413.open-remote-ssh`: `=> (socks) =>` +// `codeium.windsurf-remote-openssh`, `jeanp413.open-remote-ssh`, `google.antigravity-remote-openssh`: `=> (socks) =>` // Windows `ms-vscode-remote.remote-ssh`: `between local port ` +// `anysphere.remote-ssh`: `Socks port: ` export const RemoteSSHLogPortRegex = - /(?:-> socksPort (\d+) ->|=> (\d+)\(socks\) =>|between local port (\d+))/; + /(?:-> socksPort (\d+) ->|=> (\d+)\(socks\) =>|between local port (\d+)|Socks port: (\d+))/g; /** - * Given the contents of a Remote - SSH log file, find a port number used by the - * SSH process. This is typically the socks port, but the local port works too. + * Given the contents of a Remote - SSH log file, find the most recent port + * number used by the SSH process. This is typically the socks port, but the + * local port works too. * * Returns null if no port is found. */ export function findPort(text: string): number | null { - const matches = text.match(RemoteSSHLogPortRegex); - if (!matches) { + const allMatches = [...text.matchAll(RemoteSSHLogPortRegex)]; + if (allMatches.length === 0) { return null; } - if (matches.length < 2) { - return null; - } - const portStr = matches[1] || matches[2] || matches[3]; + + const lastMatch = allMatches.at(-1)!; + const portStr = lastMatch[1] || lastMatch[2] || lastMatch[3] || lastMatch[4]; if (!portStr) { return null; } From b6e05ed1fb2c3eec8dc2468d61de5ca224523cd9 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 1 Dec 2025 17:16:32 +0300 Subject: [PATCH 2/5] Add tests --- src/remote/sshProcess.ts | 101 +------ test/mocks/testHelpers.ts | 61 +++- test/mocks/vscode.runtime.ts | 32 +- test/unit/remote/sshProcess.test.ts | 439 ++++++++++++++++++++++++++++ 4 files changed, 531 insertions(+), 102 deletions(-) create mode 100644 test/unit/remote/sshProcess.test.ts diff --git a/src/remote/sshProcess.ts b/src/remote/sshProcess.ts index abeefdaf..2b572aea 100644 --- a/src/remote/sshProcess.ts +++ b/src/remote/sshProcess.ts @@ -20,15 +20,6 @@ export interface NetworkInfo { using_coder_connect: boolean; } -/** - * Process information from find-process. - */ -interface ProcessInfo { - pid: number; - name: string; - cmd: string; -} - /** * Options for creating an SshProcessMonitor. */ @@ -44,27 +35,6 @@ export interface SshProcessMonitorOptions { remoteSshExtensionId: string; } -/** - * Checks if a process is an actual SSH process (not a shell wrapper). - * Filters out processes like "sh -c ... | ssh ..." where ssh appears mid-command. - */ -function isActualSshProcess(p: ProcessInfo): boolean { - // Process name is exactly "ssh" or "ssh.exe" - if (p.name === "ssh" || p.name === "ssh.exe") { - return true; - } - // Command starts with ssh binary (not piped through shell) - if (/^(ssh|ssh\.exe)\s/i.test(p.cmd)) { - return true; - } - // Command starts with full path to ssh (Unix or Windows) - // e.g., "/usr/bin/ssh " or "C:\Program Files\OpenSSH\ssh.exe " - if (/^[\w/\\:.-]+([/\\])ssh(\.exe)?\s/i.test(p.cmd)) { - return true; - } - return false; -} - /** * Finds the Remote SSH extension's log file path. */ @@ -139,6 +109,7 @@ export class SshProcessMonitor implements vscode.Disposable { private currentPid: number | undefined; private logFilePath: string | undefined; private pendingTimeout: NodeJS.Timeout | undefined; + private lastStaleSearchTime = 0; private constructor(options: SshProcessMonitorOptions) { this.options = { @@ -216,7 +187,7 @@ export class SshProcessMonitor implements vscode.Disposable { while (!this.disposed) { attempt++; - if (attempt % 5 === 0) { + if (attempt % 10 === 0) { logger.debug( `SSH process search attempt ${attempt} for host: ${sshHost}`, ); @@ -231,15 +202,6 @@ export class SshProcessMonitor implements vscode.Disposable { return; } - // Fall back to hostname-based search - // const pidByHost = await this.findSshProcessByHost(); - // if (pidByHost !== undefined) { - // logger.info(`Found SSH process by hostname (PID: ${pidByHost})`); - // this.setCurrentPid(pidByHost); - // this.startMonitoring(); - // return; - // } - await this.delay(pollInterval); } } @@ -262,6 +224,7 @@ export class SshProcessMonitor implements vscode.Disposable { const logContent = await fs.readFile(logPath, "utf8"); this.options.logger.debug(`Read Remote SSH log file: ${logPath}`); + const port = findPort(logContent); if (!port) { return undefined; @@ -280,45 +243,6 @@ export class SshProcessMonitor implements vscode.Disposable { } } - /** - * Attempts to find an SSH process by hostname. - * Less accurate than port-based as multiple windows may connect to same host. - * Returns the PID if found, undefined otherwise. - */ - private async findSshProcessByHost(): Promise { - const { sshHost, logger } = this.options; - - try { - // Find all processes with "ssh" in name - const processes = await find("name", "ssh"); - const matches = processes.filter( - (p) => p.cmd.includes(sshHost) && isActualSshProcess(p), - ); - - if (matches.length === 0) { - return undefined; - } - - const preferred = matches.find( - (p) => p.name === "ssh" || p.name === "ssh.exe", - ); - if (preferred) { - return preferred.pid; - } - - if (matches.length > 1) { - logger.warn( - `Found ${matches.length} SSH processes for host, using first`, - ); - } - - return matches[0].pid; - } catch (error) { - logger.debug(`Error searching for SSH process: ${error}`); - return undefined; - } - } - /** * Updates the current PID and fires change events. */ @@ -406,15 +330,20 @@ export class SshProcessMonitor implements vscode.Disposable { const ageMs = Date.now() - stats.mtime.getTime(); if (ageMs > staleThreshold) { - logger.info( + // Prevent tight loop: if we just searched due to stale, wait before searching again + const timeSinceLastSearch = Date.now() - this.lastStaleSearchTime; + if (timeSinceLastSearch < staleThreshold) { + await this.delay(staleThreshold - timeSinceLastSearch); + continue; + } + + logger.debug( `Network info stale (${Math.round(ageMs / 1000)}s old), searching for new SSH process`, ); - this.currentPid = undefined; - this.statusBarItem.hide(); - this._onPidChange.fire(undefined); - this.searchForProcess().catch((err) => { - logger.error("Error restarting SSH process search", err); - }); + + // searchForProcess will update PID if a different process is found + this.lastStaleSearchTime = Date.now(); + await this.searchForProcess(); return; } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index faf2a72d..5678cd48 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -29,7 +29,7 @@ export class MockConfigurationProvider { get(key: string, defaultValue: T): T; get(key: string, defaultValue?: T): T | undefined { const value = this.config.get(key); - return value !== undefined ? (value as T) : defaultValue; + return value === undefined ? defaultValue : (value as T); } /** @@ -53,7 +53,7 @@ export class MockConfigurationProvider { return { get: vi.fn((key: string, defaultValue?: unknown) => { const value = snapshot.get(getFullKey(key)); - return value !== undefined ? value : defaultValue; + return value === undefined ? defaultValue : value; }), has: vi.fn((key: string) => { return snapshot.has(getFullKey(key)); @@ -141,7 +141,7 @@ export class MockProgressReporter { * Use this to control user responses in tests. */ export class MockUserInteraction { - private responses = new Map(); + private readonly responses = new Map(); private externalUrls: string[] = []; constructor() { @@ -211,7 +211,7 @@ export class MockUserInteraction { // Simple in-memory implementation of Memento export class InMemoryMemento implements vscode.Memento { - private storage = new Map(); + private readonly storage = new Map(); get(key: string): T | undefined; get(key: string, defaultValue: T): T; @@ -235,9 +235,11 @@ export class InMemoryMemento implements vscode.Memento { // Simple in-memory implementation of SecretStorage export class InMemorySecretStorage implements vscode.SecretStorage { - private secrets = new Map(); + private readonly secrets = new Map(); private isCorrupted = false; - private listeners: Array<(e: vscode.SecretStorageChangeEvent) => void> = []; + private readonly listeners: Array< + (e: vscode.SecretStorageChangeEvent) => void + > = []; onDidChange: vscode.Event = (listener) => { this.listeners.push(listener); @@ -350,3 +352,50 @@ export function createMockStream( destroy: vi.fn(), } as unknown as IncomingMessage; } + +/** + * Mock status bar that integrates with vscode.window.createStatusBarItem. + * Use this to inspect status bar state in tests. + */ +export class MockStatusBar { + text = ""; + tooltip: string | vscode.MarkdownString = ""; + backgroundColor: vscode.ThemeColor | undefined; + color: string | vscode.ThemeColor | undefined; + command: string | vscode.Command | undefined; + accessibilityInformation: vscode.AccessibilityInformation | undefined; + name: string | undefined; + priority: number | undefined; + alignment: vscode.StatusBarAlignment = vscode.StatusBarAlignment.Left; + + readonly show = vi.fn(); + readonly hide = vi.fn(); + readonly dispose = vi.fn(); + + constructor() { + this.setupVSCodeMock(); + } + + /** + * Reset all status bar state + */ + reset(): void { + this.text = ""; + this.tooltip = ""; + this.backgroundColor = undefined; + this.color = undefined; + this.command = undefined; + this.show.mockClear(); + this.hide.mockClear(); + this.dispose.mockClear(); + } + + /** + * Setup the vscode.window.createStatusBarItem mock + */ + private setupVSCodeMock(): void { + vi.mocked(vscode.window.createStatusBarItem).mockReturnValue( + this as unknown as vscode.StatusBarItem, + ); + } +} diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 2201a851..4da3796f 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -55,18 +55,28 @@ export class Uri { } } -// mini event -const makeEvent = () => { - const listeners = new Set<(e: T) => void>(); - const event = (listener: (e: T) => void) => { - listeners.add(listener); - return { dispose: () => listeners.delete(listener) }; +/** + * Mock EventEmitter that matches vscode.EventEmitter interface. + */ +export class EventEmitter { + private readonly listeners = new Set<(e: T) => void>(); + + event = (listener: (e: T) => void) => { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; }; - return { event, fire: (e: T) => listeners.forEach((l) => l(e)) }; -}; -const onDidChangeConfiguration = makeEvent(); -const onDidChangeWorkspaceFolders = makeEvent(); + fire(data: T): void { + this.listeners.forEach((l) => l(data)); + } + + dispose(): void { + this.listeners.clear(); + } +} + +const onDidChangeConfiguration = new EventEmitter(); +const onDidChangeWorkspaceFolders = new EventEmitter(); export const window = { showInformationMessage: vi.fn(), @@ -83,6 +93,7 @@ export const window = { dispose: vi.fn(), clear: vi.fn(), })), + createStatusBarItem: vi.fn(), }; export const commands = { @@ -132,6 +143,7 @@ const vscode = { ExtensionMode, UIKind, Uri, + EventEmitter, window, commands, workspace, diff --git a/test/unit/remote/sshProcess.test.ts b/test/unit/remote/sshProcess.test.ts new file mode 100644 index 00000000..d85d239a --- /dev/null +++ b/test/unit/remote/sshProcess.test.ts @@ -0,0 +1,439 @@ +import find from "find-process"; +import { vol } from "memfs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SshProcessMonitor, + type SshProcessMonitorOptions, +} from "@/remote/sshProcess"; + +import { createMockLogger, MockStatusBar } from "../../mocks/testHelpers"; + +import type * as fs from "node:fs"; + +vi.mock("find-process", () => ({ default: vi.fn() })); + +vi.mock("node:fs/promises", async () => { + const memfs: { fs: typeof fs } = await vi.importActual("memfs"); + return memfs.fs.promises; +}); + +describe("SshProcessMonitor", () => { + let activeMonitors: SshProcessMonitor[] = []; + let statusBar: MockStatusBar; + + beforeEach(() => { + vi.clearAllMocks(); + vol.reset(); + activeMonitors = []; + statusBar = new MockStatusBar(); + + // Default: process found immediately + vi.mocked(find).mockResolvedValue([ + { pid: 999, name: "ssh", cmd: "ssh host" }, + ]); + }); + + afterEach(() => { + for (const m of activeMonitors) { + m.dispose(); + } + activeMonitors = []; + vol.reset(); + }); + + describe("process discovery", () => { + it("finds SSH process by port from Remote SSH logs", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + }); + + const monitor = createMonitor({ codeLogDir: "/logs/window1" }); + const pid = await waitForEvent(monitor.onPidChange); + + expect(find).toHaveBeenCalledWith("port", 12345); + expect(pid).toBe(999); + }); + + it("retries until process is found", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + }); + + // First 2 calls return nothing, third call finds the process + vi.mocked(find) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ pid: 888, name: "ssh", cmd: "ssh host" }]); + + const monitor = createMonitor({ codeLogDir: "/logs/window1" }); + const pid = await waitForEvent(monitor.onPidChange); + + expect(vi.mocked(find).mock.calls.length).toBeGreaterThanOrEqual(3); + expect(pid).toBe(888); + }); + + it("retries when Remote SSH log appears later", async () => { + // Start with no log file + vol.fromJSON({}); + + vi.mocked(find).mockResolvedValue([ + { pid: 777, name: "ssh", cmd: "ssh host" }, + ]); + + const monitor = createMonitor({ codeLogDir: "/logs/window1" }); + + // Add the log file after a delay + setTimeout(() => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 55555 ->", + }); + }, 50); + + const pid = await waitForEvent(monitor.onPidChange); + + expect(find).toHaveBeenCalledWith("port", 55555); + expect(pid).toBe(777); + }); + + it("reconnects when network info becomes stale", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/network/999.json": JSON.stringify({ + p2p: true, + latency: 10, + preferred_derp: "", + derp_latency: {}, + upload_bytes_sec: 0, + download_bytes_sec: 0, + using_coder_connect: false, + }), + }); + + // First search finds PID 999, after reconnect finds PID 888 + vi.mocked(find) + .mockResolvedValueOnce([{ pid: 999, name: "ssh", cmd: "ssh" }]) + .mockResolvedValue([{ pid: 888, name: "ssh", cmd: "ssh" }]); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + networkInfoPath: "/network", + networkPollInterval: 10, + }); + + // Initial PID + const firstPid = await waitForEvent(monitor.onPidChange); + expect(firstPid).toBe(999); + + // Network info will become stale after 50ms (5 * networkPollInterval) + // Monitor keeps showing last status, only fires when PID actually changes + const pids: (number | undefined)[] = []; + monitor.onPidChange((pid) => pids.push(pid)); + + // Wait for reconnection to find new PID + await waitFor(() => pids.includes(888), 200); + + // Should NOT fire undefined - we keep showing last status while searching + expect(pids).toContain(888); + }); + + 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": + "-> socksPort 12345 ->", + "/network/999.json": JSON.stringify({ + p2p: true, + latency: 10, + preferred_derp: "", + derp_latency: {}, + upload_bytes_sec: 0, + download_bytes_sec: 0, + using_coder_connect: false, + }), + }); + + // Always returns the same PID + vi.mocked(find).mockResolvedValue([ + { pid: 999, name: "ssh", cmd: "ssh" }, + ]); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + networkInfoPath: "/network", + networkPollInterval: 10, + }); + + // Wait for initial PID + await waitForEvent(monitor.onPidChange); + + // Track subsequent events + const pids: (number | undefined)[] = []; + monitor.onPidChange((pid) => pids.push(pid)); + + // Wait long enough for stale check to trigger and re-find same process + await new Promise((r) => setTimeout(r, 100)); + + // No events should fire - same process found, no change + expect(pids).toEqual([]); + }); + }); + + describe("log file discovery", () => { + it("finds log file matching PID pattern", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/proxy-logs/999.log": "", + "/proxy-logs/other.log": "", + }); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + proxyLogDir: "/proxy-logs", + }); + const logPath = await waitForEvent(monitor.onLogFilePathChange); + + expect(logPath).toBe("/proxy-logs/999.log"); + expect(monitor.getLogFilePath()).toBe("/proxy-logs/999.log"); + }); + + it("finds log file with prefix pattern", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/proxy-logs/coder-ssh-999.log": "", + }); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + proxyLogDir: "/proxy-logs", + }); + const logPath = await waitForEvent(monitor.onLogFilePathChange); + + expect(logPath).toBe("/proxy-logs/coder-ssh-999.log"); + }); + + it("returns undefined when no proxyLogDir set", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/proxy-logs/coder-ssh-999.log": "", // ignored + }); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + proxyLogDir: undefined, + }); + + // Wait for process to be found + await waitForEvent(monitor.onPidChange); + + expect(monitor.getLogFilePath()).toBeUndefined(); + }); + }); + + describe("network status", () => { + it("shows P2P connection in status bar", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/network/999.json": JSON.stringify({ + p2p: true, + latency: 25.5, + preferred_derp: "NYC", + derp_latency: { NYC: 10 }, + upload_bytes_sec: 1024, + download_bytes_sec: 2048, + using_coder_connect: false, + }), + }); + + createMonitor({ + codeLogDir: "/logs/window1", + networkInfoPath: "/network", + }); + await waitFor(() => statusBar.text.includes("Direct")); + + expect(statusBar.text).toContain("Direct"); + expect(statusBar.text).toContain("25.50ms"); + expect(statusBar.tooltip).toContain("peer-to-peer"); + }); + + it("shows relay connection with DERP region", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/network/999.json": JSON.stringify({ + p2p: false, + latency: 50, + preferred_derp: "SFO", + derp_latency: { SFO: 20, NYC: 40 }, + upload_bytes_sec: 512, + download_bytes_sec: 1024, + using_coder_connect: false, + }), + }); + + createMonitor({ + codeLogDir: "/logs/window1", + networkInfoPath: "/network", + }); + await waitFor(() => statusBar.text.includes("SFO")); + + expect(statusBar.text).toContain("SFO"); + expect(statusBar.tooltip).toContain("relay"); + }); + + it("shows Coder Connect status", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/network/999.json": JSON.stringify({ + p2p: false, + latency: 0, + preferred_derp: "", + derp_latency: {}, + upload_bytes_sec: 0, + download_bytes_sec: 0, + using_coder_connect: true, + }), + }); + + createMonitor({ + codeLogDir: "/logs/window1", + networkInfoPath: "/network", + }); + await waitFor(() => statusBar.text.includes("Coder Connect")); + + expect(statusBar.text).toContain("Coder Connect"); + }); + }); + + describe("dispose", () => { + it("disposes status bar", () => { + const monitor = createMonitor(); + monitor.dispose(); + + expect(statusBar.dispose).toHaveBeenCalled(); + }); + + it("stops searching for process after dispose", async () => { + // Log file exists so port can be found and find() is called + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + }); + + // find() always returns empty - monitor will keep retrying + vi.mocked(find).mockResolvedValue([]); + + const monitor = createMonitor({ codeLogDir: "/logs/window1" }); + + // Let a few poll cycles run + await new Promise((r) => setTimeout(r, 30)); + const callsBeforeDispose = vi.mocked(find).mock.calls.length; + expect(callsBeforeDispose).toBeGreaterThan(0); + + monitor.dispose(); + + // Wait and verify no new calls + await new Promise((r) => setTimeout(r, 50)); + expect(vi.mocked(find).mock.calls.length).toBe(callsBeforeDispose); + }); + + it("does not fire log file event after dispose", async () => { + // Start with SSH log but no proxy log file + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + }); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + proxyLogDir: "/proxy-logs", + }); + + // Wait for PID - this starts the log file search loop + await waitForEvent(monitor.onPidChange); + + const events: string[] = []; + monitor.onLogFilePathChange(() => events.push("logPath")); + + monitor.dispose(); + + // Now add the log file that WOULD have been found + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/proxy-logs/999.log": "", + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(events).toEqual([]); + }); + + it("is idempotent - can be called multiple times", () => { + const monitor = createMonitor(); + + monitor.dispose(); + monitor.dispose(); + monitor.dispose(); + + // Should not throw, and dispose should only be called once on status bar + expect(statusBar.dispose).toHaveBeenCalledTimes(1); + }); + }); + + function createMonitor(overrides: Partial = {}) { + const monitor = SshProcessMonitor.start({ + sshHost: "coder-vscode--user--workspace", + networkInfoPath: "/network", + codeLogDir: "/logs/window1", + remoteSshExtensionId: "ms-vscode-remote.remote-ssh", + logger: createMockLogger(), + pollInterval: 10, + networkPollInterval: 10, + ...overrides, + }); + activeMonitors.push(monitor); + return monitor; + } +}); + +/** Wait for a VS Code event to fire once */ +function waitForEvent( + event: (listener: (e: T) => void) => { dispose(): void }, + timeout = 1000, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + disposable.dispose(); + reject(new Error(`waitForEvent timed out after ${timeout}ms`)); + }, timeout); + + const disposable = event((value) => { + clearTimeout(timer); + disposable.dispose(); + resolve(value); + }); + }); +} + +/** Poll for a condition to become true */ +async function waitFor( + condition: () => boolean, + timeout = 1000, + interval = 5, +): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeout) { + throw new Error(`waitFor timed out after ${timeout}ms`); + } + await new Promise((r) => setTimeout(r, interval)); + } +} From a26acbc99a2b3e5f3403a82d17579b8eb62574fa Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 2 Dec 2025 00:02:15 +0300 Subject: [PATCH 3/5] Refactoring --- src/remote/sshProcess.ts | 117 ++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 56 deletions(-) diff --git a/src/remote/sshProcess.ts b/src/remote/sshProcess.ts index 2b572aea..faebc711 100644 --- a/src/remote/sshProcess.ts +++ b/src/remote/sshProcess.ts @@ -28,58 +28,15 @@ export interface SshProcessMonitorOptions { networkInfoPath: string; proxyLogDir?: string; logger: Logger; + // Poll interval for SSH process and file discovery pollInterval?: number; + // Poll interval for network info updates networkPollInterval?: number; // For port-based SSH process discovery codeLogDir: string; remoteSshExtensionId: string; } -/** - * Finds the Remote SSH extension's log file path. - */ -async function findRemoteSshLogPath( - codeLogDir: string, - extensionId: string, -): Promise { - const logsParentDir = path.dirname(codeLogDir); - - // Try extension-specific folder (for VS Code clones like Cursor, Windsurf) - try { - const extensionLogDir = path.join(logsParentDir, extensionId); - // Node returns these directories sorted already! - const files = await fs.readdir(extensionLogDir); - files.reverse(); - - const remoteSsh = files.find((file) => file.includes("Remote - SSH")); - if (remoteSsh) { - return path.join(extensionLogDir, remoteSsh); - } - } catch { - // Extension-specific folder doesn't exist, try fallback - } - - try { - // Node returns these directories sorted already! - const dirs = await fs.readdir(logsParentDir); - dirs.reverse(); - const outputDirs = dirs.filter((d) => d.startsWith("output_logging_")); - - if (outputDirs.length > 0) { - const outputPath = path.join(logsParentDir, outputDirs[0]); - const files = await fs.readdir(outputPath); - const remoteSSHLog = files.find((f) => f.includes("Remote - SSH")); - if (remoteSSHLog) { - return path.join(outputPath, remoteSSHLog); - } - } - } catch { - // output_logging folder doesn't exist - } - - return undefined; -} - /** * Monitors the SSH process for a Coder workspace connection and displays * network status in the VS Code status bar. @@ -116,6 +73,7 @@ export class SshProcessMonitor implements vscode.Disposable { ...options, proxyLogDir: options.proxyLogDir, pollInterval: options.pollInterval ?? 1000, + // Matches the SSH update interval networkPollInterval: options.networkPollInterval ?? 3000, }; this.statusBarItem = vscode.window.createStatusBarItem( @@ -128,7 +86,7 @@ export class SshProcessMonitor implements vscode.Disposable { * Creates and starts an SSH process monitor. * Begins searching for the SSH process in the background. */ - static start(options: SshProcessMonitorOptions): SshProcessMonitor { + public static start(options: SshProcessMonitorOptions): SshProcessMonitor { const monitor = new SshProcessMonitor(options); monitor.searchForProcess().catch((err) => { options.logger.error("Error in SSH process monitor", err); @@ -187,16 +145,14 @@ export class SshProcessMonitor implements vscode.Disposable { while (!this.disposed) { attempt++; - if (attempt % 10 === 0) { + if (attempt === 1 || attempt % 10 === 0) { logger.debug( `SSH process search attempt ${attempt} for host: ${sshHost}`, ); } - // Try port-based discovery first (unique per VS Code window) const pidByPort = await this.findSshProcessByPort(); if (pidByPort !== undefined) { - logger.info(`Found SSH process by port (PID: ${pidByPort})`); this.setCurrentPid(pidByPort); this.startMonitoring(); return; @@ -250,16 +206,16 @@ export class SshProcessMonitor implements vscode.Disposable { const previousPid = this.currentPid; this.currentPid = pid; - if (previousPid !== undefined && previousPid !== pid) { + if (previousPid === undefined) { + this.options.logger.info(`SSH connection established (PID: ${pid})`); + this._onPidChange.fire(pid); + } else if (previousPid !== pid) { this.options.logger.info( `SSH process changed from ${previousPid} to ${pid}`, ); this.logFilePath = undefined; this._onLogFilePathChange.fire(undefined); this._onPidChange.fire(pid); - } else if (previousPid === undefined) { - this.options.logger.info(`SSH connection established (PID: ${pid})`); - this._onPidChange.fire(pid); } } @@ -349,7 +305,8 @@ export class SshProcessMonitor implements vscode.Disposable { const content = await fs.readFile(networkInfoFile, "utf8"); const network = JSON.parse(content) as NetworkInfo; - this.updateStatusBar(network); + const isStale = ageMs > this.options.networkPollInterval * 2; + this.updateStatusBar(network, isStale); } catch (error) { logger.debug( `Failed to read network info: ${(error as Error).message}`, @@ -363,7 +320,7 @@ export class SshProcessMonitor implements vscode.Disposable { /** * Updates the status bar with network information. */ - private updateStatusBar(network: NetworkInfo): void { + private updateStatusBar(network: NetworkInfo, isStale: boolean): void { let statusText = "$(globe) "; // Coder Connect doesn't populate any other stats @@ -409,8 +366,56 @@ export class SshProcessMonitor implements vscode.Disposable { } this.statusBarItem.tooltip = tooltip; - statusText += "(" + network.latency.toFixed(2) + "ms)"; + const latencyText = isStale + ? `(~${network.latency.toFixed(2)}ms)` + : `(${network.latency.toFixed(2)}ms)`; + statusText += latencyText; this.statusBarItem.text = statusText; this.statusBarItem.show(); } } + +/** + * Finds the Remote SSH extension's log file path. + */ +async function findRemoteSshLogPath( + codeLogDir: string, + extensionId: string, +): Promise { + const logsParentDir = path.dirname(codeLogDir); + + // Try extension-specific folder (for VS Code clones like Cursor, Windsurf) + try { + const extensionLogDir = path.join(logsParentDir, extensionId); + // Node returns these directories sorted already! + const files = await fs.readdir(extensionLogDir); + files.reverse(); + + const remoteSsh = files.find((file) => file.includes("Remote - SSH")); + if (remoteSsh) { + return path.join(extensionLogDir, remoteSsh); + } + } catch { + // Extension-specific folder doesn't exist, try fallback + } + + try { + // Node returns these directories sorted already! + const dirs = await fs.readdir(logsParentDir); + dirs.reverse(); + const outputDirs = dirs.filter((d) => d.startsWith("output_logging_")); + + if (outputDirs.length > 0) { + const outputPath = path.join(logsParentDir, outputDirs[0]); + const files = await fs.readdir(outputPath); + const remoteSSHLog = files.find((f) => f.includes("Remote - SSH")); + if (remoteSSHLog) { + return path.join(outputPath, remoteSSHLog); + } + } + } catch { + // output_logging folder doesn't exist + } + + return undefined; +} From be4a36abb88d4b369a7225ca2f8301fa12ee2982 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 4 Dec 2025 17:25:39 +0300 Subject: [PATCH 4/5] Address review comments --- src/remote/remote.ts | 31 ++-- src/remote/sshProcess.ts | 50 +++++-- src/util.ts | 9 +- test/unit/remote/sshProcess.test.ts | 17 ++- test/unit/util.test.ts | 220 +++++++++++++++++----------- 5 files changed, 205 insertions(+), 122 deletions(-) diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 1883486b..4193e46d 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -741,21 +741,24 @@ export class Remote { featureSet: FeatureSet, ): vscode.Disposable { return vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration("coder.proxyLogDirectory")) { - const newLogDir = this.getLogDir(featureSet); - if (newLogDir !== currentLogDir) { - vscode.window - .showInformationMessage( - "Log directory configuration changed. Reload window to apply.", - "Reload", - ) - .then((action) => { - if (action === "Reload") { - vscode.commands.executeCommand("workbench.action.reloadWindow"); - } - }); - } + if (!e.affectsConfiguration("coder.proxyLogDirectory")) { + return; } + const newLogDir = this.getLogDir(featureSet); + if (newLogDir === currentLogDir) { + return; + } + + vscode.window + .showInformationMessage( + "Log directory configuration changed. Reload window to apply.", + "Reload", + ) + .then((action) => { + if (action === "Reload") { + vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + }); }); } diff --git a/src/remote/sshProcess.ts b/src/remote/sshProcess.ts index faebc711..e86cf154 100644 --- a/src/remote/sshProcess.ts +++ b/src/remote/sshProcess.ts @@ -28,8 +28,10 @@ export interface SshProcessMonitorOptions { networkInfoPath: string; proxyLogDir?: string; logger: Logger; - // Poll interval for SSH process and file discovery - pollInterval?: number; + // Initial poll interval for SSH process and file discovery (ms) + discoveryPollIntervalMs?: number; + // Maximum backoff interval for process and file discovery (ms) + maxDiscoveryBackoffMs?: number; // Poll interval for network info updates networkPollInterval?: number; // For port-based SSH process discovery @@ -72,7 +74,8 @@ export class SshProcessMonitor implements vscode.Disposable { this.options = { ...options, proxyLogDir: options.proxyLogDir, - pollInterval: options.pollInterval ?? 1000, + discoveryPollIntervalMs: options.discoveryPollIntervalMs ?? 1000, + maxDiscoveryBackoffMs: options.maxDiscoveryBackoffMs ?? 30_000, // Matches the SSH update interval networkPollInterval: options.networkPollInterval ?? 3000, }; @@ -135,12 +138,13 @@ export class SshProcessMonitor implements vscode.Disposable { /** * Searches for the SSH process indefinitely until found or disposed. - * Tries port-based discovery first (more accurate), falls back to hostname-based. - * When found, starts monitoring. + * Starts monitoring when it finds the process through the port. */ private async searchForProcess(): Promise { - const { pollInterval, logger, sshHost } = this.options; + const { discoveryPollIntervalMs, maxDiscoveryBackoffMs, logger, sshHost } = + this.options; let attempt = 0; + let currentBackoff = discoveryPollIntervalMs; while (!this.disposed) { attempt++; @@ -158,7 +162,8 @@ export class SshProcessMonitor implements vscode.Disposable { return; } - await this.delay(pollInterval); + await this.delay(currentBackoff); + currentBackoff = Math.min(currentBackoff * 2, maxDiscoveryBackoffMs); } } @@ -173,13 +178,14 @@ export class SshProcessMonitor implements vscode.Disposable { const logPath = await findRemoteSshLogPath( codeLogDir, remoteSshExtensionId, + logger, ); if (!logPath) { return undefined; } const logContent = await fs.readFile(logPath, "utf8"); - this.options.logger.debug(`Read Remote SSH log file: ${logPath}`); + this.options.logger.debug(`Read Remote SSH log file:`, logPath); const port = findPort(logContent); if (!port) { @@ -235,11 +241,18 @@ export class SshProcessMonitor implements vscode.Disposable { * Polls until found or PID changes. */ private async searchForLogFile(): Promise { - const { proxyLogDir: logDir, logger, pollInterval } = this.options; + const { + proxyLogDir: logDir, + logger, + discoveryPollIntervalMs, + maxDiscoveryBackoffMs, + } = this.options; if (!logDir) { return; } + let currentBackoff = discoveryPollIntervalMs; + const targetPid = this.currentPid; while (!this.disposed && this.currentPid === targetPid) { try { @@ -263,7 +276,8 @@ export class SshProcessMonitor implements vscode.Disposable { logger.debug(`Could not read log directory: ${logDir}`); } - await this.delay(pollInterval); + await this.delay(currentBackoff); + currentBackoff = Math.min(currentBackoff * 2, maxDiscoveryBackoffMs); } } @@ -377,10 +391,13 @@ export class SshProcessMonitor implements vscode.Disposable { /** * Finds the Remote SSH extension's log file path. + * Tries extension-specific folder first (Cursor, Windsurf, Antigravity), + * then output_logging_ fallback (MS VS Code). */ async function findRemoteSshLogPath( codeLogDir: string, extensionId: string, + logger: Logger, ): Promise { const logsParentDir = path.dirname(codeLogDir); @@ -395,8 +412,12 @@ async function findRemoteSshLogPath( if (remoteSsh) { return path.join(extensionLogDir, remoteSsh); } + // Folder exists but no Remote SSH log yet + logger.debug( + `Extension log folder exists but no Remote SSH log found: ${extensionLogDir}`, + ); } catch { - // Extension-specific folder doesn't exist, try fallback + // Extension-specific folder doesn't exist - expected for MS VS Code, try fallback } try { @@ -412,9 +433,14 @@ async function findRemoteSshLogPath( if (remoteSSHLog) { return path.join(outputPath, remoteSSHLog); } + logger.debug( + `Output logging folder exists but no Remote SSH log found: ${outputPath}`, + ); + } else { + logger.debug(`No output_logging_ folders found in: ${logsParentDir}`); } } catch { - // output_logging folder doesn't exist + logger.debug(`Could not read logs parent directory: ${logsParentDir}`); } return undefined; diff --git a/src/util.ts b/src/util.ts index 56090826..776ba1db 100644 --- a/src/util.ts +++ b/src/util.ts @@ -14,12 +14,11 @@ export interface AuthorityParts { export const AuthorityPrefix = "coder-vscode"; // Regex patterns to find the SSH port from Remote SSH extension logs. -// `ms-vscode-remote.remote-ssh`: `-> socksPort ->` +// `ms-vscode-remote.remote-ssh`: `-> socksPort ->` or `between local port ` // `codeium.windsurf-remote-openssh`, `jeanp413.open-remote-ssh`, `google.antigravity-remote-openssh`: `=> (socks) =>` -// Windows `ms-vscode-remote.remote-ssh`: `between local port ` // `anysphere.remote-ssh`: `Socks port: ` export const RemoteSSHLogPortRegex = - /(?:-> socksPort (\d+) ->|=> (\d+)\(socks\) =>|between local port (\d+)|Socks port: (\d+))/g; + /(?:-> socksPort (\d+) ->|between local port (\d+)|=> (\d+)\(socks\) =>|Socks port: (\d+))/g; /** * Given the contents of a Remote - SSH log file, find the most recent port @@ -34,7 +33,11 @@ export function findPort(text: string): number | null { return null; } + // Get the last match, which is the most recent port. const lastMatch = allMatches.at(-1)!; + // Each capture group corresponds to a different Remote SSH extension log format: + // [0] full match, [1] and [2] ms-vscode-remote.remote-ssh, + // [3] windsurf/open-remote-ssh/antigravity, [4] anysphere.remote-ssh const portStr = lastMatch[1] || lastMatch[2] || lastMatch[3] || lastMatch[4]; if (!portStr) { return null; diff --git a/test/unit/remote/sshProcess.test.ts b/test/unit/remote/sshProcess.test.ts index d85d239a..1ec0e048 100644 --- a/test/unit/remote/sshProcess.test.ts +++ b/test/unit/remote/sshProcess.test.ts @@ -30,7 +30,7 @@ describe("SshProcessMonitor", () => { // Default: process found immediately vi.mocked(find).mockResolvedValue([ - { pid: 999, name: "ssh", cmd: "ssh host" }, + { pid: 999, ppid: 1, name: "ssh", cmd: "ssh host" }, ]); }); @@ -66,7 +66,9 @@ describe("SshProcessMonitor", () => { vi.mocked(find) .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ pid: 888, name: "ssh", cmd: "ssh host" }]); + .mockResolvedValueOnce([ + { pid: 888, ppid: 1, name: "ssh", cmd: "ssh host" }, + ]); const monitor = createMonitor({ codeLogDir: "/logs/window1" }); const pid = await waitForEvent(monitor.onPidChange); @@ -80,7 +82,7 @@ describe("SshProcessMonitor", () => { vol.fromJSON({}); vi.mocked(find).mockResolvedValue([ - { pid: 777, name: "ssh", cmd: "ssh host" }, + { pid: 777, ppid: 1, name: "ssh", cmd: "ssh host" }, ]); const monitor = createMonitor({ codeLogDir: "/logs/window1" }); @@ -116,8 +118,8 @@ describe("SshProcessMonitor", () => { // First search finds PID 999, after reconnect finds PID 888 vi.mocked(find) - .mockResolvedValueOnce([{ pid: 999, name: "ssh", cmd: "ssh" }]) - .mockResolvedValue([{ pid: 888, name: "ssh", cmd: "ssh" }]); + .mockResolvedValueOnce([{ pid: 999, ppid: 1, name: "ssh", cmd: "ssh" }]) + .mockResolvedValue([{ pid: 888, ppid: 1, name: "ssh", cmd: "ssh" }]); const monitor = createMonitor({ codeLogDir: "/logs/window1", @@ -158,7 +160,7 @@ describe("SshProcessMonitor", () => { // Always returns the same PID vi.mocked(find).mockResolvedValue([ - { pid: 999, name: "ssh", cmd: "ssh" }, + { pid: 999, ppid: 1, name: "ssh", cmd: "ssh" }, ]); const monitor = createMonitor({ @@ -395,7 +397,8 @@ describe("SshProcessMonitor", () => { codeLogDir: "/logs/window1", remoteSshExtensionId: "ms-vscode-remote.remote-ssh", logger: createMockLogger(), - pollInterval: 10, + discoveryPollIntervalMs: 10, + maxDiscoveryBackoffMs: 100, networkPollInterval: 10, ...overrides, }); diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index a5d6eb7a..3015a47d 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -5,99 +5,104 @@ import { countSubstring, escapeCommandArg, expandPath, + findPort, parseRemoteAuthority, toSafeHost, } from "@/util"; -it("ignore unrelated authorities", () => { - const tests = [ - "vscode://ssh-remote+some-unrelated-host.com", - "vscode://ssh-remote+coder-vscode", - "vscode://ssh-remote+coder-vscode-test", - "vscode://ssh-remote+coder-vscode-test--foo--bar", - "vscode://ssh-remote+coder-vscode-foo--bar", - "vscode://ssh-remote+coder--foo--bar", - ]; - for (const test of tests) { - expect(parseRemoteAuthority(test)).toBe(null); - } -}); - -it("should error on invalid authorities", () => { - const tests = [ - "vscode://ssh-remote+coder-vscode--foo", - "vscode://ssh-remote+coder-vscode--", - "vscode://ssh-remote+coder-vscode--foo--", - "vscode://ssh-remote+coder-vscode--foo--bar--", - ]; - for (const test of tests) { - expect(() => parseRemoteAuthority(test)).toThrow("Invalid"); - } -}); - -it("should parse authority", () => { - expect( - parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar"), - ).toStrictEqual({ - agent: "", - host: "coder-vscode--foo--bar", - label: "", - username: "foo", - workspace: "bar", - }); - expect( - parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar--baz"), - ).toStrictEqual({ - agent: "baz", - host: "coder-vscode--foo--bar--baz", - label: "", - username: "foo", - workspace: "bar", - }); - expect( - parseRemoteAuthority( - "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar", - ), - ).toStrictEqual({ - agent: "", - host: "coder-vscode.dev.coder.com--foo--bar", - label: "dev.coder.com", - username: "foo", - workspace: "bar", - }); - expect( - parseRemoteAuthority( - "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--baz", - ), - ).toStrictEqual({ - agent: "baz", - host: "coder-vscode.dev.coder.com--foo--bar--baz", - label: "dev.coder.com", - username: "foo", - workspace: "bar", - }); - expect( - parseRemoteAuthority( - "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz", - ), - ).toStrictEqual({ - agent: "baz", - host: "coder-vscode.dev.coder.com--foo--bar.baz", - label: "dev.coder.com", - username: "foo", - workspace: "bar", +describe("parseRemoteAuthority", () => { + it("ignore unrelated authorities", () => { + const tests = [ + "vscode://ssh-remote+some-unrelated-host.com", + "vscode://ssh-remote+coder-vscode", + "vscode://ssh-remote+coder-vscode-test", + "vscode://ssh-remote+coder-vscode-test--foo--bar", + "vscode://ssh-remote+coder-vscode-foo--bar", + "vscode://ssh-remote+coder--foo--bar", + ]; + for (const test of tests) { + expect(parseRemoteAuthority(test)).toBe(null); + } + }); + + it("should error on invalid authorities", () => { + const tests = [ + "vscode://ssh-remote+coder-vscode--foo", + "vscode://ssh-remote+coder-vscode--", + "vscode://ssh-remote+coder-vscode--foo--", + "vscode://ssh-remote+coder-vscode--foo--bar--", + ]; + for (const test of tests) { + expect(() => parseRemoteAuthority(test)).toThrow("Invalid"); + } + }); + + it("should parse authority", () => { + expect( + parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar"), + ).toStrictEqual({ + agent: "", + host: "coder-vscode--foo--bar", + label: "", + username: "foo", + workspace: "bar", + }); + expect( + parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar--baz"), + ).toStrictEqual({ + agent: "baz", + host: "coder-vscode--foo--bar--baz", + label: "", + username: "foo", + workspace: "bar", + }); + expect( + parseRemoteAuthority( + "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar", + ), + ).toStrictEqual({ + agent: "", + host: "coder-vscode.dev.coder.com--foo--bar", + label: "dev.coder.com", + username: "foo", + workspace: "bar", + }); + expect( + parseRemoteAuthority( + "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--baz", + ), + ).toStrictEqual({ + agent: "baz", + host: "coder-vscode.dev.coder.com--foo--bar--baz", + label: "dev.coder.com", + username: "foo", + workspace: "bar", + }); + expect( + parseRemoteAuthority( + "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz", + ), + ).toStrictEqual({ + agent: "baz", + host: "coder-vscode.dev.coder.com--foo--bar.baz", + label: "dev.coder.com", + username: "foo", + workspace: "bar", + }); }); }); -it("escapes url host", () => { - expect(toSafeHost("https://foobar:8080")).toBe("foobar"); - expect(toSafeHost("https://ほげ")).toBe("xn--18j4d"); - expect(toSafeHost("https://test.😉.invalid")).toBe("test.xn--n28h.invalid"); - expect(toSafeHost("https://dev.😉-coder.com")).toBe( - "dev.xn---coder-vx74e.com", - ); - expect(() => toSafeHost("invalid url")).toThrow("Invalid URL"); - expect(toSafeHost("http://ignore-port.com:8080")).toBe("ignore-port.com"); +describe("toSafeHost", () => { + it("escapes url host", () => { + expect(toSafeHost("https://foobar:8080")).toBe("foobar"); + expect(toSafeHost("https://ほげ")).toBe("xn--18j4d"); + expect(toSafeHost("https://test.😉.invalid")).toBe("test.xn--n28h.invalid"); + expect(toSafeHost("https://dev.😉-coder.com")).toBe( + "dev.xn---coder-vx74e.com", + ); + expect(() => toSafeHost("invalid url")).toThrow("Invalid URL"); + expect(toSafeHost("http://ignore-port.com:8080")).toBe("ignore-port.com"); + }); }); describe("countSubstring", () => { @@ -190,3 +195,46 @@ describe("expandPath", () => { expect(expandPath("~/${userHome}/foo")).toBe(`${home}/${home}/foo`); }); }); + +describe("findPort", () => { + it.each([[""], ["some random log text without ports"]])( + "returns null for <%s>", + (input) => { + expect(findPort(input)).toBe(null); + }, + ); + + it.each([ + [ + "ms-vscode-remote.remote-ssh", + "[10:30:45] SSH established -> socksPort 12345 -> ready", + 12345, + ], + [ + "ms-vscode-remote.remote-ssh[2]", + "Forwarding between local port 54321 and remote", + 54321, + ], + [ + "windsurf/open-remote-ssh/antigravity", + "[INFO] Connection => 9999(socks) => target", + 9999, + ], + [ + "anysphere.remote-ssh", + "[DEBUG] Initialized Socks port: 8888 proxy", + 8888, + ], + ])("finds port from %s log format", (_name, input, expected) => { + expect(findPort(input)).toBe(expected); + }); + + it("returns most recent port when multiple matches exist", () => { + const log = ` +[10:30:00] Starting connection -> socksPort 1111 -> initialized +[10:30:05] Reconnecting => 2222(socks) => retry +[10:30:10] Final connection Socks port: 3333 established + `; + expect(findPort(log)).toBe(3333); + }); +}); From 081819e6df8d2fdc20a4aa3a248ee8f5a3a94e88 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 8 Dec 2025 12:15:46 +0300 Subject: [PATCH 5/5] Add CHANGELOG entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ebd676..425ed11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - WebSocket connections now automatically reconnect on network failures, improving reliability when communicating with Coder deployments. +- Improved SSH process and log file discovery with better reconnect handling and support for + VS Code forks (Cursor, Windsurf, Antigravity). ## [v1.11.4](https://github.com/coder/vscode-coder/releases/tag/v1.11.4) 2025-11-20