Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@
"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",
"minimum": 0,
"default": 250
},
"coder.httpClientLogLevel": {
"markdownDescription": "Controls the verbosity of HTTP client logging. This affects what details are logged for each HTTP request and response.",
"type": "string",
Expand Down
147 changes: 147 additions & 0 deletions src/remote/networkStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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 = 2;

const WARNING_BACKGROUND = new vscode.ThemeColor(
"statusBarItem.warningBackground",
);

const CODER_CONNECT_TEXT = "$(globe) Coder Connect";
const CODER_CONNECT_TOOLTIP = markdown(
"$(cloud) Connected using Coder Connect. Detailed network stats aren't collected for this connection type.",
);

interface NetworkThresholds {
latencyMs: number;
}

function connectionSummary(network: NetworkInfo): string {
if (network.p2p) {
return "$(zap) Directly connected peer-to-peer.";
}
return `$(broadcast) Connected via ${network.preferred_derp} relay. Will switch to peer-to-peer when available.`;
}

function buildStatusText(network: NetworkInfo, isStale: boolean): string {
const label = network.p2p ? "Direct" : network.preferred_derp;
const staleMarker = isStale ? "~" : "";
return `$(globe) ${label} (${staleMarker}${network.latency.toFixed(2)}ms)`;
}

/**
* Manages network status bar presentation.
* Warning state is debounced over consecutive polls to avoid flicker.
*/
export class NetworkStatusReporter {
private warningCounter = 0;
private isWarningActive = false;

constructor(private readonly statusBarItem: vscode.StatusBarItem) {}

update(network: NetworkInfo, isStale: boolean): void {
// Coder Connect doesn't populate latency/throughput, so we show a dedicated
// message and skip the slowness machinery entirely.
if (network.using_coder_connect) {
this.warningCounter = 0;
this.isWarningActive = false;
this.statusBarItem.text = CODER_CONNECT_TEXT;
this.statusBarItem.tooltip = CODER_CONNECT_TOOLTIP;
this.statusBarItem.backgroundColor = undefined;
this.statusBarItem.command = undefined;
this.statusBarItem.show();
return;
}

const thresholds: NetworkThresholds = {
latencyMs: vscode.workspace
.getConfiguration("coder")
.get<number>("networkThreshold.latencyMs", 250),
Comment thread
EhabY marked this conversation as resolved.
};
const isSlow =
thresholds.latencyMs > 0 && network.latency > thresholds.latencyMs;
this.updateWarningState(isSlow);

this.statusBarItem.text = buildStatusText(network, isStale);
this.statusBarItem.tooltip = this.buildTooltip(
network,
thresholds,
isStale,
);
this.statusBarItem.backgroundColor = this.isWarningActive
? WARNING_BACKGROUND
: undefined;
this.statusBarItem.command = this.isWarningActive
? "coder.pingWorkspace"
: undefined;
this.statusBarItem.show();
}

private updateWarningState(isSlow: boolean): void {
if (isSlow) {
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;
}
}

private buildTooltip(
network: NetworkInfo,
thresholds: NetworkThresholds,
isStale: boolean,
): vscode.MarkdownString {
const fmt = (bytesPerSec: number) =>
prettyBytes(bytesPerSec * 8, { bits: true }) + "/s";

const sections: string[] = [];
if (this.isWarningActive) {
sections.push("$(warning) **Slow connection detected**");
}
sections.push(connectionSummary(network));

const thresholdSuffix =
thresholds.latencyMs > 0 ? ` (threshold: ${thresholds.latencyMs}ms)` : "";
const metrics = [
`Latency: ${network.latency.toFixed(2)}ms${thresholdSuffix}`,
`Download: ${fmt(network.download_bytes_sec)}`,
`Upload: ${fmt(network.upload_bytes_sec)}`,
];
// Two trailing spaces + \n = hard line break (tight rows within a section).
sections.push(metrics.join(" \n"));

if (this.isWarningActive) {
sections.push(
"[$(pulse) Run latency test](command:coder.pingWorkspace) · " +
"[$(gear) Configure threshold](command:workbench.action.openSettings?%22coder.networkThreshold%22)",
);
}

if (isStale) {
sections.push(
"$(history) Readings are stale; waiting for a fresh sample.",
);
}

// Blank line between sections = paragraph break.
return markdown(sections.join("\n\n"));
}
}

function markdown(value: string): vscode.MarkdownString {
const md = new vscode.MarkdownString(value);
md.isTrusted = true;
md.supportThemeIcons = true;
return md;
}
87 changes: 13 additions & 74 deletions src/remote/sshProcess.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -195,6 +198,7 @@ export class SshProcessMonitor implements vscode.Disposable {
vscode.StatusBarAlignment.Left,
1000,
);
this.reporter = new NetworkStatusReporter(this.statusBarItem);
}

/**
Expand Down Expand Up @@ -457,48 +461,40 @@ 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,
};
// undefined = file read OK; string = reason to trigger a new process search
let searchReason: string | undefined;

try {
const stats = await fs.stat(filePath);
const ageMs = Date.now() - stats.mtime.getTime();
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++;
logger.debug(
`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();
Expand All @@ -508,63 +504,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();
}
}

/**
Expand Down
37 changes: 23 additions & 14 deletions test/mocks/testHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,29 @@ import type { IncomingMessage } from "node:http";
import type { CoderApi } from "@/api/coderApi";
import type { CliCredentialManager } from "@/core/cliCredentialManager";
import type { Logger } from "@/logging/logger";
import type { NetworkInfo } from "@/remote/sshProcess";
import type {
EventHandler,
EventPayloadMap,
ParsedMessageEvent,
UnidirectionalStream,
} from "@/websocket/eventStreamConnection";

export function makeNetworkInfo(
overrides: Partial<NetworkInfo> = {},
): 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,
};
}

/**
* Mock configuration provider that integrates with the vscode workspace configuration mock.
* Use this to set configuration values that will be returned by vscode.workspace.getConfiguration().
Expand Down Expand Up @@ -480,23 +496,25 @@ export function createMockStream(
* Mock status bar that integrates with vscode.window.createStatusBarItem.
* Use this to inspect status bar state in tests.
*/
export class MockStatusBar {
export class MockStatusBarItem implements vscode.StatusBarItem {
readonly id = "mock-status-bar";
text = "";
tooltip: string | vscode.MarkdownString = "";
tooltip: string | vscode.MarkdownString | undefined = "";
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 priority: number | undefined = undefined;
readonly alignment: vscode.StatusBarAlignment =
vscode.StatusBarAlignment.Left;

readonly show = vi.fn();
readonly hide = vi.fn();
readonly dispose = vi.fn();

constructor() {
this.setupVSCodeMock();
vi.mocked(vscode.window.createStatusBarItem).mockReturnValue(this);
}

/**
Expand All @@ -512,15 +530,6 @@ export class MockStatusBar {
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,
);
}
}

/**
Expand Down
Loading