Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/cpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ use lsp_profiler::wire::methods;
use serde_json::{json, Value};

use crate::harness::{
artifact, count, error_code, harness, in_program_fs, site, snapshot, start, stop, ENTRY_POINT,
FIXTURE, HOT_FUNCTION, ORDER_TYPE,
artifact, count, error_code, harness, in_file, in_program_fs, site, snapshot, start, stop,
CLASSIFY_COUNT, ENTRY_POINT, FIXTURE, HOT_FUNCTION, MATCHING_FS, MATCH_FUNCTION, MATCH_LINES,
ORDER_TYPE,
};
use crate::support::{assert_only_busy_threads_count, call, service, Fixture};

Expand Down Expand Up @@ -115,6 +116,63 @@ async fn cpu_profiling_finds_the_fsharp_hot_function_and_its_lines() {
service.shutdown().await;
}

/// [PROF-DOTNET-CPU-SYMBOLS]: F# compiles a match's decision tree to hidden
/// sequence points. A sample there still ran in `classify`, so it keeps the
/// method's file and the visible line before it: one hot function, in its file.
#[tokio::test(flavor = "multi_thread")]
async fn cpu_samples_in_fsharp_match_code_keep_their_file() {
let mut fixture = Fixture::start(FIXTURE);
let dir = tempfile::tempdir().expect("tempdir");
let service = service(dir.path().to_path_buf());
let started = call(&service, methods::CPU_START, json!({ "pid": fixture.pid })).await;
for _ in 0..3 {
let classified = fixture.send(&format!("classify {CLASSIFY_COUNT}"));
assert!(classified.starts_with("OK classify"), "{classified}");
}
let report = call(
&service,
methods::CPU_STOP,
json!({ "session_id": started["session_id"] }),
)
.await;
let entries: Vec<&Value> = report["hot_functions"]
.as_array()
.expect("hot_functions")
.iter()
.filter(|f| {
f["name"]
.as_str()
.is_some_and(|n| n.starts_with(MATCH_FUNCTION))
})
.collect();
assert!(
entries.iter().all(|f| in_file(f, MATCHING_FS)),
"every sample of the match keeps {MATCHING_FS}: {entries:?}"
);
assert_eq!(
entries.len(),
1,
"{MATCH_FUNCTION} is one hot function, never split by a fileless twin: {report}"
);
let line = entries[0]["line"].as_i64().unwrap_or(0);
assert!(
MATCH_LINES.contains(&line),
"its line is in its body: {line}"
);
let lines: Vec<i64> = report["hot_lines"]
.as_array()
.expect("hot_lines")
.iter()
.filter(|l| in_file(l, MATCHING_FS))
.map(|l| l["line"].as_i64().unwrap_or(0))
.collect();
assert!(
lines.iter().any(|l| MATCH_LINES.contains(l)),
"a hot line sits inside the match: {lines:?}"
);
service.shutdown().await;
}

