diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs b/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs index 81f1b22..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() { @@ -451,3 +453,52 @@ 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 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 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": 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"] == LEAK_LIST) + .collect(); + assert_eq!(own.len(), 1, "one row for the requested type: {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 _ = 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..6148cd1 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.contains(" 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..60700ed 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,17 @@ 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, + #[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, } impl Fixture { @@ -46,24 +53,50 @@ 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. + #[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}")); + 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/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..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. @@ -738,7 +740,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 +750,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/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/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}"; 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..a92ab84 100644 --- a/dotnet/fixtures/HotLeakAppFs/Program.fs +++ b/dotnet/fixtures/HotLeakAppFs/Program.fs @@ -31,24 +31,172 @@ 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 = + 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 + 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 + cycles.Add { Label = quoted.Sku; Next = None } + + /// 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 + let mutable private busy = false + 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 + Volatile.Write(&busy, true) + sink <- sink + Hot.spin 200_000 + LeakCache.grow 50 + Volatile.Write(&busy, false) + else + // 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 + let private describe () = Shapes.counts () + + let private reply (line: string) = + stdout.WriteLine line + stdout.Flush() + + 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 %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 "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 ()) + | [ "work"; "on" ] -> + Volatile.Write(&working, true) + 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 = 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 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/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/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/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 = /