Skip to content

Commit 1f96268

Browse files
committed
feat: add Coder Ping Workspace command (#749)
Add a `coder.pingWorkspace` command that runs `coder ping <workspace>` locally and streams output to a custom PTY terminal. This helps users diagnose connection problems by showing latency, P2P diagnostics, and DERP info in real-time. The command supports three invocation modes: - Command Palette: shows a workspace picker (filtered to running) - Sidebar context menu: under a new "Diagnostics" submenu - When connected: pings the current workspace automatically Other changes: - Extract `resolveCliEnv` to deduplicate CLI bootstrap boilerplate - Parameterize `pickWorkspace` with filter/title options and fix QuickPick disposal leak - Encode workspace running status in tree item contextValue using a tag-based format (e.g. `coderWorkspaceSingleAgent+running`) so status-dependent menus use regex matching
1 parent c9e552d commit 1f96268

File tree

4 files changed

+270
-55
lines changed

4 files changed

+270
-55
lines changed

package.json

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,12 @@
259259
"when": "!coder.authenticated && coder.loaded"
260260
}
261261
],
262+
"submenus": [
263+
{
264+
"id": "coder.diagnostics",
265+
"label": "Diagnostics"
266+
}
267+
],
262268
"commands": [
263269
{
264270
"command": "coder.login",
@@ -363,6 +369,11 @@
363369
"command": "coder.applyRecommendedSettings",
364370
"title": "Apply Recommended SSH Settings",
365371
"category": "Coder"
372+
},
373+
{
374+
"command": "coder.pingWorkspace",
375+
"title": "Ping Workspace",
376+
"category": "Coder"
366377
}
367378
],
368379
"menus": {
@@ -391,6 +402,10 @@
391402
"command": "coder.navigateToWorkspaceSettings",
392403
"when": "coder.workspace.connected"
393404
},
405+
{
406+
"command": "coder.pingWorkspace",
407+
"when": "coder.authenticated"
408+
},
394409
{
395410
"command": "coder.workspace.update",
396411
"when": "coder.workspace.updatable"
@@ -485,18 +500,28 @@
485500
"view/item/context": [
486501
{
487502
"command": "coder.openFromSidebar",
488-
"when": "coder.authenticated && viewItem == coderWorkspaceSingleAgent || coder.authenticated && viewItem == coderAgent",
503+
"when": "coder.authenticated && viewItem =~ /^coderWorkspaceSingleAgent/ || coder.authenticated && viewItem =~ /^coderAgent/",
489504
"group": "inline"
490505
},
491506
{
492507
"command": "coder.navigateToWorkspace",
493-
"when": "coder.authenticated && viewItem == coderWorkspaceSingleAgent || coder.authenticated && viewItem == coderWorkspaceMultipleAgents",
508+
"when": "coder.authenticated && viewItem =~ /^coderWorkspaceSingleAgent/ || coder.authenticated && viewItem =~ /^coderWorkspaceMultipleAgents/",
494509
"group": "inline"
495510
},
496511
{
497512
"command": "coder.navigateToWorkspaceSettings",
498-
"when": "coder.authenticated && viewItem == coderWorkspaceSingleAgent || coder.authenticated && viewItem == coderWorkspaceMultipleAgents",
513+
"when": "coder.authenticated && viewItem =~ /^coderWorkspaceSingleAgent/ || coder.authenticated && viewItem =~ /^coderWorkspaceMultipleAgents/",
499514
"group": "inline"
515+
},
516+
{
517+
"submenu": "coder.diagnostics",
518+
"when": "coder.authenticated && viewItem =~ /\\+running/",
519+
"group": "navigation"
520+
}
521+
],
522+
"coder.diagnostics": [
523+
{
524+
"command": "coder.pingWorkspace"
500525
}
501526
],
502527
"statusBar/remoteIndicator": [

src/commands.ts

Lines changed: 231 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
type Workspace,
33
type WorkspaceAgent,
44
} from "coder/site/src/api/typesGenerated";
5+
import { spawn } from "node:child_process";
56
import * as fs from "node:fs/promises";
67
import * as path from "node:path";
78
import * as semver from "semver";
@@ -491,21 +492,11 @@ export class Commands {
491492
title: `Connecting to AI Agent...`,
492493
},
493494
async () => {
494-
const terminal = vscode.window.createTerminal(app.name);
495-
496-
// If workspace_name is provided, run coder ssh before the command
497-
const baseUrl = this.requireExtensionBaseUrl();
498-
const safeHost = toSafeHost(baseUrl);
499-
const binary = await this.cliManager.fetchBinary(
495+
const { binary, globalFlags } = await this.resolveCliEnv(
500496
this.extensionClient,
501497
);
502498

503-
const version = semver.parse(await cliUtils.version(binary));
504-
const featureSet = featureSetForVersion(version);
505-
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
506-
const configs = vscode.workspace.getConfiguration();
507-
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
508-
const globalFlags = getGlobalFlags(configs, auth);
499+
const terminal = vscode.window.createTerminal(app.name);
509500
terminal.sendText(
510501
`${escapeCommandArg(binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`,
511502
);
@@ -661,25 +652,207 @@ export class Commands {
661652
}
662653
}
663654

655+
public async pingWorkspace(item?: OpenableTreeItem): Promise<void> {
656+
let client: CoderApi;
657+
let workspaceId: string;
658+
659+
if (item) {
660+
client = this.extensionClient;
661+
workspaceId = createWorkspaceIdentifier(item.workspace);
662+
} else if (this.workspace && this.remoteWorkspaceClient) {
663+
client = this.remoteWorkspaceClient;
664+
workspaceId = createWorkspaceIdentifier(this.workspace);
665+
} else {
666+
client = this.extensionClient;
667+
const workspace = await this.pickWorkspace({
668+
title: "Ping a running workspace",
669+
initialValue: "owner:me status:running ",
670+
filter: (w) => w.latest_build.status === "running",
671+
});
672+
if (!workspace) {
673+
return;
674+
}
675+
workspaceId = createWorkspaceIdentifier(workspace);
676+
}
677+
678+
return this.spawnPing(client, workspaceId);
679+
}
680+
681+
private spawnPing(client: CoderApi, workspaceId: string): Thenable<void> {
682+
return withProgress(
683+
{
684+
location: vscode.ProgressLocation.Notification,
685+
title: `Starting ping for ${workspaceId}...`,
686+
},
687+
async () => {
688+
const { binary, globalFlags } = await this.resolveCliEnv(client);
689+
690+
const writeEmitter = new vscode.EventEmitter<string>();
691+
const closeEmitter = new vscode.EventEmitter<number | void>();
692+
693+
const args = [...globalFlags, "ping", escapeCommandArg(workspaceId)];
694+
const cmd = `${escapeCommandArg(binary)} ${args.join(" ")}`;
695+
// On Unix, spawn in a new process group so we can signal the
696+
// entire group (shell + coder binary) on Ctrl+C. On Windows,
697+
// detached opens a visible console window and negative-PID kill
698+
// is unsupported, so we fall back to proc.kill().
699+
const useProcessGroup = process.platform !== "win32";
700+
const proc = spawn(cmd, {
701+
shell: true,
702+
detached: useProcessGroup,
703+
});
704+
705+
let closed = false;
706+
let exited = false;
707+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
708+
709+
const sendSignal = (sig: "SIGINT" | "SIGKILL") => {
710+
try {
711+
if (useProcessGroup && proc.pid) {
712+
process.kill(-proc.pid, sig);
713+
} else {
714+
proc.kill(sig);
715+
}
716+
} catch {
717+
// Process already exited.
718+
}
719+
};
720+
721+
const gracefulKill = () => {
722+
sendSignal("SIGINT");
723+
// Escalate to SIGKILL if the process doesn't exit promptly.
724+
forceKillTimer = setTimeout(() => sendSignal("SIGKILL"), 5000);
725+
};
726+
727+
const terminal = vscode.window.createTerminal({
728+
name: `Coder Ping: ${workspaceId}`,
729+
pty: {
730+
onDidWrite: writeEmitter.event,
731+
onDidClose: closeEmitter.event,
732+
open: () => {
733+
writeEmitter.fire("Press Ctrl+C (^C) to stop.\r\n");
734+
writeEmitter.fire("─".repeat(40) + "\r\n");
735+
},
736+
close: () => {
737+
closed = true;
738+
clearTimeout(forceKillTimer);
739+
sendSignal("SIGKILL");
740+
writeEmitter.dispose();
741+
closeEmitter.dispose();
742+
},
743+
handleInput: (data: string) => {
744+
if (exited) {
745+
closeEmitter.fire();
746+
} else if (data === "\x03") {
747+
if (forceKillTimer) {
748+
// Second Ctrl+C: force kill immediately.
749+
clearTimeout(forceKillTimer);
750+
sendSignal("SIGKILL");
751+
} else {
752+
if (!closed) {
753+
writeEmitter.fire("\r\nStopping...\r\n");
754+
}
755+
gracefulKill();
756+
}
757+
}
758+
},
759+
},
760+
});
761+
762+
const fireLines = (data: Buffer) => {
763+
if (closed) {
764+
return;
765+
}
766+
const lines = data
767+
.toString()
768+
.split(/\r*\n/)
769+
.filter((line) => line !== "");
770+
for (const line of lines) {
771+
writeEmitter.fire(line + "\r\n");
772+
}
773+
};
774+
775+
proc.stdout?.on("data", fireLines);
776+
proc.stderr?.on("data", fireLines);
777+
proc.on("error", (err) => {
778+
exited = true;
779+
clearTimeout(forceKillTimer);
780+
if (closed) {
781+
return;
782+
}
783+
writeEmitter.fire(`\r\nFailed to start: ${err.message}\r\n`);
784+
writeEmitter.fire("Press any key to close.\r\n");
785+
});
786+
proc.on("close", (code, signal) => {
787+
exited = true;
788+
clearTimeout(forceKillTimer);
789+
if (closed) {
790+
return;
791+
}
792+
let reason: string;
793+
if (signal === "SIGKILL") {
794+
reason = "Ping force killed (SIGKILL)";
795+
} else if (signal) {
796+
reason = "Ping stopped";
797+
} else {
798+
reason = `Process exited with code ${code}`;
799+
}
800+
writeEmitter.fire(`\r\n${reason}. Press any key to close.\r\n`);
801+
});
802+
803+
terminal.show(false);
804+
},
805+
);
806+
}
807+
808+
private async resolveCliEnv(
809+
client: CoderApi,
810+
): Promise<{ binary: string; globalFlags: string[] }> {
811+
const baseUrl = client.getAxiosInstance().defaults.baseURL;
812+
if (!baseUrl) {
813+
throw new Error("You are not logged in");
814+
}
815+
const safeHost = toSafeHost(baseUrl);
816+
const binary = await this.cliManager.fetchBinary(client);
817+
const version = semver.parse(await cliUtils.version(binary));
818+
const featureSet = featureSetForVersion(version);
819+
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
820+
const configs = vscode.workspace.getConfiguration();
821+
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
822+
const globalFlags = getGlobalFlags(configs, auth);
823+
return { binary, globalFlags };
824+
}
825+
664826
/**
665827
* Ask the user to select a workspace. Return undefined if canceled.
666828
*/
667-
private async pickWorkspace(): Promise<Workspace | undefined> {
829+
private async pickWorkspace(options?: {
830+
title?: string;
831+
initialValue?: string;
832+
placeholder?: string;
833+
filter?: (w: Workspace) => boolean;
834+
}): Promise<Workspace | undefined> {
668835
const quickPick = vscode.window.createQuickPick();
669-
quickPick.value = "owner:me ";
670-
quickPick.placeholder = "owner:me template:go";
671-
quickPick.title = `Connect to a workspace`;
836+
quickPick.value = options?.initialValue ?? "owner:me ";
837+
quickPick.placeholder = options?.placeholder ?? "owner:me template:go";
838+
quickPick.title = options?.title ?? "Connect to a workspace";
839+
const filter = options?.filter;
840+
672841
let lastWorkspaces: readonly Workspace[];
673-
quickPick.onDidChangeValue((value) => {
674-
quickPick.busy = true;
675-
this.extensionClient
676-
.getWorkspaces({
677-
q: value,
678-
})
679-
.then((workspaces) => {
680-
lastWorkspaces = workspaces.workspaces;
681-
const items: vscode.QuickPickItem[] = workspaces.workspaces.map(
682-
(workspace) => {
842+
const disposables: vscode.Disposable[] = [];
843+
disposables.push(
844+
quickPick.onDidChangeValue((value) => {
845+
quickPick.busy = true;
846+
this.extensionClient
847+
.getWorkspaces({
848+
q: value,
849+
})
850+
.then((workspaces) => {
851+
const filtered = filter
852+
? workspaces.workspaces.filter(filter)
853+
: workspaces.workspaces;
854+
lastWorkspaces = filtered;
855+
quickPick.items = filtered.map((workspace) => {
683856
let icon = "$(debug-start)";
684857
if (workspace.latest_build.status !== "running") {
685858
icon = "$(debug-stop)";
@@ -692,32 +865,40 @@ export class Commands {
692865
label: `${icon} ${workspace.owner_name} / ${workspace.name}`,
693866
detail: `Template: ${workspace.template_display_name || workspace.template_name} • Status: ${status}`,
694867
};
695-
},
696-
);
697-
quickPick.items = items;
698-
})
699-
.catch((ex) => {
700-
this.logger.error("Failed to fetch workspaces", ex);
701-
if (ex instanceof CertificateError) {
702-
void ex.showNotification();
703-
}
704-
})
705-
.finally(() => {
706-
quickPick.busy = false;
707-
});
708-
});
868+
});
869+
})
870+
.catch((ex) => {
871+
this.logger.error("Failed to fetch workspaces", ex);
872+
if (ex instanceof CertificateError) {
873+
void ex.showNotification();
874+
}
875+
})
876+
.finally(() => {
877+
quickPick.busy = false;
878+
});
879+
}),
880+
);
881+
709882
quickPick.show();
710883
return new Promise<Workspace | undefined>((resolve) => {
711-
quickPick.onDidHide(() => {
712-
resolve(undefined);
713-
});
714-
quickPick.onDidChangeSelection((selected) => {
715-
if (selected.length < 1) {
716-
return resolve(undefined);
717-
}
718-
const workspace = lastWorkspaces[quickPick.items.indexOf(selected[0])];
719-
resolve(workspace);
720-
});
884+
disposables.push(
885+
quickPick.onDidHide(() => {
886+
resolve(undefined);
887+
}),
888+
quickPick.onDidChangeSelection((selected) => {
889+
if (selected.length < 1) {
890+
return resolve(undefined);
891+
}
892+
const workspace =
893+
lastWorkspaces[quickPick.items.indexOf(selected[0])];
894+
resolve(workspace);
895+
}),
896+
);
897+
}).finally(() => {
898+
for (const d of disposables) {
899+
d.dispose();
900+
}
901+
quickPick.dispose();
721902
});
722903
}
723904

src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
307307
"coder.applyRecommendedSettings",
308308
commands.applyRecommendedSettings.bind(commands),
309309
),
310+
vscode.commands.registerCommand(
311+
"coder.pingWorkspace",
312+
commands.pingWorkspace.bind(commands),
313+
),
310314
);
311315

312316
const remote = new Remote(serviceContainer, commands, ctx);

0 commit comments

Comments
 (0)