Skip to content

Commit 2404a03

Browse files
authored
Add command to easily collect trace of LSP process (#8371)
2 parents 40f59ba + 11d21a3 commit 2404a03

File tree

7 files changed

+357
-6
lines changed

7 files changed

+357
-6
lines changed

l10n/bundle.l10n.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,49 @@
186186
"Sending request": "Sending request",
187187
"C# Project Context Status": "C# Project Context Status",
188188
"Active File Context": "Active File Context",
189+
"Initializing dotnet-trace.../dotnet-trace is a command name and should not be localized": {
190+
"message": "Initializing dotnet-trace...",
191+
"comment": [
192+
"dotnet-trace is a command name and should not be localized"
193+
]
194+
},
195+
"Failed to execute dotnet-trace command: {0}/dotnet-trace is a command name and should not be localized": {
196+
"message": "Failed to execute dotnet-trace command: {0}",
197+
"comment": [
198+
"dotnet-trace is a command name and should not be localized"
199+
]
200+
},
201+
"Language server process not found, ensure the server is running.": "Language server process not found, ensure the server is running.",
202+
"Select Trace Folder": "Select Trace Folder",
203+
"Select Folder to Save Trace File": "Select Folder to Save Trace File",
204+
"Folder for trace file {0} does not exist": "Folder for trace file {0} does not exist",
205+
"Enter dotnet-trace arguments/dotnet-trace is a command name and should not be localized": {
206+
"message": "Enter dotnet-trace arguments",
207+
"comment": [
208+
"dotnet-trace is a command name and should not be localized"
209+
]
210+
},
211+
"You can modify the default arguments if needed": "You can modify the default arguments if needed",
212+
"Recording trace...": "Recording trace...",
213+
"Install": "Install",
214+
"dotnet-trace not found, run \"{0}\" to install it?/dotnet-trace is a command name and should not be localized": {
215+
"message": "dotnet-trace not found, run \"{0}\" to install it?",
216+
"comment": [
217+
"dotnet-trace is a command name and should not be localized"
218+
]
219+
},
220+
"Installing dotnet-trace.../dotnet-trace is a command name and should not be localized": {
221+
"message": "Installing dotnet-trace...",
222+
"comment": [
223+
"dotnet-trace is a command name and should not be localized"
224+
]
225+
},
226+
"Failed to install dotnet-trace, it may need to be manually installed. See C# output for details./dotnet-trace is a command name and should not be localized": {
227+
"message": "Failed to install dotnet-trace, it may need to be manually installed. See C# output for details.",
228+
"comment": [
229+
"dotnet-trace is a command name and should not be localized"
230+
]
231+
},
189232
"C# configuration has changed. Would you like to reload the window to apply your changes?": "C# configuration has changed. Would you like to reload the window to apply your changes?",
190233
"Generated document not found": "Generated document not found",
191234
"Nested Code Action": "Nested Code Action",

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1898,6 +1898,12 @@
18981898
"category": "CSharp",
18991899
"enablement": "dotnet.server.activationContext == 'OmniSharp'"
19001900
},
1901+
{
1902+
"command": "csharp.recordTrace",
1903+
"title": "%command.csharp.recordTrace%",
1904+
"category": "CSharp",
1905+
"enablement": "dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit'"
1906+
},
19011907
{
19021908
"command": "extension.showRazorCSharpWindow",
19031909
"title": "%command.extension.showRazorCSharpWindow%",

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"command.csharp.attachToProcess": "Attach to a .NET 5+ or .NET Core process",
1818
"command.csharp.reportIssue": "Report an issue",
1919
"command.csharp.showDecompilationTerms": "Show the decompiler terms agreement",
20+
"command.csharp.recordTrace": "Record a performance trace of the C# Language Server",
2021
"command.extension.showRazorCSharpWindow": "Show Razor CSharp",
2122
"command.extension.showRazorHtmlWindow": "Show Razor Html",
2223
"command.razor.reportIssue": "Report a Razor issue",

src/lsptoolshost/activate.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { registerCopilotRelatedFilesProvider } from './copilot/relatedFilesProvi
3131
import { registerCopilotContextProviders } from './copilot/contextProviders';
3232
import { RazorLogger } from '../razor/src/razorLogger';
3333
import { registerRazorEndpoints } from './razor/razorEndpoints';
34+
import { registerTraceCommand } from './profiling/profiling';
3435

3536
let _channel: vscode.LogOutputChannel;
3637
let _traceChannel: vscode.OutputChannel;
@@ -75,6 +76,8 @@ export async function activateRoslynLanguageServer(
7576
_traceChannel
7677
);
7778

79+
registerTraceCommand(context, languageServer, outputChannel);
80+
7881
registerLanguageStatusItems(context, languageServer, languageServerEvents);
7982
registerMiscellaneousFileNotifier(context, languageServer);
8083
registerCopilotRelatedFilesProvider(context, languageServer, _channel);

src/lsptoolshost/profiling/profiling.ts

Lines changed: 276 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import * as fs from 'fs';
7+
import * as vscode from 'vscode';
68
import { EOL } from 'os';
7-
import { LogOutputChannel } from 'vscode';
9+
import { RoslynLanguageServer } from '../server/roslynLanguageServer';
10+
import { showErrorMessageWithOptions } from '../../shared/observers/utils/showMessage';
11+
import { execChildProcess } from '../../common';
812

9-
export function getProfilingEnvVars(outputChannel: LogOutputChannel): NodeJS.ProcessEnv {
13+
const TraceTerminalName = 'dotnet-trace';
14+
15+
export function getProfilingEnvVars(outputChannel: vscode.LogOutputChannel): NodeJS.ProcessEnv {
1016
let profilingEnvVars = {};
1117
if (process.env.ROSLYN_DOTNET_EventPipeOutputPath) {
1218
profilingEnvVars = {
@@ -24,3 +30,271 @@ export function getProfilingEnvVars(outputChannel: LogOutputChannel): NodeJS.Pro
2430

2531
return profilingEnvVars;
2632
}
33+
34+
export function registerTraceCommand(
35+
context: vscode.ExtensionContext,
36+
languageServer: RoslynLanguageServer,
37+
outputChannel: vscode.LogOutputChannel
38+
): void {
39+
context.subscriptions.push(
40+
vscode.commands.registerCommand('csharp.recordTrace', async () => {
41+
await vscode.window.withProgress(
42+
{
43+
location: vscode.ProgressLocation.Notification,
44+
title: 'dotnet-trace',
45+
cancellable: true,
46+
},
47+
async (progress, token) => {
48+
progress.report({
49+
message: vscode.l10n.t({
50+
message: 'Initializing dotnet-trace...',
51+
comment: 'dotnet-trace is a command name and should not be localized',
52+
}),
53+
});
54+
try {
55+
await executeDotNetTraceCommand(languageServer, progress, outputChannel, token);
56+
} catch (error) {
57+
const errorMessage = error instanceof Error ? error.message : String(error);
58+
showErrorMessageWithOptions(
59+
vscode,
60+
vscode.l10n.t({
61+
message: 'Failed to execute dotnet-trace command: {0}',
62+
args: [errorMessage],
63+
comment: 'dotnet-trace is a command name and should not be localized',
64+
}),
65+
{ modal: true }
66+
);
67+
}
68+
}
69+
);
70+
})
71+
);
72+
}
73+
74+
async function executeDotNetTraceCommand(
75+
languageServer: RoslynLanguageServer,
76+
progress: vscode.Progress<{
77+
message?: string;
78+
increment?: number;
79+
}>,
80+
outputChannel: vscode.LogOutputChannel,
81+
cancellationToken: vscode.CancellationToken
82+
): Promise<void> {
83+
const processId = languageServer.processId;
84+
85+
if (!processId) {
86+
throw new Error(vscode.l10n.t('Language server process not found, ensure the server is running.'));
87+
}
88+
89+
let traceFolder: string | undefined = '';
90+
if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders?.length >= 1) {
91+
traceFolder = vscode.workspace.workspaceFolders[0].uri.fsPath;
92+
}
93+
94+
// Prompt the user for the folder to save the trace file
95+
// By default, choose the first workspace folder if available.
96+
const uris = await vscode.window.showOpenDialog({
97+
canSelectFiles: false,
98+
canSelectFolders: true,
99+
canSelectMany: false,
100+
defaultUri: traceFolder ? vscode.Uri.file(traceFolder) : undefined,
101+
openLabel: vscode.l10n.t('Select Trace Folder'),
102+
title: vscode.l10n.t('Select Folder to Save Trace File'),
103+
});
104+
105+
if (uris === undefined || uris.length === 0) {
106+
// User cancelled the dialog
107+
return;
108+
}
109+
110+
traceFolder = uris[0].fsPath;
111+
112+
if (!fs.existsSync(traceFolder)) {
113+
throw new Error(vscode.l10n.t(`Folder for trace file {0} does not exist`, traceFolder));
114+
}
115+
116+
const dotnetTraceArgs = `--process-id ${processId} --clreventlevel informational --providers "Microsoft-DotNETCore-SampleProfiler,Microsoft-Windows-DotNETRuntime"`;
117+
118+
// Show an input box pre-populated with the default dotnet trace arguments
119+
const userArgs = await vscode.window.showInputBox({
120+
value: dotnetTraceArgs,
121+
placeHolder: vscode.l10n.t({
122+
message: 'Enter dotnet-trace arguments',
123+
comment: 'dotnet-trace is a command name and should not be localized',
124+
}),
125+
prompt: vscode.l10n.t('You can modify the default arguments if needed'),
126+
});
127+
if (userArgs === undefined) {
128+
// User cancelled the input box
129+
return;
130+
}
131+
132+
const terminal = await getOrCreateTerminal(traceFolder, outputChannel);
133+
134+
const dotnetTraceInstalled = await verifyOrAcquireDotnetTrace(traceFolder, progress, outputChannel);
135+
if (!dotnetTraceInstalled) {
136+
// Cancelled or unable to install dotnet-trace
137+
return;
138+
}
139+
140+
const args = ['collect', ...userArgs.split(' ')];
141+
142+
progress.report({ message: vscode.l10n.t('Recording trace...') });
143+
await runDotnetTrace(args, terminal, cancellationToken);
144+
}
145+
146+
async function verifyOrAcquireDotnetTrace(
147+
folder: string,
148+
progress: vscode.Progress<{
149+
message?: string;
150+
increment?: number;
151+
}>,
152+
channel: vscode.LogOutputChannel
153+
): Promise<boolean> {
154+
try {
155+
await execChildProcess('dotnet-trace --version', folder, process.env);
156+
return true; // If the command succeeds, dotnet-trace is installed.
157+
} catch (error) {
158+
channel.debug(`Failed to execute dotnet-trace --version with error: ${error}`);
159+
}
160+
161+
const confirmAction = {
162+
title: vscode.l10n.t('Install'),
163+
};
164+
const installCommand = 'dotnet tool install --global dotnet-trace';
165+
const confirmResult = await vscode.window.showInformationMessage(
166+
vscode.l10n.t({
167+
message: 'dotnet-trace not found, run "{0}" to install it?',
168+
args: [installCommand],
169+
comment: 'dotnet-trace is a command name and should not be localized',
170+
}),
171+
{
172+
modal: true,
173+
},
174+
confirmAction
175+
);
176+
177+
if (confirmResult !== confirmAction) {
178+
return false;
179+
}
180+
181+
progress.report({
182+
message: vscode.l10n.t({
183+
message: 'Installing dotnet-trace...',
184+
comment: 'dotnet-trace is a command name and should not be localized',
185+
}),
186+
});
187+
188+
try {
189+
await execChildProcess(installCommand, folder, process.env);
190+
return true;
191+
} catch (error) {
192+
channel.error(`Failed to install dotnet-trace with error: ${error}`);
193+
await vscode.window.showErrorMessage(
194+
vscode.l10n.t({
195+
message:
196+
'Failed to install dotnet-trace, it may need to be manually installed. See C# output for details.',
197+
comment: 'dotnet-trace is a command name and should not be localized',
198+
}),
199+
{
200+
modal: true,
201+
}
202+
);
203+
return false;
204+
}
205+
}
206+
207+
async function runDotnetTrace(
208+
args: string[],
209+
terminal: vscode.Terminal,
210+
token: vscode.CancellationToken
211+
): Promise<void> {
212+
// Use a terminal to execute the dotnet-trace. This is much simpler and more reliable than executing dotnet-trace
213+
// directly via the child_process module as dotnet-trace relies on shell input in order to stop the trace.
214+
// Without using a psuedo-terminal, it is extremely difficult to send the correct signal to stop the trace.
215+
//
216+
// Luckily, VSCode allows us to use the built in terminal (a psuedo-terminal) to execute commands, which also provides a way to send input to it.
217+
218+
terminal.show();
219+
const command = `dotnet-trace ${args.join(' ')}`;
220+
221+
const shellIntegration = terminal.shellIntegration;
222+
if (shellIntegration) {
223+
await new Promise<number>((resolve, _) => {
224+
const execution = shellIntegration.executeCommand(command);
225+
226+
// If the progress is cancelled, we need to send a Ctrl+C to the terminal to stop the command.
227+
const cancelDisposable = token.onCancellationRequested(() => {
228+
terminal.sendText('^C');
229+
});
230+
231+
vscode.window.onDidEndTerminalShellExecution((e) => {
232+
if (e.execution === execution) {
233+
cancelDisposable.dispose(); // Clean up the cancellation listener.
234+
resolve(e.exitCode ?? 1); // If exitCode is undefined, assume failure (1).
235+
}
236+
});
237+
});
238+
} else {
239+
// Without shell integration we can't listen for the command to finish. We can't execute it as a child process either (see above).
240+
// Instead we fire and forget the command. The user can stop the trace collection by interacting with the terminal directly.
241+
terminal.sendText(command);
242+
}
243+
}
244+
245+
async function getOrCreateTerminal(folder: string, outputChannel: vscode.LogOutputChannel): Promise<vscode.Terminal> {
246+
const existing = vscode.window.terminals.find((t) => t.name === TraceTerminalName);
247+
if (existing) {
248+
const options: vscode.TerminalOptions = existing.creationOptions;
249+
if (options.cwd === folder) {
250+
// If the terminal already exists and was created for the same folder, re-use it.
251+
return await waitForTerminalReady(existing, outputChannel);
252+
}
253+
}
254+
255+
existing?.dispose(); // Dispose of the existing terminal if it exists but is for a different folder.
256+
257+
const options: vscode.TerminalOptions = {
258+
name: TraceTerminalName,
259+
cwd: folder,
260+
};
261+
262+
const terminal = vscode.window.createTerminal(options);
263+
264+
return await waitForTerminalReady(terminal, outputChannel);
265+
}
266+
267+
async function waitForTerminalReady(
268+
terminal: vscode.Terminal,
269+
outputChannel: vscode.LogOutputChannel
270+
): Promise<vscode.Terminal> {
271+
// The shell integration feature is required for us to be able to see the result of a command in the terminal.
272+
// However the shell integration feature has a couple of special behaviors:
273+
// 1. It is not available immediately after the terminal is created, we must wait to see if it is available.
274+
// 2. Shell integration is not available in all scenarios (e.g. cmd on windows) and may never be set.
275+
276+
// Subscribe to the terminal shell integration change event to see if it ever gets set.
277+
const terminalPromise = new Promise<boolean>((resolve) => {
278+
vscode.window.onDidChangeTerminalShellIntegration((e) => {
279+
if (e.terminal === terminal) {
280+
resolve(true);
281+
}
282+
});
283+
284+
if (terminal.shellIntegration) {
285+
resolve(true);
286+
}
287+
});
288+
289+
// Race with a promise that resolves after a timeout to ensure we don't wait indefinitely for a terminal that may never have shell integration.
290+
const timeout = new Promise<boolean>((resolve) => {
291+
setTimeout((_) => resolve(false), 3000);
292+
});
293+
294+
const shellIntegration = await Promise.race([terminalPromise, timeout]);
295+
if (!shellIntegration) {
296+
outputChannel.debug('The terminal shell integration is not available for the dotnet-trace terminal.');
297+
}
298+
299+
return terminal;
300+
}

0 commit comments

Comments
 (0)