From 7f803a0c835b3ca42ad81e541a4f142f9a7d5530 Mon Sep 17 00:00:00 2001 From: hongjr03 Date: Sat, 23 May 2026 22:30:16 +0800 Subject: [PATCH 1/5] feat(vscode): add diagnostics profiling --- Cargo.toml | 1 + crates/base-db/src/source_db.rs | 247 +++++++++--- docs/src/content/docs/commands-status-logs.md | 24 +- .../content/docs/en/commands-status-logs.md | 24 +- editors/vscode/README.md | 3 + editors/vscode/l10n/bundle.l10n.zh-cn.json | 12 + editors/vscode/package.json | 10 + editors/vscode/package.nls.json | 3 +- editors/vscode/package.nls.zh-cn.json | 3 +- editors/vscode/src/extension.ts | 7 + editors/vscode/src/profiling.ts | 381 ++++++++++++++++++ editors/vscode/src/profilingArgs.ts | 19 + editors/vscode/src/profilingDiagnostics.ts | 80 ++++ editors/vscode/src/profilingSession.ts | 211 ++++++++++ editors/vscode/src/profilingTrace.ts | 335 +++++++++++++++ editors/vscode/src/profilingTypes.ts | 57 +++ editors/vscode/test/profiling.test.ts | 195 +++++++++ src/global_state/dispatcher.rs | 77 +++- src/global_state/handlers/notification.rs | 1 + src/global_state/main_loop.rs | 164 +++++++- src/global_state/process_changes.rs | 1 + src/global_state/project_status.rs | 1 + src/global_state/reload.rs | 1 + src/main.rs | 72 +++- src/tests.rs | 8 + 25 files changed, 1857 insertions(+), 80 deletions(-) create mode 100644 editors/vscode/src/profiling.ts create mode 100644 editors/vscode/src/profilingArgs.ts create mode 100644 editors/vscode/src/profilingDiagnostics.ts create mode 100644 editors/vscode/src/profilingSession.ts create mode 100644 editors/vscode/src/profilingTrace.ts create mode 100644 editors/vscode/src/profilingTypes.ts create mode 100644 editors/vscode/test/profiling.test.ts diff --git a/Cargo.toml b/Cargo.toml index fe11c41b..04a1f786 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ serde_json.workspace = true thiserror.workspace = true toml = "0.9.8" tracing-subscriber = { version = "0.3.17", default-features = false, features = ["registry", "fmt", "tracing-log",] } +tracing-chrome = "0.7.2" tracing.workspace = true triomphe.workspace = true diff --git a/crates/base-db/src/source_db.rs b/crates/base-db/src/source_db.rs index 7a6b1de2..c6a8273c 100644 --- a/crates/base-db/src/source_db.rs +++ b/crates/base-db/src/source_db.rs @@ -88,12 +88,20 @@ impl SourceFileKind { } fn parse_src(db: &dyn SourceDb, file_id: FileId) -> SyntaxTree { + let _span = tracing::info_span!("slang.parse_src", ?file_id).entered(); let text = db.file_text(file_id); match db.file_kind(file_id) { SourceFileKind::SystemVerilog | SourceFileKind::IncludeHeader => { // HIR source maps are local to the queried file; project-aware include // expansion belongs to parse_src_for_compilation. + let _span = tracing::info_span!( + "slang.syntax_tree.from_text", + ?file_id, + bytes = text.len(), + include_buffer_count = 0usize + ) + .entered(); SyntaxTree::from_text_with_options( &text, "", @@ -180,6 +188,7 @@ fn syntax_tree_options_for_file( db: &dyn SourceRootDb, file_id: FileId, ) -> syntax::SyntaxTreeOptions { + let _span = tracing::info_span!("slang.syntax_tree_options.file", ?file_id).entered(); let project_config = db.project_config(); let profile_id = db.file_compilation_profile(file_id); let include_buffers = db.include_buffers_for_profile(profile_id).as_ref().clone(); @@ -202,12 +211,25 @@ fn syntax_tree_options_for_profile( } fn parse_src_for_compilation(db: &dyn SourceRootDb, file_id: FileId) -> SyntaxTree { - let text = db.file_text(file_id); + let _span = tracing::info_span!("slang.parse_for_compilation", ?file_id).entered(); + let text = { + let _span = + tracing::info_span!("slang.parse_for_compilation.file_text", ?file_id).entered(); + db.file_text(file_id) + }; let identity = source_file_identity(db, file_id); match db.file_kind(file_id) { SourceFileKind::SystemVerilog | SourceFileKind::IncludeHeader => { let options = syntax_tree_options_for_file(db, file_id); + let include_buffer_count = options.include_buffers.len(); + let _span = tracing::info_span!( + "slang.parse_for_compilation.from_text", + ?file_id, + bytes = text.len(), + include_buffer_count + ) + .entered(); SyntaxTree::from_text_with_options(&text, &identity.name, &identity.path, &options) } SourceFileKind::LibraryMap => { @@ -257,14 +279,40 @@ fn parse_diagnostics(db: &dyn SourceRootDb, file_id: FileId) -> Arc<[SyntaxDiagn return Arc::from(Vec::::new()); } - let tree = db.parse_src_for_compilation(file_id); + let _span = tracing::info_span!("slang.parse_diagnostics", ?file_id).entered(); + let tree = { + let _span = tracing::info_span!("slang.parse_diagnostics.parse_tree", ?file_id).entered(); + db.parse_src_for_compilation(file_id) + }; let root_buffer_id = tree.buffer_id(); - let diags = tree - .diagnostics_with_options(&config.slang.warnings) - .into_iter() - .filter(|diag| diag.buffer_id.is_none_or(|buffer_id| buffer_id == root_buffer_id)) - .filter_map(|diag| config.apply_rules(DiagnosticSource::Parse, diag)) - .collect::>(); + let raw_diagnostics = { + let _span = tracing::info_span!("slang.parse.raw_diagnostics", ?file_id).entered(); + tree.diagnostics_with_options(&config.slang.warnings) + }; + let raw_diagnostic_count = raw_diagnostics.len(); + let mut non_root_buffer_count = 0usize; + let mut ignored_diagnostic_count = 0usize; + let mut diags = Vec::new(); + + for diag in raw_diagnostics { + if !diag.buffer_id.is_none_or(|buffer_id| buffer_id == root_buffer_id) { + non_root_buffer_count += 1; + continue; + } + + match config.apply_rules(DiagnosticSource::Parse, diag) { + Some(diag) => diags.push(diag), + None => ignored_diagnostic_count += 1, + } + } + + tracing::info!( + raw_diagnostic_count, + non_root_buffer_count, + ignored_diagnostic_count, + diagnostic_count = diags.len(), + "parse diagnostics complete" + ); Arc::from(diags) } @@ -395,8 +443,21 @@ fn compilation_profile_diagnostics( let project_config = db.project_config(); let plan = db.compilation_plan_for_profile(Some(profile_id)); - let compilation_include_buffers = - compilation_plan::compilation_source_buffers_for_plan(db, &plan); + let compilation_include_buffers = { + let _span = tracing::info_span!("slang.semantic.compilation_buffers").entered(); + compilation_plan::compilation_source_buffers_for_plan(db, &plan) + }; + let root_count = plan.roots.len(); + let top_module_count = plan.top_modules.len(); + let include_buffer_count = compilation_include_buffers.len(); + let _span = tracing::info_span!( + "slang.compilation_profile_diagnostics", + ?profile_id, + root_count, + top_module_count, + include_buffer_count + ) + .entered(); let compilation_options = syntax_tree_options_for_profile( &project_config, Some(profile_id), @@ -405,61 +466,135 @@ fn compilation_profile_diagnostics( let mut compilation = Compilation::new_with_top_modules(&plan.top_modules); let mut buffer_file_ids = FxHashMap::default(); let path_file_ids = path_file_ids(db); - for file_id in plan.roots.iter().copied() { - let text = db.file_text(file_id); - let identity = source_file_identity(db, file_id); - let buffer_ids = match db.file_kind(file_id) { - SourceFileKind::SystemVerilog => compilation.add_syntax_tree_from_text( - &text, - &identity.name, - &identity.path, - &compilation_options, - ), - SourceFileKind::LibraryMap => compilation.add_library_map_syntax_tree_from_text( - &text, - &identity.name, - &identity.path, - ), - SourceFileKind::IncludeHeader | SourceFileKind::ProjectManifest => continue, - }; - insert_buffer_file_ids(&mut buffer_file_ids, &path_file_ids, buffer_ids, file_id); + let mut compilation_root_count = 0usize; + let mut compilation_buffer_count = 0usize; + { + let _span = tracing::info_span!("slang.semantic.add_roots", root_count).entered(); + for file_id in plan.roots.iter().copied() { + let text = { + let _span = + tracing::info_span!("slang.semantic.add_root.file_text", ?file_id).entered(); + db.file_text(file_id) + }; + let identity = source_file_identity(db, file_id); + let buffer_ids = match db.file_kind(file_id) { + SourceFileKind::SystemVerilog => { + let include_buffer_count = compilation_options.include_buffers.len(); + let _span = tracing::info_span!( + "slang.semantic.add_root.from_text", + ?file_id, + bytes = text.len(), + include_buffer_count + ) + .entered(); + compilation.add_syntax_tree_from_text( + &text, + &identity.name, + &identity.path, + &compilation_options, + ) + } + SourceFileKind::LibraryMap => compilation.add_library_map_syntax_tree_from_text( + &text, + &identity.name, + &identity.path, + ), + SourceFileKind::IncludeHeader | SourceFileKind::ProjectManifest => continue, + }; + compilation_root_count += 1; + compilation_buffer_count += 1 + buffer_ids.source_buffers.len(); + insert_buffer_file_ids(&mut buffer_file_ids, &path_file_ids, buffer_ids, file_id); + } } + tracing::info!( + compilation_root_count, + compilation_buffer_count, + mapped_buffer_count = buffer_file_ids.len(), + "semantic compilation roots added" + ); let mut diagnostics = Vec::new(); if config.parse.enabled { - diagnostics.extend( - compilation - .parse_diagnostics_with_options(&config.slang.warnings) - .into_iter() - .filter_map(|diag| { - let diag_file_id = diag - .buffer_id - .and_then(|buffer_id| buffer_file_ids.get(&buffer_id).copied())?; - let diag = config.apply_rules(DiagnosticSource::Parse, diag)?; - Some(CompilationDiagnostic { - file_id: diag_file_id, - source: DiagnosticSource::Parse, - diagnostic: diag, - }) - }), - ); - } - - diagnostics.extend( - compilation - .semantic_diagnostics_with_options(&config.slang.warnings) - .into_iter() - .filter_map(|diag| { - let diag_file_id = diag + let raw_diagnostics = { + let _span = tracing::info_span!("slang.semantic.parse_diagnostics").entered(); + compilation.parse_diagnostics_with_options(&config.slang.warnings) + }; + let raw_diagnostic_count = raw_diagnostics.len(); + let mut unmapped_buffer_count = 0usize; + let mut ignored_diagnostic_count = 0usize; + { + let _span = + tracing::info_span!("slang.semantic.map_parse_diagnostics", raw_diagnostic_count) + .entered(); + diagnostics.extend(raw_diagnostics.into_iter().filter_map(|diag| { + let diag_file_id = match diag .buffer_id - .and_then(|buffer_id| buffer_file_ids.get(&buffer_id).copied())?; - let diag = config.apply_rules(DiagnosticSource::Semantic, diag)?; + .and_then(|buffer_id| buffer_file_ids.get(&buffer_id).copied()) + { + Some(file_id) => file_id, + None => { + unmapped_buffer_count += 1; + return None; + } + }; + let diag = match config.apply_rules(DiagnosticSource::Parse, diag) { + Some(diag) => diag, + None => { + ignored_diagnostic_count += 1; + return None; + } + }; Some(CompilationDiagnostic { file_id: diag_file_id, - source: DiagnosticSource::Semantic, + source: DiagnosticSource::Parse, diagnostic: diag, }) - }), + })); + } + tracing::info!( + raw_diagnostic_count, + unmapped_buffer_count, + ignored_diagnostic_count, + diagnostic_count = diagnostics.len(), + "compilation parse diagnostics complete" + ); + } + + let raw_semantic_diagnostics = { + let _span = tracing::info_span!("slang.semantic.raw_diagnostics").entered(); + compilation.semantic_diagnostics_with_options(&config.slang.warnings) + }; + let raw_semantic_diagnostic_count = raw_semantic_diagnostics.len(); + let mut unmapped_semantic_buffer_count = 0usize; + let mut ignored_semantic_diagnostic_count = 0usize; + { + let _span = + tracing::info_span!("slang.semantic.map_diagnostics", raw_semantic_diagnostic_count) + .entered(); + diagnostics.extend(raw_semantic_diagnostics.into_iter().filter_map(|diag| { + let diag_file_id = + diag.buffer_id.and_then(|buffer_id| buffer_file_ids.get(&buffer_id).copied()); + let Some(diag_file_id) = diag_file_id else { + unmapped_semantic_buffer_count += 1; + return None; + }; + let Some(diag) = config.apply_rules(DiagnosticSource::Semantic, diag) else { + ignored_semantic_diagnostic_count += 1; + return None; + }; + Some(CompilationDiagnostic { + file_id: diag_file_id, + source: DiagnosticSource::Semantic, + diagnostic: diag, + }) + })); + } + tracing::info!( + raw_semantic_diagnostic_count, + unmapped_semantic_buffer_count, + ignored_semantic_diagnostic_count, + diagnostic_count = diagnostics.len(), + "semantic diagnostics complete" ); Arc::from(diagnostics) diff --git a/docs/src/content/docs/commands-status-logs.md b/docs/src/content/docs/commands-status-logs.md index f93324d5..72034176 100644 --- a/docs/src/content/docs/commands-status-logs.md +++ b/docs/src/content/docs/commands-status-logs.md @@ -5,13 +5,14 @@ description: Vizsla 的命令面板命令、状态栏提示和输出通道。 ## 命令面板命令 -VS Code 扩展贡献了三个命令: +VS Code 扩展贡献了这些命令: | 命令 | 作用 | | --- | --- | | `Vizsla: Show Language Server Output` | 打开 `Vizsla Language Server` 输出通道。 | | `Vizsla: Restart Language Server` | 停止并重新启动语言服务器。 | | `Vizsla: Show Server Version` | 执行服务器 `--version`, 并显示第一行版本输出。 | +| `Vizsla: Profile Diagnostics` | 对工作区或当前 Verilog/SystemVerilog 文件运行一次独立 diagnostics profiling, 并生成 trace、summary 和 flamegraph。 | ## 状态栏 @@ -39,6 +40,27 @@ VS Code 扩展贡献了三个命令: - bundled server 查找结果。 - 启动、停止、重启和版本查询结果。 +执行 `Vizsla: Profile Diagnostics` 时, 扩展还会打开 `Vizsla Profiling` 输出通道。这里会显示本次 profiling 的目标、产物目录、诊断请求耗时和生成的文件路径。 + +## 性能分析诊断 + +执行 `Vizsla: Profile Diagnostics`。扩展会启动一个独立的临时语言服务器进程, 然后根据选择的目标发送一次诊断请求: + +- 工作区目标发送 `workspace/diagnostic`, 用于观察项目级诊断路径。 +- 当前文件目标发送 `textDocument/diagnostic`, 用于缩小到单文件诊断路径。 + +请求结束后扩展会关闭临时进程; 这个过程不会重启或影响正在使用的语言服务器。 + +完成后会生成: + +| 文件 | 说明 | +| --- | --- | +| `trace.json` | Chrome/Perfetto 兼容 trace。 | +| `summary.json` | 请求耗时、diagnostics 汇总和 top span 汇总。 | +| `trace.folded` | 从 trace 生成的 folded stack。 | +| `flamegraph.svg` | 可直接打开的火焰图。 | +| `server.log` | 临时语言服务器日志。 | + ## 查询服务器版本 你可以从命令面板执行 `Vizsla: Show Server Version`。扩展会解析当前服务器启动配置, 然后执行: diff --git a/docs/src/content/docs/en/commands-status-logs.md b/docs/src/content/docs/en/commands-status-logs.md index be35d294..0e4e31c5 100644 --- a/docs/src/content/docs/en/commands-status-logs.md +++ b/docs/src/content/docs/en/commands-status-logs.md @@ -5,13 +5,14 @@ description: Vizsla command palette commands, status bar messages, and output ch ## Command Palette Commands -The VS Code extension contributes three commands: +The VS Code extension contributes these commands: | Command | Purpose | | --- | --- | | `Vizsla: Show Language Server Output` | Opens the `Vizsla Language Server` output channel. | | `Vizsla: Restart Language Server` | Stops and restarts the language server. | | `Vizsla: Show Server Version` | Runs the server with `--version` and shows the first output line. | +| `Vizsla: Profile Diagnostics` | Runs one isolated diagnostics profiling pass for the workspace or current Verilog/SystemVerilog file and writes trace, summary, and flamegraph artifacts. | ## Status Bar @@ -39,6 +40,27 @@ The extension output channel is named `Vizsla Language Server`. It records: - Bundled server lookup result. - Start, stop, restart, and version query results. +When `Vizsla: Profile Diagnostics` runs, the extension also opens the `Vizsla Profiling` output channel. It records the target, artifact directory, diagnostic request time, and generated file paths. + +## Profile Diagnostics + +Run `Vizsla: Profile Diagnostics`. The extension starts a temporary language server process, then sends one diagnostics request for the selected target: + +- Workspace targets send `workspace/diagnostic` to measure the project-level diagnostics path. +- Current-file targets send `textDocument/diagnostic` to narrow the run to one file. + +After the request finishes, the extension shuts the temporary process down. It does not restart or affect the language server used by your editor session. + +The command generates: + +| File | Description | +| --- | --- | +| `trace.json` | Chrome/Perfetto-compatible trace. | +| `summary.json` | Request timing, diagnostics summary, and top span summary. | +| `trace.folded` | Folded stack generated from the trace. | +| `flamegraph.svg` | Flamegraph view that can be opened directly. | +| `server.log` | Temporary language server log. | + ## Query Server Version Run `Vizsla: Show Server Version` from the command palette. The extension resolves the current server startup configuration and then runs: diff --git a/editors/vscode/README.md b/editors/vscode/README.md index ecabe0a7..be62d925 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -13,9 +13,12 @@ The command palette also provides: - `Vizsla: Show Language Server Output` - `Vizsla: Restart Language Server` - `Vizsla: Show Server Version` +- `Vizsla: Profile Diagnostics` When server launch settings change, the extension prompts you to restart the language server so the new command, arguments, working directory, or trace setting can take effect. +`Vizsla: Profile Diagnostics` starts an isolated temporary language server session, runs either a workspace `workspace/diagnostic` request or a current-file `textDocument/diagnostic` request, writes trace and summary artifacts, and opens the `Vizsla Profiling` output channel with the generated paths. + ## Configuration The extension launches the bundled server by default. Advanced users can override the server command with `vizsla.server.command` and pass additional arguments with `vizsla.server.args` or `vizsla.server.additionalArgs`. diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index dbe631bb..381fc4f9 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -55,6 +55,18 @@ "Ignore this diagnostic type in user settings": "在用户设置中忽略此类诊断", "Ignore this diagnostic type in workspace settings": "在工作区设置中忽略此类诊断", "Open a Verilog or SystemVerilog file first.": "请先打开 Verilog 或 SystemVerilog 文件。", + "Running Vizsla diagnostics profile": "正在运行 Vizsla 诊断性能分析", + "Open a workspace or a Verilog/SystemVerilog file first.": "请先打开工作区或 Verilog/SystemVerilog 文件。", + "Select diagnostics profile target": "选择诊断性能分析目标", + "Workspace Diagnostics": "工作区诊断", + "Runs workspace/diagnostic for {0}": "对 {0} 运行 workspace/diagnostic", + "Current File Diagnostics": "当前文件诊断", + "Runs textDocument/diagnostic for {0}": "对 {0} 运行 textDocument/diagnostic", + "Vizsla diagnostics profile complete.": "Vizsla 诊断性能分析已完成。", + "Open Flamegraph": "打开火焰图", + "Open Summary": "打开摘要", + "Show in Folder": "在文件夹中显示", + "Failed to profile diagnostics: {0}": "无法分析诊断性能:{0}", "Qihe analysis is only available for Verilog files.": "Qihe 分析仅适用于 Verilog 文件。", "Vizsla language server is not running.": "Vizsla 语言服务器未运行。", "Failed to run Qihe analysis: {0}": "无法运行 Qihe 分析:{0}", diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 60631499..9430ab1b 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -358,6 +358,11 @@ "command": "vizsla.runQiheAnalysis", "title": "%command.runQiheAnalysis.title%", "icon": "$(beaker)" + }, + { + "command": "vizsla.profileDiagnostics", + "title": "%command.profileDiagnostics.title%", + "icon": "$(pulse)" } ], "menus": { @@ -366,6 +371,11 @@ "command": "vizsla.runQiheAnalysis", "when": "resourceLangId == verilog || resourceLangId == systemverilog", "group": "navigation@10" + }, + { + "command": "vizsla.profileDiagnostics", + "when": "resourceLangId == verilog || resourceLangId == systemverilog", + "group": "navigation@20" } ] } diff --git a/editors/vscode/package.nls.json b/editors/vscode/package.nls.json index a5a5aafb..60050341 100644 --- a/editors/vscode/package.nls.json +++ b/editors/vscode/package.nls.json @@ -43,5 +43,6 @@ "command.showServerVersion.title": "Vizsla: Show Server Version", "command.reloadWorkspace.title": "Vizsla: Reload Project Configuration", "command.showStatus.title": "Vizsla: Show Status", - "command.runQiheAnalysis.title": "Vizsla: Run Qihe Analysis" + "command.runQiheAnalysis.title": "Vizsla: Run Qihe Analysis", + "command.profileDiagnostics.title": "Vizsla: Profile Diagnostics" } diff --git a/editors/vscode/package.nls.zh-cn.json b/editors/vscode/package.nls.zh-cn.json index f523cb5b..ade1f6cb 100644 --- a/editors/vscode/package.nls.zh-cn.json +++ b/editors/vscode/package.nls.zh-cn.json @@ -43,5 +43,6 @@ "command.showServerVersion.title": "Vizsla:显示服务器版本", "command.reloadWorkspace.title": "Vizsla:重新加载项目配置", "command.showStatus.title": "Vizsla:显示状态", - "command.runQiheAnalysis.title": "Vizsla:运行 Qihe 分析" + "command.runQiheAnalysis.title": "Vizsla:运行 Qihe 分析", + "command.profileDiagnostics.title": "Vizsla:分析诊断性能" } diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 31cbe65a..ce10e217 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -13,6 +13,7 @@ import { import { getBundledServerPath, getPlatformFolder } from './platform'; import { registerDiagnosticActions } from './diagnosticActions'; +import { registerProfilingCommand } from './profiling'; import { DEFAULT_PROJECT_CONFIG_TEXT, PROJECT_CONFIG_FILE_NAMES, @@ -811,6 +812,12 @@ export async function activate(context: vscode.ExtensionContext): Promise }, ); context.subscriptions.push(runQiheRegistration); + context.subscriptions.push( + registerProfilingCommand(context, { + resolveLaunch: () => resolveServerLaunch(context, readConfiguration()), + createEnv: createServerEnv, + }), + ); const reloadWorkspaceRegistration = vscode.commands.registerCommand( reloadWorkspaceCommand, diff --git a/editors/vscode/src/profiling.ts b/editors/vscode/src/profiling.ts new file mode 100644 index 00000000..e34d3814 --- /dev/null +++ b/editors/vscode/src/profiling.ts @@ -0,0 +1,381 @@ +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import * as vscode from 'vscode'; + +import { stripProfileArgs } from './profilingArgs'; +import { + type DiagnosticProfileRequest, + diagnosticsFromProfileResponse, + summarizeDiagnostics, +} from './profilingDiagnostics'; +import { LspProfileSession } from './profilingSession'; +import { summarizeTraceFile } from './profilingTrace'; +import type { + ProfileArtifacts, + ProfileRunSummary, + ProfileTarget, + ProfilingDependencies, + ServerLaunch, +} from './profilingTypes'; + +export const profileDiagnosticsCommand = 'vizsla.profileDiagnostics'; + +const profileOutputChannelName = 'Vizsla Profiling'; +const profileTimeoutMs = 120_000; +const shutdownTimeoutMs = 15_000; +const defaultLogFilter = 'vizsla=info,base_db=info,ide=info,project_model=info,hir=info'; +const defaultTraceFilter = [ + 'vizsla=trace', + 'base_db=trace', + 'hir=trace', + 'ide=trace', + 'project_model=trace', + 'slang=trace', + 'utils=trace', + 'vfs=trace', + 'vfs_notify=trace', +].join(','); + +export function registerProfilingCommand( + context: vscode.ExtensionContext, + deps: ProfilingDependencies, +): vscode.Disposable { + const channel = vscode.window.createOutputChannel(profileOutputChannelName); + context.subscriptions.push(channel); + + return vscode.commands.registerCommand(profileDiagnosticsCommand, async () => { + await runProfileDiagnostics(context, deps, channel); + }); +} + +async function runProfileDiagnostics( + context: vscode.ExtensionContext, + deps: ProfilingDependencies, + channel: vscode.OutputChannel, +): Promise { + const target = await selectProfileTarget(); + if (!target) { + return; + } + + const artifacts = profileArtifacts(context, target); + channel.clear(); + channel.show(true); + channel.appendLine(`Profiling ${profileTargetLabel(target)}`); + channel.appendLine(`Artifacts: ${artifacts.dir}`); + + let session: LspProfileSession | undefined; + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Running Vizsla diagnostics profile'), + cancellable: true, + }, + async (_progress, token) => { + token.onCancellationRequested(() => session?.dispose()); + await runProfileSession( + target, + artifacts, + deps.resolveLaunch(), + deps, + channel, + (activeSession) => { + session = activeSession; + }, + ); + }, + ); + + await showProfileCompleteMessage(artifacts); + } catch (error) { + const message = vscode.l10n.t( + 'Failed to profile diagnostics: {0}', + (error as Error).message, + ); + channel.appendLine(`[ERROR] ${message}`); + vscode.window.showErrorMessage(message); + } finally { + session?.dispose(); + } +} + +type ProfileTargetPick = vscode.QuickPickItem & { target: ProfileTarget }; + +async function selectProfileTarget(): Promise { + const options = profileTargetOptions(); + if (options.length === 0) { + vscode.window.showWarningMessage( + vscode.l10n.t('Open a workspace or a Verilog/SystemVerilog file first.'), + ); + return undefined; + } + + if (options.length === 1) { + return options[0].target; + } + + const picked = await vscode.window.showQuickPick(options, { + placeHolder: vscode.l10n.t('Select diagnostics profile target'), + }); + return picked?.target; +} + +function profileTargetOptions(): ProfileTargetPick[] { + const options: ProfileTargetPick[] = []; + for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { + if (workspaceFolder.uri.scheme !== 'file') { + continue; + } + options.push({ + label: vscode.l10n.t('Workspace Diagnostics'), + description: workspaceFolder.name, + detail: vscode.l10n.t('Runs workspace/diagnostic for {0}', workspaceFolder.uri.fsPath), + target: { + scope: 'workspace', + workspaceRoot: workspaceFolder.uri.fsPath, + workspaceName: workspaceFolder.name || path.basename(workspaceFolder.uri.fsPath), + }, + }); + } + + const documentTarget = currentDocumentProfileTarget(); + if (documentTarget?.scope === 'document') { + options.push({ + label: vscode.l10n.t('Current File Diagnostics'), + description: path.basename(documentTarget.document.uri.fsPath), + detail: vscode.l10n.t( + 'Runs textDocument/diagnostic for {0}', + documentTarget.document.uri.fsPath, + ), + target: documentTarget, + }); + } + + return options; +} + +function currentDocumentProfileTarget(): ProfileTarget | undefined { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return undefined; + } + + const { document } = editor; + if (!['verilog', 'systemverilog'].includes(document.languageId)) { + return undefined; + } + + if (document.uri.scheme !== 'file') { + return undefined; + } + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + const workspaceRoot = workspaceFolder?.uri.fsPath ?? path.dirname(document.uri.fsPath); + const workspaceName = (workspaceFolder?.name ?? path.basename(workspaceRoot)) || 'workspace'; + return { scope: 'document', document, workspaceRoot, workspaceName }; +} + +function profileArtifacts(context: vscode.ExtensionContext, target: ProfileTarget): ProfileArtifacts { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const stem = profileArtifactStem(target).replace(/[^\w.-]+/g, '_'); + const dir = path.join(context.globalStorageUri.fsPath, 'profiles', `${timestamp}-${stem}`); + return { + dir, + trace: path.join(dir, 'trace.json'), + summary: path.join(dir, 'summary.json'), + folded: path.join(dir, 'trace.folded'), + svg: path.join(dir, 'flamegraph.svg'), + log: path.join(dir, 'server.log'), + }; +} + +function profileArtifactStem(target: ProfileTarget): string { + if (target.scope === 'document') { + return path.basename(target.document.uri.fsPath, path.extname(target.document.uri.fsPath)); + } + return `workspace-${target.workspaceName || path.basename(target.workspaceRoot)}`; +} + +function profileTargetLabel(target: ProfileTarget): string { + if (target.scope === 'document') { + return `${target.document.uri.fsPath} (${diagnosticRequestForTarget(target).method})`; + } + return `${target.workspaceRoot} (${diagnosticRequestForTarget(target).method})`; +} + +function diagnosticRequestForTarget(target: ProfileTarget): { + method: DiagnosticProfileRequest; + params: unknown; +} { + if (target.scope === 'workspace') { + return { + method: 'workspace/diagnostic', + params: { previousResultIds: [] }, + }; + } + + return { + method: 'textDocument/diagnostic', + params: { textDocument: { uri: target.document.uri.toString() } }, + }; +} + +async function runProfileSession( + target: ProfileTarget, + artifacts: ProfileArtifacts, + launch: ServerLaunch, + deps: ProfilingDependencies, + channel: vscode.OutputChannel, + setActiveSession: (session: LspProfileSession) => void, +): Promise { + await fs.promises.mkdir(artifacts.dir, { recursive: true }); + const started = Date.now(); + const session = createProfileSession(launch, artifacts, deps, channel); + setActiveSession(session); + + try { + await session.initialize(target, profileTimeoutMs); + const diagnosticRequest = diagnosticRequestForTarget(target); + const requestStarted = Date.now(); + const response = await session.request( + diagnosticRequest.method, + diagnosticRequest.params, + profileTimeoutMs, + ); + const requestElapsedMs = Date.now() - requestStarted; + const diagnostics = diagnosticsFromProfileResponse(response, diagnosticRequest.method); + + await stopProfileSession(session, channel); + + const summary: ProfileRunSummary = { + scope: target.scope, + request: diagnosticRequest.method, + ...(target.scope === 'document' ? { file: target.document.uri.fsPath } : {}), + workspace: target.workspaceRoot, + elapsed_ms: Date.now() - started, + diagnostic_request_ms: requestElapsedMs, + diagnostics: summarizeDiagnostics(diagnostics), + artifacts: { + trace: artifacts.trace, + folded: artifacts.folded, + flamegraph: artifacts.svg, + server_log: artifacts.log, + }, + trace_summary: await summarizeTraceFile(artifacts.trace, artifacts.folded, artifacts.svg), + }; + await writeJsonFile(artifacts.summary, summary); + channel.appendLine(`Request: ${diagnosticRequest.method}`); + channel.appendLine(`Diagnostic request: ${requestElapsedMs} ms`); + channel.appendLine(`Diagnostics: ${diagnostics.length}`); + channel.appendLine(`Summary: ${artifacts.summary}`); + channel.appendLine(`Flamegraph: ${artifacts.svg}`); + } finally { + session.dispose(); + } +} + +function createProfileSession( + launch: ServerLaunch, + artifacts: ProfileArtifacts, + deps: ProfilingDependencies, + channel: vscode.OutputChannel, +): LspProfileSession { + const serverArgs = [ + ...stripProfileArgs([...launch.args, ...launch.additionalArgs]), + '--log', + defaultLogFilter, + '--log_file', + artifacts.log, + '--profile_trace', + artifacts.trace, + ]; + channel.appendLine(`Server: ${launch.command}`); + channel.appendLine(`Working directory: ${launch.cwd}`); + + const child = spawn(launch.command, serverArgs, { + cwd: launch.cwd, + env: { + ...deps.createEnv(), + VIZSLA_PROFILE_TRACE_FILTER: defaultTraceFilter, + }, + stdio: 'pipe', + }); + + return new LspProfileSession(child, channel, readVizslaInitializationOptions); +} + +async function stopProfileSession( + session: LspProfileSession, + channel: vscode.OutputChannel, +): Promise { + await session.request('shutdown', null, shutdownTimeoutMs).catch((error: unknown) => { + channel.appendLine(`[WARN] Shutdown request failed: ${(error as Error).message}`); + }); + session.notify('exit', {}); + await session.waitForExit(shutdownTimeoutMs); +} + +async function showProfileCompleteMessage(artifacts: ProfileArtifacts): Promise { + const openFlamegraph = vscode.l10n.t('Open Flamegraph'); + const openSummary = vscode.l10n.t('Open Summary'); + const showInFolder = vscode.l10n.t('Show in Folder'); + const selection = await vscode.window.showInformationMessage( + vscode.l10n.t('Vizsla diagnostics profile complete.'), + openFlamegraph, + openSummary, + showInFolder, + ); + if (selection === openFlamegraph) { + await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(artifacts.svg)); + } else if (selection === openSummary) { + await vscode.window.showTextDocument(vscode.Uri.file(artifacts.summary)); + } else if (selection === showInFolder) { + await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(artifacts.summary)); + } +} + +function readVizslaInitializationOptions(): Record { + const config = vscode.workspace.getConfiguration('vizsla'); + return { + files_excludeDirs: config.get('files.excludeDirs') ?? [], + files_watcher: config.get('files.watcher') ?? 'client', + workspace_auto_reload: config.get('workspace.auto.reload') ?? true, + scope_visibility: config.get('scope.visibility') ?? 'private', + formatter_provider: config.get('formatter.provider') ?? 'verible', + formatter_path: config.get('formatter.path') ?? null, + formatter_args: config.get('formatter.args') ?? ['--failsafe_success=false'], + formatting_on_enter: config.get('formatting.on.enter') ?? true, + formatting_in_comments: config.get('formatting.in.comments') ?? true, + formatting_indent_width: config.get('formatting.indent.width') ?? 4, + inlayHints_port_connection_enable: config.get('inlayHints.port.connection.enable') ?? true, + inlayHints_parameter_assignment_enable: + config.get('inlayHints.parameter.assignment.enable') ?? true, + inlayHints_end_structure_enable: config.get('inlayHints.end.structure.enable') ?? true, + lens_instantiations_enable: config.get('lens.instantiations.enable') ?? true, + semantic_tokens_port_clk_rst_enable: + config.get('semantic.tokens.port.clk.rst.enable') ?? true, + semantic_tokens_port_input_output_enable: + config.get('semantic.tokens.port.input.output.enable') ?? true, + diagnostics: { + enable: config.get('diagnostics.enable') ?? true, + update: config.get('diagnostics.update') ?? 'onSave', + parse: { enable: config.get('diagnostics.parse.enable') ?? true }, + semantic: { enable: config.get('diagnostics.semantic.enable') ?? true }, + slang: { + warnings: config.get('diagnostics.slang.warnings') ?? [], + rules: config.get('diagnostics.slang.rules') ?? [], + }, + }, + signature_help_params_only: config.get('signature.help.params.only') ?? false, + qihe_command: config.get('qihe.command') ?? 'qihe', + qihe_compileArgs: config.get('qihe.compileArgs') ?? [], + qihe_runArgs: config.get('qihe.runArgs') ?? ['-g', 'std'], + }; +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + await fs.promises.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} diff --git a/editors/vscode/src/profilingArgs.ts b/editors/vscode/src/profilingArgs.ts new file mode 100644 index 00000000..7a70e81c --- /dev/null +++ b/editors/vscode/src/profilingArgs.ts @@ -0,0 +1,19 @@ +export function stripProfileArgs(args: string[]): string[] { + const stripped: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (['--log', '--log_file', '--profile_trace'].includes(arg)) { + index += 1; + continue; + } + if ( + arg.startsWith('--log=') || + arg.startsWith('--log_file=') || + arg.startsWith('--profile_trace=') + ) { + continue; + } + stripped.push(arg); + } + return stripped; +} diff --git a/editors/vscode/src/profilingDiagnostics.ts b/editors/vscode/src/profilingDiagnostics.ts new file mode 100644 index 00000000..ebbfa7fa --- /dev/null +++ b/editors/vscode/src/profilingDiagnostics.ts @@ -0,0 +1,80 @@ +import type { LspMessage } from './profilingTypes'; + +export type DiagnosticProfileRequest = 'workspace/diagnostic' | 'textDocument/diagnostic'; + +export function diagnosticsFromProfileResponse( + response: LspMessage, + request: DiagnosticProfileRequest, +): LspMessage[] { + return request === 'workspace/diagnostic' + ? diagnosticsFromWorkspaceResponse(response) + : diagnosticsFromDocumentResponse(response); +} + +export function diagnosticsFromDocumentResponse(response: LspMessage): LspMessage[] { + const result = asObject(response.result); + const items = Array.isArray(result?.items) ? result.items : []; + return items.filter(isObject); +} + +export function diagnosticsFromWorkspaceResponse(response: LspMessage): LspMessage[] { + const result = asObject(response.result); + const reports = Array.isArray(result?.items) ? result.items : []; + const diagnostics: LspMessage[] = []; + + for (const report of reports) { + const reportObject = asObject(report); + const items = Array.isArray(reportObject?.items) ? reportObject.items : []; + diagnostics.push(...items.filter(isObject)); + } + + return diagnostics; +} + +export function summarizeDiagnostics(diagnostics: LspMessage[]): Record { + const severities = new Map(); + const sources = new Map(); + const codes = new Map(); + const messages = new Map(); + + for (const diagnostic of diagnostics) { + count(severities, diagnostic.severity === undefined ? 'none' : String(diagnostic.severity)); + count(sources, typeof diagnostic.source === 'string' ? diagnostic.source : 'none'); + count(codes, diagnosticCode(diagnostic)); + const firstLine = String(diagnostic.message ?? '').split(/\r?\n/, 1)[0] ?? ''; + count(messages, firstLine.slice(0, 180)); + } + + return { + diagnostic_count: diagnostics.length, + severity: topCounts(severities, Number.MAX_SAFE_INTEGER), + sources: topCounts(sources, 20), + codes: topCounts(codes, 20), + top_messages: topCounts(messages, 20), + }; +} + +function diagnosticCode(diagnostic: LspMessage): string { + if (isObject(diagnostic.code)) { + return String(diagnostic.code.value ?? 'none'); + } + return diagnostic.code === undefined ? 'none' : String(diagnostic.code); +} + +function count(map: Map, key: string): void { + map.set(key, (map.get(key) ?? 0) + 1); +} + +function topCounts(map: Map, limit: number): [string, number][] { + return [...map.entries()] + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, limit); +} + +function isObject(value: unknown): value is LspMessage { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function asObject(value: unknown): LspMessage | undefined { + return isObject(value) ? value : undefined; +} diff --git a/editors/vscode/src/profilingSession.ts b/editors/vscode/src/profilingSession.ts new file mode 100644 index 00000000..dffc3a09 --- /dev/null +++ b/editors/vscode/src/profilingSession.ts @@ -0,0 +1,211 @@ +import { type ChildProcessWithoutNullStreams } from 'node:child_process'; + +import * as vscode from 'vscode'; + +import type { LspMessage, ProfileTarget } from './profilingTypes'; + +export class LspProfileSession { + private buffer = Buffer.alloc(0); + private nextId = 1; + private disposed = false; + private readonly pending = new Map< + number, + { + method: string; + resolve: (message: LspMessage) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + } + >(); + + constructor( + private readonly child: ChildProcessWithoutNullStreams, + private readonly channel: vscode.OutputChannel, + private readonly configurationProvider: () => Record, + ) { + child.stdout.on('data', (chunk: Buffer) => this.readStdout(chunk)); + child.stderr.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf8').trimEnd(); + if (text.length > 0) { + this.channel.appendLine(text); + } + }); + child.on('error', (error) => this.failPending(error)); + child.on('exit', (code, signal) => { + if (this.pending.size > 0) { + this.failPending(new Error(`profile server exited with code ${code} signal ${signal}`)); + } + }); + } + + async initialize(target: ProfileTarget, timeoutMs: number): Promise { + await this.request( + 'initialize', + { + processId: process.pid, + rootUri: vscode.Uri.file(target.workspaceRoot).toString(), + workspaceFolders: [ + { + uri: vscode.Uri.file(target.workspaceRoot).toString(), + name: target.workspaceName, + }, + ], + capabilities: { + window: { workDoneProgress: true }, + workspace: { + workspaceFolders: true, + configuration: true, + diagnostics: { refreshSupport: true }, + }, + textDocument: { + synchronization: { didSave: true }, + diagnostic: { + dynamicRegistration: false, + relatedDocumentSupport: true, + }, + }, + }, + initializationOptions: this.configurationProvider(), + clientInfo: { name: 'vizsla-profile-runner', version: 'local' }, + }, + timeoutMs, + ); + this.notify('initialized', {}); + if (target.scope === 'document') { + this.notify('textDocument/didOpen', { + textDocument: { + uri: target.document.uri.toString(), + languageId: target.document.languageId, + version: target.document.version, + text: target.document.getText(), + }, + }); + } + } + + request(method: string, params: unknown, timeoutMs: number): Promise { + const id = this.nextId; + this.nextId += 1; + const message = { jsonrpc: '2.0', id, method, params }; + this.send(message); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${method} timed out after ${timeoutMs} ms`)); + }, timeoutMs); + this.pending.set(id, { method, resolve, reject, timer }); + }); + } + + notify(method: string, params: unknown): void { + this.send({ jsonrpc: '2.0', method, params }); + } + + async waitForExit(timeoutMs: number): Promise { + if (this.child.exitCode !== null || this.child.signalCode !== null) { + return; + } + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.dispose(); + reject(new Error(`profile server did not exit after ${timeoutMs} ms`)); + }, timeoutMs); + this.child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(new Error(`${pending.method} cancelled`)); + this.pending.delete(id); + } + if (this.child.exitCode === null && this.child.signalCode === null) { + this.child.kill(); + } + } + + private readStdout(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]); + for (;;) { + const headerEnd = this.buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) { + return; + } + + const header = this.buffer.subarray(0, headerEnd).toString('ascii'); + const contentLength = /Content-Length:\s*(\d+)/i.exec(header)?.[1]; + if (!contentLength) { + this.buffer = this.buffer.subarray(headerEnd + 4); + continue; + } + + const length = Number(contentLength); + const messageStart = headerEnd + 4; + const messageEnd = messageStart + length; + if (this.buffer.length < messageEnd) { + return; + } + + const text = this.buffer.subarray(messageStart, messageEnd).toString('utf8'); + this.buffer = this.buffer.subarray(messageEnd); + try { + this.handleMessage(JSON.parse(text) as LspMessage); + } catch (error) { + this.channel.appendLine( + `[WARN] Failed to parse server message: ${(error as Error).message}`, + ); + } + } + } + + private handleMessage(message: LspMessage): void { + const id = typeof message.id === 'number' ? message.id : undefined; + const method = typeof message.method === 'string' ? message.method : undefined; + + if (id !== undefined && method === undefined) { + const pending = this.pending.get(id); + if (!pending) { + return; + } + this.pending.delete(id); + clearTimeout(pending.timer); + if (message.error) { + pending.reject(new Error(JSON.stringify(message.error))); + } else { + pending.resolve(message); + } + return; + } + + if (id !== undefined && method) { + this.respondToServerRequest(id, method); + } + } + + private respondToServerRequest(id: number, method: string): void { + const result = method === 'workspace/configuration' ? [this.configurationProvider()] : null; + this.send({ jsonrpc: '2.0', id, result }); + } + + private send(message: unknown): void { + const text = JSON.stringify(message); + this.child.stdin.write(`Content-Length: ${Buffer.byteLength(text, 'utf8')}\r\n\r\n${text}`); + } + + private failPending(error: Error): void { + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(error); + this.pending.delete(id); + } + } +} diff --git a/editors/vscode/src/profilingTrace.ts b/editors/vscode/src/profilingTrace.ts new file mode 100644 index 00000000..85501212 --- /dev/null +++ b/editors/vscode/src/profilingTrace.ts @@ -0,0 +1,335 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { LspMessage } from './profilingTypes'; + +type Frame = { + name: string; + startUs: number; + childUs: number; +}; + +type SpanAggregate = { + count: number; + totalMs: number; + maxMs: number; +}; + +type TraceSummary = { + eventCount: number; + threadCount: number; + spans: Map; + folded: Map; +}; + +type FlameNode = { + name: string; + value: number; + children: Map; +}; + +export async function summarizeTraceFile( + tracePath: string, + foldedPath: string, + svgPath: string, +): Promise> { + const text = await fs.promises.readFile(tracePath, 'utf8'); + const parsed = JSON.parse(text) as unknown; + const summary = summarizeTraceEvents(traceEvents(parsed), 100); + await writeFoldedFile(foldedPath, summary.folded); + await writeFlamegraphSvg(foldedPath, svgPath, path.basename(tracePath)); + return traceSummaryJson(summary, 30); +} + +export function traceSummaryFromJson( + parsed: unknown, + options?: { minUs?: number; top?: number }, +): Record { + const summary = summarizeTraceEvents(traceEvents(parsed), options?.minUs ?? 100); + return traceSummaryJson(summary, options?.top ?? 30); +} + +export function foldedStacksFromJson(parsed: unknown, minUs = 100): Map { + return summarizeTraceEvents(traceEvents(parsed), minUs).folded; +} + +function traceEvents(parsed: unknown): LspMessage[] { + if (Array.isArray(parsed)) { + return parsed.filter(isObject); + } + const object = asObject(parsed); + return Array.isArray(object?.traceEvents) ? object.traceEvents.filter(isObject) : []; +} + +function summarizeTraceEvents(events: LspMessage[], minUs: number): TraceSummary { + const threadNames = collectThreadNames(events); + const stacks = new Map(); + const summary: TraceSummary = { + eventCount: events.length, + threadCount: 0, + spans: new Map(), + folded: new Map(), + }; + + for (const event of events) { + const phase = typeof event.ph === 'string' ? event.ph : undefined; + if (!phase || !['B', 'E', 'X'].includes(phase)) { + continue; + } + const key = eventThreadKey(event); + const timestamp = numberValue(event.ts); + const name = typeof event.name === 'string' ? event.name : 'unknown'; + const stack = getOrInsert(stacks, key, () => []); + + if (phase === 'B') { + stack.push({ name, startUs: timestamp, childUs: 0 }); + } else if (phase === 'E') { + const frame = stack.pop(); + if (!frame) { + continue; + } + const durationUs = Math.max(0, timestamp - frame.startUs); + const parent = stack.at(-1); + if (parent) { + parent.childUs += durationUs; + } + recordSpan(summary, threadNames, key, stack, frame.name, durationUs, frame.childUs, minUs); + } else { + const durationUs = Math.max(0, numberValue(event.dur)); + recordSpan(summary, threadNames, key, stack, name, durationUs, 0, minUs); + } + } + + summary.threadCount = Math.max(stacks.size, threadNames.size); + return summary; +} + +function traceSummaryJson(summary: TraceSummary, top: number): Record { + return { + event_count: summary.eventCount, + thread_count: summary.threadCount, + top_by_total_ms: topSpanRows(summary.spans, top, true), + top_by_max_ms: topSpanRows(summary.spans, top, false), + }; +} + +function collectThreadNames(events: LspMessage[]): Map { + const names = new Map(); + for (const event of events) { + if (event.ph !== 'M' || event.name !== 'thread_name') { + continue; + } + const args = asObject(event.args); + names.set(eventThreadKey(event), typeof args?.name === 'string' ? args.name : 'thread'); + } + return names; +} + +function eventThreadKey(event: LspMessage): string { + return `${numberValue(event.pid)}:${numberValue(event.tid)}`; +} + +function recordSpan( + summary: TraceSummary, + threadNames: Map, + key: string, + stack: Frame[], + name: string, + durationUs: number, + childUs: number, + minUs: number, +): void { + const durationMs = durationUs / 1000; + const aggregate = getOrInsert(summary.spans, name, () => ({ count: 0, totalMs: 0, maxMs: 0 })); + aggregate.count += 1; + aggregate.totalMs += durationMs; + aggregate.maxMs = Math.max(aggregate.maxMs, durationMs); + + const selfUs = Math.max(0, durationUs - childUs); + if (selfUs >= minUs) { + const frames = [ + sanitizeFrameName(threadNames.get(key) ?? 'thread'), + ...stack.map((frame) => sanitizeFrameName(frame.name)), + sanitizeFrameName(name), + ]; + const folded = frames.join(';'); + summary.folded.set(folded, (summary.folded.get(folded) ?? 0) + selfUs); + } +} + +function sanitizeFrameName(name: string): string { + return name.replace(/;/g, ',').replace(/\r?\n/g, ' '); +} + +function topSpanRows( + spans: Map, + limit: number, + byTotal: boolean, +): Record[] { + const rows = [...spans.entries()].map(([name, aggregate]) => ({ + name, + count: aggregate.count, + total_ms: aggregate.totalMs, + max_ms: aggregate.maxMs, + mean_ms: aggregate.totalMs / aggregate.count, + })); + rows.sort((left, right) => + byTotal ? right.total_ms - left.total_ms : right.max_ms - left.max_ms, + ); + return rows.slice(0, limit); +} + +async function writeFoldedFile(foldedPath: string, folded: Map): Promise { + const lines = [...folded.entries()].map(([stack, value]) => `${stack} ${value.toFixed(0)}`); + await fs.promises.writeFile(foldedPath, `${lines.join('\n')}\n`, 'utf8'); +} + +async function writeFlamegraphSvg( + foldedPath: string, + svgPath: string, + title: string, +): Promise { + const folded = await fs.promises.readFile(foldedPath, 'utf8'); + const root: FlameNode = { name: 'all', value: 0, children: new Map() }; + for (const line of folded.split(/\r?\n/).filter((item) => item.trim().length > 0)) { + const index = line.lastIndexOf(' '); + if (index < 0) { + continue; + } + const stack = line.slice(0, index).split(';'); + const value = Number(line.slice(index + 1)); + if (Number.isFinite(value)) { + insertFlameStack(root, stack, value); + } + } + + const width = 1400; + const marginLeft = 10; + const marginTop = 38; + const marginBottom = 28; + const frameHeight = 17; + const graphWidth = width - marginLeft * 2; + const depth = flameDepth(root); + const height = marginTop + marginBottom + depth * frameHeight; + const scale = root.value > 0 ? graphWidth / root.value : 1; + const output: string[] = []; + + output.push(''); + output.push( + ``, + ); + output.push( + '', + ); + output.push(''); + output.push(`${escapeXml(title)}`); + renderFlameNode(root, marginLeft, 0, height, marginBottom, frameHeight, scale, output); + output.push(''); + await fs.promises.writeFile(svgPath, `${output.join('\n')}\n`, 'utf8'); +} + +function insertFlameStack(node: FlameNode, stack: string[], value: number): void { + node.value += value; + const [head, ...tail] = stack; + if (!head) { + return; + } + const child = getOrInsert(node.children, head, () => ({ + name: head, + value: 0, + children: new Map(), + })); + insertFlameStack(child, tail, value); +} + +function flameDepth(node: FlameNode): number { + const childDepth = [...node.children.values()].reduce( + (maxDepth, child) => Math.max(maxDepth, flameDepth(child)), + 0, + ); + return 1 + childDepth; +} + +function renderFlameNode( + node: FlameNode, + x: number, + depth: number, + height: number, + marginBottom: number, + frameHeight: number, + scale: number, + output: string[], +): void { + const children = [...node.children.values()].sort((left, right) => right.value - left.value); + let cursor = x; + const y = height - marginBottom - (depth + 1) * frameHeight; + + for (const child of children) { + const rectWidth = child.value * scale; + if (rectWidth < 0.5) { + cursor += rectWidth; + continue; + } + const name = escapeXml(child.name); + const ms = child.value / 1000; + output.push( + `${name} (${ms.toFixed(3)} ms)`, + ); + if (rectWidth > 24) { + const maxChars = Math.max(1, Math.floor(rectWidth / 7)); + const label = truncateLabel(child.name, maxChars); + output.push( + `${escapeXml(label)}`, + ); + } + output.push(''); + renderFlameNode(child, cursor, depth + 1, height, marginBottom, frameHeight, scale, output); + cursor += rectWidth; + } +} + +function truncateLabel(label: string, maxChars: number): string { + if ([...label].length <= maxChars) { + return label; + } + const keep = Math.max(0, maxChars - 3); + return `${[...label].slice(0, keep).join('')}...`; +} + +function flameColor(name: string): string { + const hash = [...Buffer.from(name)].reduce( + (acc, byte, index) => (acc + (index + 1) * byte) >>> 0, + 0, + ); + return `rgb(${180 + (hash % 60)},${80 + (Math.floor(hash / 7) % 90)},${40 + (Math.floor(hash / 13) % 60)})`; +} + +function numberValue(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +function isObject(value: unknown): value is LspMessage { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function asObject(value: unknown): LspMessage | undefined { + return isObject(value) ? value : undefined; +} + +function getOrInsert(map: Map, key: K, create: () => V): V { + const existing = map.get(key); + if (existing !== undefined) { + return existing; + } + const value = create(); + map.set(key, value); + return value; +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/editors/vscode/src/profilingTypes.ts b/editors/vscode/src/profilingTypes.ts new file mode 100644 index 00000000..b6604c24 --- /dev/null +++ b/editors/vscode/src/profilingTypes.ts @@ -0,0 +1,57 @@ +import type * as vscode from 'vscode'; + +export type LspMessage = Record; + +export interface ServerLaunch { + command: string; + args: string[]; + additionalArgs: string[]; + cwd: string; +} + +export interface ProfilingDependencies { + resolveLaunch(): ServerLaunch; + createEnv(logLevel?: 'info' | 'debug', backtrace?: '1' | 'full'): NodeJS.ProcessEnv; +} + +export type ProfileScope = 'workspace' | 'document'; + +type ProfileBaseTarget = { + workspaceRoot: string; + workspaceName: string; +}; + +export type ProfileTarget = + | (ProfileBaseTarget & { + scope: 'workspace'; + }) + | (ProfileBaseTarget & { + scope: 'document'; + document: vscode.TextDocument; + }); + +export type ProfileArtifacts = { + dir: string; + trace: string; + summary: string; + folded: string; + svg: string; + log: string; +}; + +export type ProfileRunSummary = { + scope: ProfileScope; + request: 'workspace/diagnostic' | 'textDocument/diagnostic'; + file?: string; + workspace: string; + elapsed_ms: number; + diagnostic_request_ms: number; + diagnostics: Record; + artifacts: { + trace: string; + folded: string; + flamegraph: string; + server_log: string; + }; + trace_summary: Record; +}; diff --git a/editors/vscode/test/profiling.test.ts b/editors/vscode/test/profiling.test.ts new file mode 100644 index 00000000..57e92bfc --- /dev/null +++ b/editors/vscode/test/profiling.test.ts @@ -0,0 +1,195 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { stripProfileArgs } from '../src/profilingArgs'; +import { + diagnosticsFromDocumentResponse, + diagnosticsFromProfileResponse, + diagnosticsFromWorkspaceResponse, + summarizeDiagnostics, +} from '../src/profilingDiagnostics'; +import { + foldedStacksFromJson, + summarizeTraceFile, + traceSummaryFromJson, +} from '../src/profilingTrace'; + +test('removes profiling-owned server arguments before launching a profile session', () => { + assert.deepEqual( + stripProfileArgs([ + '--foo', + 'bar', + '--log', + 'debug', + '--log_file=old.log', + '--profile_trace', + 'old.json', + '--profile_trace=older.json', + '--baz', + ]), + ['--foo', 'bar', '--baz'], + ); +}); + +test('summarizes document diagnostics defensively', () => { + const diagnostics = diagnosticsFromDocumentResponse({ + result: { + items: [ + { + severity: 1, + source: 'slang', + code: { value: '6:129' }, + message: 'first line\nsecond line', + }, + { + severity: 2, + source: 'vizsla', + code: 'missing-manifest', + message: 'Project manifest missing', + }, + ], + }, + }); + + assert.equal(diagnostics.length, 2); + assert.deepEqual(summarizeDiagnostics(diagnostics), { + diagnostic_count: 2, + severity: [ + ['1', 1], + ['2', 1], + ], + sources: [ + ['slang', 1], + ['vizsla', 1], + ], + codes: [ + ['6:129', 1], + ['missing-manifest', 1], + ], + top_messages: [ + ['first line', 1], + ['Project manifest missing', 1], + ], + }); +}); + +test('summarizes workspace diagnostics defensively', () => { + const response = { + result: { + items: [ + { + kind: 'full', + uri: 'file:///workspace/a.sv', + items: [ + { + severity: 1, + source: 'vizsla', + code: 'missing-manifest', + message: 'Project manifest missing', + }, + ], + }, + { + kind: 'unchanged', + uri: 'file:///workspace/b.sv', + resultId: 'previous', + }, + { + kind: 'full', + uri: 'file:///workspace/c.sv', + items: [ + { + severity: 2, + source: 'slang', + code: { value: '6:129' }, + message: 'first line\nsecond line', + }, + ], + }, + ], + }, + }; + + const diagnostics = diagnosticsFromWorkspaceResponse(response); + + assert.deepEqual(diagnosticsFromProfileResponse(response, 'workspace/diagnostic'), diagnostics); + assert.equal(diagnostics.length, 2); + assert.deepEqual(summarizeDiagnostics(diagnostics), { + diagnostic_count: 2, + severity: [ + ['1', 1], + ['2', 1], + ], + sources: [ + ['slang', 1], + ['vizsla', 1], + ], + codes: [ + ['6:129', 1], + ['missing-manifest', 1], + ], + top_messages: [ + ['first line', 1], + ['Project manifest missing', 1], + ], + }); +}); + +test('summarizes chrome trace spans and folded self time', () => { + const trace = { + traceEvents: [ + { ph: 'M', name: 'thread_name', pid: 1, tid: 7, args: { name: 'worker' } }, + { ph: 'B', name: 'outer', pid: 1, tid: 7, ts: 0 }, + { ph: 'B', name: 'inner', pid: 1, tid: 7, ts: 100 }, + { ph: 'E', name: 'inner', pid: 1, tid: 7, ts: 300 }, + { ph: 'E', name: 'outer', pid: 1, tid: 7, ts: 500 }, + { ph: 'X', name: 'instant-like', pid: 1, tid: 8, ts: 10, dur: 250 }, + ], + }; + + assert.deepEqual(traceSummaryFromJson(trace, { minUs: 50, top: 2 }), { + event_count: 6, + thread_count: 2, + top_by_total_ms: [ + { name: 'outer', count: 1, total_ms: 0.5, max_ms: 0.5, mean_ms: 0.5 }, + { name: 'instant-like', count: 1, total_ms: 0.25, max_ms: 0.25, mean_ms: 0.25 }, + ], + top_by_max_ms: [ + { name: 'outer', count: 1, total_ms: 0.5, max_ms: 0.5, mean_ms: 0.5 }, + { name: 'instant-like', count: 1, total_ms: 0.25, max_ms: 0.25, mean_ms: 0.25 }, + ], + }); + + assert.deepEqual([...foldedStacksFromJson(trace, 50).entries()], [ + ['worker;outer;inner', 200], + ['worker;outer', 300], + ['thread;instant-like', 250], + ]); +}); + +test('writes trace summary, folded stacks, and flamegraph artifacts', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'vizsla-profile-test-')); + const trace = path.join(dir, 'trace.json'); + const folded = path.join(dir, 'trace.folded'); + const svg = path.join(dir, 'flamegraph.svg'); + + await fs.writeFile( + trace, + JSON.stringify({ + traceEvents: [ + { ph: 'B', name: 'outer', pid: 1, tid: 1, ts: 0 }, + { ph: 'E', name: 'outer', pid: 1, tid: 1, ts: 150 }, + ], + }), + 'utf8', + ); + + const summary = await summarizeTraceFile(trace, folded, svg); + + assert.equal(summary.event_count, 2); + assert.match(await fs.readFile(folded, 'utf8'), /thread;outer 150/); + assert.match(await fs.readFile(svg, 'utf8'), / { match from_json(R::METHOD, &req.params) { Ok(params) => { + tracing::info!( + method = R::METHOD, + id = ?req.id, + "parsed request params" + ); let panic_context = format!("request: {} {params:#?}", R::METHOD); Some((req, params, panic_context)) } Err(err) => { + tracing::warn!( + method = R::METHOD, + id = ?req.id, + "failed to parse request params: {err:#}" + ); let err_code = lsp_server::ErrorCode::InvalidParams as i32; let response = Response::new_err(req.id, err_code, err.to_string()); self.global_state.respond(response); @@ -61,8 +71,16 @@ impl ReqDispatcher<'_> { let Some((req, params, panic_context)) = self.parse::() else { return self; }; + let request_id = req.id.clone(); let result = { + let _span = tracing::info_span!( + "lsp.request.handle", + method = R::METHOD, + id = ?request_id, + mode = "sync_mut" + ) + .entered(); let _pctx = utils::panic_context::enter(panic_context); f(self.global_state, params) }; @@ -83,10 +101,18 @@ impl ReqDispatcher<'_> { let Some((req, params, panic_context)) = self.parse::() else { return self; }; + let request_id = req.id.clone(); let global_state_snapshot = self.global_state.make_snapshot(); let result = panic::catch_unwind(move || { + let _span = tracing::info_span!( + "lsp.request.handle", + method = R::METHOD, + id = ?request_id, + mode = "sync" + ) + .entered(); let _pctx = utils::panic_context::enter(panic_context); f(global_state_snapshot, params) }); @@ -147,9 +173,24 @@ impl ReqDispatcher<'_> { }; let world = self.global_state.make_snapshot(); + let request_id = req.id.clone(); + tracing::info!( + method = R::METHOD, + id = ?request_id, + ?intent, + "queued async request handler" + ); self.global_state.task_pool.handle.spawn_and_send(intent, move || { let result = panic::catch_unwind(move || { + let _span = tracing::info_span!( + "lsp.request.handle", + method = R::METHOD, + id = ?request_id, + mode = "async", + ?intent + ) + .entered(); let _pctx = utils::panic_context::enter(panic_context); f(world, params) }); @@ -205,17 +246,35 @@ where R::Params: DeserializeOwned, R::Result: Serialize, { + let _span = tracing::info_span!("lsp.response.encode", method = R::METHOD, id = ?id).entered(); match result { - Ok(res) => Ok(Response::new_ok(id, &res)), + Ok(res) => { + let response = Response::new_ok(id, &res); + tracing::info!(error = false, "encoded request response"); + Ok(response) + } Err(error) => match error.downcast::() { - Ok(lsp_error) => Ok(Response::new_err(id, lsp_error.code, lsp_error.message)), + Ok(lsp_error) => { + tracing::info!(error = true, code = lsp_error.code, "encoded LSP error response"); + Ok(Response::new_err(id, lsp_error.code, lsp_error.message)) + } Err(error) => match error.downcast::() { - Ok(cancelled) => Err(cancelled), - Err(error) => Ok(Response::new_err( - id, - lsp_server::ErrorCode::InternalError as i32, - error.to_string(), - )), + Ok(cancelled) => { + tracing::info!("request response cancelled"); + Err(cancelled) + } + Err(error) => { + tracing::info!( + error = true, + code = lsp_server::ErrorCode::InternalError as i32, + "encoded internal error response" + ); + Ok(Response::new_err( + id, + lsp_server::ErrorCode::InternalError as i32, + error.to_string(), + )) + } }, }, } @@ -245,6 +304,7 @@ where message.push_str(panic_message) }; + tracing::error!(method = R::METHOD, "request handler panicked"); Ok(Response::new_err(id, lsp_server::ErrorCode::InternalError as i32, message)) } } @@ -275,6 +335,7 @@ impl NotifDispatcher<'_> { None => return self, }; + let _span = tracing::info_span!("lsp.notification.handle", method = N::METHOD).entered(); let params = match notif.extract::(N::METHOD) { Ok(it) => it, Err(ExtractError::JsonError { method, error }) => { diff --git a/src/global_state/handlers/notification.rs b/src/global_state/handlers/notification.rs index e801a010..2c5790cd 100644 --- a/src/global_state/handlers/notification.rs +++ b/src/global_state/handlers/notification.rs @@ -329,6 +329,7 @@ mod tests { process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }, root_path.clone(), lsp_types::ClientCapabilities::default(), diff --git a/src/global_state/main_loop.rs b/src/global_state/main_loop.rs index 40be38fa..5b1366cb 100644 --- a/src/global_state/main_loop.rs +++ b/src/global_state/main_loop.rs @@ -26,6 +26,40 @@ enum Event { Vfs(vfs_loader::Message), } +impl Event { + fn kind(&self) -> &'static str { + match self { + Event::Lsp(Message::Request(_)) => "lsp.request", + Event::Lsp(Message::Notification(_)) => "lsp.notification", + Event::Lsp(Message::Response(_)) => "lsp.response", + Event::Task(task) => task.kind(), + Event::Vfs(vfs_loader::Message::Progress { .. }) => "vfs.progress", + Event::Vfs(vfs_loader::Message::Loaded { .. }) => "vfs.loaded", + } + } + + fn summary(&self) -> String { + match self { + Event::Lsp(Message::Request(req)) => { + format!("request method={} id={:?}", req.method, req.id) + } + Event::Lsp(Message::Notification(notif)) => { + format!("notification method={}", notif.method) + } + Event::Lsp(Message::Response(res)) => { + format!("response id={:?} error={}", res.id, res.error.is_some()) + } + Event::Task(task) => task.summary(), + Event::Vfs(vfs_loader::Message::Progress { n_done, n_total, .. }) => { + format!("vfs progress {n_done}/{n_total}") + } + Event::Vfs(vfs_loader::Message::Loaded { files }) => { + format!("vfs loaded files={}", files.len()) + } + } + } +} + #[derive(Debug)] pub(crate) struct PublishDiagnosticsTask { pub(crate) file_id: FileId, @@ -50,6 +84,71 @@ pub(crate) enum QiheTask { Failed { message: String, progress_token: String }, } +impl Task { + fn kind(&self) -> &'static str { + match self { + Task::Response(_) => "task.response", + Task::Retry(_) => "task.retry", + Task::FetchWorkspace(FetchWorkspaceProgress::Begin(_)) => "task.fetch_workspace.begin", + Task::FetchWorkspace(FetchWorkspaceProgress::End(_, _)) => "task.fetch_workspace.end", + Task::Diagnostics(_) => "task.diagnostics", + Task::Qihe(task) => task.kind(), + } + } + + fn summary(&self) -> String { + match self { + Task::Response(res) => { + format!("task response id={:?} error={}", res.id, res.error.is_some()) + } + Task::Retry(req) => format!("task retry method={} id={:?}", req.method, req.id), + Task::FetchWorkspace(FetchWorkspaceProgress::Begin(cause)) => { + format!("task fetch workspace begin cause={cause}") + } + Task::FetchWorkspace(FetchWorkspaceProgress::End(workspaces, errors)) => { + format!( + "task fetch workspace end workspaces={} errors={}", + workspaces.len(), + errors.len() + ) + } + Task::Diagnostics(tasks) => { + let diagnostic_count = diagnostic_task_item_count(tasks); + format!("task diagnostics files={} diagnostics={diagnostic_count}", tasks.len()) + } + Task::Qihe(task) => task.summary(), + } + } +} + +impl QiheTask { + fn kind(&self) -> &'static str { + match self { + QiheTask::Log { .. } => "task.qihe.log", + QiheTask::Finished { .. } => "task.qihe.finished", + QiheTask::Failed { .. } => "task.qihe.failed", + } + } + + fn summary(&self) -> String { + match self { + QiheTask::Log { token, message } => { + format!("task qihe log token={token} bytes={}", message.len()) + } + QiheTask::Finished { progress_token, .. } => { + format!("task qihe finished token={progress_token}") + } + QiheTask::Failed { progress_token, message } => { + format!("task qihe failed token={progress_token} message={message}") + } + } + } +} + +fn diagnostic_task_item_count(tasks: &[PublishDiagnosticsTask]) -> usize { + tasks.iter().map(|task| task.diagnostics.len()).sum() +} + pub fn main_loop( config: Config, connection: Connection, @@ -141,11 +240,28 @@ impl GlobalState { fn handle_event(&mut self, event: Event) -> anyhow::Result<()> { let loop_start = Instant::now(); - - let event_dbg_msg = format!("{event:?}"); - tracing::debug!("{} [handle_event]: {}", format!("{loop_start:?}"), event_dbg_msg); + let event_kind = event.kind(); + let event_summary = event.summary(); + + let event_dbg_msg = { + let _span = tracing::info_span!( + "main_loop.event_debug_format", + event.kind = event_kind, + event.summary = %event_summary + ) + .entered(); + format!("{event:?}") + }; + tracing::debug!(event.summary = %event_summary, "handle_event start"); let was_stuck = self.is_stuck(); + let event_span = tracing::info_span!( + "main_loop.handle_event", + event.kind = event_kind, + event.summary = %event_summary, + was_stuck + ); + let _event_span = event_span.enter(); match event { Event::Lsp(msg) => match msg { @@ -188,11 +304,20 @@ impl GlobalState { let loop_duration = loop_start.elapsed(); if loop_duration > Duration::from_millis(100) && was_stuck { tracing::warn!( - "overly long loop turn took {loop_duration:?} (event handling took {event_handling_duration:?}): {event_dbg_msg}" + event.summary = %event_summary, + event.debug_len = event_dbg_msg.len(), + ?loop_duration, + ?event_handling_duration, + "overly long loop turn" ); } - tracing::debug!("{loop_start:?} [handle_event]: {event_dbg_msg} done in {loop_duration:?}"); + tracing::debug!( + event.summary = %event_summary, + event.debug_len = event_dbg_msg.len(), + ?loop_duration, + "handle_event done" + ); Ok(()) } @@ -300,6 +425,15 @@ impl GlobalState { } fn process_task(&mut self, task: Task) { + let task_kind = task.kind(); + let task_summary = task.summary(); + let _span = tracing::info_span!( + "main_loop.process_task", + task.kind = task_kind, + task.summary = %task_summary + ) + .entered(); + match task { Task::Response(res) => self.respond(res), Task::Retry(req) => { @@ -395,17 +529,29 @@ impl GlobalState { diagnostics: Vec, force_push: bool, ) { + let task_count = diagnostics.len(); + let diagnostic_count = diagnostic_task_item_count(&diagnostics); + let _span = + tracing::info_span!("diagnostics.publish", task_count, diagnostic_count, force_push) + .entered(); + if self.config.cli_pull_diagnostics_support() && !force_push { + tracing::info!("skipping push diagnostics for pull-capable client"); return; } + let mut published_files = 0usize; + let mut published_diagnostics = 0usize; + let mut skipped_files = 0usize; for diag in diagnostics { + let file_diagnostics = diag.diagnostics.len(); let should_publish = match self.diagnostics.get(&diag.file_id) { Some(prev) => prev != &diag.diagnostics, None => !diag.diagnostics.is_empty(), }; if !should_publish { + skipped_files += 1; continue; } @@ -422,6 +568,14 @@ impl GlobalState { version: diag.version, }, ); + published_files += 1; + published_diagnostics += file_diagnostics; } + tracing::info!( + published_files, + published_diagnostics, + skipped_files, + "publish diagnostics complete" + ); } } diff --git a/src/global_state/process_changes.rs b/src/global_state/process_changes.rs index 89c564ed..747d6b2c 100644 --- a/src/global_state/process_changes.rs +++ b/src/global_state/process_changes.rs @@ -313,6 +313,7 @@ mod tests { process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }, root_path.clone(), ClientCapabilities::default(), diff --git a/src/global_state/project_status.rs b/src/global_state/project_status.rs index 4e11667c..498e58ee 100644 --- a/src/global_state/project_status.rs +++ b/src/global_state/project_status.rs @@ -93,6 +93,7 @@ mod tests { process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }, root_path.clone(), lsp_types::ClientCapabilities::default(), diff --git a/src/global_state/reload.rs b/src/global_state/reload.rs index 6014c723..b7d21f19 100644 --- a/src/global_state/reload.rs +++ b/src/global_state/reload.rs @@ -279,6 +279,7 @@ mod tests { process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }, root_path.clone(), lsp_types::ClientCapabilities::default(), diff --git a/src/main.rs b/src/main.rs index cf90022c..e4ae194f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,8 @@ #![feature(try_blocks)] -use std::{env, fs, io, path::PathBuf}; +use std::{ + env, fs, io, + path::{Path, PathBuf}, +}; use anyhow::Context; use clap::Parser; @@ -10,7 +13,7 @@ use lsp_server::Connection; use lsp_types::{MessageType, ShowMessageParams}; use slang as _; use tracing_subscriber::{ - Registry, filter::Targets, fmt::writer::BoxMakeWriter, layer::SubscriberExt, + Layer, Registry, filter::Targets, fmt::writer::BoxMakeWriter, layer::SubscriberExt, util::SubscriberInitExt, }; use utils::{ @@ -38,6 +41,17 @@ const VERSION: &str = formatcp!( env!("VIZSLA_COMMIT_HASH"), env!("VIZSLA_BUILD_DATE") ); +const DEFAULT_PROFILE_TRACE_FILTER: &str = concat!( + "vizsla=trace,", + "base_db=trace,", + "hir=trace,", + "ide=trace,", + "project_model=trace,", + "slang=trace,", + "utils=trace,", + "vfs=trace,", + "vfs_notify=trace" +); #[derive(Clone, Debug, Parser)] #[clap(name = DEFAULT_PROCESS_NAME, version = VERSION)] @@ -50,9 +64,31 @@ pub struct Opt { #[clap(long = "log_file", default_value = None)] pub log_filename: Option, + + /// Write a Chrome/Perfetto-compatible tracing profile to this JSON file. + /// + /// This can also be set with VIZSLA_PROFILE_TRACE. The captured targets + /// default to project crates and can be overridden with + /// VIZSLA_PROFILE_TRACE_FILTER. + #[clap(long = "profile_trace", default_value = None)] + pub profile_trace: Option, } -fn setup_logging(opt: &Opt) -> anyhow::Result<()> { +fn profile_trace_path(opt: &Opt) -> Option { + opt.profile_trace.clone().or_else(|| env::var_os("VIZSLA_PROFILE_TRACE").map(PathBuf::from)) +} + +fn create_profile_trace_file(path: &Path) -> anyhow::Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("could not create profile trace directory: {}", parent.display()) + })?; + } + fs::File::create(path) + .with_context(|| format!("could not create profile trace file: {}", path.display())) +} + +fn setup_logging(opt: &Opt) -> anyhow::Result> { let target: Targets = opt.log.parse().with_context(|| format!("invalid log filter: `{}`", opt.log))?; @@ -70,11 +106,33 @@ fn setup_logging(opt: &Opt) -> anyhow::Result<()> { None => BoxMakeWriter::new(io::stderr), }; - let fmt_layer = tracing_subscriber::fmt::layer().with_writer(writer); + let fmt_layer = tracing_subscriber::fmt::layer().with_writer(writer).with_filter(target); - Registry::default().with(target).with(fmt_layer).init(); + let subscriber = Registry::default().with(fmt_layer); + let profile_guard = if let Some(path) = profile_trace_path(opt) { + let profile_filter_text = env::var("VIZSLA_PROFILE_TRACE_FILTER") + .unwrap_or_else(|_| DEFAULT_PROFILE_TRACE_FILTER.to_owned()); + let profile_filter = + profile_filter_text.parse::().context("invalid profile trace filter")?; + let file = create_profile_trace_file(&path)?; + let (chrome_layer, guard) = tracing_chrome::ChromeLayerBuilder::new() + .writer(file) + .include_args(true) + .include_locations(false) + .build(); + subscriber.with(chrome_layer.with_filter(profile_filter)).init(); + tracing::info!( + path = %path.display(), + filter = %profile_filter_text, + "profile trace enabled" + ); + Some(guard) + } else { + subscriber.init(); + None + }; - Ok(()) + Ok(profile_guard) } #[allow(deprecated)] @@ -189,7 +247,7 @@ fn main() -> anyhow::Result<()> { } let opt = Opt::parse(); - setup_logging(&opt)?; + let _profile_guard = setup_logging(&opt)?; run_server(opt)?; Ok(()) } diff --git a/src/tests.rs b/src/tests.rs index 47620cd7..3ddecdc9 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -111,6 +111,7 @@ fn test_server_config_with_i18n( process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }; config::Config::new( opt, @@ -1584,6 +1585,7 @@ fn configured_include_dirs_suppress_include_defined_macro_diagnostic() { process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }; let config = config::Config::new( opt, @@ -1679,6 +1681,7 @@ fn unsaved_library_include_header_changes_are_used_for_dependent_diagnostics() { process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }; let config = config::Config::new( opt, @@ -1792,6 +1795,7 @@ fn unsaved_include_header_changes_are_used_for_dependent_diagnostics() { process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }; let config = config::Config::new( opt, @@ -1889,6 +1893,7 @@ fn project_manifest_is_not_diagnosed_as_systemverilog() { process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }; let config = config::Config::new( opt, @@ -2001,6 +2006,7 @@ fn restored_project_manifest_clears_diagnostics_for_excluded_files() { process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }; let config = config::Config::new( opt, @@ -2114,6 +2120,7 @@ fn workspace_scan_refreshes_diagnostics_for_unopened_systemverilog_dependency() process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }; let config = config::Config::new( opt, @@ -2273,6 +2280,7 @@ fn deleted_workspace_file_requests_diagnostic_refresh() { process_name: "vizsla-test".to_string(), log: "error".to_string(), log_filename: None, + profile_trace: None, }; let config = config::Config::new( opt, From 013a72bf3fcbb90a2c834d66da4a7677fd823e46 Mon Sep 17 00:00:00 2001 From: hongjr03 Date: Sat, 23 May 2026 22:48:12 +0800 Subject: [PATCH 2/5] fix: clean server logs and add interactive flamegraph --- docs/src/content/docs/commands-status-logs.md | 5 +- .../content/docs/en/commands-status-logs.md | 5 +- editors/vscode/README.md | 2 +- editors/vscode/src/profiling.ts | 14 +- editors/vscode/src/profilingTrace.ts | 165 +++++++++++++++++- editors/vscode/src/profilingTypes.ts | 2 + editors/vscode/test/profiling.test.ts | 5 +- src/main.rs | 3 +- 8 files changed, 190 insertions(+), 11 deletions(-) diff --git a/docs/src/content/docs/commands-status-logs.md b/docs/src/content/docs/commands-status-logs.md index 72034176..8ee78074 100644 --- a/docs/src/content/docs/commands-status-logs.md +++ b/docs/src/content/docs/commands-status-logs.md @@ -55,10 +55,11 @@ VS Code 扩展贡献了这些命令: | 文件 | 说明 | | --- | --- | -| `trace.json` | Chrome/Perfetto 兼容 trace。 | +| `trace.json` | Chrome/Perfetto/speedscope 兼容 trace。 | | `summary.json` | 请求耗时、diagnostics 汇总和 top span 汇总。 | | `trace.folded` | 从 trace 生成的 folded stack。 | -| `flamegraph.svg` | 可直接打开的火焰图。 | +| `flamegraph.html` | 可点击缩放和搜索的交互式火焰图。 | +| `flamegraph.svg` | 静态火焰图备用文件。 | | `server.log` | 临时语言服务器日志。 | ## 查询服务器版本 diff --git a/docs/src/content/docs/en/commands-status-logs.md b/docs/src/content/docs/en/commands-status-logs.md index 0e4e31c5..3a242305 100644 --- a/docs/src/content/docs/en/commands-status-logs.md +++ b/docs/src/content/docs/en/commands-status-logs.md @@ -55,10 +55,11 @@ The command generates: | File | Description | | --- | --- | -| `trace.json` | Chrome/Perfetto-compatible trace. | +| `trace.json` | Chrome/Perfetto/speedscope-compatible trace. | | `summary.json` | Request timing, diagnostics summary, and top span summary. | | `trace.folded` | Folded stack generated from the trace. | -| `flamegraph.svg` | Flamegraph view that can be opened directly. | +| `flamegraph.html` | Interactive flamegraph with click-to-zoom and search. | +| `flamegraph.svg` | Static flamegraph fallback. | | `server.log` | Temporary language server log. | ## Query Server Version diff --git a/editors/vscode/README.md b/editors/vscode/README.md index be62d925..c43da161 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -17,7 +17,7 @@ The command palette also provides: When server launch settings change, the extension prompts you to restart the language server so the new command, arguments, working directory, or trace setting can take effect. -`Vizsla: Profile Diagnostics` starts an isolated temporary language server session, runs either a workspace `workspace/diagnostic` request or a current-file `textDocument/diagnostic` request, writes trace and summary artifacts, and opens the `Vizsla Profiling` output channel with the generated paths. +`Vizsla: Profile Diagnostics` starts an isolated temporary language server session, runs either a workspace `workspace/diagnostic` request or a current-file `textDocument/diagnostic` request, writes trace, summary, and interactive flamegraph artifacts, and opens the `Vizsla Profiling` output channel with the generated paths. ## Configuration diff --git a/editors/vscode/src/profiling.ts b/editors/vscode/src/profiling.ts index e34d3814..8f309baf 100644 --- a/editors/vscode/src/profiling.ts +++ b/editors/vscode/src/profiling.ts @@ -187,6 +187,7 @@ function profileArtifacts(context: vscode.ExtensionContext, target: ProfileTarge trace: path.join(dir, 'trace.json'), summary: path.join(dir, 'summary.json'), folded: path.join(dir, 'trace.folded'), + html: path.join(dir, 'flamegraph.html'), svg: path.join(dir, 'flamegraph.svg'), log: path.join(dir, 'server.log'), }; @@ -261,17 +262,24 @@ async function runProfileSession( artifacts: { trace: artifacts.trace, folded: artifacts.folded, + flamegraph_html: artifacts.html, flamegraph: artifacts.svg, server_log: artifacts.log, }, - trace_summary: await summarizeTraceFile(artifacts.trace, artifacts.folded, artifacts.svg), + trace_summary: await summarizeTraceFile( + artifacts.trace, + artifacts.folded, + artifacts.svg, + artifacts.html, + ), }; await writeJsonFile(artifacts.summary, summary); channel.appendLine(`Request: ${diagnosticRequest.method}`); channel.appendLine(`Diagnostic request: ${requestElapsedMs} ms`); channel.appendLine(`Diagnostics: ${diagnostics.length}`); channel.appendLine(`Summary: ${artifacts.summary}`); - channel.appendLine(`Flamegraph: ${artifacts.svg}`); + channel.appendLine(`Interactive flamegraph: ${artifacts.html}`); + channel.appendLine(`Static flamegraph: ${artifacts.svg}`); } finally { session.dispose(); } @@ -329,7 +337,7 @@ async function showProfileCompleteMessage(artifacts: ProfileArtifacts): Promise< showInFolder, ); if (selection === openFlamegraph) { - await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(artifacts.svg)); + await vscode.env.openExternal(vscode.Uri.file(artifacts.html)); } else if (selection === openSummary) { await vscode.window.showTextDocument(vscode.Uri.file(artifacts.summary)); } else if (selection === showInFolder) { diff --git a/editors/vscode/src/profilingTrace.ts b/editors/vscode/src/profilingTrace.ts index 85501212..127dee6d 100644 --- a/editors/vscode/src/profilingTrace.ts +++ b/editors/vscode/src/profilingTrace.ts @@ -28,16 +28,24 @@ type FlameNode = { children: Map; }; +type FlameNodeJson = { + name: string; + value: number; + children: FlameNodeJson[]; +}; + export async function summarizeTraceFile( tracePath: string, foldedPath: string, svgPath: string, + htmlPath: string, ): Promise> { const text = await fs.promises.readFile(tracePath, 'utf8'); const parsed = JSON.parse(text) as unknown; const summary = summarizeTraceEvents(traceEvents(parsed), 100); await writeFoldedFile(foldedPath, summary.folded); await writeFlamegraphSvg(foldedPath, svgPath, path.basename(tracePath)); + await writeFlamegraphHtml(foldedPath, htmlPath, path.basename(tracePath)); return traceSummaryJson(summary, 30); } @@ -189,6 +197,21 @@ async function writeFlamegraphSvg( svgPath: string, title: string, ): Promise { + const root = await flameRootFromFoldedFile(foldedPath); + const output = renderFlamegraphSvg(root, title); + await fs.promises.writeFile(svgPath, `${output.join('\n')}\n`, 'utf8'); +} + +async function writeFlamegraphHtml( + foldedPath: string, + htmlPath: string, + title: string, +): Promise { + const root = await flameRootFromFoldedFile(foldedPath); + await fs.promises.writeFile(htmlPath, flamegraphHtml(root, title), 'utf8'); +} + +async function flameRootFromFoldedFile(foldedPath: string): Promise { const folded = await fs.promises.readFile(foldedPath, 'utf8'); const root: FlameNode = { name: 'all', value: 0, children: new Map() }; for (const line of folded.split(/\r?\n/).filter((item) => item.trim().length > 0)) { @@ -202,7 +225,10 @@ async function writeFlamegraphSvg( insertFlameStack(root, stack, value); } } + return root; +} +function renderFlamegraphSvg(root: FlameNode, title: string): string[] { const width = 1400; const marginLeft = 10; const marginTop = 38; @@ -225,7 +251,132 @@ async function writeFlamegraphSvg( output.push(`${escapeXml(title)}`); renderFlameNode(root, marginLeft, 0, height, marginBottom, frameHeight, scale, output); output.push(''); - await fs.promises.writeFile(svgPath, `${output.join('\n')}\n`, 'utf8'); + return output; +} + +function flamegraphHtml(root: FlameNode, title: string): string { + const data = JSON.stringify(flameNodeJson(root)).replace(/ + + + + +${escapeHtml(title)} flamegraph + + + +
+
+

${escapeHtml(title)}

+
all
+
+
+ + +
+
+
+ + + +`; } function insertFlameStack(node: FlameNode, stack: string[], value: number): void { @@ -250,6 +401,14 @@ function flameDepth(node: FlameNode): number { return 1 + childDepth; } +function flameNodeJson(node: FlameNode): FlameNodeJson { + return { + name: node.name, + value: node.value, + children: [...node.children.values()].map(flameNodeJson), + }; +} + function renderFlameNode( node: FlameNode, x: number, @@ -333,3 +492,7 @@ function escapeXml(text: string): string { .replace(/>/g, '>') .replace(/"/g, '"'); } + +function escapeHtml(text: string): string { + return escapeXml(text).replace(/'/g, '''); +} diff --git a/editors/vscode/src/profilingTypes.ts b/editors/vscode/src/profilingTypes.ts index b6604c24..b49e5275 100644 --- a/editors/vscode/src/profilingTypes.ts +++ b/editors/vscode/src/profilingTypes.ts @@ -35,6 +35,7 @@ export type ProfileArtifacts = { trace: string; summary: string; folded: string; + html: string; svg: string; log: string; }; @@ -50,6 +51,7 @@ export type ProfileRunSummary = { artifacts: { trace: string; folded: string; + flamegraph_html: string; flamegraph: string; server_log: string; }; diff --git a/editors/vscode/test/profiling.test.ts b/editors/vscode/test/profiling.test.ts index 57e92bfc..d9fdbb21 100644 --- a/editors/vscode/test/profiling.test.ts +++ b/editors/vscode/test/profiling.test.ts @@ -175,6 +175,7 @@ test('writes trace summary, folded stacks, and flamegraph artifacts', async () = const trace = path.join(dir, 'trace.json'); const folded = path.join(dir, 'trace.folded'); const svg = path.join(dir, 'flamegraph.svg'); + const html = path.join(dir, 'flamegraph.html'); await fs.writeFile( trace, @@ -187,9 +188,11 @@ test('writes trace summary, folded stacks, and flamegraph artifacts', async () = 'utf8', ); - const summary = await summarizeTraceFile(trace, folded, svg); + const summary = await summarizeTraceFile(trace, folded, svg, html); assert.equal(summary.event_count, 2); assert.match(await fs.readFile(folded, 'utf8'), /thread;outer 150/); assert.match(await fs.readFile(svg, 'utf8'), / anyhow::Result None => BoxMakeWriter::new(io::stderr), }; - let fmt_layer = tracing_subscriber::fmt::layer().with_writer(writer).with_filter(target); + let fmt_layer = + tracing_subscriber::fmt::layer().with_ansi(false).with_writer(writer).with_filter(target); let subscriber = Registry::default().with(fmt_layer); let profile_guard = if let Some(path) = profile_trace_path(opt) { From 177fa7a61968e52a1e10e4446e2cdb2daf9843d3 Mon Sep 17 00:00:00 2001 From: hongjr03 Date: Sat, 23 May 2026 23:08:43 +0800 Subject: [PATCH 3/5] feat(vscode): open profiles in bundled speedscope --- docs/src/content/docs/commands-status-logs.md | 5 +- .../content/docs/en/commands-status-logs.md | 5 +- editors/vscode/README.md | 2 +- editors/vscode/THIRD_PARTY_NOTICES.md | 36 +++ editors/vscode/l10n/bundle.l10n.zh-cn.json | 3 +- editors/vscode/package-lock.json | 33 +++ editors/vscode/package.json | 4 +- .../vscode/scripts/copy-speedscope-assets.ts | 14 ++ editors/vscode/src/profiling.ts | 38 +-- editors/vscode/src/profilingTrace.ts | 154 ------------ editors/vscode/src/profilingTypes.ts | 4 +- editors/vscode/src/profilingViewer.ts | 226 ++++++++++++++++++ editors/vscode/test/profiling.test.ts | 7 +- 13 files changed, 343 insertions(+), 188 deletions(-) create mode 100644 editors/vscode/scripts/copy-speedscope-assets.ts create mode 100644 editors/vscode/src/profilingViewer.ts diff --git a/docs/src/content/docs/commands-status-logs.md b/docs/src/content/docs/commands-status-logs.md index 8ee78074..18be8d36 100644 --- a/docs/src/content/docs/commands-status-logs.md +++ b/docs/src/content/docs/commands-status-logs.md @@ -55,11 +55,10 @@ VS Code 扩展贡献了这些命令: | 文件 | 说明 | | --- | --- | -| `trace.json` | Chrome/Perfetto/speedscope 兼容 trace。 | +| `trace.json` | Chrome/Perfetto/Speedscope 兼容 trace, 也就是 Speedscope 的交互式输入文件。 | | `summary.json` | 请求耗时、diagnostics 汇总和 top span 汇总。 | | `trace.folded` | 从 trace 生成的 folded stack。 | -| `flamegraph.html` | 可点击缩放和搜索的交互式火焰图。 | -| `flamegraph.svg` | 静态火焰图备用文件。 | +| `flamegraph.svg` | 静态火焰图备用文件。交互式查看由扩展内置的本地 Speedscope viewer 打开 `trace.json`。 | | `server.log` | 临时语言服务器日志。 | ## 查询服务器版本 diff --git a/docs/src/content/docs/en/commands-status-logs.md b/docs/src/content/docs/en/commands-status-logs.md index 3a242305..262b3c1a 100644 --- a/docs/src/content/docs/en/commands-status-logs.md +++ b/docs/src/content/docs/en/commands-status-logs.md @@ -55,11 +55,10 @@ The command generates: | File | Description | | --- | --- | -| `trace.json` | Chrome/Perfetto/speedscope-compatible trace. | +| `trace.json` | Chrome/Perfetto/Speedscope-compatible trace, and the input file for the interactive Speedscope viewer. | | `summary.json` | Request timing, diagnostics summary, and top span summary. | | `trace.folded` | Folded stack generated from the trace. | -| `flamegraph.html` | Interactive flamegraph with click-to-zoom and search. | -| `flamegraph.svg` | Static flamegraph fallback. | +| `flamegraph.svg` | Static flamegraph fallback. Interactive viewing opens `trace.json` in the bundled local Speedscope viewer. | | `server.log` | Temporary language server log. | ## Query Server Version diff --git a/editors/vscode/README.md b/editors/vscode/README.md index c43da161..9e11276e 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -17,7 +17,7 @@ The command palette also provides: When server launch settings change, the extension prompts you to restart the language server so the new command, arguments, working directory, or trace setting can take effect. -`Vizsla: Profile Diagnostics` starts an isolated temporary language server session, runs either a workspace `workspace/diagnostic` request or a current-file `textDocument/diagnostic` request, writes trace, summary, and interactive flamegraph artifacts, and opens the `Vizsla Profiling` output channel with the generated paths. +`Vizsla: Profile Diagnostics` starts an isolated temporary language server session, runs either a workspace `workspace/diagnostic` request or a current-file `textDocument/diagnostic` request, writes trace, summary, and flamegraph artifacts, and opens the trace in the bundled Speedscope viewer when requested. ## Configuration diff --git a/editors/vscode/THIRD_PARTY_NOTICES.md b/editors/vscode/THIRD_PARTY_NOTICES.md index cbf426b9..90a8e7df 100644 --- a/editors/vscode/THIRD_PARTY_NOTICES.md +++ b/editors/vscode/THIRD_PARTY_NOTICES.md @@ -29,3 +29,39 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` + +## Speedscope + +Source: `https://github.com/jlfwong/speedscope` (bundled local viewer assets copied from npm package `speedscope`) + +License (MIT): + +``` +MIT License + +Copyright (c) 2018 Jamie Wong + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## Source Code Pro + +Source: bundled by `speedscope` in `dist/speedscope/source-code-pro.LICENSE.md`. + +License: SIL Open Font License, Version 1.1. diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index 381fc4f9..12f465a9 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -63,9 +63,10 @@ "Current File Diagnostics": "当前文件诊断", "Runs textDocument/diagnostic for {0}": "对 {0} 运行 textDocument/diagnostic", "Vizsla diagnostics profile complete.": "Vizsla 诊断性能分析已完成。", - "Open Flamegraph": "打开火焰图", + "Open in Speedscope": "用 Speedscope 打开", "Open Summary": "打开摘要", "Show in Folder": "在文件夹中显示", + "Failed to open Speedscope: {0}": "无法打开 Speedscope:{0}", "Failed to profile diagnostics: {0}": "无法分析诊断性能:{0}", "Qihe analysis is only available for Verilog files.": "Qihe 分析仅适用于 Verilog 文件。", "Vizsla language server is not running.": "Vizsla 语言服务器未运行。", diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index 72374584..347a47eb 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -17,6 +17,7 @@ "@vscode/l10n-dev": "^0.0.35", "@vscode/vsce": "^3.6.2", "esbuild": "^0.28.0", + "speedscope": "1.25.0", "tsx": "^4.22.0", "typescript": "^5.9.3" }, @@ -4366,6 +4367,38 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/speedscope": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/speedscope/-/speedscope-1.25.0.tgz", + "integrity": "sha512-cpf9HWTZf1ijLXBJrk87Ve/54tnYx6rlf0gUThvn3ook6h63hLXVOQiMf44PT+XoV503cPfvfiT5gP5vojp6Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "10.1.0" + }, + "bin": { + "speedscope": "bin/cli.mjs" + } + }, + "node_modules/speedscope/node_modules/open": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", + "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 9430ab1b..72928fa3 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -381,8 +381,9 @@ } }, "scripts": { - "bundle": "esbuild src/extension.ts --bundle --platform=node --target=node22 --format=cjs --external:vscode --outfile=dist/extension.js", + "bundle": "esbuild src/extension.ts --bundle --platform=node --target=node22 --format=cjs --external:vscode --outfile=dist/extension.js && npm run copy-speedscope", "clean": "tsx scripts/clean.ts", + "copy-speedscope": "tsx scripts/copy-speedscope-assets.ts", "typecheck": "tsc -p . --noEmit", "compile": "npm run clean && npm run typecheck && npm run bundle", "test": "npm run compile && node --import tsx --test \"test/**/*.test.ts\"", @@ -406,6 +407,7 @@ "@vscode/l10n-dev": "^0.0.35", "@vscode/vsce": "^3.6.2", "esbuild": "^0.28.0", + "speedscope": "1.25.0", "tsx": "^4.22.0", "typescript": "^5.9.3" } diff --git a/editors/vscode/scripts/copy-speedscope-assets.ts b/editors/vscode/scripts/copy-speedscope-assets.ts new file mode 100644 index 00000000..c4831956 --- /dev/null +++ b/editors/vscode/scripts/copy-speedscope-assets.ts @@ -0,0 +1,14 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const vscodeDir = path.resolve(__dirname, '..'); +const speedscopePackageJson = require.resolve('speedscope/package.json') as string; +const speedscopeReleaseDir = path.join(path.dirname(speedscopePackageJson), 'dist', 'release'); +const outputDir = path.join(vscodeDir, 'dist', 'speedscope'); + +if (!fs.existsSync(path.join(speedscopeReleaseDir, 'index.html'))) { + throw new Error(`Speedscope release assets not found at ${speedscopeReleaseDir}`); +} + +fs.rmSync(outputDir, { recursive: true, force: true }); +fs.cpSync(speedscopeReleaseDir, outputDir, { recursive: true }); diff --git a/editors/vscode/src/profiling.ts b/editors/vscode/src/profiling.ts index 8f309baf..7dffdb82 100644 --- a/editors/vscode/src/profiling.ts +++ b/editors/vscode/src/profiling.ts @@ -19,6 +19,7 @@ import type { ProfilingDependencies, ServerLaunch, } from './profilingTypes'; +import { SpeedscopeProfileViewer } from './profilingViewer'; export const profileDiagnosticsCommand = 'vizsla.profileDiagnostics'; @@ -43,10 +44,12 @@ export function registerProfilingCommand( deps: ProfilingDependencies, ): vscode.Disposable { const channel = vscode.window.createOutputChannel(profileOutputChannelName); + const viewer = new SpeedscopeProfileViewer(context); context.subscriptions.push(channel); + context.subscriptions.push(viewer); return vscode.commands.registerCommand(profileDiagnosticsCommand, async () => { - await runProfileDiagnostics(context, deps, channel); + await runProfileDiagnostics(context, deps, channel, viewer); }); } @@ -54,6 +57,7 @@ async function runProfileDiagnostics( context: vscode.ExtensionContext, deps: ProfilingDependencies, channel: vscode.OutputChannel, + viewer: SpeedscopeProfileViewer, ): Promise { const target = await selectProfileTarget(); if (!target) { @@ -89,7 +93,7 @@ async function runProfileDiagnostics( }, ); - await showProfileCompleteMessage(artifacts); + await showProfileCompleteMessage(artifacts, viewer); } catch (error) { const message = vscode.l10n.t( 'Failed to profile diagnostics: {0}', @@ -187,7 +191,6 @@ function profileArtifacts(context: vscode.ExtensionContext, target: ProfileTarge trace: path.join(dir, 'trace.json'), summary: path.join(dir, 'summary.json'), folded: path.join(dir, 'trace.folded'), - html: path.join(dir, 'flamegraph.html'), svg: path.join(dir, 'flamegraph.svg'), log: path.join(dir, 'server.log'), }; @@ -262,23 +265,17 @@ async function runProfileSession( artifacts: { trace: artifacts.trace, folded: artifacts.folded, - flamegraph_html: artifacts.html, - flamegraph: artifacts.svg, + flamegraph_svg: artifacts.svg, server_log: artifacts.log, }, - trace_summary: await summarizeTraceFile( - artifacts.trace, - artifacts.folded, - artifacts.svg, - artifacts.html, - ), + trace_summary: await summarizeTraceFile(artifacts.trace, artifacts.folded, artifacts.svg), }; await writeJsonFile(artifacts.summary, summary); channel.appendLine(`Request: ${diagnosticRequest.method}`); channel.appendLine(`Diagnostic request: ${requestElapsedMs} ms`); channel.appendLine(`Diagnostics: ${diagnostics.length}`); channel.appendLine(`Summary: ${artifacts.summary}`); - channel.appendLine(`Interactive flamegraph: ${artifacts.html}`); + channel.appendLine(`Speedscope input trace: ${artifacts.trace}`); channel.appendLine(`Static flamegraph: ${artifacts.svg}`); } finally { session.dispose(); @@ -326,18 +323,25 @@ async function stopProfileSession( await session.waitForExit(shutdownTimeoutMs); } -async function showProfileCompleteMessage(artifacts: ProfileArtifacts): Promise { - const openFlamegraph = vscode.l10n.t('Open Flamegraph'); +async function showProfileCompleteMessage( + artifacts: ProfileArtifacts, + viewer: SpeedscopeProfileViewer, +): Promise { + const openSpeedscope = vscode.l10n.t('Open in Speedscope'); const openSummary = vscode.l10n.t('Open Summary'); const showInFolder = vscode.l10n.t('Show in Folder'); const selection = await vscode.window.showInformationMessage( vscode.l10n.t('Vizsla diagnostics profile complete.'), - openFlamegraph, + openSpeedscope, openSummary, showInFolder, ); - if (selection === openFlamegraph) { - await vscode.env.openExternal(vscode.Uri.file(artifacts.html)); + if (selection === openSpeedscope) { + await viewer.open(artifacts).catch((error: unknown) => { + vscode.window.showErrorMessage( + vscode.l10n.t('Failed to open Speedscope: {0}', (error as Error).message), + ); + }); } else if (selection === openSummary) { await vscode.window.showTextDocument(vscode.Uri.file(artifacts.summary)); } else if (selection === showInFolder) { diff --git a/editors/vscode/src/profilingTrace.ts b/editors/vscode/src/profilingTrace.ts index 127dee6d..f1095310 100644 --- a/editors/vscode/src/profilingTrace.ts +++ b/editors/vscode/src/profilingTrace.ts @@ -28,24 +28,16 @@ type FlameNode = { children: Map; }; -type FlameNodeJson = { - name: string; - value: number; - children: FlameNodeJson[]; -}; - export async function summarizeTraceFile( tracePath: string, foldedPath: string, svgPath: string, - htmlPath: string, ): Promise> { const text = await fs.promises.readFile(tracePath, 'utf8'); const parsed = JSON.parse(text) as unknown; const summary = summarizeTraceEvents(traceEvents(parsed), 100); await writeFoldedFile(foldedPath, summary.folded); await writeFlamegraphSvg(foldedPath, svgPath, path.basename(tracePath)); - await writeFlamegraphHtml(foldedPath, htmlPath, path.basename(tracePath)); return traceSummaryJson(summary, 30); } @@ -202,15 +194,6 @@ async function writeFlamegraphSvg( await fs.promises.writeFile(svgPath, `${output.join('\n')}\n`, 'utf8'); } -async function writeFlamegraphHtml( - foldedPath: string, - htmlPath: string, - title: string, -): Promise { - const root = await flameRootFromFoldedFile(foldedPath); - await fs.promises.writeFile(htmlPath, flamegraphHtml(root, title), 'utf8'); -} - async function flameRootFromFoldedFile(foldedPath: string): Promise { const folded = await fs.promises.readFile(foldedPath, 'utf8'); const root: FlameNode = { name: 'all', value: 0, children: new Map() }; @@ -254,131 +237,6 @@ function renderFlamegraphSvg(root: FlameNode, title: string): string[] { return output; } -function flamegraphHtml(root: FlameNode, title: string): string { - const data = JSON.stringify(flameNodeJson(root)).replace(/ - - - - -${escapeHtml(title)} flamegraph - - - -
-
-

${escapeHtml(title)}

-
all
-
-
- - -
-
-
- - - -`; -} - function insertFlameStack(node: FlameNode, stack: string[], value: number): void { node.value += value; const [head, ...tail] = stack; @@ -401,14 +259,6 @@ function flameDepth(node: FlameNode): number { return 1 + childDepth; } -function flameNodeJson(node: FlameNode): FlameNodeJson { - return { - name: node.name, - value: node.value, - children: [...node.children.values()].map(flameNodeJson), - }; -} - function renderFlameNode( node: FlameNode, x: number, @@ -492,7 +342,3 @@ function escapeXml(text: string): string { .replace(/>/g, '>') .replace(/"/g, '"'); } - -function escapeHtml(text: string): string { - return escapeXml(text).replace(/'/g, '''); -} diff --git a/editors/vscode/src/profilingTypes.ts b/editors/vscode/src/profilingTypes.ts index b49e5275..dd68f003 100644 --- a/editors/vscode/src/profilingTypes.ts +++ b/editors/vscode/src/profilingTypes.ts @@ -35,7 +35,6 @@ export type ProfileArtifacts = { trace: string; summary: string; folded: string; - html: string; svg: string; log: string; }; @@ -51,8 +50,7 @@ export type ProfileRunSummary = { artifacts: { trace: string; folded: string; - flamegraph_html: string; - flamegraph: string; + flamegraph_svg: string; server_log: string; }; trace_summary: Record; diff --git a/editors/vscode/src/profilingViewer.ts b/editors/vscode/src/profilingViewer.ts new file mode 100644 index 00000000..77ca9d28 --- /dev/null +++ b/editors/vscode/src/profilingViewer.ts @@ -0,0 +1,226 @@ +import { randomUUID } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as path from 'node:path'; + +import * as vscode from 'vscode'; + +import type { ProfileArtifacts } from './profilingTypes'; + +type ProfileEntry = { + tracePath: string; + title: string; +}; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +const contentTypes: Record = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.txt': 'text/plain; charset=utf-8', + '.wasm': 'application/wasm', + '.woff2': 'font/woff2', +}; + +export class SpeedscopeProfileViewer implements vscode.Disposable { + private readonly profiles = new Map(); + private server: http.Server | undefined; + private port: number | undefined; + + constructor(private readonly context: vscode.ExtensionContext) {} + + async open(artifacts: ProfileArtifacts): Promise { + const port = await this.ensureServer(); + const id = randomUUID(); + const title = `Vizsla ${path.basename(artifacts.dir)}`; + + this.profiles.set(id, { tracePath: artifacts.trace, title }); + + const viewerUri = vscode.Uri.parse(`http://127.0.0.1:${port}/index.html`); + const profileUri = vscode.Uri.parse(`http://127.0.0.1:${port}/profiles/${id}/trace.json`); + const externalViewerUri = await vscode.env.asExternalUri(viewerUri); + const externalProfileUri = await vscode.env.asExternalUri(profileUri); + const targetUri = externalViewerUri.with({ + fragment: `profileURL=${encodeURIComponent(externalProfileUri.toString())}&title=${encodeURIComponent(title)}`, + }); + + await vscode.env.openExternal(targetUri); + return targetUri; + } + + dispose(): void { + this.profiles.clear(); + this.port = undefined; + this.server?.close(); + this.server = undefined; + } + + private async ensureServer(): Promise { + if (this.port !== undefined) { + return this.port; + } + + const viewerRoot = this.viewerRoot(); + const indexPath = path.join(viewerRoot, 'index.html'); + if (!fs.existsSync(indexPath)) { + throw new Error(`Speedscope assets not found at ${viewerRoot}`); + } + + const server = http.createServer((request, response) => { + void this.handleRequest(viewerRoot, request, response).catch((error: unknown) => { + if (!response.headersSent) { + response.writeHead(500, corsHeaders); + } + response.end((error as Error).message); + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + + const address = server.address(); + if (typeof address !== 'object' || address === null) { + server.close(); + throw new Error('Speedscope server did not bind to a TCP port'); + } + + this.server = server; + this.port = address.port; + return address.port; + } + + private async handleRequest( + viewerRoot: string, + request: http.IncomingMessage, + response: http.ServerResponse, + ): Promise { + if (request.method === 'OPTIONS') { + response.writeHead(204, corsHeaders); + response.end(); + return; + } + + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.writeHead(405, corsHeaders); + response.end('Method not allowed'); + return; + } + + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + if (requestUrl.pathname.startsWith('/profiles/')) { + await this.serveProfile(requestUrl.pathname, request, response); + return; + } + + await this.serveAsset(viewerRoot, requestUrl.pathname, request, response); + } + + private async serveProfile( + pathname: string, + request: http.IncomingMessage, + response: http.ServerResponse, + ): Promise { + const match = /^\/profiles\/([^/]+)\/trace\.json$/.exec(pathname); + const profile = match ? this.profiles.get(match[1]) : undefined; + if (!profile) { + response.writeHead(404, corsHeaders); + response.end('Profile not found'); + return; + } + + const stat = await fs.promises.stat(profile.tracePath).catch(() => undefined); + if (!stat?.isFile()) { + response.writeHead(404, corsHeaders); + response.end('Trace file not found'); + return; + } + + response.writeHead(200, { + ...corsHeaders, + 'Cache-Control': 'no-store', + 'Content-Length': stat.size, + 'Content-Type': 'application/json; charset=utf-8', + }); + + if (request.method === 'HEAD') { + response.end(); + return; + } + + await pipeFile(profile.tracePath, response); + } + + private async serveAsset( + viewerRoot: string, + pathname: string, + request: http.IncomingMessage, + response: http.ServerResponse, + ): Promise { + const assetPath = assetPathForRequest(viewerRoot, pathname); + if (!assetPath) { + response.writeHead(404, corsHeaders); + response.end('Not found'); + return; + } + + const stat = await fs.promises.stat(assetPath).catch(() => undefined); + if (!stat?.isFile()) { + response.writeHead(404, corsHeaders); + response.end('Not found'); + return; + } + + response.writeHead(200, { + ...corsHeaders, + 'Cache-Control': 'public, max-age=3600', + 'Content-Length': stat.size, + 'Content-Type': contentTypes[path.extname(assetPath).toLowerCase()] ?? 'application/octet-stream', + }); + + if (request.method === 'HEAD') { + response.end(); + return; + } + + await pipeFile(assetPath, response); + } + + private viewerRoot(): string { + return this.context.asAbsolutePath(path.join('dist', 'speedscope')); + } +} + +function assetPathForRequest(viewerRoot: string, pathname: string): string | undefined { + const relativePath = decodeURIComponent(pathname === '/' ? '/index.html' : pathname).replace( + /^\/+/, + '', + ); + const candidate = path.resolve(viewerRoot, relativePath); + const relativeFromRoot = path.relative(viewerRoot, candidate); + if (relativeFromRoot.startsWith('..') || path.isAbsolute(relativeFromRoot)) { + return undefined; + } + return candidate; +} + +function pipeFile(filePath: string, response: http.ServerResponse): Promise { + return new Promise((resolve, reject) => { + const stream = fs.createReadStream(filePath); + stream.once('error', reject); + stream.once('end', resolve); + stream.pipe(response); + }); +} diff --git a/editors/vscode/test/profiling.test.ts b/editors/vscode/test/profiling.test.ts index d9fdbb21..696fc016 100644 --- a/editors/vscode/test/profiling.test.ts +++ b/editors/vscode/test/profiling.test.ts @@ -170,12 +170,11 @@ test('summarizes chrome trace spans and folded self time', () => { ]); }); -test('writes trace summary, folded stacks, and flamegraph artifacts', async () => { +test('writes trace summary, folded stacks, and static flamegraph artifact', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'vizsla-profile-test-')); const trace = path.join(dir, 'trace.json'); const folded = path.join(dir, 'trace.folded'); const svg = path.join(dir, 'flamegraph.svg'); - const html = path.join(dir, 'flamegraph.html'); await fs.writeFile( trace, @@ -188,11 +187,9 @@ test('writes trace summary, folded stacks, and flamegraph artifacts', async () = 'utf8', ); - const summary = await summarizeTraceFile(trace, folded, svg, html); + const summary = await summarizeTraceFile(trace, folded, svg); assert.equal(summary.event_count, 2); assert.match(await fs.readFile(folded, 'utf8'), /thread;outer 150/); assert.match(await fs.readFile(svg, 'utf8'), / Date: Sat, 23 May 2026 23:13:17 +0800 Subject: [PATCH 4/5] fix(vscode): open profiles in editor tab --- docs/src/content/docs/commands-status-logs.md | 2 +- .../content/docs/en/commands-status-logs.md | 2 +- editors/vscode/README.md | 2 +- editors/vscode/l10n/bundle.l10n.zh-cn.json | 1 + editors/vscode/src/profilingViewer.ts | 53 ++++++++++++++++--- editors/vscode/src/profilingViewerUrl.ts | 7 +++ editors/vscode/test/profiling.test.ts | 18 +++++++ 7 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 editors/vscode/src/profilingViewerUrl.ts diff --git a/docs/src/content/docs/commands-status-logs.md b/docs/src/content/docs/commands-status-logs.md index 18be8d36..62b4b6ed 100644 --- a/docs/src/content/docs/commands-status-logs.md +++ b/docs/src/content/docs/commands-status-logs.md @@ -58,7 +58,7 @@ VS Code 扩展贡献了这些命令: | `trace.json` | Chrome/Perfetto/Speedscope 兼容 trace, 也就是 Speedscope 的交互式输入文件。 | | `summary.json` | 请求耗时、diagnostics 汇总和 top span 汇总。 | | `trace.folded` | 从 trace 生成的 folded stack。 | -| `flamegraph.svg` | 静态火焰图备用文件。交互式查看由扩展内置的本地 Speedscope viewer 打开 `trace.json`。 | +| `flamegraph.svg` | 静态火焰图备用文件。交互式查看会在 VS Code 标签页中用扩展内置的本地 Speedscope viewer 打开 `trace.json`。 | | `server.log` | 临时语言服务器日志。 | ## 查询服务器版本 diff --git a/docs/src/content/docs/en/commands-status-logs.md b/docs/src/content/docs/en/commands-status-logs.md index 262b3c1a..8bf86700 100644 --- a/docs/src/content/docs/en/commands-status-logs.md +++ b/docs/src/content/docs/en/commands-status-logs.md @@ -58,7 +58,7 @@ The command generates: | `trace.json` | Chrome/Perfetto/Speedscope-compatible trace, and the input file for the interactive Speedscope viewer. | | `summary.json` | Request timing, diagnostics summary, and top span summary. | | `trace.folded` | Folded stack generated from the trace. | -| `flamegraph.svg` | Static flamegraph fallback. Interactive viewing opens `trace.json` in the bundled local Speedscope viewer. | +| `flamegraph.svg` | Static flamegraph fallback. Interactive viewing opens `trace.json` in a VS Code tab backed by the bundled local Speedscope viewer. | | `server.log` | Temporary language server log. | ## Query Server Version diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 9e11276e..09a74fc7 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -17,7 +17,7 @@ The command palette also provides: When server launch settings change, the extension prompts you to restart the language server so the new command, arguments, working directory, or trace setting can take effect. -`Vizsla: Profile Diagnostics` starts an isolated temporary language server session, runs either a workspace `workspace/diagnostic` request or a current-file `textDocument/diagnostic` request, writes trace, summary, and flamegraph artifacts, and opens the trace in the bundled Speedscope viewer when requested. +`Vizsla: Profile Diagnostics` starts an isolated temporary language server session, runs either a workspace `workspace/diagnostic` request or a current-file `textDocument/diagnostic` request, writes trace, summary, and flamegraph artifacts, and opens the trace in a VS Code tab backed by the bundled Speedscope viewer when requested. ## Configuration diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index 12f465a9..607ae7f4 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -64,6 +64,7 @@ "Runs textDocument/diagnostic for {0}": "对 {0} 运行 textDocument/diagnostic", "Vizsla diagnostics profile complete.": "Vizsla 诊断性能分析已完成。", "Open in Speedscope": "用 Speedscope 打开", + "Vizsla Profile": "Vizsla 性能分析", "Open Summary": "打开摘要", "Show in Folder": "在文件夹中显示", "Failed to open Speedscope: {0}": "无法打开 Speedscope:{0}", diff --git a/editors/vscode/src/profilingViewer.ts b/editors/vscode/src/profilingViewer.ts index 77ca9d28..a7ced7c4 100644 --- a/editors/vscode/src/profilingViewer.ts +++ b/editors/vscode/src/profilingViewer.ts @@ -6,6 +6,7 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; import type { ProfileArtifacts } from './profilingTypes'; +import { buildSpeedscopeUrl } from './profilingViewerUrl'; type ProfileEntry = { tracePath: string; @@ -37,7 +38,7 @@ export class SpeedscopeProfileViewer implements vscode.Disposable { constructor(private readonly context: vscode.ExtensionContext) {} - async open(artifacts: ProfileArtifacts): Promise { + async open(artifacts: ProfileArtifacts): Promise { const port = await this.ensureServer(); const id = randomUUID(); const title = `Vizsla ${path.basename(artifacts.dir)}`; @@ -48,12 +49,23 @@ export class SpeedscopeProfileViewer implements vscode.Disposable { const profileUri = vscode.Uri.parse(`http://127.0.0.1:${port}/profiles/${id}/trace.json`); const externalViewerUri = await vscode.env.asExternalUri(viewerUri); const externalProfileUri = await vscode.env.asExternalUri(profileUri); - const targetUri = externalViewerUri.with({ - fragment: `profileURL=${encodeURIComponent(externalProfileUri.toString())}&title=${encodeURIComponent(title)}`, - }); - - await vscode.env.openExternal(targetUri); - return targetUri; + const targetUrl = buildSpeedscopeUrl( + externalViewerUri.toString(), + externalProfileUri.toString(), + title, + ); + + const panel = vscode.window.createWebviewPanel( + 'vizslaSpeedscopeProfile', + vscode.l10n.t('Vizsla Profile'), + vscode.ViewColumn.Beside, + { + enableScripts: true, + retainContextWhenHidden: true, + }, + ); + panel.webview.html = webviewHtml(targetUrl, new URL(targetUrl).origin, title); + panel.onDidDispose(() => this.profiles.delete(id)); } dispose(): void { @@ -224,3 +236,30 @@ function pipeFile(filePath: string, response: http.ServerResponse): Promise + + + + + +${escapeHtmlText(title)} + + + + + +`; +} + +function escapeHtmlAttribute(text: string): string { + return escapeHtmlText(text).replace(/"/g, '"'); +} + +function escapeHtmlText(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} diff --git a/editors/vscode/src/profilingViewerUrl.ts b/editors/vscode/src/profilingViewerUrl.ts new file mode 100644 index 00000000..256b3b7a --- /dev/null +++ b/editors/vscode/src/profilingViewerUrl.ts @@ -0,0 +1,7 @@ +export function buildSpeedscopeUrl(viewerUrl: string, profileUrl: string, title: string): string { + const params = new URLSearchParams({ + profileURL: profileUrl, + title, + }); + return `${viewerUrl}#${params.toString()}`; +} diff --git a/editors/vscode/test/profiling.test.ts b/editors/vscode/test/profiling.test.ts index 696fc016..492d7c4c 100644 --- a/editors/vscode/test/profiling.test.ts +++ b/editors/vscode/test/profiling.test.ts @@ -16,6 +16,7 @@ import { summarizeTraceFile, traceSummaryFromJson, } from '../src/profilingTrace'; +import { buildSpeedscopeUrl } from '../src/profilingViewerUrl'; test('removes profiling-owned server arguments before launching a profile session', () => { assert.deepEqual( @@ -193,3 +194,20 @@ test('writes trace summary, folded stacks, and static flamegraph artifact', asyn assert.match(await fs.readFile(folded, 'utf8'), /thread;outer 150/); assert.match(await fs.readFile(svg, 'utf8'), / { + const url = buildSpeedscopeUrl( + 'http://127.0.0.1:56445/index.html', + 'http://127.0.0.1:56445/profiles/faa0699b-5338-4144-8f87-16399252d266/trace.json', + 'Vizsla 2026-05-23T15-07-27-892Z-workspace-quick-start', + ); + + assert.match(url, /profileURL=http%3A%2F%2F127\.0\.0\.1/); + assert.doesNotMatch(url, /http%253A%252F%252F127\.0\.0\.1/); + + const params = new URLSearchParams(new URL(url).hash.slice(1)); + assert.equal( + params.get('profileURL'), + 'http://127.0.0.1:56445/profiles/faa0699b-5338-4144-8f87-16399252d266/trace.json', + ); +}); From 75581e347f46dd1ca97c8a73b3f6040e2735d2fc Mon Sep 17 00:00:00 2001 From: hongjr03 Date: Sat, 23 May 2026 23:22:25 +0800 Subject: [PATCH 5/5] fix(vscode): open profiles in browser --- editors/vscode/l10n/bundle.l10n.zh-cn.json | 1 - editors/vscode/src/profilingViewer.ts | 43 ++-------------------- 2 files changed, 4 insertions(+), 40 deletions(-) diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index 607ae7f4..12f465a9 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -64,7 +64,6 @@ "Runs textDocument/diagnostic for {0}": "对 {0} 运行 textDocument/diagnostic", "Vizsla diagnostics profile complete.": "Vizsla 诊断性能分析已完成。", "Open in Speedscope": "用 Speedscope 打开", - "Vizsla Profile": "Vizsla 性能分析", "Open Summary": "打开摘要", "Show in Folder": "在文件夹中显示", "Failed to open Speedscope: {0}": "无法打开 Speedscope:{0}", diff --git a/editors/vscode/src/profilingViewer.ts b/editors/vscode/src/profilingViewer.ts index a7ced7c4..b32b7f7d 100644 --- a/editors/vscode/src/profilingViewer.ts +++ b/editors/vscode/src/profilingViewer.ts @@ -38,7 +38,7 @@ export class SpeedscopeProfileViewer implements vscode.Disposable { constructor(private readonly context: vscode.ExtensionContext) {} - async open(artifacts: ProfileArtifacts): Promise { + async open(artifacts: ProfileArtifacts): Promise { const port = await this.ensureServer(); const id = randomUUID(); const title = `Vizsla ${path.basename(artifacts.dir)}`; @@ -54,18 +54,10 @@ export class SpeedscopeProfileViewer implements vscode.Disposable { externalProfileUri.toString(), title, ); + const targetUri = vscode.Uri.parse(targetUrl); - const panel = vscode.window.createWebviewPanel( - 'vizslaSpeedscopeProfile', - vscode.l10n.t('Vizsla Profile'), - vscode.ViewColumn.Beside, - { - enableScripts: true, - retainContextWhenHidden: true, - }, - ); - panel.webview.html = webviewHtml(targetUrl, new URL(targetUrl).origin, title); - panel.onDidDispose(() => this.profiles.delete(id)); + await vscode.env.openExternal(targetUri); + return targetUri; } dispose(): void { @@ -236,30 +228,3 @@ function pipeFile(filePath: string, response: http.ServerResponse): Promise - - - - - -${escapeHtmlText(title)} - - - - - -`; -} - -function escapeHtmlAttribute(text: string): string { - return escapeHtmlText(text).replace(/"/g, '"'); -} - -function escapeHtmlText(text: string): string { - return text.replace(/&/g, '&').replace(//g, '>'); -}