src/goTest: visualize profiles
Replace the virtual document populated via pprof -tree with a webview
with an iframe that shows the interface served by pprof -http.
Fixes golang/vscode-go#1747
Change-Id: I08ade5f8e080e984625c536856795c6bb4519c2e
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/345477
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Alexander Rakoczy <alex@golang.org>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/docs/commands.md b/docs/commands.md
index 316235e..ee71ec0 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -71,6 +71,10 @@
Run a test and capture a profile
+### `Go Test: Delete Profile`
+
+Delete selected profile
+
### `Go: Benchmark Package`
Runs all benchmarks in the package of the current file.
diff --git a/package.json b/package.json
index 0dcebe9..dce9eae 100644
--- a/package.json
+++ b/package.json
@@ -254,6 +254,13 @@
"category": "Test"
},
{
+ "command": "go.test.deleteProfile",
+ "title": "Go Test: Delete Profile",
+ "shortTitle": "Delete",
+ "description": "Delete selected profile",
+ "category": "Test"
+ },
+ {
"command": "go.benchmark.package",
"title": "Go: Benchmark Package",
"description": "Runs all benchmarks in the package of the current file."
@@ -2445,6 +2452,10 @@
{
"command": "go.test.captureProfile",
"when": "false"
+ },
+ {
+ "command": "go.test.deleteProfile",
+ "when": "false"
}
],
"editor/context": [
@@ -2545,6 +2556,12 @@
"when": "testId in go.tests && testId =~ /\\?(test|benchmark)/",
"group": "profile"
}
+ ],
+ "view/item/context": [
+ {
+ "command": "go.test.deleteProfile",
+ "when": "viewItem == go:test:file"
+ }
]
},
"views": {
diff --git a/src/goMain.ts b/src/goMain.ts
index c08f21e..02ed360 100644
--- a/src/goMain.ts
+++ b/src/goMain.ts
@@ -116,7 +116,7 @@
import { ExtensionAPI } from './export';
import extensionAPI from './extensionAPI';
import { GoTestExplorer, isVscodeTestingAPIAvailable } from './goTest/explore';
-import { ProfileDocumentContentProvider } from './goToolPprof';
+import { killRunningPprof } from './goTest/profile';
export let buildDiagnosticCollection: vscode.DiagnosticCollection;
export let lintDiagnosticCollection: vscode.DiagnosticCollection;
@@ -341,10 +341,6 @@
}
ctx.subscriptions.push(
- vscode.workspace.registerTextDocumentContentProvider('go-tool-pprof', new ProfileDocumentContentProvider())
- );
-
- ctx.subscriptions.push(
vscode.commands.registerCommand('go.subtest.cursor', (args) => {
const goConfig = getGoConfig();
subTestAtCursor(goConfig, args);
@@ -802,6 +798,7 @@
export function deactivate() {
return Promise.all([
cancelRunningTests(),
+ killRunningPprof(),
Promise.resolve(cleanupTempDir()),
Promise.resolve(disposeGoStatusBar())
]);
diff --git a/src/goTest/explore.ts b/src/goTest/explore.ts
index b2078ee..765b244 100644
--- a/src/goTest/explore.ts
+++ b/src/goTest/explore.ts
@@ -75,7 +75,7 @@
}
try {
- await inst.profiler.showProfiles(item);
+ await inst.profiler.show(item);
} catch (error) {
const m = 'Failed to open profiles';
outputChannel.appendLine(`${m}: ${error}`);
@@ -105,7 +105,26 @@
return;
}
- await inst.profiler.showProfiles(item);
+ await inst.profiler.show(item);
+ })
+ );
+
+ context.subscriptions.push(
+ vscode.commands.registerCommand('go.test.deleteProfile', async (file) => {
+ if (!file) {
+ await vscode.window.showErrorMessage('No profile selected');
+ return;
+ }
+
+ try {
+ await inst.profiler.delete(file);
+ } catch (error) {
+ const m = 'Failed to delete profile';
+ outputChannel.appendLine(`${m}: ${error}`);
+ outputChannel.show();
+ await vscode.window.showErrorMessage(m);
+ return;
+ }
})
);
diff --git a/src/goTest/profile.ts b/src/goTest/profile.ts
index 876dda7..f507bba 100644
--- a/src/goTest/profile.ts
+++ b/src/goTest/profile.ts
@@ -1,3 +1,4 @@
+/* eslint-disable node/no-unsupported-features/node-builtins */
/*---------------------------------------------------------
* Copyright 2021 The Go Authors. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
@@ -11,16 +12,30 @@
TreeDataProvider,
TreeItem,
TreeItemCollapsibleState,
- Uri
+ Uri,
+ ViewColumn
} from 'vscode';
import vscode = require('vscode');
-import { getTempFilePath } from '../util';
+import { promises as fs } from 'fs';
+import { ChildProcess, spawn } from 'child_process';
+import { getBinPath, getTempFilePath } from '../util';
import { GoTestResolver } from './resolve';
+import { killProcessTree } from '../utils/processUtils';
+import { correctBinname } from '../utils/pathUtils';
export type ProfilingOptions = { kind?: Kind['id'] };
const optionsMemento = 'testProfilingOptions';
const defaultOptions: ProfilingOptions = { kind: 'cpu' };
+const pprofProcesses = new Set<ChildProcess>();
+
+export function killRunningPprof() {
+ return new Promise<boolean>((resolve) => {
+ pprofProcesses.forEach((proc) => killProcessTree(proc));
+ pprofProcesses.clear();
+ resolve(true);
+ });
+}
export class GoTestProfiler {
public readonly view = new ProfileTreeDataProvider(this);
@@ -41,9 +56,8 @@
const kind = Kind.get(options.kind);
if (!kind) return [];
- const flags = [];
const run = new File(kind, item);
- flags.push(run.flag);
+ const flags = [...run.flags];
if (this.runs.has(item.id)) this.runs.get(item.id).unshift(run);
else this.runs.set(item.id, [run]);
return flags;
@@ -54,7 +68,7 @@
vscode.commands.executeCommand('setContext', 'go.profiledTests', Array.from(this.runs.keys()));
vscode.commands.executeCommand('setContext', 'go.hasProfiles', this.runs.size > 0);
- this.view.didRun();
+ this.view.fireDidChange();
}
hasProfileFor(id: string): boolean {
@@ -75,7 +89,23 @@
};
}
- async showProfiles(item: TestItem) {
+ async delete(file: File) {
+ await file.delete();
+
+ const runs = this.runs.get(file.target.id);
+ if (!runs) return;
+
+ const i = runs.findIndex((x) => x === file);
+ if (i < 0) return;
+
+ runs.splice(i, 1);
+ if (runs.length === 0) {
+ this.runs.delete(file.target.id);
+ }
+ this.view.fireDidChange();
+ }
+
+ async show(item: TestItem) {
const { query: kind, fragment: name } = Uri.parse(item.id);
if (kind !== 'test' && kind !== 'benchmark' && kind !== 'example') {
await vscode.window.showErrorMessage('Selected item is not a test, benchmark, or example');
@@ -110,6 +140,85 @@
}
}
+async function show(profile: string) {
+ const foundDot = await new Promise<boolean>((resolve, reject) => {
+ const proc = spawn(correctBinname('dot'), ['-V']);
+
+ proc.on('error', (err) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if ((err as any).code === 'ENOENT') resolve(false);
+ else reject(err);
+ });
+
+ proc.on('exit', (code, signal) => {
+ if (signal) reject(new Error(`Received signal ${signal}`));
+ else if (code) reject(new Error(`Exited with code ${code}`));
+ else resolve(true);
+ });
+ });
+ if (!foundDot) {
+ const r = await vscode.window.showErrorMessage(
+ 'Failed to execute dot. Is Graphviz installed?',
+ 'Open graphviz.org'
+ );
+ if (r) await vscode.env.openExternal(vscode.Uri.parse('https://graphviz.org/'));
+ return;
+ }
+
+ const proc = spawn(getBinPath('go'), ['tool', 'pprof', '-http=:', '-no_browser', profile]);
+ pprofProcesses.add(proc);
+
+ const port = await new Promise<string>((resolve, reject) => {
+ proc.on('error', (err) => {
+ pprofProcesses.delete(proc);
+ reject(err);
+ });
+
+ proc.on('exit', (code, signal) => {
+ pprofProcesses.delete(proc);
+ reject(signal || code);
+ });
+
+ let stderr = '';
+ function captureStdout(b: Buffer) {
+ stderr += b.toString('utf-8');
+
+ const m = stderr.match(/^Serving web UI on http:\/\/localhost:(?<port>\d+)\n/);
+ if (!m) return;
+
+ resolve(m.groups.port);
+ proc.stdout.off('data', captureStdout);
+ }
+
+ proc.stderr.on('data', captureStdout);
+ });
+
+ const panel = vscode.window.createWebviewPanel('go.profile', 'Profile', ViewColumn.Active);
+ panel.webview.options = { enableScripts: true };
+ panel.webview.html = `<html>
+ <head>
+ <style>
+ body {
+ padding: 0;
+ background: white;
+ overflow: hidden;
+ }
+
+ iframe {
+ border: 0;
+ width: 100%;
+ height: 100vh;
+ }
+ </style>
+ </head>
+ <body>
+ <iframe src="http://localhost:${port}"></iframe>
+ </body>
+ </html>`;
+
+ panel.onDidDispose(() => killProcessTree(proc));
+}
+
class Kind {
private static byID = new Map<string, Kind>();
@@ -143,24 +252,30 @@
constructor(public readonly kind: Kind, public readonly target: TestItem) {}
+ async delete() {
+ return Promise.all(
+ [getTempFilePath(`${this.name}.prof`), getTempFilePath(`${this.name}.test`)].map((file) => fs.unlink(file))
+ );
+ }
+
get label() {
return `${this.kind.label} @ ${this.when.toTimeString()}`;
}
get name() {
- return `profile-${this.id}.${this.kind.id}.prof`;
+ return `profile-${this.id}.${this.kind.id}`;
}
- get flag(): string {
- return `${this.kind.flag}=${getTempFilePath(this.name)}`;
+ get flags(): string[] {
+ return [this.kind.flag, getTempFilePath(`${this.name}.prof`), '-o', getTempFilePath(`${this.name}.test`)];
}
- get uri(): Uri {
- return Uri.from({ scheme: 'go-tool-pprof', path: getTempFilePath(this.name) });
+ get uri() {
+ return Uri.file(getTempFilePath(`${this.name}.prof`));
}
async show() {
- await vscode.window.showTextDocument(this.uri);
+ await show(getTempFilePath(`${this.name}.prof`));
}
}
@@ -172,14 +287,14 @@
constructor(private readonly profiler: GoTestProfiler) {}
- didRun() {
+ fireDidChange() {
this.didChangeTreeData.fire();
}
getTreeItem(element: TreeElement): TreeItem {
if (element instanceof File) {
const item = new TreeItem(element.label);
- item.contextValue = 'file';
+ item.contextValue = 'go:test:file';
item.command = {
title: 'Open',
command: 'vscode.open',
@@ -189,7 +304,7 @@
}
const item = new TreeItem(element.label, TreeItemCollapsibleState.Collapsed);
- item.contextValue = 'test';
+ item.contextValue = 'go:test:test';
const options: TextDocumentShowOptions = {
preserveFocus: false,
selection: new Range(element.range.start, element.range.start)
diff --git a/src/goToolPprof.ts b/src/goToolPprof.ts
deleted file mode 100644
index d947006..0000000
--- a/src/goToolPprof.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-/*---------------------------------------------------------
- * Copyright 2021 The Go Authors. All rights reserved.
- * Licensed under the MIT License. See LICENSE in the project root for license information.
- *--------------------------------------------------------*/
-import { execFile } from 'child_process';
-import { window, CancellationToken, TextDocumentContentProvider, Uri } from 'vscode';
-import { outputChannel } from './goStatus';
-import { getBinPath } from './util';
-
-export class ProfileDocumentContentProvider implements TextDocumentContentProvider {
- provideTextDocumentContent(uri: Uri, token: CancellationToken): Promise<string | undefined> {
- return this.pprof(uri, token);
- }
-
- private pprof(uri: Uri, token: CancellationToken) {
- const goBin = getBinPath('go');
- return new Promise<string | undefined>((resolve) => {
- const cp = execFile(goBin, ['tool', 'pprof', '-tree', uri.fsPath], async (err, stdout, stderr) => {
- if (err || stderr) {
- const m = 'Failed to execute `go tool pprof`';
- if (err) outputChannel.appendLine(`${m}: ${err}`);
- else outputChannel.append(`${m}:\n${stderr}`);
- outputChannel.show();
- await window.showErrorMessage(m);
- resolve(void 0);
- } else {
- resolve(stdout);
- }
- });
-
- token?.onCancellationRequested(() => cp.kill());
- });
- }
-}
diff --git a/test/integration/goTest.run.test.ts b/test/integration/goTest.run.test.ts
index 5db5448..56dc52c 100644
--- a/test/integration/goTest.run.test.ts
+++ b/test/integration/goTest.run.test.ts
@@ -56,7 +56,7 @@
'Failed to execute `go test`'
);
assert.strictEqual(stub.callCount, 1, 'expected one call to goTest');
- assert(stub.lastCall.args[0].flags.some((x) => x.startsWith('--cpuprofile=')));
+ assert(stub.lastCall.args[0].flags.some((x) => x === '--cpuprofile'));
assert(testExplorer.profiler.hasProfileFor(test.id), 'Did not create profile for test');
});