| /* 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. |
| *--------------------------------------------------------*/ |
| import { |
| EventEmitter, |
| Memento, |
| Range, |
| TestItem, |
| TextDocumentShowOptions, |
| TreeDataProvider, |
| TreeItem, |
| TreeItemCollapsibleState, |
| Uri, |
| ViewColumn |
| } from 'vscode'; |
| import vscode = require('vscode'); |
| 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); |
| |
| // Maps test IDs to profile files. See docs/test-explorer.md for details. |
| private readonly runs = new Map<string, File[]>(); |
| |
| constructor(private readonly resolver: GoTestResolver, private readonly workspaceState: Memento) {} |
| |
| get options() { |
| return this.workspaceState.get<ProfilingOptions>(optionsMemento) || defaultOptions; |
| } |
| set options(v: ProfilingOptions) { |
| this.workspaceState.update(optionsMemento, v); |
| } |
| |
| preRun(options: ProfilingOptions, item: TestItem): string[] { |
| const kind = Kind.get(options.kind); |
| if (!kind) return []; |
| |
| const run = new File(kind, item); |
| 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; |
| } |
| |
| postRun() { |
| // Update the list of tests that have profiles. |
| vscode.commands.executeCommand('setContext', 'go.profiledTests', Array.from(this.runs.keys())); |
| vscode.commands.executeCommand('setContext', 'go.hasProfiles', this.runs.size > 0); |
| |
| this.view.fireDidChange(); |
| } |
| |
| hasProfileFor(id: string): boolean { |
| return this.runs.has(id); |
| } |
| |
| async configure(): Promise<ProfilingOptions | undefined> { |
| const { kind } = await vscode.window.showQuickPick( |
| Kind.all.map((x) => ({ label: x.label, kind: x })), |
| { |
| title: 'Profile' |
| } |
| ); |
| if (!kind) return; |
| |
| return { |
| kind: kind.id |
| }; |
| } |
| |
| 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'); |
| return; |
| } |
| |
| const runs = this.runs.get(item.id); |
| if (!runs || runs.length === 0) { |
| await vscode.window.showErrorMessage(`${name} has not been profiled`); |
| return; |
| } |
| |
| await runs[0].show(); |
| } |
| |
| // Tests that have been profiled |
| get tests() { |
| const items = Array.from(this.runs.keys()); |
| items.sort((a: string, b: string) => { |
| const aWhen = this.runs.get(a)[0].when.getTime(); |
| const bWhen = this.runs.get(b)[0].when.getTime(); |
| return bWhen - aWhen; |
| }); |
| |
| // Filter out any tests that no longer exist |
| return items.map((x) => this.resolver.all.get(x)).filter((x) => x); |
| } |
| |
| // Profiles associated with the given test |
| get(item: TestItem) { |
| return this.runs.get(item.id) || []; |
| } |
| } |
| |
| 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>(); |
| |
| static get(id: string): Kind | undefined { |
| return this.byID.get(id); |
| } |
| |
| static get all() { |
| return Array.from(this.byID.values()); |
| } |
| |
| private constructor( |
| public readonly id: 'cpu' | 'mem' | 'mutex' | 'block', |
| public readonly label: string, |
| public readonly flag: string |
| ) { |
| Kind.byID.set(id, this); |
| } |
| |
| static readonly CPU = new Kind('cpu', 'CPU', '--cpuprofile'); |
| static readonly Memory = new Kind('mem', 'Memory', '--memprofile'); |
| static readonly Mutex = new Kind('mutex', 'Mutex', '--mutexprofile'); |
| static readonly Block = new Kind('block', 'Block', '--blockprofile'); |
| } |
| |
| class File { |
| private static nextID = 0; |
| |
| public readonly id = File.nextID++; |
| public readonly when = new Date(); |
| |
| 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}`; |
| } |
| |
| get flags(): string[] { |
| return [this.kind.flag, getTempFilePath(`${this.name}.prof`), '-o', getTempFilePath(`${this.name}.test`)]; |
| } |
| |
| get uri() { |
| return Uri.file(getTempFilePath(`${this.name}.prof`)); |
| } |
| |
| async show() { |
| await show(getTempFilePath(`${this.name}.prof`)); |
| } |
| } |
| |
| type TreeElement = TestItem | File; |
| |
| class ProfileTreeDataProvider implements TreeDataProvider<TreeElement> { |
| private readonly didChangeTreeData = new EventEmitter<void | TreeElement>(); |
| public readonly onDidChangeTreeData = this.didChangeTreeData.event; |
| |
| constructor(private readonly profiler: GoTestProfiler) {} |
| |
| fireDidChange() { |
| this.didChangeTreeData.fire(); |
| } |
| |
| getTreeItem(element: TreeElement): TreeItem { |
| if (element instanceof File) { |
| const item = new TreeItem(element.label); |
| item.contextValue = 'go:test:file'; |
| item.command = { |
| title: 'Open', |
| command: 'vscode.open', |
| arguments: [element.uri] |
| }; |
| return item; |
| } |
| |
| const item = new TreeItem(element.label, TreeItemCollapsibleState.Collapsed); |
| item.contextValue = 'go:test:test'; |
| const options: TextDocumentShowOptions = { |
| preserveFocus: false, |
| selection: new Range(element.range.start, element.range.start) |
| }; |
| item.command = { |
| title: 'Go to test', |
| command: 'vscode.open', |
| arguments: [element.uri, options] |
| }; |
| return item; |
| } |
| |
| getChildren(element?: TreeElement): TreeElement[] { |
| if (!element) return this.profiler.tests; |
| if (element instanceof File) return []; |
| return this.profiler.get(element); |
| } |
| } |