Skip to content

Commit bdb0b61

Browse files
firelizzard18toothrot
authored andcommitted
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 #1747 Change-Id: I08ade5f8e080e984625c536856795c6bb4519c2e Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/345477 Trust: Hyang-Ah Hana Kim <[email protected]> Trust: Alexander Rakoczy <[email protected]> Run-TryBot: Hyang-Ah Hana Kim <[email protected]> Reviewed-by: Hyang-Ah Hana Kim <[email protected]>
1 parent 1f4e83b commit bdb0b61

File tree

7 files changed

+175
-57
lines changed

7 files changed

+175
-57
lines changed

docs/commands.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ Show last captured profile
7171

7272
Run a test and capture a profile
7373

74+
### `Go Test: Delete Profile`
75+
76+
Delete selected profile
77+
7478
### `Go: Benchmark Package`
7579

7680
Runs all benchmarks in the package of the current file.

package.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,13 @@
253253
"description": "Run a test and capture a profile",
254254
"category": "Test"
255255
},
256+
{
257+
"command": "go.test.deleteProfile",
258+
"title": "Go Test: Delete Profile",
259+
"shortTitle": "Delete",
260+
"description": "Delete selected profile",
261+
"category": "Test"
262+
},
256263
{
257264
"command": "go.benchmark.package",
258265
"title": "Go: Benchmark Package",
@@ -2445,6 +2452,10 @@
24452452
{
24462453
"command": "go.test.captureProfile",
24472454
"when": "false"
2455+
},
2456+
{
2457+
"command": "go.test.deleteProfile",
2458+
"when": "false"
24482459
}
24492460
],
24502461
"editor/context": [
@@ -2545,6 +2556,12 @@
25452556
"when": "testId in go.tests && testId =~ /\\?(test|benchmark)/",
25462557
"group": "profile"
25472558
}
2559+
],
2560+
"view/item/context": [
2561+
{
2562+
"command": "go.test.deleteProfile",
2563+
"when": "viewItem == go:test:file"
2564+
}
25482565
]
25492566
},
25502567
"views": {

src/goMain.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ import { resetSurveyConfigs, showSurveyConfig, timeMinute } from './goSurvey';
116116
import { ExtensionAPI } from './export';
117117
import extensionAPI from './extensionAPI';
118118
import { GoTestExplorer, isVscodeTestingAPIAvailable } from './goTest/explore';
119-
import { ProfileDocumentContentProvider } from './goToolPprof';
119+
import { killRunningPprof } from './goTest/profile';
120120

121121
export let buildDiagnosticCollection: vscode.DiagnosticCollection;
122122
export let lintDiagnosticCollection: vscode.DiagnosticCollection;
@@ -340,10 +340,6 @@ If you would like additional configuration for diagnostics from gopls, please se
340340
GoTestExplorer.setup(ctx);
341341
}
342342

