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
51 changes: 51 additions & 0 deletions crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<LeakedPayload>` 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<i64> = 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;
}
53 changes: 53 additions & 0 deletions crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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);
Expand Down
47 changes: 40 additions & 7 deletions crates/lsp-profiler-dotnet/tests/support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ChildStdin>,
#[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<ChildStdout>,
}

impl Fixture {
Expand All @@ -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 <pid>")
.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();
Expand Down
2 changes: 1 addition & 1 deletion docs/models/profiler.td
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,7 @@ type ObjectList {
total_bytes: Int
# Largest objects of the type.
objects: List<ObjectSample>
# Largest types overall.
# Largest types overall, then the requested type's own row when it is too small to rank.
type_summary: List<TypeStat>
}

Expand Down
4 changes: 2 additions & 2 deletions docs/specs/LSP-PROFILER-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Case>` 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<Order>` 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<Order>` keeps its `Order[]` after `Clear()`, and that array must never paint the line that builds an `Order`.

### [PROF-DOTNET-MEMORY] Memory steps

Expand All @@ -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 |

Expand Down
13 changes: 5 additions & 8 deletions dotnet/LspProfiler.DotNet.Engine/ConstructionSites.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,21 @@ internal static class ConstructionSites
.DistinctBy(code => code.Value)
.ToDictionary(code => (ILOpCode)(ushort)code.Value, code => code.OperandType);

/// <summary>Each constructed type's lowest construction site, over every method body.</summary>
public static Dictionary<TypeDefinitionHandle, (string File, int Line)> Index(PEReader pe, MetadataReader metadata, MetadataReader pdb)
/// <summary>Each constructed type's lowest construction site that `locate` places, over every method body.</summary>
public static Dictionary<TypeDefinitionHandle, (string File, int Line)> Index(
PEReader pe, MetadataReader metadata, MetadataReader pdb, Func<SequencePoint, (string File, int Line)?> locate)
{
var sites = new Dictionary<TypeDefinitionHandle, (string File, int Line)>();
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;
}

/// <summary>A sequence point's document and line.</summary>
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()
Expand Down
14 changes: 13 additions & 1 deletion dotnet/LspProfiler.DotNet.Engine/HeapSummary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/// <summary>The largest types, then the requested type's own row when it is too small to rank.</summary>
private static List<TypeRow> Summary(LiveHeap live, SourceLocator locator, List<int> 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}";
Expand Down
41 changes: 30 additions & 11 deletions dotnet/LspProfiler.DotNet.Engine/SourceLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<DocumentHandle, string?> _documents = [];
private Dictionary<TypeDefinitionHandle, (string File, int Line)>? _constructed;
private bool? _onThisMachine;

public static ModuleSymbols? Open(string assembly)
{
Expand All @@ -76,31 +80,46 @@ private sealed class ModuleSymbols(PEReader pe, MetadataReaderProvider pdbProvid
}

public (string File, int Line)? Locate(IReadOnlyList<string> nesting) =>
Find(nesting) is { } definition ? Declared(definition) ?? Constructed(definition) : null;
OnThisMachine && Find(nesting) is { } definition ? Declared(definition) ?? Constructed(definition) : null;

/// <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);

public void Dispose()
{
pdbProvider.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;
}

/// <summary>A sequence point's document and line, when the document is on this machine.</summary>
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<string> nesting)
{
var (space, name) = TypeNames.Split(nesting[0]);
Expand Down
Loading
Loading