#[tokio::test(flavor = "multi_thread")]
async fn cpu_sessions_list_snapshot_and_refuse_a_second_attach() {
let fixture = Fixture::start(FIXTURE);
Expand Down
13 changes: 12 additions & 1 deletion crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ pub(crate) const RECORD_TYPE: &str = "HotLeakAppFs.LeakedPayload";
pub(crate) const UNION_CASE_TYPE: &str = "HotLeakAppFs.PayloadKind+Small";
pub(crate) const GENERIC_RECORD_TYPE: &str = "HotLeakAppFs.Tagged`1[System.Int32]";
pub(crate) const HOT_FUNCTION: &str = "HotLeakAppFs.Hot.spin";
/// `Matching.classify`, whose active-pattern tests compile to hidden sequence points.
pub(crate) const MATCH_FUNCTION: &str = "HotLeakAppFs.Matching.classify(";
pub(crate) const MATCHING_FS: &str = "Matching.fs";
/// `classify` in Matching.fs, from its `let` to its last arm.
pub(crate) const MATCH_LINES: std::ops::RangeInclusive<i64> = 17..=22;
/// One `classify` command's numbers: about a second of matching per round.
pub(crate) const CLASSIFY_COUNT: u32 = 50_000_000;
/// `Program.main`, which only ever waits on stdin.
pub(crate) const ENTRY_POINT: &str = "HotLeakAppFs.Program.main(";
/// `items.Add { Data = …; Label = …; Kind = kind }` in `LeakCache.grow`.
Expand All @@ -30,9 +37,13 @@ pub(crate) const QUOTE_TYPE: &str = "HotLeakAppFs.Quote";
pub(crate) const NODE_TYPE: &str = "HotLeakAppFs.Node";

pub(crate) fn in_program_fs(value: &Value) -> bool {
in_file(value, "Program.fs")
}

pub(crate) fn in_file(value: &Value, name: &str) -> bool {
value["file"]
.as_str()
.is_some_and(|file| file.ends_with("Program.fs"))
.is_some_and(|file| file.ends_with(name))
}

pub(crate) fn site<'a>(snapshot: &'a Value, type_name: &str) -> &'a Value {
Expand Down
3 changes: 2 additions & 1 deletion docs/plans/LSP-PROFILER-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Status was last verified on **2026-10-15**.
`dotnet/LspProfiler.DotNet.Engine`, built with `make dotnet`.