343-
ctx.subscriptions.push(
344-
vscode.workspace.registerTextDocumentContentProvider('go-tool-pprof', new ProfileDocumentContentProvider())
345-
);
346-
347343
ctx.subscriptions.push(
348344
vscode.commands.registerCommand('go.subtest.cursor', (args) => {
349345
const goConfig = getGoConfig();
@@ -802,6 +798,7 @@ const goNightlyPromptKey = 'goNightlyPrompt';
802798
export function deactivate() {
803799
return Promise.all([
804800
cancelRunningTests(),
801+
killRunningPprof(),
805802
Promise.resolve(cleanupTempDir()),
806803
Promise.resolve(disposeGoStatusBar())
807804
]);

src/goTest/explore.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export class GoTestExplorer {
7575
}
7676

7777
try {
78-
await inst.profiler.showProfiles(item);
78+
await inst.profiler.show(item);
7979
} catch (error) {
8080
const m = 'Failed to open profiles';
8181
outputChannel.appendLine(`${m}: ${error}`);
@@ -105,7 +105,26 @@ export class GoTestExplorer {
105105
return;
106106
}
107107

108-
await inst.profiler.showProfiles(item);
108+
await inst.profiler.show(item);
109+
})
110+
);
111+
112+
context.subscriptions.push(
113+
vscode.commands.registerCommand('go.test.deleteProfile', async (file) => {
114+
if (!file) {
115+
await vscode.window.showErrorMessage('No profile selected');
116+
return;
117+
}
118+
119+
try {
120+
await inst.profiler.delete(file);
121+
} catch (error) {
122+
const m = 'Failed to delete profile';
123+
outputChannel.appendLine(`${m}: ${error}`);
124+
outputChannel.show();
125+
await vscode.window.showErrorMessage(m);
126+
return;
127+
}
109128
})
110129
);
111130

src/goTest/profile.ts

Lines changed: 130 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable node/no-unsupported-features/node-builtins */
12
/*---------------------------------------------------------
23
* Copyright 2021 The Go Authors. All rights reserved.
34
* Licensed under the MIT License. See LICENSE in the project root for license information.
@@ -11,16 +12,30 @@ import {
1112
TreeDataProvider,
1213
TreeItem,
1314
TreeItemCollapsibleState,
14-
Uri
15+
Uri,
16+
ViewColumn
1517
} from 'vscode';
1618
import vscode = require('vscode');
17-
import { getTempFilePath } from '../util';
19+
import { promises as fs } from 'fs';
20+
import { ChildProcess, spawn } from 'child_process';
21+
import { getBinPath, getTempFilePath } from '../util';
1822
import { GoTestResolver } from './resolve';
23+
import { killProcessTree } from '../utils/processUtils';
24+
import { correctBinname } from '../utils/pathUtils';
1925

2026
export type ProfilingOptions = { kind?: Kind['id'] };
2127

2228
const optionsMemento = 'testProfilingOptions';
2329
const defaultOptions: ProfilingOptions = { kind: 'cpu' };
30+
const pprofProcesses = new Set<ChildProcess>();
31+
32+
export function killRunningPprof() {
33+
return new Promise<boolean>((resolve) => {
34+
pprofProcesses.forEach((proc) => killProcessTree(proc));
35+
pprofProcesses.clear();
36+
resolve(true);
37+
});
38+
}
2439

2540
export class GoTestProfiler {
2641
public readonly view = new ProfileTreeDataProvider(this);
@@ -41,9 +56,8 @@ export class GoTestProfiler {
4156
const kind = Kind.get(options.kind);
4257
if (!kind) return [];
4358

44-
const flags = [];
4559
const run = new File(kind, item);
46-
flags.push(run.flag);
60+
const flags = [...run.flags];
4761
if (this.runs.has(item.id)) this.runs.get(item.id).unshift(run);
4862
else this.runs.set(item.id, [run]);
4963
return flags;
@@ -54,7 +68,7 @@ export class GoTestProfiler {
5468
vscode.commands.executeCommand('setContext', 'go.profiledTests', Array.from(this.runs.keys()));
5569
vscode.commands.executeCommand('setContext', 'go.hasProfiles', this.runs.size > 0);
5670

57-
this.view.didRun();
71+
this.view.fireDidChange();
5872
}
5973

6074
hasProfileFor(id: string): boolean {
@@ -75,7 +89,23 @@ export class GoTestProfiler {
7589
};
7690
}
7791

78-
async showProfiles(item: TestItem) {
92+
async delete(file: File) {
93+
await file.delete();
94+
95+
const runs = this.runs.get(file.target.id);
96+
if (!runs) return;
97+
98+
const i = runs.findIndex((x) => x === file);
99+
if (i < 0) return;
100+
101+
runs.splice(i, 1);
102+
if (runs.length === 0) {
103+
this.runs.delete(file.target.id);
104+
}
105+
this.view.fireDidChange();
106+
}
107+
108+
async show(item: TestItem) {
79109
const { query: kind, fragment: name } = Uri.parse(item.id);
80110
if (kind !== 'test' && kind !== 'benchmark' && kind !== 'example') {
81111
await vscode.window.showErrorMessage('Selected item is not a test, benchmark, or example');
@@ -110,6 +140,85 @@ export class GoTestProfiler {
110140
}
111141
}
112142

143+
async function show(profile: string) {
144+
const foundDot = await new Promise<boolean>((resolve, reject) => {
145+
const proc = spawn(correctBinname('dot'), ['-V']);
146+
147+
proc.on('error', (err) => {
148+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
149+
if ((err as any).code === 'ENOENT') resolve(false);
150+
else reject(err);
151+
});
152+
153+
proc.on('exit', (code, signal) => {
154+
if (signal) reject(new Error(`Received signal ${signal}`));
155+
else if (code) reject(new Error(`Exited with code ${code}`));
156+
else resolve(true);
157+
});
158+
});
159+
if (!foundDot) {
160+
const r = await vscode.window.showErrorMessage(
161+
'Failed to execute dot. Is Graphviz installed?',
162+
'Open graphviz.org'
163+
);
164+
if (r) await vscode.env.openExternal(vscode.Uri.parse('https://graphviz.org/'));
165+
return;
166+
}
167+
168+
const proc = spawn(getBinPath('go'), ['tool', 'pprof', '-http=:', '-no_browser', profile]);
169+
pprofProcesses.add(proc);
170+
171+
const port = await new Promise<string>((resolve, reject) => {
172+
proc.on('error', (err) => {
173+
pprofProcesses.delete(proc);
174+
reject(err);
175+
});
176+
177+
proc.on('exit', (code, signal) => {
178+
pprofProcesses.delete(proc);
179+
reject(signal || code);
180+
});
181+
182+
let stderr = '';
183+
function captureStdout(b: Buffer) {
184+
stderr += b.toString('utf-8');
185+
186+
const m = stderr.match(/^Serving web UI on http:\/\/localhost:(?<port>\d+)\n/);
187+
if (!m) return;
188+
189+
resolve(m.groups.port);
190+
proc.stdout.off('data', captureStdout);
191+
}
192+
193+
proc.stderr.on('data', captureStdout);
194+
});
195+
196+
const panel = vscode.window.createWebviewPanel('go.profile', 'Profile', ViewColumn.Active);
197+
panel.webview.options = { enableScripts: true };
198+
panel.webview.html = `<html>
199+
<head>
200+
<style>
201+
body {
202+
padding: 0;
203+
background: white;
204+
overflow: hidden;
205+
}
206+
207+
iframe {
208+
border: 0;
209+
width: 100%;
210+
height: 100vh;
211+
}
212+
</style>
213+
</head>
214+
<body>
215+
<iframe src="http://localhost:${port}"></iframe>
216+
</body>
217+
</html>`;
218+
219+
panel.onDidDispose(() => killProcessTree(proc));
220+
}
221+
113222
class Kind {
114223
private static byID = new Map<string, Kind>();
115224

@@ -143,24 +252,30 @@ class File {
143252

144253
constructor(public readonly kind: Kind, public readonly target: TestItem) {}
145254

255+
async delete() {
256+
return Promise.all(
257+
[getTempFilePath(`${this.name}.prof`), getTempFilePath(`${this.name}.test`)].map((file) => fs.unlink(file))
258+
);
259+
}
260+
146261
get label() {
147262
return `${this.kind.label} @ ${this.when.toTimeString()}`;
148263
}
149264

150265
get name() {
151-
return `profile-${this.id}.${this.kind.id}.prof`;
266+
return `profile-${this.id}.${this.kind.id}`;
152267
}
153268

154-
get flag(): string {
155-
return `${this.kind.flag}=${getTempFilePath(this.name)}`;
269+
get flags(): string[] {
270+
return [this.kind.flag, getTempFilePath(`${this.name}.prof`), '-o', getTempFilePath(`${this.name}.test`)];
156271
}
157272

158-
get uri(): Uri {
159-
return Uri.from({ scheme: 'go-tool-pprof', path: getTempFilePath(this.name) });
273+
get uri() {
274+
return Uri.file(getTempFilePath(`${this.name}.prof`));
160275
}
161276

162277
async show() {
163-
await vscode.window.showTextDocument(this.uri);
278+
await show(getTempFilePath(`${this.name}.prof`));
164279
}
165280
}
166281

@@ -172,14 +287,14 @@ class ProfileTreeDataProvider implements TreeDataProvider<TreeElement> {
172287

173288
constructor(private readonly profiler: GoTestProfiler) {}
174289

175-
didRun() {
290+
fireDidChange() {
176291
this.didChangeTreeData.fire();
177292
}
178293

179294
getTreeItem(element: TreeElement): TreeItem {
180295
if (element instanceof File) {
181296
const item = new TreeItem(element.label);
182-
item.contextValue = 'file';
297+
item.contextValue = 'go:test:file';
183298
item.command = {
184299
title: 'Open',
185300
command: 'vscode.open',
@@ -189,7 +304,7 @@ class ProfileTreeDataProvider implements TreeDataProvider<TreeElement> {
189304
}
190305

191306
const item = new TreeItem(element.label, TreeItemCollapsibleState.Collapsed);
192-
item.contextValue = 'test';
307+
item.contextValue = 'go:test:test';
193308
const options: TextDocumentShowOptions = {
194309
preserveFocus: false,
195310
selection: new Range(element.range.start, element.range.start)

src/goToolPprof.ts

Lines changed: 0 additions & 34 deletions
This file was deleted.

test/integration/goTest.run.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ suite('Go Test Runner', () => {
5656
'Failed to execute `go test`'
5757
);
5858
assert.strictEqual(stub.callCount, 1, 'expected one call to goTest');
59-
assert(stub.lastCall.args[0].flags.some((x) => x.startsWith('--cpuprofile=')));
59+
assert(stub.lastCall.args[0].flags.some((x) => x === '--cpuprofile'));
6060
assert(testExplorer.profiler.hasProfileFor(test.id), 'Did not create profile for test');
6161
});
6262

0 commit comments

Comments
 (0)