From e079bcf46aae5380c2e361aeb59d912b9905808c Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:02:33 +1100 Subject: [PATCH 01/10] Keep a memory site with no location unlocated in the UI An array or a framework type has no source location ([PROF-DOTNET-SOURCE]), and the spec keeps such a site in the UI with no diagnostic. The wire adapter turned its absent file and line into "" and line 0, so the dashboard listed a 0th line and decorations tried to paint a file named "". MemoryAllocation's file and line are now optional: an unlocated site lists by name and paints no line. --- .../src/memory-dashboard-mapping.ts | 12 ++++++++--- .../lsp-profiler-ui/src/memory-decorations.ts | 20 +++++++++++-------- .../test/suite/memory-runtime-neutral.test.ts | 15 ++++++++++++++ packages/lsp-profiler-ui/src/wire-adapters.ts | 4 +++- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/lsp-profiler-ui/src/memory-dashboard-mapping.ts b/packages/lsp-profiler-ui/src/memory-dashboard-mapping.ts index aa5450f..6a04a0b 100644 --- a/packages/lsp-profiler-ui/src/memory-dashboard-mapping.ts +++ b/packages/lsp-profiler-ui/src/memory-dashboard-mapping.ts @@ -48,17 +48,23 @@ function toLeakConfidence(value: string): LeakConfidence { return LEAK_CONFIDENCES.find((grade) => grade === upper) ?? "LOW"; } -/** Read one allocation entry, defaulting every absent field. */ +/** Read one allocation entry, defaulting its counts; an absent location stays absent. */ function toAllocation(raw: Record): MemoryAllocation { return { - file: asString(raw.file), - line: asNumber(raw.line), + ...locationFields(raw), size: asNumber(raw.size), count: asNumber(raw.count), ...typeNameField(raw), }; } +/** The site's file and 1-based line, or neither when the runtime located none. */ +function locationFields(raw: Record): { file?: string; line?: number } { + const file = asString(raw.file); + const line = asNumber(raw.line); + return file === "" || line < 1 ? {} : { file, line }; +} + /** The runtime-reported type name, present only when the runtime named one. */ function typeNameField(raw: Record): { typeName?: string } { const typeName = asString(raw.typeName); diff --git a/packages/lsp-profiler-ui/src/memory-decorations.ts b/packages/lsp-profiler-ui/src/memory-decorations.ts index 3ac61ae..0e333a1 100644 --- a/packages/lsp-profiler-ui/src/memory-decorations.ts +++ b/packages/lsp-profiler-ui/src/memory-decorations.ts @@ -21,8 +21,10 @@ import { pathKeyer } from "./editor-path-key"; /** Per-line allocation data from the LSP server. */ export interface MemoryAllocation { - file: string; - line: number; + /** Source path; absent for a type with no location, such as an array ([PROF-DOTNET-SOURCE]). */ + file?: string; + /** 1-based line; present exactly when `file` is. */ + line?: number; size: number; count: number; /** The runtime's own name for the allocated type; absent when it attributes allocations to lines only. */ @@ -235,12 +237,14 @@ function confidenceBadge(confidence: LeakConfidence): string { /** Apply memory allocation decorations to visible editors; a new snapshot drops the last diff's badges. */ export function applyMemoryDecorations(result: MemorySnapshotResult): void { leakLayer.clear(); - allocationLayer.paint(result.topAllocations.map((alloc) => ({ - file: alloc.file, - line: alloc.line, - color: memColor(alloc.size), - text: ` \u2588\u2588\u2588\u2588 ${formatBytes(alloc.size)} allocated (${alloc.count} objects)`, - }))); + // A site with no location still lists in the dashboard but paints no line. + allocationLayer.paint(result.topAllocations.flatMap(({ file, line, size, count }) => + file === undefined || line === undefined ? [] : [{ + file, + line, + color: memColor(size), + text: ` \u2588\u2588\u2588\u2588 ${formatBytes(size)} allocated (${count} objects)`, + }])); Logger.info(`Memory decorations applied: ${result.topAllocations.length} allocations`); } diff --git a/packages/lsp-profiler-ui/src/test/suite/memory-runtime-neutral.test.ts b/packages/lsp-profiler-ui/src/test/suite/memory-runtime-neutral.test.ts index bfbe25f..4f782fa 100644 --- a/packages/lsp-profiler-ui/src/test/suite/memory-runtime-neutral.test.ts +++ b/packages/lsp-profiler-ui/src/test/suite/memory-runtime-neutral.test.ts @@ -58,6 +58,21 @@ suite('Memory UI — runtime-reported type names survive to the screen', () => { assert.strictEqual(snap.topAllocations[0]?.file, '/src/main.py'); }); + // [PROF-DOTNET-SOURCE] + [PROF-WIRE]: an array, or a framework type, has no + // location. It still appears in the UI, by name only: no `""` file, no line 0. + test('a site with no location reaches the dashboard by name, with no invented file or line', () => { + const snap = toDashboardSnapshot(toMemoryIngestResult(snapshotResult([ + { type_name: 'App.Order[]', size_bytes: 8192, count: 2 }, + { type_name: 'App.Order', file: '/src/Ledger.fs', line: 21, size_bytes: 4096, count: 64 }, + ]))); + const [array, order] = snap.topAllocations; + assert.strictEqual(array?.typeName, 'App.Order[]', 'the unlocated site is still listed'); + assert.strictEqual(array?.file, undefined, 'no file is invented'); + assert.strictEqual(array?.line, undefined, 'no line 0 is invented'); + assert.strictEqual(array?.size, 8192); + assert.deepStrictEqual([order?.file, order?.line], ['/src/Ledger.fs', 21], 'a located site keeps its anchor'); + }); + test('a suspected leak keeps the runtime\'s type name', () => { const diff = toDashboardDiff({ kind: 'diff', diff --git a/packages/lsp-profiler-ui/src/wire-adapters.ts b/packages/lsp-profiler-ui/src/wire-adapters.ts index dc67c82..5235bb8 100644 --- a/packages/lsp-profiler-ui/src/wire-adapters.ts +++ b/packages/lsp-profiler-ui/src/wire-adapters.ts @@ -85,7 +85,9 @@ export function toUiProcess(process: WireProcess): ProcessInfo { } function allocation(site: { file?: string | null; line?: number | null; size_bytes: number; count: number; type_name?: string | null }): Record { - return { file: site.file ?? "", line: site.line ?? 0, size: site.size_bytes, count: site.count, typeName: site.type_name ?? "" }; + // [PROF-WIRE]: a site with no location carries neither field, never `""` and line 0. + const located = site.file != null && site.line != null ? { file: site.file, line: site.line } : {}; + return { ...located, size: site.size_bytes, count: site.count, typeName: site.type_name ?? "" }; } function snapshotFields(snapshot: HeapSnapshot, timeline: TimelinePoint[], heapProfilePath: string | null | undefined): Record { From ef76c030a79b6e6081aa5353897c922ca5681d42 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:11:11 +1100 Subject: [PATCH 02/10] fixes --- .../lsp-profiler-dotnet/tests/dotnet_e2e.rs | 27 ++++ .../tests/dotnet_fsharp_e2e.rs | 53 ++++++ .../lsp-profiler-dotnet/tests/support/mod.rs | 39 ++++- .../ConstructionSites.cs | 13 +- .../SourceLocator.cs | 41 +++-- dotnet/fixtures/HotLeakAppFs/Program.fs | 153 +++++++++++++++++- 6 files changed, 295 insertions(+), 31 deletions(-) diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs b/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs index 81f1b22..2061335 100644 --- a/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs +++ b/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs @@ -451,3 +451,30 @@ async fn a_snapshot_that_loses_events_fails_instead_of_misreporting_the_heap() { ); assert!(!graph.exists(), "no partial graph is kept"); } + +/// Objects of a type too small to rank among the largest types still get their +/// own summary row, agreeing with the totals ([PROF-DOTNET-MEMORY] objects): +/// the program's few `Thread` objects are far outside its twenty largest types. +#[tokio::test(flavor = "multi_thread")] +async fn objects_of_a_small_type_keep_its_own_summary_row() { + let fixture = Fixture::start(FIXTURE); + let dir = tempfile::tempdir().expect("tempdir"); + let service = service(dir.path().to_path_buf()); + let started = call(&service, methods::MEMORY_START, json!({ "pid": fixture.pid })).await; + let session = json!({ "memory_session_id": started["memory_session_id"] }); + let snapshot = call(&service, methods::MEMORY_SNAPSHOT, session.clone()).await; + assert_eq!(completed(&snapshot)["outcome"]["kind"], "Snapshot", "{snapshot}"); + let params = json!({ "memory_session_id": started["memory_session_id"], "type_name": "System.Threading.Thread", "limit": 1 }); + let objects = call(&service, methods::MEMORY_OBJECTS_BY_TYPE, params).await; + let list = &completed(&objects)["outcome"]["objects"]; + let rows = list["type_summary"].as_array().expect("type summary"); + let own: Vec<&Value> = rows.iter().filter(|row| row["type_name"] == "System.Threading.Thread").collect(); + assert_eq!(own.len(), 1, "one row for the requested type: {list}"); + assert!(list["total_count"].as_i64().unwrap_or(0) >= 2, "the main and hot-worker threads: {list}"); + assert_eq!(own[0]["count"], list["total_count"], "{list}"); + assert_eq!(own[0]["size_bytes"], list["total_bytes"], "{list}"); + let sizes: Vec = rows.iter().map(|row| row["size_bytes"].as_i64().expect("bytes")).collect(); + assert!(sizes.windows(2).all(|pair| pair[0] >= pair[1]), "still largest first: {sizes:?}"); + let _ = call(&service, methods::MEMORY_STOP, session).await; + service.shutdown().await; +} diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs index 669e737..93a070a 100644 --- a/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs +++ b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs @@ -38,6 +38,8 @@ const RECORD_CONSTRUCTED_AT: i64 = 23; const UNION_CASE_CONSTRUCTED_AT: i64 = 21; /// `let order = { Value = items.Count; Tag = "order" }` in `LeakCache.grow`. const GENERIC_RECORD_CONSTRUCTED_AT: i64 = 22; +/// Types FSharp.Core defines, such as the printf machinery `$"..."` builds. +const FSHARP_CORE_NAMESPACE: &str = "Microsoft.FSharp."; fn in_program_fs(value: &Value) -> bool { value["file"] @@ -147,6 +149,57 @@ async fn fsharp_records_and_union_cases_anchor_where_the_program_constructs_them service.shutdown().await; } +/// [PROF-DOTNET-SOURCE]: FSharp.Core embeds a PDB whose documents name the +/// build server's checkout. A location no editor on this machine can open is +/// no location: the type is reported by name alone, and the program's own +/// types keep their anchors. +#[tokio::test(flavor = "multi_thread")] +async fn a_pdb_naming_a_build_servers_paths_locates_nothing() { + let mut fixture = Fixture::start(FIXTURE); + // Orders hold an F# list, quotes a Set: FSharp.Core objects the program retains. + let grown = fixture.send("grow 40"); + assert!(grown.starts_with("OK 40 orders=40"), "{grown}"); + let dir = tempfile::tempdir().expect("tempdir"); + let service = service(dir.path().to_path_buf()); + let started = call( + &service, + methods::MEMORY_START, + json!({ "pid": fixture.pid }), + ) + .await; + let session = json!({ "memory_session_id": started["memory_session_id"] }); + let step = call(&service, methods::MEMORY_SNAPSHOT, session.clone()).await; + let snapshot = &completed(&step)["outcome"]["snapshot"]; + let sites = snapshot["sites"].as_array().expect("sites"); + + let library: Vec<&Value> = sites + .iter() + .filter(|s| { + s["type_name"] + .as_str() + .is_some_and(|n| n.starts_with(FSHARP_CORE_NAMESPACE)) + }) + .collect(); + assert!( + !library.is_empty(), + "the program holds FSharp.Core objects: {snapshot}" + ); + for site in &library { + assert!(site["file"].is_null(), "no build-server anchor: {site}"); + assert!(site["line"].is_null(), "no line without a file: {site}"); + } + for site in sites.iter().filter(|s| !s["file"].is_null()) { + let file = site["file"].as_str().expect("file is a string"); + assert!( + std::path::Path::new(file).exists(), + "every anchor exists here: {site}" + ); + } + assert_eq!(site(snapshot, RECORD_TYPE)["line"], RECORD_CONSTRUCTED_AT); + let _ = call(&service, methods::MEMORY_STOP, session).await; + service.shutdown().await; +} + #[tokio::test(flavor = "multi_thread")] async fn an_fsharp_module_value_roots_the_leak_under_its_source_name() { let fixture = Fixture::start(FIXTURE); diff --git a/crates/lsp-profiler-dotnet/tests/support/mod.rs b/crates/lsp-profiler-dotnet/tests/support/mod.rs index ed2f939..ef538bd 100644 --- a/crates/lsp-profiler-dotnet/tests/support/mod.rs +++ b/crates/lsp-profiler-dotnet/tests/support/mod.rs @@ -4,7 +4,7 @@ use std::io::{BufRead, BufReader, Write}; use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use std::sync::Arc; use async_trait::async_trait; @@ -26,10 +26,13 @@ fn built(relative: &str, hint: &str) -> PathBuf { path } -/// A fixture app under `dotnet/fixtures`, killed on drop. +/// A fixture app under `dotnet/fixtures`, killed on drop. Its stdout stays +/// open so a fixture with a line protocol can be driven like a user would. pub struct Fixture { pub child: Child, pub pid: u32, + stdin: Option, + lines: BufReader, } impl Fixture { @@ -46,24 +49,46 @@ impl Fixture { .stderr(Stdio::null()) .spawn() .unwrap_or_else(|error| panic!("spawn {name}: {error}")); + let stdin = child.stdin.take().expect("fixture stdin"); let stdout = child.stdout.take().expect("fixture stdout"); + let mut lines = BufReader::new(stdout); let mut line = String::new(); - let _ = BufReader::new(stdout) - .read_line(&mut line) - .expect("READY line"); + let _ = lines.read_line(&mut line).expect("READY line"); let pid: u32 = line .trim() .strip_prefix("READY ") .expect("READY ") .parse() .expect("pid"); - Self { child, pid } + Self { + child, + pid, + stdin: Some(stdin), + lines, + } + } + + /// Send one line command and return the one reply line the fixture writes. + /// The fixture must answer every command with exactly one line. + pub fn send(&mut self, command: &str) -> String { + let stdin = self.stdin.as_mut().expect("fixture stdin is open"); + writeln!(stdin, "{command}").unwrap_or_else(|error| panic!("send {command}: {error}")); + stdin + .flush() + .unwrap_or_else(|error| panic!("flush {command}: {error}")); + let mut line = String::new(); + let read = self + .lines + .read_line(&mut line) + .unwrap_or_else(|error| panic!("reply to {command}: {error}")); + assert!(read > 0, "the fixture exited before answering {command}"); + line.trim().to_owned() } } impl Drop for Fixture { fn drop(&mut self) { - if let Some(mut stdin) = self.child.stdin.take() { + if let Some(mut stdin) = self.stdin.take() { let _ = stdin.write_all(b"\n"); } let _ = self.child.kill(); diff --git a/dotnet/LspProfiler.DotNet.Engine/ConstructionSites.cs b/dotnet/LspProfiler.DotNet.Engine/ConstructionSites.cs index b2ca512..f81bb9c 100644 --- a/dotnet/LspProfiler.DotNet.Engine/ConstructionSites.cs +++ b/dotnet/LspProfiler.DotNet.Engine/ConstructionSites.cs @@ -21,24 +21,21 @@ internal static class ConstructionSites .DistinctBy(code => code.Value) .ToDictionary(code => (ILOpCode)(ushort)code.Value, code => code.OperandType); - /// Each constructed type's lowest construction site, over every method body. - public static Dictionary Index(PEReader pe, MetadataReader metadata, MetadataReader pdb) + /// Each constructed type's lowest construction site that `locate` places, over every method body. + public static Dictionary Index( + PEReader pe, MetadataReader metadata, MetadataReader pdb, Func locate) { var sites = new Dictionary(); foreach (var (type, point) in metadata.MethodDefinitions.SelectMany(method => InMethod(pe, metadata, pdb, method))) { - if (!sites.TryGetValue(type, out var known) || point.StartLine < known.Line) + if (locate(point) is { } site && (!sites.TryGetValue(type, out var known) || site.Line < known.Line)) { - sites[type] = Location(pdb, point); + sites[type] = site; } } return sites; } - /// A sequence point's document and line. - public static (string File, int Line) Location(MetadataReader pdb, SequencePoint point) => - (pdb.GetString(pdb.GetDocument(point.Document).Name), point.StartLine); - private static List<(TypeDefinitionHandle Type, SequencePoint Point)> InMethod(PEReader pe, MetadataReader metadata, MetadataReader pdb, MethodDefinitionHandle method) { var points = pdb.GetMethodDebugInformation(method.ToDebugInformationHandle()).GetSequencePoints() diff --git a/dotnet/LspProfiler.DotNet.Engine/SourceLocator.cs b/dotnet/LspProfiler.DotNet.Engine/SourceLocator.cs index 8808a3f..dbe5330 100644 --- a/dotnet/LspProfiler.DotNet.Engine/SourceLocator.cs +++ b/dotnet/LspProfiler.DotNet.Engine/SourceLocator.cs @@ -5,7 +5,9 @@ // memory finding about the type. A type with no visible point of its own, such // as an F# record or union, anchors where the assembly's code constructs it // (ConstructionSites). Types from assemblies without a PDB (the framework) have -// no location and are reported by name alone. Neither has an array: `Order[]` +// no location and are reported by name alone, as are those whose PDB names +// documents this machine doesn't have: FSharp.Core embeds a PDB pointing at its +// build server's checkout, an anchor no editor here can open. Neither has an array: `Order[]` // is the runtime's type, grown wherever a collection resizes, so anchoring it at // Order's line would paint that line long after the last Order died. using System.Reflection.Metadata; @@ -55,7 +57,9 @@ private sealed class ModuleSymbols(PEReader pe, MetadataReaderProvider pdbProvid { private readonly MetadataReader _metadata = pe.GetMetadataReader(); private readonly MetadataReader _pdb = pdbProvider.GetMetadataReader(); + private readonly Dictionary _documents = []; private Dictionary? _constructed; + private bool? _onThisMachine; public static ModuleSymbols? Open(string assembly) { @@ -76,7 +80,10 @@ private sealed class ModuleSymbols(PEReader pe, MetadataReaderProvider pdbProvid } public (string File, int Line)? Locate(IReadOnlyList nesting) => - Find(nesting) is { } definition ? Declared(definition) ?? Constructed(definition) : null; + OnThisMachine && Find(nesting) is { } definition ? Declared(definition) ?? Constructed(definition) : null; + + /// Whether any document the PDB names is here; when none is, no IL is scanned for sites. + private bool OnThisMachine => _onThisMachine ??= _pdb.Documents.Any(document => Document(document) is not null); public void Dispose() { @@ -84,23 +91,35 @@ public void Dispose() pe.Dispose(); } - private (string File, int Line)? Declared(TypeDefinitionHandle definition) - { - var points = _metadata.GetTypeDefinition(definition).GetMethods() + private (string File, int Line)? Declared(TypeDefinitionHandle definition) => + _metadata.GetTypeDefinition(definition).GetMethods() .SelectMany(SequencePoints) .Where(point => !point.IsHidden) - .OrderBy(point => point.StartLine) - .Take(1) - .ToList(); - return points.Count == 0 ? null : ConstructionSites.Location(_pdb, points[0]); - } + .Select(Location) + .Where(site => site is not null) + .MinBy(site => site!.Value.Line); private (string File, int Line)? Constructed(TypeDefinitionHandle definition) { - _constructed ??= ConstructionSites.Index(pe, _metadata, _pdb); + _constructed ??= ConstructionSites.Index(pe, _metadata, _pdb, Location); return _constructed.TryGetValue(definition, out var site) ? site : null; } + /// A sequence point's document and line, when the document is on this machine. + private (string File, int Line)? Location(SequencePoint point) => + Document(point.Document) is { } file ? (file, point.StartLine) : null; + + private string? Document(DocumentHandle handle) + { + if (!_documents.TryGetValue(handle, out var file)) + { + var named = _pdb.GetString(_pdb.GetDocument(handle).Name); + file = File.Exists(named) ? named : null; + _documents[handle] = file; + } + return file; + } + private TypeDefinitionHandle? Find(IReadOnlyList nesting) { var (space, name) = TypeNames.Split(nesting[0]); diff --git a/dotnet/fixtures/HotLeakAppFs/Program.fs b/dotnet/fixtures/HotLeakAppFs/Program.fs index 6ba8ba3..6705b13 100644 --- a/dotnet/fixtures/HotLeakAppFs/Program.fs +++ b/dotnet/fixtures/HotLeakAppFs/Program.fs @@ -31,24 +31,167 @@ module Hot = total +/// A single-case union: every id is its own heap object, never a struct. +type CustomerId = CustomerId of int + +/// A union with a nullary case (no runtime class) and a case with fields (a nested class). +type Priority = + | Standard + | Express of days: int + +/// A struct record: stored inline, so it is never a heap type of its own. +[] +type Money = { Cents: int64 } + +type OrderLine = { Sku: string; Quantity: int; Price: Money } + +type Order = + { Id: int + Customer: CustomerId + Lines: OrderLine list + Priority: Priority + Data: byte array } + +/// A memoized quote: built once per SKU and retained by the cache below. +type Quote = { Sku: string; Price: decimal; Tags: Set } + +/// Two nodes can point at each other, so the captured heap contains a real cycle. +type Node = { Label: string; mutable Next: Node option } + +/// The F# heap zoo: every shape is retained by a module value so it stays reachable. +/// The line protocol grows one shape at a time: `grow`, `payload`, `blob`, `release all`, +/// `work on|off` and `stats`; `stop` closes the program. +module Shapes = + let orders = ResizeArray() + let subscriptions = Event() + let cycles = ResizeArray() + let blobs = ResizeArray() + let private cache = System.Collections.Generic.Dictionary(StringComparer.Ordinal) + let mutable private subscribed = 0 + + let private makeOrder (id: int) = + let customer = CustomerId(id % 1_000) + let price = { Cents = int64 (100 + id % 900) } + let line = { Sku = sprintf "SKU-%05d" (id % 400); Quantity = 1 + id % 4; Price = price } + let priority = if id % 3 = 0 then Express(1 + id % 5) else Standard + { Id = id; Customer = customer; Lines = [ line ]; Priority = priority; Data = Array.zeroCreate 256 } + + let private makeQuote (sku: string) = + let price = 10M + decimal (sku.Length % 17) * 1.5M + let tags = if price > 20M then set [ "retail"; "premium" ] else set [ "retail" ] + { Sku = sku; Price = price; Tags = tags } + + /// Appends `count` orders, one never-removed subscription closure each and one + /// cached quote each; the first call also builds a two-node reference cycle. + let grow count = + for _ in 1..count do + let created = makeOrder orders.Count + orders.Add created + subscriptions.Publish.Add(fun published -> ignore (published.Id + created.Id)) + |> ignore + subscribed <- subscribed + 1 + let quoted = makeQuote(sprintf "SKU-Q%06d" orders.Count) + cache.[quoted.Sku] <- quoted + + if cycles.Count = 0 then + let first = { Label = "first"; Next = None } + let second = { Label = "second"; Next = Some first } + first.Next <- Some second + cycles.Add first + cycles.Add second + + /// Retains one 1 MiB array per megabyte. + let retain mebibytes = + for _ in 1..mebibytes do + blobs.Add(Array.zeroCreate (1024 * 1024)) + + /// Drops every retained shape. The event's handler closures stay: a handler is never + /// unsubscribed, which is the leak this fixture exists to show. + let release () = + LeakCache.items.Clear() + orders.Clear() + cycles.Clear() + blobs.Clear() + cache.Clear() + + let counts () = + sprintf + "orders=%d subscriptions=%d cycles=%d blobs=%d quotes=%d payloads=%d" + orders.Count + subscribed + cycles.Count + blobs.Count + cache.Count + LeakCache.items.Count + module Program = + let mutable private working = true + + /// The one gate: the workload thread and every command mutate the same modules, + /// so a command never observes (or races) a half-finished growth step. + let private gate = obj () + let private work (stop: CancellationToken) () = let mutable sink = 0.0 while not stop.IsCancellationRequested do - sink <- sink + Hot.spin 200_000 - LeakCache.grow 50 + if Volatile.Read(&working) then + lock gate (fun () -> + sink <- sink + Hot.spin 200_000 + LeakCache.grow 50) + else + Thread.Sleep 20 GC.KeepAlive sink + let private describe () = Shapes.counts () + + let private reply (line: string) = + stdout.WriteLine line + stdout.Flush() + + let private positive (text: string) (action: int -> unit) = + match Int32.TryParse text with + | true, count when count > 0 -> + action count + sprintf "OK %s %s" text (describe ()) + | _ -> sprintf "ERR count %s" text + + let private apply (line: string) : string = + match + line.Split([| ' ' |], StringSplitOptions.RemoveEmptyEntries) |> List.ofArray + with + | [ "grow"; count ] -> positive count Shapes.grow + | [ "payload"; count ] -> positive count LeakCache.grow + | [ "blob"; count ] -> positive count Shapes.retain + | [ "release"; "all" ] -> + Shapes.release () + sprintf "OK release all %s" (describe ()) + | [ "work"; "on" ] -> + Volatile.Write(&working, true) + sprintf "OK work on %s" (describe ()) + | [ "work"; "off" ] -> + Volatile.Write(&working, false) + sprintf "OK work off %s" (describe ()) + | [ "stats" ] -> sprintf "OK stats %s" (describe ()) + | _ -> sprintf "ERR unknown %s" line + + let private handle (line: string) : string = lock gate (fun () -> apply line) + [] let main _ = use stop = new CancellationTokenSource() let worker = Thread(work stop.Token, Name = "hot-worker", IsBackground = true) worker.Start() - stdout.WriteLine $"READY {Environment.ProcessId}" - stdout.Flush() - stdin.ReadToEnd() |> ignore + reply (sprintf "READY %d" Environment.ProcessId) + let mutable reading = true + + while reading do + match stdin.ReadLine() with + | null -> reading <- false + | "stop" -> reading <- false + | line -> reply (handle line) + stop.Cancel() worker.Join() 0 From b9066b192da4b71a2a1452a9bc487c2e5bad50c8 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:13:23 +1100 Subject: [PATCH 03/10] Keep the requested type's own row in the objects summary The objects step summarised only the twenty largest types, so a type too small to rank (an F# union case such as OrderStatus+Paid, or the one list that holds a leak) had no row agreeing with its totals. The summary now appends that type's row after the ranked ones, keeping largest-first order. --- .../lsp-profiler-dotnet/tests/dotnet_e2e.rs | 40 +++++++++++++++---- docs/models/profiler.td | 2 +- docs/specs/LSP-PROFILER-SPEC.md | 4 +- .../LspProfiler.DotNet.Engine/HeapSummary.cs | 14 ++++++- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs b/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs index 2061335..750504d 100644 --- a/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs +++ b/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs @@ -31,6 +31,8 @@ const FIXTURE_TYPE: &str = "HotLeakApp.LeakedPayload"; const HOT_FUNCTION: &str = "HotLeakApp.Hot.Spin"; /// `Program.Main`, which only ever waits on stdin. const ENTRY_POINT: &str = "HotLeakApp.Program.Main("; +/// `LeakCache.Items`: one small object, outside any top-twenty summary. +const LEAK_LIST: &str = "System.Collections.Generic.List`1[HotLeakApp.LeakedPayload]"; #[tokio::test(flavor = "multi_thread")] async fn the_fixture_is_recognised_as_an_attachable_dotnet_process() { @@ -454,27 +456,49 @@ async fn a_snapshot_that_loses_events_fails_instead_of_misreporting_the_heap() { /// Objects of a type too small to rank among the largest types still get their /// own summary row, agreeing with the totals ([PROF-DOTNET-MEMORY] objects): -/// the program's few `Thread` objects are far outside its twenty largest types. +/// the one `List` that holds the leak is 32 bytes, far below the +/// twenty largest types. #[tokio::test(flavor = "multi_thread")] async fn objects_of_a_small_type_keep_its_own_summary_row() { let fixture = Fixture::start(FIXTURE); let dir = tempfile::tempdir().expect("tempdir"); let service = service(dir.path().to_path_buf()); - let started = call(&service, methods::MEMORY_START, json!({ "pid": fixture.pid })).await; + let started = call( + &service, + methods::MEMORY_START, + json!({ "pid": fixture.pid }), + ) + .await; let session = json!({ "memory_session_id": started["memory_session_id"] }); let snapshot = call(&service, methods::MEMORY_SNAPSHOT, session.clone()).await; - assert_eq!(completed(&snapshot)["outcome"]["kind"], "Snapshot", "{snapshot}"); - let params = json!({ "memory_session_id": started["memory_session_id"], "type_name": "System.Threading.Thread", "limit": 1 }); + assert_eq!( + completed(&snapshot)["outcome"]["kind"], + "Snapshot", + "{snapshot}" + ); + let params = json!({ "memory_session_id": started["memory_session_id"], "type_name": LEAK_LIST, "limit": 1 }); let objects = call(&service, methods::MEMORY_OBJECTS_BY_TYPE, params).await; let list = &completed(&objects)["outcome"]["objects"]; let rows = list["type_summary"].as_array().expect("type summary"); - let own: Vec<&Value> = rows.iter().filter(|row| row["type_name"] == "System.Threading.Thread").collect(); + let own: Vec<&Value> = rows + .iter() + .filter(|row| row["type_name"] == LEAK_LIST) + .collect(); assert_eq!(own.len(), 1, "one row for the requested type: {list}"); - assert!(list["total_count"].as_i64().unwrap_or(0) >= 2, "the main and hot-worker threads: {list}"); + assert_eq!( + list["total_count"], 1, + "LeakCache.Items is the only list of payloads: {list}" + ); assert_eq!(own[0]["count"], list["total_count"], "{list}"); assert_eq!(own[0]["size_bytes"], list["total_bytes"], "{list}"); - let sizes: Vec = rows.iter().map(|row| row["size_bytes"].as_i64().expect("bytes")).collect(); - assert!(sizes.windows(2).all(|pair| pair[0] >= pair[1]), "still largest first: {sizes:?}"); + let sizes: Vec = rows + .iter() + .map(|row| row["size_bytes"].as_i64().expect("bytes")) + .collect(); + assert!( + sizes.windows(2).all(|pair| pair[0] >= pair[1]), + "still largest first: {sizes:?}" + ); let _ = call(&service, methods::MEMORY_STOP, session).await; service.shutdown().await; } diff --git a/docs/models/profiler.td b/docs/models/profiler.td index 964cabf..f7e7724 100644 --- a/docs/models/profiler.td +++ b/docs/models/profiler.td @@ -642,7 +642,7 @@ type ObjectList { total_bytes: Int # Largest objects of the type. objects: List - # Largest types overall. + # Largest types overall, then the requested type's own row when it is too small to rank. type_summary: List } diff --git a/docs/specs/LSP-PROFILER-SPEC.md b/docs/specs/LSP-PROFILER-SPEC.md index 1e5e09e..f2049fd 100644 --- a/docs/specs/LSP-PROFILER-SPEC.md +++ b/docs/specs/LSP-PROFILER-SPEC.md @@ -738,7 +738,7 @@ Every member F# generates for a record, a union or a union case has only hidden - a `newobj` of one of the type's constructors (records, closures, anonymous records), including a generic type's constructor reached through a member reference; - a call to the static `New` factory F# emits on a union. It constructs the nested `Case` class, or the union itself for a single-case union. -Only methods with visible sequence points are searched, so the generated factories themselves never count. Types without a PDB, such as the framework's, have no location and appear by name only. An array (`Element[]`, `Element[,]`) is the runtime's own type, built wherever a collection grows, so it has no location whatever its element type: a `ResizeArray` keeps its `Order[]` after `Clear()`, and that array must never paint the line that builds an `Order`. +Only methods with visible sequence points are searched, so the generated factories themselves never count. Types without a PDB, such as the framework's, have no location and appear by name only. A location is a file on this machine: a PDB names the documents it was built from, and a package's (FSharp.Core embeds one) names its build server's checkout, so a type whose sequence points all sit in documents absent here has no location either. An array (`Element[]`, `Element[,]`) is the runtime's own type, built wherever a collection grows, so it has no location whatever its element type: a `ResizeArray` keeps its `Order[]` after `Clear()`, and that array must never paint the line that builds an `Order`. ### [PROF-DOTNET-MEMORY] Memory steps @@ -748,7 +748,7 @@ Only methods with visible sequence points are searched, so the generated factori | `snapshot` | `snapshot` | `Snapshot`; sites are live types anchored at their declaration | | `diff` | `snapshot` | `Compare` | | `references` | `refs` on the latest graph | `Refs`; an error before any snapshot | -| `objectsByType` | `objects` on the latest graph | `Objects`; an error before any snapshot | +| `objectsByType` | `objects` on the latest graph | `Objects`; the summary ranks the twenty largest types, then adds the requested type's own row when it is too small to rank; an error before any snapshot | | `gcCollect` | `gc` | `Gc`; `collected` is 1 when a collection was observed, `freed_bytes` is the drop against the last known heap size | | `stop` | none | `Ack`; deletes the session's graphs | diff --git a/dotnet/LspProfiler.DotNet.Engine/HeapSummary.cs b/dotnet/LspProfiler.DotNet.Engine/HeapSummary.cs index 0ddd37e..2608fd8 100644 --- a/dotnet/LspProfiler.DotNet.Engine/HeapSummary.cs +++ b/dotnet/LspProfiler.DotNet.Engine/HeapSummary.cs @@ -32,7 +32,19 @@ public static Objects Objects(LiveHeap live, SourceLocator locator, string typeN matching.Count, matching.Sum(live.Graph.SizeOf), matching.Take(limit).Select(node => Describe(live, node)).ToList(), - Rows(Tally(live), locator, summaryTypes)); + Summary(live, locator, matching, typeName, summaryTypes)); + } + + /// The largest types, then the requested type's own row when it is too small to rank. + private static List Summary(LiveHeap live, SourceLocator locator, List matching, string typeName, int maxTypes) + { + var rows = Rows(Tally(live), locator, maxTypes); + if (matching.Count == 0 || rows.Exists(row => string.Equals(row.TypeName, typeName, StringComparison.Ordinal))) + { + return rows; + } + var type = live.Graph.TypeOf(matching[0]); + return [.. rows, Row(type, new TypeTally(matching.Count, matching.Sum(live.Graph.SizeOf)), locator.Locate(type))]; } public static string Id(HeapGraph graph, int node) => $"0x{graph.AddressOf(node):x}"; From d47112fff819fc23fbd48f7b0f7c1453f4176706 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:24:27 +1100 Subject: [PATCH 04/10] Let the C# engine suite compile beside the F# line protocol Each test crate compiles tests/support on its own, and only the F# suite drives a fixture's line protocol, so Fixture::send and the stdout reader it reads were dead code in the C# suite's crate, which denies dead_code. Both now carry a documented per-item allow. --- crates/lsp-profiler-dotnet/tests/support/mod.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/lsp-profiler-dotnet/tests/support/mod.rs b/crates/lsp-profiler-dotnet/tests/support/mod.rs index ef538bd..60700ed 100644 --- a/crates/lsp-profiler-dotnet/tests/support/mod.rs +++ b/crates/lsp-profiler-dotnet/tests/support/mod.rs @@ -32,6 +32,10 @@ pub struct Fixture { pub child: Child, pub pid: u32, stdin: Option, + #[allow( + dead_code, + reason = "read only by `send`, which only the F# suite's line protocol calls; each test crate compiles this module on its own" + )] lines: BufReader, } @@ -70,6 +74,10 @@ impl Fixture { /// Send one line command and return the one reply line the fixture writes. /// The fixture must answer every command with exactly one line. + #[allow( + dead_code, + reason = "only the F# fixture speaks a line protocol; the C# suite's crate never calls this" + )] pub fn send(&mut self, command: &str) -> String { let stdin = self.stdin.as_mut().expect("fixture stdin is open"); writeln!(stdin, "{command}").unwrap_or_else(|error| panic!("send {command}: {error}")); From 20d9f3dcca37d251f231a1a075641b472f8664dd Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:30:26 +1100 Subject: [PATCH 05/10] Keep the F# fixture's hot loop outside any lock The fixture serialised its workload and its line protocol with one lock wrapped around Hot.spin, so the CPU profile no longer put Hot.spin on the busy thread's stack for most ticks and the F# CPU suite failed. The workload now runs lock-free and flags its own busy state, and idle work keeps allocating so a snapshot's induced collection can run. The FSharp.Core anchor test reads the order count from the reply, whatever its prefix. --- .../tests/dotnet_fsharp_e2e.rs | 2 +- dotnet/fixtures/HotLeakAppFs/Program.fs | 49 ++++++++++--------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs index 93a070a..6148cd1 100644 --- a/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs +++ b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs @@ -158,7 +158,7 @@ async fn a_pdb_naming_a_build_servers_paths_locates_nothing() { let mut fixture = Fixture::start(FIXTURE); // Orders hold an F# list, quotes a Set: FSharp.Core objects the program retains. let grown = fixture.send("grow 40"); - assert!(grown.starts_with("OK 40 orders=40"), "{grown}"); + assert!(grown.contains(" orders=40 "), "{grown}"); let dir = tempfile::tempdir().expect("tempdir"); let service = service(dir.path().to_path_buf()); let started = call( diff --git a/dotnet/fixtures/HotLeakAppFs/Program.fs b/dotnet/fixtures/HotLeakAppFs/Program.fs index 6705b13..a92ab84 100644 --- a/dotnet/fixtures/HotLeakAppFs/Program.fs +++ b/dotnet/fixtures/HotLeakAppFs/Program.fs @@ -84,6 +84,13 @@ module Shapes = /// Appends `count` orders, one never-removed subscription closure each and one /// cached quote each; the first call also builds a two-node reference cycle. let grow count = + if cycles.Count = 0 then + let first = { Label = "first"; Next = None } + let second = { Label = "second"; Next = Some first } + first.Next <- Some second + cycles.Add first + cycles.Add second + for _ in 1..count do let created = makeOrder orders.Count orders.Add created @@ -92,13 +99,7 @@ module Shapes = subscribed <- subscribed + 1 let quoted = makeQuote(sprintf "SKU-Q%06d" orders.Count) cache.[quoted.Sku] <- quoted - - if cycles.Count = 0 then - let first = { Label = "first"; Next = None } - let second = { Label = "second"; Next = Some first } - first.Next <- Some second - cycles.Add first - cycles.Add second + cycles.Add { Label = quoted.Sku; Next = None } /// Retains one 1 MiB array per megabyte. let retain mebibytes = @@ -126,21 +127,23 @@ module Shapes = module Program = let mutable private working = true - - /// The one gate: the workload thread and every command mutate the same modules, - /// so a command never observes (or races) a half-finished growth step. - let private gate = obj () + let mutable private busy = false let private work (stop: CancellationToken) () = let mutable sink = 0.0 while not stop.IsCancellationRequested do if Volatile.Read(&working) then - lock gate (fun () -> - sink <- sink + Hot.spin 200_000 - LeakCache.grow 50) + Volatile.Write(&busy, true) + sink <- sink + Hot.spin 200_000 + LeakCache.grow 50 + Volatile.Write(&busy, false) else - Thread.Sleep 20 + // Idle must still allocate: the runtime only performs the snapshot's + // induced collection when it is running managed code. + sink <- sink + Hot.spin 200_000 + GC.KeepAlive(Array.zeroCreate 4_096) + Thread.Sleep 5 GC.KeepAlive sink @@ -150,20 +153,20 @@ module Program = stdout.WriteLine line stdout.Flush() - let private positive (text: string) (action: int -> unit) = + let private positive (name: string) (text: string) (action: int -> unit) = match Int32.TryParse text with | true, count when count > 0 -> action count - sprintf "OK %s %s" text (describe ()) - | _ -> sprintf "ERR count %s" text + sprintf "OK %s %s %s" name text (describe ()) + | _ -> sprintf "ERR %s %s" name text let private apply (line: string) : string = match line.Split([| ' ' |], StringSplitOptions.RemoveEmptyEntries) |> List.ofArray with - | [ "grow"; count ] -> positive count Shapes.grow - | [ "payload"; count ] -> positive count LeakCache.grow - | [ "blob"; count ] -> positive count Shapes.retain + | [ "grow"; count ] -> positive "grow" count Shapes.grow + | [ "payload"; count ] -> positive "payload" count LeakCache.grow + | [ "blob"; count ] -> positive "blob" count Shapes.retain | [ "release"; "all" ] -> Shapes.release () sprintf "OK release all %s" (describe ()) @@ -172,11 +175,13 @@ module Program = sprintf "OK work on %s" (describe ()) | [ "work"; "off" ] -> Volatile.Write(&working, false) + while Volatile.Read(&busy) do + Thread.Sleep 1 sprintf "OK work off %s" (describe ()) | [ "stats" ] -> sprintf "OK stats %s" (describe ()) | _ -> sprintf "ERR unknown %s" line - let private handle (line: string) : string = lock gate (fun () -> apply line) + let private handle (line: string) : string = apply line [] let main _ = From 5f9ac81fb267ef2e14901c92749230b50078b936 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:31:19 +1100 Subject: [PATCH 06/10] Name each retention root by its label and layer the graph from roots to targets The retention graph ignored is_root: decodeRefGraph dropped it, and the canvas coloured nodes by depth and drew only their types. A root's label, such as an F# module value's `static sessions`, never reached the screen, and roots were not distinct, against [PROF-UI-REFGRAPH]. The random force layout also turned every graph with many targets into an unreadable knot. Roots now survive decoding, are drawn in their own colour and named by their label above their type. Rows follow each node's depth: roots on top, targets along the bottom, and a row too tight to label names its type once with a count. --- docs/specs/LSP-PROFILER-SPEC.md | 8 +- .../lsp-profiler-ui/src/memory-ref-graph.ts | 110 +++++++++--------- .../src/test/suite/profiler-webviews.test.ts | 69 ++++++++++- 3 files changed, 127 insertions(+), 60 deletions(-) diff --git a/docs/specs/LSP-PROFILER-SPEC.md b/docs/specs/LSP-PROFILER-SPEC.md index f2049fd..2404add 100644 --- a/docs/specs/LSP-PROFILER-SPEC.md +++ b/docs/specs/LSP-PROFILER-SPEC.md @@ -594,11 +594,13 @@ Snapshot, compare, GC and stop are reachable from the dashboard, the processes v ### [PROF-UI-REFGRAPH] Retention graph -The retention graph is a force layout of a `RefGraph`: +The retention graph is a layered layout of a `RefGraph`: a node's row is its `depth`, its distance from the targets, so the roots sit on top and the targets along the bottom. - node radius is logarithmic in size; -- targets, shallow retainers, roots and cycles are distinct; -- edge labels name the field. +- targets, roots and cycles are distinct; +- a root is named by its label above its type, so a static field or an F# module value reads as written (`static sessions`); +- edge labels name the field; +- a row too tight to label each node names its type once, with a count (`40 × App.Session`). The type picker offers the server's ranked type names (the latest snapshot's top types) plus free text, so it is language-neutral. It never offers names taken from source text or from one language's builtins. diff --git a/packages/lsp-profiler-ui/src/memory-ref-graph.ts b/packages/lsp-profiler-ui/src/memory-ref-graph.ts index 62ebdb0..9b2497d 100644 --- a/packages/lsp-profiler-ui/src/memory-ref-graph.ts +++ b/packages/lsp-profiler-ui/src/memory-ref-graph.ts @@ -2,9 +2,10 @@ /** * Reference-graph webview for memory profiling. * - * Renders the force-directed object-retention graph (`gc.get_referrers()` walk - * parsed by the LSP) in a Canvas 2D webview. Extracted from `memory-profiler.ts` - * so that module stays focused on command routing and the courier round-trip. + * Renders the object-retention graph in a Canvas 2D webview, layered by each + * object's distance from the targets: GC roots on top, named by their labels. + * Extracted from `memory-profiler.ts` so that module stays focused on command + * routing and the courier round-trip. * * Panel lifecycle, CSP, and safe data embedding come from the shared webview * host ([PROF-UI-WEBVIEW], profiler-webview.ts) — node `repr`/type strings @@ -55,6 +56,9 @@ interface RefGraphNode { repr: string; depth: number; isTarget: boolean; + /** A garbage-collection root; `repr` is then its root label, such as `static sessions`. */ + isRoot?: boolean; + rootKind?: string | undefined; } interface RefGraphEdge { @@ -82,6 +86,8 @@ export function decodeRefGraph(value: unknown): ReferenceGraphResult["graph"] { repr: stringField(node, "repr") ?? "", depth: numberField(node, "depth") ?? 0, isTarget: booleanField(node, "isTarget") ?? false, + isRoot: booleanField(node, "isRoot") ?? false, + rootKind: stringField(node, "rootKind"), })), edges: recordArrayField(value, "edges").map((edge) => ({ from: numberField(edge, "from") ?? 0, @@ -177,56 +183,31 @@ function buildRefGraphScriptInit(nodesJson: string, edgesJson: string, cyclesJso const ctx = canvas.getContext('2d'); // Fill the panel width so the graph is not letterboxed into 800px. const W = canvas.width = Math.max(800, canvas.parentElement ? canvas.clientWidth : 800); - const H = canvas.height; + // Layers by distance from the targets: roots on top, targets along the bottom. + const LAYER = 120; + const top = Math.max(...nodes.map((n) => n.depth)); + const H = canvas.height = Math.max(360, (top + 1) * LAYER + 40); const cycleNodeIds = new Set(cycles.flat()); + const layers = new Map(); + nodes.forEach((n) => layers.set(n.depth, [...(layers.get(n.depth) || []), n])); const nodeMap = new Map(); - nodes.forEach((n, i) => { - nodeMap.set(n.id, { + for (const [depth, members] of layers) { + const gap = W / (members.length + 1); + // A row too tight to label each node names its type once, with a count. + const crowded = gap < 160; + members.forEach((n, i) => nodeMap.set(n.id, { ...n, - x: W / 2 + (Math.random() - 0.5) * W * 0.6, - y: H / 2 + (Math.random() - 0.5) * H * 0.6, - vx: 0, vy: 0, - radius: Math.max(8, Math.min(30, Math.log2(Math.max(n.size, 1)) * 2)), - }); - }); - for (let iter = 0; iter < 60; iter++) { - const alpha = 0.3 * (1 - iter / 60); - const nodeList = Array.from(nodeMap.values()); - for (let i = 0; i < nodeList.length; i++) { - for (let j = i + 1; j < nodeList.length; j++) { - const a = nodeList[i], b = nodeList[j]; - let dx = b.x - a.x, dy = b.y - a.y; - const dist = Math.max(1, Math.sqrt(dx * dx + dy * dy)); - const force = 2000 / (dist * dist); - dx = (dx / dist) * force * alpha; - dy = (dy / dist) * force * alpha; - a.x -= dx; a.y -= dy; - b.x += dx; b.y += dy; - } - } - for (const edge of edges) { - const a = nodeMap.get(edge.from), b = nodeMap.get(edge.to); - if (!a || !b) continue; - let dx = b.x - a.x, dy = b.y - a.y; - const dist = Math.max(1, Math.sqrt(dx * dx + dy * dy)); - const force = (dist - 80) * 0.01 * alpha; - dx = (dx / dist) * force; - dy = (dy / dist) * force; - a.x += dx; a.y += dy; - b.x -= dx; b.y -= dy; - } - for (const n of nodeList) { - n.x = Math.max(40, Math.min(W - 40, n.x)); - n.y = Math.max(40, Math.min(H - 40, n.y)); - } + x: gap * (i + 1), + y: 60 + (top - depth) * LAYER, + crowded, + radius: Math.min(crowded ? gap / 2.5 : 30, Math.max(8, Math.log2(Math.max(n.size, 1)) * 2)), + })); }`; } function buildRefGraphScriptDraw(): string { return ` const edgeColor = 'rgba(136, 146, 164, 0.3)'; - ctx.strokeStyle = edgeColor; - ctx.lineWidth = 1; for (const edge of edges) { const a = nodeMap.get(edge.from), b = nodeMap.get(edge.to); if (!a || !b) continue; @@ -237,35 +218,52 @@ function buildRefGraphScriptDraw(): string { ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); - if (edge.label) { - const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; + if (edge.label && !b.crowded) { ctx.fillStyle = cssVar('--prof-text-secondary'); ctx.font = '9px monospace'; - ctx.fillText(edge.label, mx + 4, my - 4); + ctx.textAlign = 'left'; + ctx.fillText(edge.label, (a.x + b.x) / 2 + 4, (a.y + b.y) / 2 - 4); } } + ${buildRefGraphScriptNodes()} + }`; +} + +/** Nodes: targets, roots and cycles in their own colours; a root named by its label above its type. */ +function buildRefGraphScriptNodes(): string { + return ` for (const n of nodeMap.values()) { - const isCycle = cycleNodeIds.has(n.id); - const color = n.isTarget ? '#c084fc' - : isCycle ? '#f87171' - : n.depth <= 1 ? '#60a5fa' - : '#8892a4'; + const color = n.isTarget ? '#c084fc' : n.isRoot ? '#60a5fa' : cycleNodeIds.has(n.id) ? '#f87171' : '#8892a4'; ctx.beginPath(); ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); ctx.fillStyle = color + '33'; ctx.fill(); ctx.strokeStyle = color; - ctx.lineWidth = n.isTarget ? 3 : 1.5; + ctx.lineWidth = n.isTarget || n.isRoot ? 3 : 1.5; ctx.stroke(); + ctx.textAlign = 'center'; + if (n.isRoot) { + ctx.fillStyle = color; + ctx.font = 'bold 13px monospace'; + ctx.fillText(n.repr, n.x, n.y - n.radius - 10); + } + if (n.crowded) continue; ctx.fillStyle = cssVar('--prof-text'); ctx.font = '10px monospace'; - ctx.textAlign = 'center'; ctx.fillText(n.type, n.x, n.y + n.radius + 14); ctx.fillStyle = cssVar('--prof-text-secondary'); ctx.font = '9px monospace'; ctx.fillText(formatBytes(n.size), n.x, n.y + n.radius + 26); } - }`; + for (const [depth, members] of layers) { + const first = nodeMap.get(members[0].id); + if (!first.crowded) continue; + const types = new Set(members.map((n) => n.type)); + ctx.fillStyle = cssVar('--prof-text'); + ctx.font = '11px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(members.length + ' × ' + (types.size === 1 ? members[0].type : 'objects'), W / 2, first.y + first.radius + 18); + }`; } function buildRefGraphScript(nodesJson: string, edgesJson: string, cyclesJson: string): string { @@ -293,7 +291,7 @@ export function buildRefGraphHtml(result: ReferenceGraphResult): string {
Target object
-
Root retainer
+
GC root
Intermediate
Cycle member
`, diff --git a/packages/lsp-profiler-ui/src/test/suite/profiler-webviews.test.ts b/packages/lsp-profiler-ui/src/test/suite/profiler-webviews.test.ts index c0b3f84..7578a00 100644 --- a/packages/lsp-profiler-ui/src/test/suite/profiler-webviews.test.ts +++ b/packages/lsp-profiler-ui/src/test/suite/profiler-webviews.test.ts @@ -30,7 +30,7 @@ import { type MemoryDashboardSnapshot, type MemoryDiffData, } from '../../memory-dashboard'; -import { buildRefGraphHtml, type ReferenceGraphResult } from '../../memory-ref-graph'; +import { buildRefGraphHtml, decodeRefGraph, type ReferenceGraphResult } from '../../memory-ref-graph'; import { buildFlamegraphHtml, loadFlamegraphSvgDataUri, @@ -57,6 +57,29 @@ function dashboardSnapshot(overrides: Partial = {}): Me }; } +interface DrawnText { + readonly text: string; + readonly y: number; + readonly fill: string; +} + +/** + * Run a retention graph's inline script against a recording canvas and return + * every text it draws, with where and in which colour: the panel as a reader sees it. + */ +function drawnTexts(html: string): DrawnText[] { + const script = /