- [x] Builds clean under `TreatWarningsAsErrors` and `AnalysisLevel=latest-recommended` (`dotnet build -c Release`).
- [x] `cpu`: EventPipe sample profiler, rundown, `TraceLog` symbolization, ticks streamed on stop. Validated against `dotnet/fixtures/HotLeakApp`: `HotLeakApp.Hot.Spin` resolves to `Program.cs` lines 29–32 on macOS; a hidden sequence point (line 0) is reported without a location.
- [x] `cpu`: EventPipe sample profiler, rundown, `TraceLog` symbolization, ticks streamed on stop. Validated against `dotnet/fixtures/HotLeakApp`: `HotLeakApp.Hot.Spin` resolves to `Program.cs` lines 29–32 on macOS; a hidden sequence point (line 0) takes its method's nearest visible line (the F# match defect below).
- [x] **Design change: no dumps.** `createdump` hangs on this macOS for every target (also through `dotnet-dump collect`, which is what SharpLsp's old profiler used), so ClrMD and the dump path were deleted. Memory capture is the runtime's EventPipe heap snapshot ([PROF-DOTNET-HEAP-CAPTURE], [PROF-DOTNET-HEAP-GRAPH]), the same on every OS. Validated: 506 k live objects captured, `LeakedPayload` located at its declaration, growth between two snapshots, the retention path `LeakedPayload → LeakedPayload[] → List<LeakedPayload>` ending at the static root `Items`.
- [x] `gc`: `GCHeapCollect` does induce a full collection on .NET 10; the generation sizes after it are reported. Stopping a session from inside an event callback deadlocks the stream, so every session is stopped from the command thread (`GcWatch` + `SessionRun`).
- [x] `refs`, `objects`, `analyze` over the kept graph file; `failure` kinds for a dead PID (`ProcessNotFound`) and a non-.NET PID (`UnsupportedRuntime`).
Expand All @@ -70,6 +70,7 @@ Status was last verified on **2026-10-15**.
- [x] `MemoryBackend` direct mode (`memory.rs`): graphs under `<output_dir>/heap-<session>/`, deleted on stop; `diff` is a fresh snapshot the core compares.
- [x] e2e through `ProfilerService` against HotLeakApp (`tests/dotnet_e2e.rs`): the process is recognised and attachable; CPU finds `Hot.Spin` and its lines and writes all three artifacts; memory sees the leak's declaration line, growth, the static `Items` root, objects, an observed collection and a clean stop.
- [x] F# e2e against HotLeakAppFs (`tests/dotnet_fsharp_e2e.rs`, the shared harness in `tests/support/`). It found three engine defects that the C# fixture could never show, each fixed against a failing test. (1) [PROF-DOTNET-SOURCE]: F# records and unions had no location, because their generated members have only hidden sequence points. They now anchor where the code constructs them (`ConstructionSites.cs`). (2) [PROF-DOTNET-HEAP-GRAPH]: every nested type, including every F# union case, was named by its bare simple name (`Small`). The name is now rebuilt from metadata (`NestedTypeNames.cs`). (3) The static root of an F# module value was labelled `static items@15`; it is now `static items`.
- [x] **Defect — F# match code lost its file (#18).** A sample on a hidden sequence point kept no file, so its function split into a located entry and an unlocated twin. In SharpLsp's FsPricingEngine that was 61% of `Csv.parseLine`'s samples, enough to fail its Windows profiler suite. Fixed in `Symbolizer`: the frame takes its method's nearest visible sequence point ([PROF-DOTNET-CPU-SYMBOLS]). Pinned by `cpu_samples_in_fsharp_match_code_keep_their_file` over `Matching.classify`. Before the fix it failed on an unlocated twin holding 12% of the samples.
- [x] [PROF-DOTNET-HEAP-CAPTURE]: a large heap lost events without anyone knowing. SharpLsp's F# suite found this by snapshotting `HotLeakAppFs` after about 25 s. Past about six million objects, the default 256 MB EventPipe buffer dropped events in the heap's burst, the lost edges cut the leak off from its roots, and the snapshot reported 222 live objects of a 1 GB heap. The buffer is now 1024 MB, measured to hold 14.6 million objects (3.4 GB) with no loss. A snapshot that still loses events fails, naming the count, and keeps no graph (`a_snapshot_that_loses_events_fails_instead_of_misreporting_the_heap`, using `--buffer-mb 1`).

## Phase 4: `lsp-profiler-ui` [PROF-UI]
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/LSP-PROFILER-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,8 @@ The backend reports `live_progress`: each finished segment's ticks advance the s

`TraceLog` converts the trace. A `SymbolReader`, whose path is only the directories the trace's modules were loaded from, resolves each managed frame to its method and its PDB `file`:`line`. Native frames are kept only with `--native`.

A sample can land in code the compiler hid. F# compiles a `match`'s decision tree to hidden sequence points, and the reader gives those, like a prolog, no line. Such a frame takes its method's nearest visible sequence point at or before its IL offset, or the method's first visible point when none comes before, read from the same PDB. It keeps its method's file, so a function never splits into a located entry and an unlocated twin. A method with no visible sequence point, such as a compiler-generated closure, stays unlocated.

Thread samples are grouped into ticks:

- A new tick starts when a thread repeats or more than 0.5 ms passes.
Expand Down
2 changes: 1 addition & 1 deletion dotnet/LspProfiler.DotNet.Engine/CpuCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public static int Run(int pid, bool includeNative)
var segment = CpuSegment.Start(client, pid);
// The public diagnostics client exposes no process-info command; the version stays unknown.
Emit.Message(new Attached("attached", pid, null));
var symbolizer = new Symbolizer(includeNative);
using var symbolizer = new Symbolizer(includeNative);
Record(client, pid, segment, stop, symbolizer);
Emit.Message(new Stopped("stopped", symbolizer.TickCount));
return 0;
Expand Down
27 changes: 27 additions & 0 deletions dotnet/LspProfiler.DotNet.Engine/SourceLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
// 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.
//
// The same PDBs place a CPU sample that landed in code the compiler hid
// ([PROF-DOTNET-CPU-SYMBOLS]): it belongs to its method's visible line before it.
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using System.Reflection.PortableExecutable;
Expand All @@ -34,6 +37,13 @@ internal sealed class SourceLocator : IDisposable
return located;
}

/// <summary>
/// The line a sample at <paramref name="ilOffset"/> of method <paramref name="methodToken"/> ran:
/// the nearest visible sequence point at or before it, else the method's first visible point.
/// </summary>
public (string File, int Line)? Line(string module, int methodToken, int ilOffset) =>
File.Exists(module) ? Module(module)?.Line(methodToken, ilOffset) : null;

