diff --git a/package.json b/package.json index 1b63145e..6609fe95 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,11 @@ "type": "boolean", "default": false }, + "coder.networkThreshold.latencyMs": { + "markdownDescription": "Latency threshold in milliseconds. A warning indicator appears in the status bar when latency exceeds this value. Set to `0` to disable.", + "type": "number", + "default": 200 + }, "coder.httpClientLogLevel": { "markdownDescription": "Controls the verbosity of HTTP client logging. This affects what details are logged for each HTTP request and response.", "type": "string", diff --git a/src/remote/networkStatus.ts b/src/remote/networkStatus.ts new file mode 100644 index 00000000..a6ff653c --- /dev/null +++ b/src/remote/networkStatus.ts @@ -0,0 +1,153 @@ +import prettyBytes from "pretty-bytes"; +import * as vscode from "vscode"; + +import type { NetworkInfo } from "./sshProcess"; + +/** Number of consecutive polls required to trigger or clear a warning */ +const WARNING_DEBOUNCE_THRESHOLD = 3; + +const WARNING_BACKGROUND = new vscode.ThemeColor( + "statusBarItem.warningBackground", +); + +export interface NetworkThresholds { + latencyMs: number; +} + +function getThresholdConfig(): NetworkThresholds { + const cfg = vscode.workspace.getConfiguration("coder"); + return { + latencyMs: cfg.get("networkThreshold.latencyMs", 200), + }; +} + +export function isLatencySlow( + network: NetworkInfo, + thresholds: NetworkThresholds, +): boolean { + return thresholds.latencyMs > 0 && network.latency > thresholds.latencyMs; +} + +export function buildNetworkTooltip( + network: NetworkInfo, + latencySlow: boolean, + thresholds: NetworkThresholds, +): vscode.MarkdownString { + const fmt = (bytesPerSec: number) => + prettyBytes(bytesPerSec * 8, { bits: true }) + "/s"; + + const sections: string[] = []; + + if (latencySlow) { + sections.push("$(warning) **Slow connection detected**"); + } + + const metrics: string[] = []; + metrics.push( + latencySlow + ? `Latency: ${network.latency.toFixed(2)}ms (threshold: ${thresholds.latencyMs}ms)` + : `Latency: ${network.latency.toFixed(2)}ms`, + ); + metrics.push(`Download: ${fmt(network.download_bytes_sec)}`); + metrics.push(`Upload: ${fmt(network.upload_bytes_sec)}`); + + if (network.using_coder_connect) { + metrics.push("Connection: Coder Connect"); + } else if (network.p2p) { + metrics.push("Connection: Direct (P2P)"); + } else { + metrics.push(`Connection: ${network.preferred_derp} (relay)`); + } + + // Two trailing spaces + \n = hard line break (tight rows within a section). + sections.push(metrics.join(" \n")); + + if (latencySlow) { + sections.push( + "[$(pulse) Ping workspace](command:coder.pingWorkspace) ยท " + + "[$(gear) Configure threshold](command:workbench.action.openSettings?%22coder.networkThreshold%22)", + ); + } + + // Blank line between sections = paragraph break. + const md = new vscode.MarkdownString(sections.join("\n\n")); + md.isTrusted = true; + md.supportThemeIcons = true; + return md; +} + +/** + * Manages network status bar presentation and slowness warning state. + * Owns the warning debounce logic and status bar updates. + */ +export class NetworkStatusReporter { + private warningCounter = 0; + private isWarningActive = false; + + constructor(private readonly statusBarItem: vscode.StatusBarItem) {} + + update(network: NetworkInfo, isStale: boolean): void { + let statusText = "$(globe) "; + + // Coder Connect doesn't populate any other stats + if (network.using_coder_connect) { + this.warningCounter = 0; + this.isWarningActive = false; + this.statusBarItem.text = statusText + "Coder Connect "; + this.statusBarItem.tooltip = "You're connected using Coder Connect."; + this.statusBarItem.backgroundColor = undefined; + this.statusBarItem.command = undefined; + this.statusBarItem.show(); + return; + } + + const thresholds = getThresholdConfig(); + const latencySlow = isLatencySlow(network, thresholds); + this.updateWarningState(latencySlow); + + if (network.p2p) { + statusText += "Direct "; + } else { + statusText += network.preferred_derp + " "; + } + + const latencyText = isStale + ? `(~${network.latency.toFixed(2)}ms)` + : `(${network.latency.toFixed(2)}ms)`; + statusText += latencyText; + this.statusBarItem.text = statusText; + + if (this.isWarningActive) { + this.statusBarItem.backgroundColor = WARNING_BACKGROUND; + this.statusBarItem.command = "coder.pingWorkspace"; + } else { + this.statusBarItem.backgroundColor = undefined; + this.statusBarItem.command = undefined; + } + + this.statusBarItem.tooltip = buildNetworkTooltip( + network, + this.isWarningActive, + thresholds, + ); + + this.statusBarItem.show(); + } + + private updateWarningState(latencySlow: boolean): void { + if (latencySlow) { + this.warningCounter = Math.min( + this.warningCounter + 1, + WARNING_DEBOUNCE_THRESHOLD, + ); + } else { + this.warningCounter = Math.max(this.warningCounter - 1, 0); + } + + if (this.warningCounter >= WARNING_DEBOUNCE_THRESHOLD) { + this.isWarningActive = true; + } else if (this.warningCounter === 0) { + this.isWarningActive = false; + } + } +} diff --git a/src/remote/sshProcess.ts b/src/remote/sshProcess.ts index c53c31a8..df88938a 100644 --- a/src/remote/sshProcess.ts +++ b/src/remote/sshProcess.ts @@ -1,12 +1,14 @@ 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"; +import { NetworkStatusReporter } from "./networkStatus"; + +import type { Logger } from "../logging/logger"; + /** * Network information from the Coder CLI. */ @@ -76,6 +78,7 @@ export class SshProcessMonitor implements vscode.Disposable { private logFilePath: string | undefined; private pendingTimeout: NodeJS.Timeout | undefined; private lastStaleSearchTime = 0; + private readonly reporter: NetworkStatusReporter; /** * Helper to clean up files in a directory. @@ -195,6 +198,7 @@ export class SshProcessMonitor implements vscode.Disposable { vscode.StatusBarAlignment.Left, 1000, ); + this.reporter = new NetworkStatusReporter(this.statusBarItem); } /** @@ -457,9 +461,7 @@ export class SshProcessMonitor implements vscode.Disposable { while (!this.disposed && this.currentPid !== undefined) { const filePath = path.join(networkInfoPath, `${this.currentPid}.json`); - let search: { needed: true; reason: string } | { needed: false } = { - needed: false, - }; + let searchReason: string | undefined; try { const stats = await fs.stat(filePath); @@ -467,15 +469,12 @@ export class SshProcessMonitor implements vscode.Disposable { readFailures = 0; if (ageMs > staleThreshold) { - search = { - needed: true, - reason: `Network info stale (${Math.round(ageMs / 1000)}s old)`, - }; + searchReason = `Network info stale (${Math.round(ageMs / 1000)}s old)`; } else { const content = await fs.readFile(filePath, "utf8"); const network = JSON.parse(content) as NetworkInfo; const isStale = ageMs > networkPollInterval * 2; - this.updateStatusBar(network, isStale); + this.reporter.update(network, isStale); } } catch (error) { readFailures++; @@ -483,22 +482,18 @@ export class SshProcessMonitor implements vscode.Disposable { `Failed to read network info (attempt ${readFailures}): ${(error as Error).message}`, ); if (readFailures >= maxReadFailures) { - search = { - needed: true, - reason: `Network info missing for ${readFailures} attempts`, - }; + searchReason = `Network info missing for ${readFailures} attempts`; } } - // Search for new process if needed (with throttling) - if (search.needed) { + if (searchReason !== undefined) { const timeSinceLastSearch = Date.now() - this.lastStaleSearchTime; if (timeSinceLastSearch < staleThreshold) { await this.delay(staleThreshold - timeSinceLastSearch); continue; } - logger.debug(`${search.reason}, searching for new SSH process`); + logger.debug(`${searchReason}, searching for new SSH process`); // searchForProcess will update PID if a different process is found this.lastStaleSearchTime = Date.now(); await this.searchForProcess(); @@ -508,63 +503,6 @@ export class SshProcessMonitor implements vscode.Disposable { await this.delay(networkPollInterval); } } - - /** - * Updates the status bar with network information. - */ - private updateStatusBar(network: NetworkInfo, isStale: boolean): 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; - const latencyText = isStale - ? `(~${network.latency.toFixed(2)}ms)` - : `(${network.latency.toFixed(2)}ms)`; - statusText += latencyText; - this.statusBarItem.text = statusText; - this.statusBarItem.show(); - } } /** diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index f8e3b490..633155e5 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -40,6 +40,23 @@ export const InputBoxValidationSeverity = E({ Error: 3, }); +export class MarkdownString { + value: string; + isTrusted = false; + supportThemeIcons = false; + + constructor(value = "") { + this.value = value; + } +} + +export class ThemeColor { + id: string; + constructor(id: string) { + this.id = id; + } +} + export class Uri { constructor( public scheme: string, @@ -118,7 +135,7 @@ export const window = { }; export const commands = { - registerCommand: vi.fn(), + registerCommand: vi.fn(() => ({ dispose: vi.fn() })), executeCommand: vi.fn(), }; @@ -170,6 +187,8 @@ const vscode = { InputBoxValidationSeverity, Uri, EventEmitter, + MarkdownString, + ThemeColor, window, commands, workspace, diff --git a/test/unit/remote/networkStatus.test.ts b/test/unit/remote/networkStatus.test.ts new file mode 100644 index 00000000..4a9dcb96 --- /dev/null +++ b/test/unit/remote/networkStatus.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { ThemeColor } from "vscode"; + +import { + buildNetworkTooltip, + isLatencySlow, + NetworkStatusReporter, + type NetworkThresholds, +} from "@/remote/networkStatus"; + +import { + MockConfigurationProvider, + MockStatusBar, +} from "../../mocks/testHelpers"; + +import type { NetworkInfo } from "@/remote/sshProcess"; + +function makeNetwork(overrides: Partial = {}): NetworkInfo { + return { + p2p: true, + latency: 50, + preferred_derp: "NYC", + derp_latency: { NYC: 10 }, + upload_bytes_sec: 1_250_000, + download_bytes_sec: 6_250_000, + using_coder_connect: false, + ...overrides, + }; +} + +const defaultThresholds: NetworkThresholds = { latencyMs: 200 }; + +function tooltip( + overrides: Partial = {}, + options: { + latencySlow?: boolean; + thresholds?: NetworkThresholds; + } = {}, +) { + return buildNetworkTooltip( + makeNetwork(overrides), + options.latencySlow ?? false, + options.thresholds ?? defaultThresholds, + ); +} + +describe("isLatencySlow", () => { + it("returns false when latency is within threshold", () => { + expect(isLatencySlow(makeNetwork({ latency: 50 }), defaultThresholds)).toBe( + false, + ); + }); + + it("returns true when latency exceeds threshold", () => { + expect( + isLatencySlow(makeNetwork({ latency: 250 }), defaultThresholds), + ).toBe(true); + }); + + it("ignores latency when threshold is 0", () => { + expect( + isLatencySlow(makeNetwork({ latency: 9999 }), { latencyMs: 0 }), + ).toBe(false); + }); +}); + +describe("buildNetworkTooltip", () => { + it("shows all metrics without warning or actions in normal state", () => { + const t = tooltip(); + expect(t.value).toContain("Latency: 50.00ms"); + expect(t.value).toContain("Download: 50 Mbit/s"); + expect(t.value).toContain("Upload: 10 Mbit/s"); + expect(t.value).not.toContain("$(warning)"); + expect(t.value).not.toContain("Slow connection"); + expect(t.value).not.toContain("command:coder.pingWorkspace"); + }); + + it("shows warning header, threshold, and action links when latency is slow", () => { + const t = tooltip({ latency: 350 }, { latencySlow: true }); + expect(t.value).toContain("$(warning) **Slow connection detected**"); + expect(t.value).toContain("Latency: 350.00ms (threshold: 200ms)"); + expect(t.value).toContain("command:coder.pingWorkspace"); + expect(t.value).toContain("command:workbench.action.openSettings"); + expect(t.value).toContain("Ping workspace"); + expect(t.value).toContain("Configure threshold"); + }); + + it("does not mark throughput lines with warnings", () => { + const t = tooltip({ download_bytes_sec: 100_000 }, { latencySlow: true }); + expect(t.value).not.toContain("Download: 800 kbit/s $(warning)"); + }); + + it.each<{ desc: string; overrides: Partial; expected: string }>([ + { + desc: "P2P", + overrides: { p2p: true }, + expected: "Connection: Direct (P2P)", + }, + { + desc: "relay", + overrides: { p2p: false, preferred_derp: "SFO" }, + expected: "Connection: SFO (relay)", + }, + { + desc: "Coder Connect", + overrides: { using_coder_connect: true }, + expected: "Connection: Coder Connect", + }, + ])("shows $desc connection type", ({ overrides, expected }) => { + expect(tooltip(overrides).value).toContain(expected); + }); +}); + +describe("NetworkStatusReporter hysteresis", () => { + function setup(latencyMs: number) { + const cfg = new MockConfigurationProvider(); + cfg.set("coder.networkThreshold.latencyMs", latencyMs); + const bar = new MockStatusBar(); + const reporter = new NetworkStatusReporter( + bar as unknown as import("vscode").StatusBarItem, + ); + return { bar, reporter }; + } + + const slow = makeNetwork({ latency: 500 }); + const healthy = makeNetwork({ latency: 50 }); + + it("does not warn if slow polls never reach the debounce threshold", () => { + const { bar, reporter } = setup(100); + reporter.update(slow, false); + reporter.update(slow, false); + reporter.update(healthy, false); + reporter.update(healthy, false); + expect(bar.backgroundColor).toBeUndefined(); + expect(bar.command).toBeUndefined(); + }); + + it("stays warning if a single healthy poll appears mid-streak", () => { + const { bar, reporter } = setup(100); + for (let i = 0; i < 3; i++) { + reporter.update(slow, false); + } + expect(bar.backgroundColor).toBeInstanceOf(ThemeColor); + + reporter.update(healthy, false); + expect(bar.backgroundColor).toBeInstanceOf(ThemeColor); + + reporter.update(slow, false); + expect(bar.backgroundColor).toBeInstanceOf(ThemeColor); + }); + + it("clears immediately when Coder Connect takes over", () => { + const { bar, reporter } = setup(100); + for (let i = 0; i < 3; i++) { + reporter.update(slow, false); + } + expect(bar.backgroundColor).toBeInstanceOf(ThemeColor); + + reporter.update(makeNetwork({ using_coder_connect: true }), false); + expect(bar.backgroundColor).toBeUndefined(); + expect(bar.command).toBeUndefined(); + }); +}); diff --git a/test/unit/remote/sshProcess.test.ts b/test/unit/remote/sshProcess.test.ts index b8726b33..021b9424 100644 --- a/test/unit/remote/sshProcess.test.ts +++ b/test/unit/remote/sshProcess.test.ts @@ -3,16 +3,35 @@ import { vol } from "memfs"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ThemeColor } from "vscode"; import { SshProcessMonitor, + type NetworkInfo, type SshProcessMonitorOptions, } from "@/remote/sshProcess"; -import { createMockLogger, MockStatusBar } from "../../mocks/testHelpers"; +import { + createMockLogger, + MockConfigurationProvider, + MockStatusBar, +} from "../../mocks/testHelpers"; import type * as fs from "node:fs"; +function makeNetworkJson(overrides: Partial = {}): string { + return JSON.stringify({ + p2p: true, + latency: 50, + preferred_derp: "NYC", + derp_latency: { NYC: 10 }, + upload_bytes_sec: 1_000_000, + download_bytes_sec: 5_000_000, + using_coder_connect: false, + ...overrides, + }); +} + vi.mock("find-process", () => ({ default: vi.fn() })); vi.mock("node:fs/promises", async () => { @@ -29,6 +48,8 @@ describe("SshProcessMonitor", () => { vol.reset(); activeMonitors = []; statusBar = new MockStatusBar(); + // Provide default threshold config so getThresholdConfig() works + new MockConfigurationProvider(); // Default: process found immediately vi.mocked(find).mockResolvedValue([ @@ -402,20 +423,20 @@ describe("SshProcessMonitor", () => { }); }); + function tooltipText(): string { + const t = statusBar.tooltip; + if (typeof t === "string") { + return t; + } + return t?.value ?? ""; + } + 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, - }), + "/network/999.json": makeNetworkJson({ latency: 25.5 }), }); createMonitor({ @@ -426,21 +447,17 @@ describe("SshProcessMonitor", () => { expect(statusBar.text).toContain("Direct"); expect(statusBar.text).toContain("25.50ms"); - expect(statusBar.tooltip).toContain("peer-to-peer"); + expect(tooltipText()).toContain("Direct (P2P)"); }); 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({ + "/network/999.json": makeNetworkJson({ 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, }), }); @@ -451,22 +468,14 @@ describe("SshProcessMonitor", () => { await waitFor(() => statusBar.text.includes("SFO")); expect(statusBar.text).toContain("SFO"); - expect(statusBar.tooltip).toContain("relay"); + expect(tooltipText()).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, - }), + "/network/999.json": makeNetworkJson({ using_coder_connect: true }), }); createMonitor({ @@ -768,6 +777,88 @@ describe("SshProcessMonitor", () => { }); }); + describe("slowness detection", () => { + const sshLog = { + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + }; + + function setThresholds(latencyMs: number) { + const mockConfig = new MockConfigurationProvider(); + mockConfig.set("coder.networkThreshold.latencyMs", latencyMs); + } + + function startWithNetwork(networkOverrides: Partial = {}) { + vol.fromJSON({ + ...sshLog, + "/network/999.json": makeNetworkJson(networkOverrides), + }); + return createMonitor({ + codeLogDir: "/logs/window1", + networkInfoPath: "/network", + networkPollInterval: 10, + }); + } + + it("shows warning background after 3 consecutive slow polls", async () => { + setThresholds(100); + startWithNetwork({ latency: 200 }); + + await waitFor( + () => statusBar.backgroundColor instanceof ThemeColor, + 2000, + ); + expect(statusBar.backgroundColor).toBeInstanceOf(ThemeColor); + }); + + it("clears warning after 3 consecutive healthy polls", async () => { + setThresholds(100); + startWithNetwork({ latency: 200 }); + + await waitFor( + () => statusBar.backgroundColor instanceof ThemeColor, + 2000, + ); + + // Improve latency โ€” warning should clear after 3 healthy polls + vol.fromJSON({ + ...sshLog, + "/network/999.json": makeNetworkJson({ latency: 50 }), + }); + await waitFor(() => statusBar.backgroundColor === undefined, 2000); + expect(statusBar.backgroundColor).toBeUndefined(); + }); + + it("sets ping command when latency is slow", async () => { + setThresholds(100); + startWithNetwork({ latency: 200 }); + + await waitFor(() => statusBar.command === "coder.pingWorkspace", 2000); + expect(statusBar.command).toBe("coder.pingWorkspace"); + }); + + it("does not show warning for Coder Connect connections", async () => { + setThresholds(100); + startWithNetwork({ using_coder_connect: true }); + + await waitFor(() => statusBar.text.includes("Coder Connect"), 2000); + expect(statusBar.backgroundColor).toBeUndefined(); + expect(statusBar.command).toBeUndefined(); + }); + + it("includes threshold info in tooltip when warning is active", async () => { + setThresholds(100); + startWithNetwork({ latency: 200 }); + + await waitFor( + () => statusBar.backgroundColor instanceof ThemeColor, + 2000, + ); + expect(tooltipText()).toContain("threshold: 100ms"); + expect(tooltipText()).toContain("Ping workspace"); + }); + }); + function createMonitor(overrides: Partial = {}) { const monitor = SshProcessMonitor.start({ sshHost: "coder-vscode--user--workspace",