Skip to content

Commit

Permalink
src/goTest: visualize profiles
Browse files Browse the repository at this point in the history
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 <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>
  • Loading branch information
firelizzard18 authored and toothrot committed Oct 13, 2021
1 parent 1f4e83b commit bdb0b61
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 57 deletions.
4 changes: 4 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ Show last captured profile

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.
Expand Down
17 changes: 17 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,13 @@
"description": "Run a test and capture a profile",
"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",
Expand Down Expand Up @@ -2445,6 +2452,10 @@
{
"command": "go.test.captureProfile",
"when": "false"
},
{
"command": "go.test.deleteProfile",
"when": "false"
}
],
"editor/context": [
Expand Down Expand Up @@ -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": {
Expand Down
7 changes: 2 additions & 5 deletions src/goMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ import { resetSurveyConfigs, showSurveyConfig, timeMinute } from './goSurvey';
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;
Expand Down Expand Up @@ -340,10 +340,6 @@ If you would like additional configuration for diagnostics from gopls, please se
GoTestExplorer.setup(ctx);
}

ctx.subscriptions.push(
vscode.workspace.registerTextDocumentContentProvider('go-tool-pprof', new ProfileDocumentContentProvider())
);

ctx.subscriptions.push(
vscode.commands.registerCommand('go.subtest.cursor', (args) => {
const goConfig = getGoConfig();
Expand Down Expand Up @@ -802,6 +798,7 @@ const goNightlyPromptKey = 'goNightlyPrompt';
export function deactivate() {
return Promise.all([
cancelRunningTests(),
killRunningPprof(),
Promise.resolve(cleanupTempDir()),
Promise.resolve(disposeGoStatusBar())
]);
Expand Down
23 changes: 21 additions & 2 deletions src/goTest/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class GoTestExplorer {
}

try {
await inst.profiler.showProfiles(item);
await inst.profiler.show(item);
} catch (error) {
const m = 'Failed to open profiles';
outputChannel.appendLine(`${m}: ${error}`);
Expand Down Expand Up @@ -105,7 +105,26 @@ export class GoTestExplorer {
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;
}
})
);

Expand Down
145 changes: 130 additions & 15 deletions src/goTest/profile.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -11,16 +12,30 @@ import {
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);
Expand All @@ -41,9 +56,8 @@ export class GoTestProfiler {
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;
Expand All @@ -54,7 +68,7 @@ export class GoTestProfiler {
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 {
Expand All @@ -75,7 +89,23 @@ export class GoTestProfiler {
};
}

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');
Expand Down Expand Up @@ -110,6 +140,85 @@ export class GoTestProfiler {
}
}

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>();

Expand Down Expand Up @@ -143,24 +252,30 @@ class File {

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`));
}
}

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

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',
Expand All @@ -189,7 +304,7 @@ class ProfileTreeDataProvider implements TreeDataProvider<TreeElement> {
}

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)
Expand Down
34 changes: 0 additions & 34 deletions src/goToolPprof.ts

This file was deleted.

2 changes: 1 addition & 1 deletion test/integration/goTest.run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ suite('Go Test Runner', () => {
'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');
});

Expand Down

0 comments on commit bdb0b61

Please sign in to comment.