public void Dispose()
{
foreach (var symbols in _modules.Values)
Expand Down Expand Up @@ -82,6 +92,23 @@ private sealed class ModuleSymbols(PEReader pe, MetadataReaderProvider pdbProvid
public (string File, int Line)? Locate(IReadOnlyList<string> nesting) =>
OnThisMachine && Find(nesting) is { } definition ? Declared(definition) ?? Constructed(definition) : null;

/// <summary>An F# match's decision tree is hidden code: it belongs to the visible point before it.</summary>
public (string File, int Line)? Line(int methodToken, int ilOffset)
{
var visible = MethodPoints(methodToken).Where(point => !point.IsHidden).ToList();
var before = visible.FindLastIndex(point => point.Offset <= ilOffset);
return visible.Count == 0 ? null : Location(visible[Math.Max(before, 0)]);
}

/// <summary>A method's sequence points; none for a token that names no method of this PDB.</summary>
private IEnumerable<SequencePoint> MethodPoints(int methodToken)
{
var row = methodToken & 0xFFFFFF;
return methodToken >>> 24 == (int)TableIndex.MethodDef && row > 0 && row <= _pdb.MethodDebugInformation.Count
? SequencePoints(MetadataTokens.MethodDefinitionHandle(row))
: [];
}

/// <summary>Whether any document the PDB names is here; when none is, no IL is scanned for sites.</summary>
private bool OnThisMachine => _onThisMachine ??= _pdb.Documents.Any(document => Document(document) is not null);

Expand Down
38 changes: 32 additions & 6 deletions dotnet/LspProfiler.DotNet.Engine/Symbolizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
// Method names come from the JIT rundown; source lines come from the IL-to-
// native maps and the portable PDBs that sit beside the application's
// assemblies. The symbol path is the set of directories the trace's modules
// were loaded from, so lookup never leaves the machine.
// were loaded from, so lookup never leaves the machine. A sample in code the
// compiler hid, such as an F# match's decision tree, has no line of its own;
// it takes its method's nearest visible line before it, so it keeps its file.
using System.Reflection.Metadata;
using Microsoft.Diagnostics.Symbols;
using Microsoft.Diagnostics.Tracing;
using Microsoft.Diagnostics.Tracing.Etlx;
using Microsoft.Diagnostics.Tracing.EventPipe;

namespace LspProfiler.DotNet.Engine;

internal sealed class Symbolizer(bool includeNative)
internal sealed class Symbolizer(bool includeNative) : IDisposable
{
private const string SampleProvider = "Microsoft-DotNETCore-SampleProfiler";
private const int TicksPerMessage = 256;
Expand All @@ -39,6 +42,8 @@ public void Stream(string trace)
TickCount += output.Finish();
}

public void Dispose() => _frames.Dispose();

private static string SymbolPath(TraceLog log) =>
string.Join(';', log.ModuleFiles
.Select(module => Path.GetDirectoryName(module.FilePath))
Expand Down Expand Up @@ -121,8 +126,9 @@ private static bool IsManaged(TraceEvent sample) =>
/// Deduplicates symbolized frames and remembers which are not yet sent. IDs are
/// stable for the whole recording; code addresses only within one segment's log.
/// </summary>
internal sealed class FrameTable(bool includeNative)
internal sealed class FrameTable(bool includeNative) : IDisposable
{
private readonly SourceLocator _locator = new();
private readonly Dictionary<CodeAddressIndex, int?> _byAddress = [];
private readonly Dictionary<FrameRecord, int> _ids = [];
private readonly List<FrameRecord> _fresh = [];
Expand Down Expand Up @@ -187,9 +193,29 @@ private int Intern(FrameRecord record)
return null;
}
var name = string.IsNullOrEmpty(address.FullMethodName) ? $"{address.ModuleName}!?" : address.FullMethodName;
var source = managed ? SourceOf(address) : null;
var line = source?.LineNumber is > 0 and var known ? known : (int?)null;
return new FrameRecord(name, line is null ? null : source?.SourceFile?.BuildTimeFilePath, line, NullIfEmpty(address.ModuleName));
var (file, line) = managed ? Located(address) : (null, null);
return new FrameRecord(name, file, line, NullIfEmpty(address.ModuleName));
}

public void Dispose() => _locator.Dispose();

/// <summary>
/// A managed frame's file and line. A line the compiler hid, or none at all (a prolog), takes the
/// method's nearest visible sequence point from its PDB, so a function's samples keep their file.
/// A method with no visible point, such as a compiler-generated closure, stays unlocated.
/// [PROF-DOTNET-CPU-SYMBOLS]
/// </summary>
private (string? File, int? Line) Located(TraceCodeAddress address)
{
var source = SourceOf(address);
if (source is { LineNumber: > 0 and not SequencePoint.HiddenLine, SourceFile.BuildTimeFilePath: { } file })
{
return (file, source.LineNumber);
}
return address.ModuleFile?.FilePath is { } module && address.Method is { } method
&& _locator.Line(module, method.MethodToken, address.ILOffset) is { } visible
? visible
: (null, null);
}

private SourceLocation? SourceOf(TraceCodeAddress address)
Expand Down
4 changes: 3 additions & 1 deletion dotnet/fixtures/HotLeakAppFs/HotLeakAppFs.fsproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- The F# twin of HotLeakApp: a hot loop on known lines and a growing module-level
cache of records holding union cases. Records, unions and module values compile
to IL shapes C# never emits, so the engine is proven on F# itself. -->
to IL shapes C# never emits, and a match's decision tree compiles to hidden sequence
points, so the engine is proven on F# itself. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
Expand All @@ -10,6 +11,7 @@
<WarningLevel>5</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Compile Include="Matching.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
31 changes: 31 additions & 0 deletions dotnet/fixtures/HotLeakAppFs/Matching.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Hot pattern matching. F# compiles a match's decision tree, here the active-pattern
// tests, to hidden sequence points: a sample there has no line of its own, yet it ran
// in this file, inside `classify`. Tests [PROF-DOTNET-CPU-SYMBOLS].
namespace HotLeakAppFs

open System.Runtime.CompilerServices

module Matching =
/// The quotient when `divisor` divides `n`.
[<return: Struct>]
[<MethodImpl(MethodImplOptions.NoInlining)>]
let (|Multiple|_|) (divisor: int) (n: int) =
if n % divisor = 0 then ValueSome(n / divisor) else ValueNone

/// Each active-pattern test runs in the decision tree, between the arms' lines.
[<MethodImpl(MethodImplOptions.NoInlining)>]
let classify (n: int) =
match n with
| Multiple 15 quotient -> quotient % 4
| Multiple 5 quotient -> quotient % 3
| Multiple 3 quotient -> quotient % 2
| _ -> 0

/// The sum of the classes of 0 .. count - 1.
let classifyAll (count: int) =
let mutable total = 0

for n in 0 .. count - 1 do
total <- total + classify n

total
3 changes: 2 additions & 1 deletion dotnet/fixtures/HotLeakAppFs/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ 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.
/// `work on|off`, `classify` and `stats`; `stop` closes the program.
module Shapes =
let orders = ResizeArray<Order>()
let subscriptions = Event<Order>()
Expand Down Expand Up @@ -167,6 +167,7 @@ module Program =
| [ "grow"; count ] -> positive "grow" count Shapes.grow
| [ "payload"; count ] -> positive "payload" count LeakCache.grow
| [ "blob"; count ] -> positive "blob" count Shapes.retain
| [ "classify"; count ] -> positive "classify" count (Matching.classifyAll >> ignore)
| [ "release"; "all" ] ->
Shapes.release ()
sprintf "OK release all %s" (describe ())
Expand Down
Loading