diff --git a/crates/lsp-profiler-dotnet/src/memory.rs b/crates/lsp-profiler-dotnet/src/memory.rs index 610400a..2f1b714 100644 --- a/crates/lsp-profiler-dotnet/src/memory.rs +++ b/crates/lsp-profiler-dotnet/src/memory.rs @@ -31,6 +31,9 @@ struct SessionState { snapshots: u32, /// Heap bytes the latest snapshot or collection reported. heap_bytes: Option, + /// The largest heap any snapshot of the session reported: the wire's + /// `peak_bytes`, peak since tracking began, which a collection never lowers. + peak_bytes: i64, } /// The .NET memory backend. @@ -90,15 +93,18 @@ impl DotNetMemory { "the engine answered a snapshot with another message".to_owned(), )); }; + let mut peak_bytes = heap_bytes; self.remember(&context.session_id, |state| { state.graph = Some(graph.clone()); state.heap_bytes = Some(heap_bytes); + state.peak_bytes = state.peak_bytes.max(heap_bytes).max(live_bytes); + peak_bytes = state.peak_bytes; }) .await; Ok(HeapSnapshot { snapshot_id: String::new(), current_bytes: live_bytes, - peak_bytes: heap_bytes, + peak_bytes, object_count: live_objects, gc_generations: vec![], generation_sizes: Some(generation_sizes(generation_bytes)), diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs b/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs index 750504d..a1f413c 100644 --- a/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs +++ b/crates/lsp-profiler-dotnet/tests/dotnet_e2e.rs @@ -384,6 +384,17 @@ async fn a_duration_caps_the_recorded_time() { ) .await; tokio::time::sleep(Duration::from_secs(3)).await; + // [PROF-CPU-SESSIONS] the duration ended the session: it is no longer active. + let listed = call(&service, methods::CPU_LIST, json!({})).await; + assert!( + !listed["sessions"] + .as_array() + .expect("sessions") + .iter() + .any(|session| session["session_id"] == started["session_id"]), + "a session past its duration is not listed as active: {listed}" + ); + // Its report is kept for the next stop. let report = call( &service, methods::CPU_STOP, diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs deleted file mode 100644 index 6148cd1..0000000 --- a/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e.rs +++ /dev/null @@ -1,255 +0,0 @@ -//! Full-stack .NET profiling of an F# program through the shared service. Tests -//! [PROF-DOTNET-CPU-SYMBOLS], [PROF-DOTNET-HEAP-GRAPH] and [PROF-DOTNET-SOURCE] -//! on the IL only F# emits. -//! -//! Real engine, real fixture, no mocks: `HotLeakAppFs` burns CPU in `Hot.spin` -//! and grows the module value `LeakCache.items` with records that hold union -//! cases and a generic record. Every member the compiler generates for a record or a union carries -//! only hidden sequence points, and a module value lives in a mangled -//! `items@` field, so none of this is proven by the C# fixture. - -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::indexing_slicing, - missing_docs -)] - -mod support; - -use std::time::Duration; - -use lsp_profiler::wire::methods; -use serde_json::{json, Value}; -use support::{assert_only_busy_threads_count, call, completed, service, Fixture}; - -const FIXTURE: &str = "HotLeakAppFs"; -const RECORD_TYPE: &str = "HotLeakAppFs.LeakedPayload"; -const UNION_CASE_TYPE: &str = "HotLeakAppFs.PayloadKind+Small"; -const GENERIC_RECORD_TYPE: &str = "HotLeakAppFs.Tagged`1[System.Int32]"; -const HOT_FUNCTION: &str = "HotLeakAppFs.Hot.spin"; -/// `Program.main`, which only ever waits on stdin. -const ENTRY_POINT: &str = "HotLeakAppFs.Program.main("; -/// `items.Add { Data = …; Label = …; Kind = kind }` in `LeakCache.grow`. -const RECORD_CONSTRUCTED_AT: i64 = 23; -/// `let kind = Small items.Count` in `LeakCache.grow`. -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"] - .as_str() - .is_some_and(|file| file.ends_with("Program.fs")) -} - -fn site<'a>(snapshot: &'a Value, type_name: &str) -> &'a Value { - snapshot["sites"] - .as_array() - .expect("sites") - .iter() - .find(|site| site["type_name"] == type_name) - .unwrap_or_else(|| panic!("{type_name} absent: {snapshot}")) -} - -#[tokio::test(flavor = "multi_thread")] -async fn cpu_profiling_finds_the_fsharp_hot_function_and_its_lines() { - let 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; - assert_eq!(started["runtime"], "dotnet"); - tokio::time::sleep(Duration::from_secs(3)).await; - let report = call( - &service, - methods::CPU_STOP, - json!({ "session_id": started["session_id"] }), - ) - .await; - assert!( - report["total_samples"].as_i64().unwrap_or(0) > 100, - "{report}" - ); - let spin = report["hot_functions"] - .as_array() - .expect("hot_functions") - .iter() - .find(|f| { - f["name"] - .as_str() - .is_some_and(|n| n.starts_with(HOT_FUNCTION)) - }) - .unwrap_or_else(|| panic!("{HOT_FUNCTION} absent: {report}")); - assert!(in_program_fs(spin), "{spin}"); - assert!( - report["hot_lines"] - .as_array() - .expect("hot_lines") - .iter() - .any(|l| in_program_fs(l) && (26..=31).contains(&l["line"].as_i64().unwrap_or(0))), - "hot line inside Hot.spin expected: {report}" - ); - assert_only_busy_threads_count(&report, ENTRY_POINT, HOT_FUNCTION); - service.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn fsharp_records_and_union_cases_anchor_where_the_program_constructs_them() { - 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 step = call(&service, methods::MEMORY_SNAPSHOT, session.clone()).await; - let snapshot = &completed(&step)["outcome"]["snapshot"]; - - let record = site(snapshot, RECORD_TYPE); - assert!( - in_program_fs(record), - "a record has a source location: {record}" - ); - assert_eq!(record["line"], RECORD_CONSTRUCTED_AT, "{record}"); - let case = site(snapshot, UNION_CASE_TYPE); - assert!( - in_program_fs(case), - "a union case has a source location: {case}" - ); - assert_eq!(case["line"], UNION_CASE_CONSTRUCTED_AT, "{case}"); - let generic = site(snapshot, GENERIC_RECORD_TYPE); - assert!( - in_program_fs(generic), - "a generic record has a source location: {generic}" - ); - assert_eq!(generic["line"], GENERIC_RECORD_CONSTRUCTED_AT, "{generic}"); - - tokio::time::sleep(Duration::from_secs(2)).await; - let diff = call(&service, methods::MEMORY_DIFF, session.clone()).await; - let outcome = &completed(&diff)["outcome"]; - let grown = outcome["diff"]["grown"] - .as_array() - .expect("grown") - .iter() - .find(|g| g["type_name"] == RECORD_TYPE) - .unwrap_or_else(|| panic!("growth of {RECORD_TYPE} absent: {diff}")); - assert!(grown["count_diff"].as_i64().unwrap_or(0) > 0, "{grown}"); - assert_eq!( - grown["line"], RECORD_CONSTRUCTED_AT, - "growth anchors in source: {grown}" - ); - let _ = call(&service, methods::MEMORY_STOP, session).await; - 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); - 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 id = &started["memory_session_id"]; - let _ = call( - &service, - methods::MEMORY_SNAPSHOT, - json!({ "memory_session_id": id }), - ) - .await; - let refs = call( - &service, - methods::MEMORY_REFERENCES, - json!({ "memory_session_id": id, "type_name": RECORD_TYPE, "limit": 40, "max_depth": 6 }), - ) - .await; - let graph = &completed(&refs)["outcome"]["graph"]; - let nodes = graph["nodes"].as_array().expect("nodes"); - assert!( - nodes - .iter() - .any(|n| n["is_target"] == true && n["type_name"] == RECORD_TYPE), - "{graph}" - ); - let roots: Vec<&Value> = nodes.iter().filter(|n| n["is_root"] == true).collect(); - assert!( - roots - .iter() - .any(|n| n["root_kind"] == "Static" && n["label"] == "static items"), - "the module value `items` holds the leak: {roots:?}" - ); - assert!( - roots - .iter() - .all(|n| !n["label"].as_str().unwrap_or("").contains('@')), - "no compiler-mangled field name reaches a label: {roots:?}" - ); - let _ = call( - &service, - methods::MEMORY_STOP, - json!({ "memory_session_id": id }), - ) - .await; - service.shutdown().await; -} diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/cpu.rs b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/cpu.rs new file mode 100644 index 0000000..d18b13b --- /dev/null +++ b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/cpu.rs @@ -0,0 +1,295 @@ +//! Process recognition, CPU profiling and CPU session steps on the F# fixture. + +use std::time::Duration; + +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, +}; +use crate::support::{assert_only_busy_threads_count, call, service, Fixture}; + +#[tokio::test(flavor = "multi_thread")] +async fn the_fsharp_fixture_is_recognised_as_an_attachable_dotnet_process() { + let fixture = Fixture::start(FIXTURE); + let dir = tempfile::tempdir().expect("tempdir"); + let service = service(dir.path().to_path_buf()); + let list = call( + &service, + methods::PROCESSES, + json!({ "workspace_roots": [] }), + ) + .await; + let entry = list["processes"] + .as_array() + .expect("processes") + .iter() + .find(|p| p["pid"] == fixture.pid) + .cloned() + .unwrap_or_else(|| panic!("fixture pid {} missing from {list}", fixture.pid)); + assert_eq!(entry["runtime"], "dotnet", "{entry}"); + assert_eq!(entry["attachable"], true, "{entry}"); + assert!( + entry["entry"].as_str().is_some_and(|e| e.contains(FIXTURE)), + "the F# entry assembly is named: {entry}" + ); + assert!( + entry["runtime_version"] + .as_str() + .is_some_and(|v| !v.is_empty()), + "the runtime version is resolved: {entry}" + ); + service.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn cpu_profiling_finds_the_fsharp_hot_function_and_its_lines() { + let 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; + assert_eq!(started["runtime"], "dotnet"); + tokio::time::sleep(Duration::from_secs(3)).await; + let report = call( + &service, + methods::CPU_STOP, + json!({ "session_id": started["session_id"] }), + ) + .await; + assert!( + report["total_samples"].as_i64().unwrap_or(0) > 100, + "{report}" + ); + let spin = report["hot_functions"] + .as_array() + .expect("hot_functions") + .iter() + .find(|f| { + f["name"] + .as_str() + .is_some_and(|n| n.starts_with(HOT_FUNCTION)) + }) + .unwrap_or_else(|| panic!("{HOT_FUNCTION} absent: {report}")); + assert!(in_program_fs(spin), "{spin}"); + assert!( + spin["percentage"].as_f64().unwrap_or(0.0) >= 2.0, + "[PROF-AGGREGATION-THRESHOLD] a hot function is at least 2%: {spin}" + ); + assert!( + spin["self_percentage"].as_f64().unwrap_or(0.0) + <= spin["percentage"].as_f64().unwrap_or(0.0), + "self never exceeds inclusive: {spin}" + ); + assert!( + report["hot_lines"] + .as_array() + .expect("hot_lines") + .iter() + .any(|l| in_program_fs(l) && (26..=31).contains(&l["line"].as_i64().unwrap_or(0))), + "hot line inside Hot.spin expected: {report}" + ); + assert_only_busy_threads_count(&report, ENTRY_POINT, HOT_FUNCTION); + let shares: Vec = report["hot_functions"] + .as_array() + .expect("hot_functions") + .iter() + .map(|f| f["percentage"].as_f64().unwrap_or(0.0)) + .collect(); + assert!( + shares.windows(2).all(|pair| pair[0] >= pair[1]), + "[PROF-INVARIANTS] shares arrive ranked: {shares:?}" + ); + for line in report["hot_lines"].as_array().expect("hot_lines") { + assert!( + line["line"].as_i64().unwrap_or(0) >= 1, + "1-based line: {line}" + ); + assert!( + line["percentage"].as_f64().unwrap_or(0.0) >= 1.0, + "[PROF-AGGREGATION-THRESHOLD] a line is hot at 1%: {line}" + ); + } + assert_every_export_names_the_fsharp_function(&report); + service.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn cpu_sessions_list_snapshot_and_refuse_a_second_attach() { + let 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; + let session = json!({ "session_id": started["session_id"] }); + let listed = call(&service, methods::CPU_LIST, json!({})).await; + let entry = listed["sessions"] + .as_array() + .expect("sessions") + .iter() + .find(|s| s["session_id"] == started["session_id"]) + .unwrap_or_else(|| panic!("the live session is listed: {listed}")); + assert_eq!(entry["pid"], fixture.pid, "{entry}"); + assert_eq!(entry["runtime"], "dotnet", "{entry}"); + assert!( + entry["started_at"].as_str().is_some_and(|t| !t.is_empty()), + "a start time is reported: {entry}" + ); + assert_eq!( + error_code(&service, methods::CPU_START, json!({ "pid": fixture.pid })).await, + -32004, + "[PROF-CPU-START] a second session is ALREADY_PROFILING" + ); + assert_eq!( + listed["sessions"].as_array().map(Vec::len), + Some(1), + "the refusal stacks nothing" + ); + tokio::time::sleep(Duration::from_secs(2)).await; + let snapshot = call(&service, methods::CPU_SNAPSHOT, session.clone()).await; + assert!( + snapshot["total_samples"].as_i64().unwrap_or(0) > 0, + "[PROF-CPU-SNAPSHOT] a mid-session snapshot has samples: {snapshot}" + ); + let after = call(&service, methods::CPU_LIST, json!({})).await; + assert!( + after["sessions"] + .as_array() + .expect("sessions") + .iter() + .any(|s| s["session_id"] == started["session_id"]), + "[PROF-CPU-SESSIONS] a snapshot never ends the session: {after}" + ); + let report = call(&service, methods::CPU_STOP, session.clone()).await; + assert!( + report["total_samples"].as_i64().unwrap_or(0) > 0, + "{report}" + ); + assert_eq!( + error_code(&service, methods::CPU_STOP, session).await, + -32000, + "[PROF-ERRORS] stopping a stopped session is OTHER" + ); + assert_eq!( + call(&service, methods::CPU_LIST, json!({})).await["sessions"] + .as_array() + .map(Vec::len), + Some(0), + "no session survives the stop" + ); + service.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_dead_pid_is_process_not_found_and_a_non_dotnet_pid_is_unsupported() { + let dir = tempfile::tempdir().expect("tempdir"); + let service = service(dir.path().to_path_buf()); + for method in [methods::CPU_START, methods::MEMORY_START] { + assert_eq!( + error_code(&service, method, json!({ "pid": 2_147_483_647 })).await, + -32001, + "[PROF-ERRORS] {method} on a missing PID" + ); + assert_eq!( + error_code(&service, method, json!({ "pid": std::process::id() })).await, + -32002, + "[PROF-ERRORS] {method} on a non-.NET process" + ); + } + assert_eq!( + error_code( + &service, + methods::KILL, + json!({ "pid": std::process::id() }) + ) + .await, + -32002, + "[PROF-PROCESSES-KILL] kill never reaches a process outside the profiler's reach" + ); + service.shutdown().await; +} + +/// [PROF-EXPORT]: each export is written and names the F# hot function. +fn assert_every_export_names_the_fsharp_function(report: &Value) { + for key in ["speedscope_path", "flamegraph_path", "cpuprofile_path"] { + let _ = artifact(report, key); + } + let speedscope: Value = serde_json::from_str( + &std::fs::read_to_string(artifact(report, "speedscope_path")).unwrap(), + ) + .expect("speedscope JSON"); + assert!( + speedscope["shared"]["frames"] + .as_array() + .expect("frames") + .iter() + .any(|frame| frame["name"] + .as_str() + .is_some_and(|name| name.contains("Hot.spin"))), + "a speedscope frame names the F# function: {speedscope}" + ); + let cpuprofile: Value = serde_json::from_str( + &std::fs::read_to_string(artifact(report, "cpuprofile_path")).unwrap(), + ) + .expect("cpuprofile JSON"); + assert!( + cpuprofile["nodes"] + .as_array() + .expect("nodes") + .iter() + .any(|node| node["callFrame"]["functionName"] + .as_str() + .is_some_and(|name| name.contains("spin"))), + "a .cpuprofile node names the F# function: {cpuprofile}" + ); + let flamegraph = std::fs::read_to_string(artifact(report, "flamegraph_path")).unwrap(); + assert!( + flamegraph.contains(" = snapshot["sites"] + .as_array() + .expect("sites") + .iter() + .filter_map(|site| site["type_name"].as_str()) + .collect(); + assert!( + names.iter().all(|name| !name.starts_with(" 0, + "{report}" + ); + stop(&h, &session).await; +} diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/harness.rs b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/harness.rs new file mode 100644 index 0000000..f1a8e92 --- /dev/null +++ b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/harness.rs @@ -0,0 +1,150 @@ +//! The F# fixture's constants and the helpers every F# suite module shares. + +use std::path::PathBuf; + +use lsp_profiler::wire::methods; +use lsp_profiler::ProfilerService; +use serde_json::{json, Value}; + +use crate::support::{call, completed, service, Fixture}; + +pub(crate) const FIXTURE: &str = "HotLeakAppFs"; +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"; +/// `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`. +pub(crate) const RECORD_CONSTRUCTED_AT: i64 = 23; +/// `let kind = Small items.Count` in `LeakCache.grow`. +pub(crate) const UNION_CASE_CONSTRUCTED_AT: i64 = 21; +/// `let order = { Value = items.Count; Tag = "order" }` in `LeakCache.grow`. +pub(crate) const GENERIC_RECORD_CONSTRUCTED_AT: i64 = 22; +/// The F# shapes the line protocol builds, each at the line that constructs it. +pub(crate) const ORDER_TYPE: &str = "HotLeakAppFs.Order"; +pub(crate) const ORDER_LINE_TYPE: &str = "HotLeakAppFs.OrderLine"; +pub(crate) const CUSTOMER_TYPE: &str = "HotLeakAppFs.CustomerId"; +pub(crate) const EXPRESS_TYPE: &str = "HotLeakAppFs.Priority+Express"; +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 { + value["file"] + .as_str() + .is_some_and(|file| file.ends_with("Program.fs")) +} + +pub(crate) fn site<'a>(snapshot: &'a Value, type_name: &str) -> &'a Value { + snapshot["sites"] + .as_array() + .expect("sites") + .iter() + .find(|site| site["type_name"] == type_name) + .unwrap_or_else(|| panic!("{type_name} absent: {snapshot}")) +} + +pub(crate) fn snapshot_of(step: &Value) -> &Value { + &completed(step)["outcome"]["snapshot"] +} + +/// The integer counts a fixture reply reports: `OK … orders=3 subscriptions=3 …`. +pub(crate) fn counts(reply: &str) -> std::collections::HashMap<&str, i64> { + assert!( + reply.starts_with("OK "), + "the fixture accepted the command: {reply}" + ); + reply + .split_whitespace() + .filter_map(|word| word.split_once('=')) + .filter_map(|(key, value)| value.parse().ok().map(|value| (key, value))) + .collect() +} + +pub(crate) fn count(reply: &str, key: &str) -> i64 { + *counts(reply) + .get(key) + .unwrap_or_else(|| panic!("the reply carries {key}: {reply}")) +} + +pub(crate) fn artifact(report: &Value, key: &str) -> PathBuf { + let path = PathBuf::from( + report["artifacts"][key] + .as_str() + .unwrap_or_else(|| panic!("{key} missing: {report}")), + ); + assert!(path.is_file(), "{key} is written: {}", path.display()); + path +} + +pub(crate) fn leak_type<'a>(diff: &'a Value, type_name: &str) -> &'a Value { + diff["leaks"] + .as_array() + .expect("leaks") + .iter() + .find(|leak| leak["type_name"] == type_name) + .unwrap_or_else(|| panic!("{type_name} is a suspected leak: {diff}")) +} + +pub(crate) async fn error_code(service: &ProfilerService, method: &str, params: Value) -> i64 { + match service.handle(method, params).await { + Ok(value) => panic!("{method} must be refused, got {value}"), + Err(error) => error.code(), + } +} + +/// A fresh F# program and service, with the workload stopped so every heap change +/// is one the test made. The `Fixture` must keep living for the test's duration. +pub(crate) struct Harness { + pub(crate) fixture: Fixture, + pub(crate) service: ProfilerService, + #[allow(dead_code)] + pub(crate) dir: tempfile::TempDir, +} + +pub(crate) async fn harness() -> Harness { + let mut fixture = Fixture::start(FIXTURE); + let off = fixture.send("work off"); + assert_eq!( + count(&off, "orders"), + 0, + "a fresh program has no orders: {off}" + ); + let release = fixture.send("release all"); + assert_eq!(count(&release, "payloads"), 0, "{release}"); + let dir = tempfile::tempdir().expect("tempdir"); + let service = service(dir.path().to_path_buf()); + Harness { + fixture, + service, + dir, + } +} + +pub(crate) async fn start(h: &Harness) -> Value { + let started = call( + &h.service, + methods::MEMORY_START, + json!({ "pid": h.fixture.pid }), + ) + .await; + assert_eq!(started["runtime"], "dotnet", "{started}"); + assert_eq!(started["mode"]["kind"], "Direct", "{started}"); + json!({ "memory_session_id": started["memory_session_id"] }) +} + +pub(crate) async fn snapshot(h: &Harness, session: &Value) -> Value { + let step = call(&h.service, methods::MEMORY_SNAPSHOT, session.clone()).await; + snapshot_of(&step).clone() +} + +pub(crate) async fn diff(h: &Harness, session: &Value) -> Value { + let step = call(&h.service, methods::MEMORY_DIFF, session.clone()).await; + completed(&step)["outcome"].clone() +} + +pub(crate) async fn stop(h: &Harness, session: &Value) { + let stopped = call(&h.service, methods::MEMORY_STOP, session.clone()).await; + assert_eq!(completed(&stopped)["outcome"]["kind"], "Ack", "{stopped}"); + h.service.shutdown().await; +} diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/main.rs b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/main.rs new file mode 100644 index 0000000..840b002 --- /dev/null +++ b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/main.rs @@ -0,0 +1,31 @@ +//! Full-stack .NET profiling of an F# program through the shared service. Tests +//! [PROF-DOTNET-CPU-SYMBOLS], [PROF-DOTNET-HEAP-GRAPH], [PROF-DOTNET-SOURCE], +//! [PROF-DOTNET-MEMORY], [PROF-MEMORY-CONFIDENCE] and [PROF-TESTING] on the IL +//! only F# emits. +//! +//! Real engine, real fixture, no mocks: `HotLeakAppFs` burns CPU in `Hot.spin` +//! and grows the module value `LeakCache.items` with records that hold union +//! cases and a generic record. Its line protocol adds the rest of the F# heap +//! zoo one shape at a time: orders (records over a single-case union, a union +//! case with fields, a list and a struct record), quotes behind a never-evicting +//! cache, never-removed event-handler closures, a two-node reference cycle and +//! retained arrays. Every member the compiler generates for a record or a union +//! carries only hidden sequence points, and a module value lives in a mangled +//! `items@` field, so none of this is proven by the C# fixture. + +#![allow( + clippy::allow_attributes, + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::indexing_slicing, + missing_docs +)] + +#[path = "../support/mod.rs"] +mod support; + +mod cpu; +mod harness; +mod memory; +mod steps; diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/memory.rs b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/memory.rs new file mode 100644 index 0000000..be7588a --- /dev/null +++ b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/memory.rs @@ -0,0 +1,348 @@ +//! What an F# heap snapshot reports: anchors, roots, the leak ladder, releases and package PDBs. + +use lsp_profiler::wire::methods; +use serde_json::{json, Value}; + +use crate::harness::{ + count, diff, harness, in_program_fs, leak_type, site, snapshot, start, stop, CUSTOMER_TYPE, + EXPRESS_TYPE, GENERIC_RECORD_CONSTRUCTED_AT, GENERIC_RECORD_TYPE, NODE_TYPE, ORDER_LINE_TYPE, + ORDER_TYPE, QUOTE_TYPE, RECORD_CONSTRUCTED_AT, RECORD_TYPE, UNION_CASE_CONSTRUCTED_AT, + UNION_CASE_TYPE, +}; +use crate::support::{call, completed}; + +#[tokio::test(flavor = "multi_thread")] +async fn a_fsharp_records_and_unions_anchor_where_each_shape_is_constructed() { + let mut h = harness().await; + let _ = h.fixture.send("payload 100"); + let grown = h.fixture.send("grow 300"); + assert_eq!(count(&grown, "orders"), 300, "{grown}"); + assert_eq!( + count(&grown, "subscriptions"), + 300, + "one closure per order: {grown}" + ); + assert_eq!( + count(&grown, "cycles"), + 302, + "a cycle plus one node per order: {grown}" + ); + assert_eq!( + count(&grown, "quotes"), + 300, + "one cached quote per order: {grown}" + ); + let started = call( + &h.service, + methods::MEMORY_START, + json!({ "pid": h.fixture.pid, "max_sites": 200 }), + ) + .await; + let session = json!({ "memory_session_id": started["memory_session_id"] }); + let snapshot = snapshot(&h, &session).await; + + for (type_name, line, live) in [ + (RECORD_TYPE, RECORD_CONSTRUCTED_AT, 0), + (UNION_CASE_TYPE, UNION_CASE_CONSTRUCTED_AT, 0), + (GENERIC_RECORD_TYPE, GENERIC_RECORD_CONSTRUCTED_AT, 0), + (ORDER_TYPE, 77, 300), + (ORDER_LINE_TYPE, 75, 300), + (CUSTOMER_TYPE, 73, 300), + (EXPRESS_TYPE, 76, 100), + (QUOTE_TYPE, 82, 300), + (NODE_TYPE, 88, 302), + ] { + let found = site(&snapshot, type_name); + assert!( + in_program_fs(found), + "a shape has a source location: {found}" + ); + assert_eq!( + found["line"], line, + "{type_name} anchors where it is built: {found}" + ); + assert!(found["size_bytes"].as_i64().unwrap_or(0) > 0, "{found}"); + assert!(found["count"].as_i64().unwrap_or(0) > 0, "{found}"); + if live > 0 { + assert_eq!(found["count"], live, "{type_name}: every live instance"); + } + } + // The event-handler closure is an F# lambda, anchored at its own line. + let closures = snapshot["sites"] + .as_array() + .expect("sites") + .iter() + .filter(|site| site["line"] == 97 && in_program_fs(site)) + .count(); + assert!( + closures > 0, + "the subscription closure anchors at line 97: {snapshot}" + ); + + // A struct record is inline and a nullary union case has no class, so neither is a heap type. + let names: Vec<&str> = snapshot["sites"] + .as_array() + .expect("sites") + .iter() + .filter_map(|site| site["type_name"].as_str()) + .collect(); + assert!( + !names.contains(&"HotLeakAppFs.Money"), + "a struct record lives inline: {names:?}" + ); + assert!( + !names.contains(&"HotLeakAppFs.Priority+Standard"), + "a nullary union case has no class: {names:?}" + ); + + // [PROF-DOTNET-SOURCE] arrays are the runtime's own type: never anchored to source. + for array in [ + "System.Byte[]", + "HotLeakAppFs.Order[]", + "HotLeakAppFs.LeakedPayload[]", + ] { + let found = site(&snapshot, array); + assert_eq!( + found["file"], + Value::Null, + "{array} has no source location: {found}" + ); + assert_eq!(found["line"], Value::Null, "{array} has no line: {found}"); + } + stop(&h, &session).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_fsharp_leak_roots_are_module_values_under_their_source_names() { + let mut h = harness().await; + let _ = h.fixture.send("payload 100"); + let _ = h.fixture.send("grow 100"); + let session = start(&h).await; + let _ = snapshot(&h, &session).await; + + for (type_name, expected_root) in [ + (RECORD_TYPE, "static items"), + (ORDER_TYPE, "static orders"), + (QUOTE_TYPE, "static cache"), + (NODE_TYPE, "static cycles"), + ] { + let refs = call( + &h.service, + methods::MEMORY_REFERENCES, + json!({ + "memory_session_id": session["memory_session_id"], + "type_name": type_name, + "limit": 60, + "max_depth": 8 + }), + ) + .await; + let graph = &completed(&refs)["outcome"]["graph"]; + let nodes = graph["nodes"].as_array().expect("nodes"); + assert!( + nodes + .iter() + .any(|n| n["is_target"] == true && n["type_name"] == type_name), + "{type_name} instances are targets: {graph}" + ); + let roots: Vec<&Value> = nodes.iter().filter(|n| n["is_root"] == true).collect(); + assert!( + roots + .iter() + .any(|n| n["root_kind"] == "Static" && n["label"] == expected_root), + "{type_name} is held by `{expected_root}`: {roots:?}" + ); + assert!( + roots + .iter() + .all(|n| !n["label"].as_str().unwrap_or("").contains('@')), + "no compiler-mangled field name reaches a label: {roots:?}" + ); + let edges = graph["edges"].as_array().expect("edges"); + assert!(!edges.is_empty(), "{type_name} has holding edges: {graph}"); + assert!( + graph["cycles"].is_array(), + "cycles are reported (possibly empty): {graph}" + ); + assert!(graph["truncated"].is_boolean(), "{graph}"); + for node in nodes { + assert!(node["depth"].as_i64().unwrap_or(-1) >= 0, "{node}"); + assert!(!node["label"].as_str().unwrap_or("").is_empty(), "{node}"); + assert!( + !node["type_name"].as_str().unwrap_or("").is_empty(), + "{node}" + ); + } + } + stop(&h, &session).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_fsharp_leak_ladder_is_low_then_medium_then_high() { + let mut h = harness().await; + let _ = h.fixture.send("payload 100"); + let session = start(&h).await; + let _ = snapshot(&h, &session).await; + + let mut bands = Vec::new(); + for _ in 0..3 { + let _ = h.fixture.send("payload 100"); + let outcome = diff(&h, &session).await; + let leak = leak_type(&outcome, RECORD_TYPE); + assert!(leak["count_growth"].as_i64().unwrap_or(0) > 0, "{leak}"); + assert!(leak["size_growth"].as_i64().unwrap_or(0) > 0, "{leak}"); + assert!( + !leak["reason"].as_str().unwrap_or("").is_empty(), + "the ladder explains itself: {leak}" + ); + assert!(in_program_fs(leak), "a leak anchors in source: {leak}"); + assert_eq!(leak["line"], RECORD_CONSTRUCTED_AT, "{leak}"); + bands.push( + leak["confidence"]["kind"] + .as_str() + .unwrap_or_default() + .to_owned(), + ); + } + assert_eq!( + bands, + ["Low", "Medium", "High"], + "[PROF-MEMORY-CONFIDENCE] {bands:?}" + ); + stop(&h, &session).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn one_growth_over_ten_mebibytes_is_medium_and_arrays_stay_unlocated() { + let mut h = harness().await; + let _ = h.fixture.send("payload 10"); + let session = start(&h).await; + let _ = snapshot(&h, &session).await; + let added = h.fixture.send("blob 12"); + assert_eq!(count(&added, "blobs"), 12, "{added}"); + let outcome = diff(&h, &session).await; + let leak = leak_type(&outcome, "System.Byte[]"); + assert!( + leak["size_growth"].as_i64().unwrap_or(0) >= 10 * 1024 * 1024, + "the 12 MiB growth is measured: {leak}" + ); + assert_eq!( + leak["confidence"]["kind"], "Medium", + "[PROF-MEMORY-CONFIDENCE] one growth over 10 MiB is Medium: {leak}" + ); + assert_eq!( + leak["file"], + Value::Null, + "an array is never located: {leak}" + ); + assert_eq!(leak["line"], Value::Null, "an array has no line: {leak}"); + stop(&h, &session).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_release_resets_the_streak_and_a_collection_frees_bytes() { + let mut h = harness().await; + let _ = h.fixture.send("payload 100"); + let session = start(&h).await; + let _ = snapshot(&h, &session).await; + let _ = h.fixture.send("payload 100"); + let low = diff(&h, &session).await; + assert_eq!(leak_type(&low, RECORD_TYPE)["confidence"]["kind"], "Low"); + + let released = h.fixture.send("release all"); + assert_eq!(count(&released, "payloads"), 0, "{released}"); + assert_eq!(count(&released, "orders"), 0, "{released}"); + let flat = diff(&h, &session).await; + assert!( + flat["leaks"] + .as_array() + .expect("leaks") + .iter() + .all(|leak| leak["type_name"] != RECORD_TYPE), + "a released leak stops being suspected: {flat}" + ); + assert!( + flat["diff"]["total_freed"].as_i64().unwrap_or(0) >= 0, + "freed is a magnitude: {flat}" + ); + + let gc = call(&h.service, methods::MEMORY_GC_COLLECT, session.clone()).await; + let collected = &completed(&gc)["outcome"]["gc"]; + assert_eq!( + collected["collected"], 1, + "[PROF-DOTNET-GC] a collection is observed: {gc}" + ); + assert!( + collected["freed_bytes"].as_i64().unwrap_or(0) >= 0, + "freed bytes are reported: {collected}" + ); + assert_eq!( + collected["uncollectable"].as_array().map(Vec::len), + usize::try_from(collected["uncollectable_count"].as_u64().unwrap_or(0)).ok(), + "{collected}" + ); + + let _ = h.fixture.send("payload 100"); + let again = diff(&h, &session).await; + assert_eq!( + leak_type(&again, RECORD_TYPE)["confidence"]["kind"], + "Low", + "[PROF-MEMORY-CONFIDENCE] a flat diff reset the streak: {again}" + ); + stop(&h, &session).await; +} + +/// [PROF-DOTNET-SOURCE]: FSharp.Core embeds a PDB whose documents name its build +/// server's checkout (`/Users/runner/work/…`). A location no editor here can open +/// is no location: lists, sets and handler closures are 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 h = harness().await; + let grown = h.fixture.send("grow 40"); + assert_eq!(count(&grown, "orders"), 40, "{grown}"); + let session = start(&h).await; + let snapshot = snapshot(&h, &session).await; + 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("Microsoft.FSharp.")) + }) + .collect(); + assert!( + !library.is_empty(), + "the program holds FSharp.Core objects: {snapshot}" + ); + for found in &library { + assert_eq!( + found["file"], + Value::Null, + "no build-server anchor: {found}" + ); + assert_eq!( + found["line"], + Value::Null, + "no line without a file: {found}" + ); + assert!( + found["count"].as_i64().unwrap_or(0) > 0, + "still listed: {found}" + ); + } + for found in sites.iter().filter(|s| !s["file"].is_null()) { + let file = found["file"].as_str().expect("file is a string"); + assert!( + std::path::Path::new(file).exists(), + "every anchor exists here: {found}" + ); + } + assert!( + in_program_fs(site(&snapshot, ORDER_TYPE)), + "the program's own record keeps its anchor" + ); + stop(&h, &session).await; +} diff --git a/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/steps.rs b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/steps.rs new file mode 100644 index 0000000..47484e1 --- /dev/null +++ b/crates/lsp-profiler-dotnet/tests/dotnet_fsharp_e2e/steps.rs @@ -0,0 +1,424 @@ +//! The memory steps over a kept F# graph: objects, references, refusals, the timeline and caps. + +use std::path::PathBuf; + +use lsp_profiler::wire::methods; +use serde_json::{json, Value}; + +use crate::harness::{ + count, error_code, harness, site, snapshot, start, stop, ORDER_TYPE, RECORD_TYPE, +}; +use crate::support::{call, completed}; + +#[tokio::test(flavor = "multi_thread")] +async fn objects_by_type_honours_every_limit_and_ranks_the_largest_types() { + let mut h = harness().await; + let _ = h.fixture.send("payload 200"); + let _ = h.fixture.send("grow 200"); + let session = start(&h).await; + let snapshot = snapshot(&h, &session).await; + let recorded = site(&snapshot, ORDER_TYPE)["count"].as_i64().unwrap_or(0); + + for limit in [1, 3, 10, 50] { + let listed = call( + &h.service, + methods::MEMORY_OBJECTS_BY_TYPE, + json!({ + "memory_session_id": session["memory_session_id"], + "type_name": ORDER_TYPE, + "limit": limit + }), + ) + .await; + let objects = &completed(&listed)["outcome"]["objects"]; + assert_eq!(objects["type_name"], ORDER_TYPE, "{objects}"); + assert_eq!(objects["total_count"], recorded, "{objects}"); + assert!( + objects["total_bytes"].as_i64().unwrap_or(0) > 0, + "{objects}" + ); + assert_eq!( + objects["objects"].as_array().map(Vec::len), + Some(limit), + "the limit is honoured: {objects}" + ); + for sample in objects["objects"].as_array().expect("objects") { + assert_eq!(sample["type_name"], ORDER_TYPE, "{sample}"); + assert!(sample["size_bytes"].as_i64().unwrap_or(0) > 0, "{sample}"); + assert!( + !sample["label"].as_str().unwrap_or("").is_empty(), + "{sample}" + ); + } + let sizes: Vec = objects["type_summary"] + .as_array() + .expect("summary") + .iter() + .map(|stat| stat["size_bytes"].as_i64().unwrap_or(0)) + .collect(); + assert!( + sizes.windows(2).all(|pair| pair[0] >= pair[1]), + "the summary is ranked largest first: {sizes:?}" + ); + assert!( + objects["type_summary"] + .as_array() + .expect("summary") + .iter() + .all(|stat| !stat["type_name"].as_str().unwrap_or("").is_empty()), + "{objects}" + ); + } + + let absent = call( + &h.service, + methods::MEMORY_OBJECTS_BY_TYPE, + json!({ + "memory_session_id": session["memory_session_id"], + "type_name": "HotLeakAppFs.NoSuchType", + "limit": 5 + }), + ) + .await; + let empty = &completed(&absent)["outcome"]["objects"]; + assert_eq!(empty["total_count"], 0, "{empty}"); + assert_eq!( + empty["objects"].as_array().map(Vec::len), + Some(0), + "{empty}" + ); + assert!( + empty["type_summary"] + .as_array() + .is_some_and(|s| !s.is_empty()), + "the summary still describes the heap: {empty}" + ); + stop(&h, &session).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn references_honour_depth_and_budget_and_the_snapshot_survives_the_target() { + let mut h = harness().await; + let _ = h.fixture.send("payload 100"); + let _ = h.fixture.send("grow 100"); + let session = start(&h).await; + let _ = snapshot(&h, &session).await; + + for (depth, limit) in [(1, 5), (3, 20), (8, 60)] { + let refs = call( + &h.service, + methods::MEMORY_REFERENCES, + json!({ + "memory_session_id": session["memory_session_id"], + "type_name": ORDER_TYPE, + "limit": limit, + "max_depth": depth + }), + ) + .await; + let graph = &completed(&refs)["outcome"]["graph"]; + let nodes = graph["nodes"].as_array().expect("nodes"); + assert!(!nodes.is_empty(), "{graph}"); + assert!(nodes.len() <= limit, "the node budget is honoured: {graph}"); + assert!( + nodes + .iter() + .any(|n| n["is_target"] == true && n["type_name"] == ORDER_TYPE), + "{graph}" + ); + assert!( + nodes + .iter() + .all(|n| n["depth"].as_i64().unwrap_or(i64::MAX) <= depth + 1), + "the depth cap is honoured: {graph}" + ); + let ids: std::collections::HashSet<&str> = + nodes.iter().filter_map(|n| n["id"].as_str()).collect(); + for edge in graph["edges"].as_array().expect("edges") { + assert!( + ids.contains(edge["from"].as_str().unwrap_or_default()) + && ids.contains(edge["to"].as_str().unwrap_or_default()), + "every edge joins two known nodes: {edge}" + ); + } + } + + // [PROF-DOTNET-MEMORY] refs and objects re-open the kept graph, so they still + // answer after the program is gone; a new capture does not. + let mut fixture = h.fixture; + let _ = fixture.child.kill(); + let _ = fixture.child.wait(); + let refs = call( + &h.service, + methods::MEMORY_REFERENCES, + json!({ + "memory_session_id": session["memory_session_id"], + "type_name": ORDER_TYPE, + "limit": 20, + "max_depth": 4 + }), + ) + .await; + assert_eq!(completed(&refs)["outcome"]["kind"], "Refs", "{refs}"); + let objects = call( + &h.service, + methods::MEMORY_OBJECTS_BY_TYPE, + json!({ + "memory_session_id": session["memory_session_id"], + "type_name": ORDER_TYPE, + "limit": 3 + }), + ) + .await; + assert_eq!( + completed(&objects)["outcome"]["kind"], + "Objects", + "{objects}" + ); + assert_eq!( + error_code( + &h.service, + methods::MEMORY_SNAPSHOT, + json!({ "memory_session_id": session["memory_session_id"] }) + ) + .await, + -32001, + "[PROF-ERRORS] a new capture on an exited program is PROCESS_NOT_FOUND" + ); + let _ = call(&h.service, methods::MEMORY_STOP, session.clone()).await; + h.service.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn every_fsharp_step_refuses_before_a_snapshot_and_after_a_stop() { + let h = harness().await; + let session = start(&h).await; + for method in [methods::MEMORY_REFERENCES, methods::MEMORY_OBJECTS_BY_TYPE] { + assert_eq!( + error_code( + &h.service, + method, + json!({ + "memory_session_id": session["memory_session_id"], + "type_name": RECORD_TYPE, + "limit": 5 + }) + ) + .await, + -32000, + "[PROF-DOTNET-MEMORY] {method} before any snapshot is OTHER" + ); + } + let marker = "not-a-payload-7fd1"; + let refused = h + .service + .handle( + methods::MEMORY_INGEST, + json!({ "memory_session_id": session["memory_session_id"], "output": marker }), + ) + .await + .expect_err("garbage is refused"); + assert_ne!( + refused.code(), + 0, + "[PROF-MEMORY-INGEST] unrecognised output is refused: {refused}" + ); + assert_eq!( + error_code( + &h.service, + methods::MEMORY_INGEST, + json!({ "memory_session_id": "no-such-session", "output": "{}" }) + ) + .await, + -32000, + "an unknown session is OTHER" + ); + let _ = snapshot(&h, &session).await; + stop(&h, &session).await; + for method in [ + methods::MEMORY_SNAPSHOT, + methods::MEMORY_DIFF, + methods::MEMORY_REFERENCES, + methods::MEMORY_OBJECTS_BY_TYPE, + methods::MEMORY_GC_COLLECT, + methods::MEMORY_STOP, + ] { + assert_eq!( + error_code( + &h.service, + method, + json!({ "memory_session_id": session["memory_session_id"], "type_name": RECORD_TYPE }) + ) + .await, + -32000, + "[PROF-ERRORS] {method} on a stopped session is OTHER" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_timeline_and_heapprofile_are_written_for_every_fsharp_snapshot() { + let mut h = harness().await; + let _ = h.fixture.send("payload 50"); + let session = start(&h).await; + let first = snapshot(&h, &session).await; + let first_outcome = completed( + &call( + &h.service, + methods::MEMORY_SNAPSHOT, + json!({ "memory_session_id": session["memory_session_id"] }), + ) + .await, + )["outcome"] + .clone(); + let first_path = first_outcome["heapprofile_path"] + .as_str() + .unwrap_or_else(|| panic!("a heapprofile is written: {first_outcome}")); + assert!(PathBuf::from(first_path).is_file(), "{first_path}"); + let profile: Value = + serde_json::from_str(&std::fs::read_to_string(first_path).unwrap()).expect("V8 JSON"); + assert!( + profile["head"].is_object(), + "a V8 call-tree root: {profile}" + ); + assert!(profile["samples"].is_array(), "V8 samples: {profile}"); + + let _ = h.fixture.send("payload 50"); + let second_outcome = completed( + &call( + &h.service, + methods::MEMORY_SNAPSHOT, + json!({ "memory_session_id": session["memory_session_id"] }), + ) + .await, + )["outcome"] + .clone(); + let second = &second_outcome["snapshot"]; + let timeline = second_outcome["timeline"].as_array().expect("timeline"); + assert_eq!( + timeline.len(), + 3, + "[PROF-MEMORY-TIMELINE] one point per snapshot" + ); + for point in timeline { + assert!( + point["elapsed_secs"].as_f64().unwrap_or(-1.0) >= 0.0, + "{point}" + ); + assert!(point["current_bytes"].as_i64().unwrap_or(0) > 0, "{point}"); + assert!( + point["peak_bytes"].as_i64().unwrap_or(0) + >= point["current_bytes"].as_i64().unwrap_or(0), + "peak never falls below current: {point}" + ); + assert!(point["object_count"].as_i64().unwrap_or(0) > 0, "{point}"); + assert!( + point["snapshot_id"] + .as_str() + .is_some_and(|id| !id.is_empty()), + "{point}" + ); + } + assert_eq!(second["gc_generations"], json!([]), "counts stay absent"); + assert!( + second["generation_sizes"] + .as_array() + .is_some_and(|s| !s.is_empty()), + "generation bytes have names and units: {second}" + ); + assert!( + second["current_bytes"].as_i64().unwrap_or(0) + >= first["current_bytes"].as_i64().unwrap_or(0), + "the heap grew across the snapshots" + ); + assert!( + second_outcome["trending_upward"].is_boolean(), + "[PROF-MEMORY-TIMELINE] trending is explicit: {second_outcome}" + ); + for pair in timeline.windows(2) { + assert!( + pair[1]["elapsed_secs"].as_f64().unwrap_or(0.0) + >= pair[0]["elapsed_secs"].as_f64().unwrap_or(0.0), + "time never runs backwards: {pair:?}" + ); + assert!( + pair[1]["peak_bytes"].as_i64().unwrap_or(0) + >= pair[0]["peak_bytes"].as_i64().unwrap_or(0), + "peak never falls: {pair:?}" + ); + } + stop(&h, &session).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn traceback_depth_and_max_sites_cap_an_fsharp_snapshot() { + let mut h = harness().await; + let _ = h.fixture.send("payload 200"); + let _ = h.fixture.send("grow 100"); + let started = call( + &h.service, + methods::MEMORY_START, + json!({ "pid": h.fixture.pid, "traceback_depth": 3, "max_sites": 7 }), + ) + .await; + let session = json!({ "memory_session_id": started["memory_session_id"] }); + let snapshot = snapshot(&h, &session).await; + let sites = snapshot["sites"].as_array().expect("sites"); + assert!( + !sites.is_empty(), + "the snapshot still summarises the heap: {snapshot}" + ); + for found in sites { + assert!( + found["traceback"].as_array().map_or(0, Vec::len) <= 3, + "traceback_depth caps every stack: {found}" + ); + for frame in found["traceback"].as_array().expect("traceback") { + assert!(frame["line"].as_i64().unwrap_or(0) >= 1, "{frame}"); + assert!(!frame["file"].as_str().unwrap_or("").is_empty(), "{frame}"); + } + } + stop(&h, &session).await; +} + +/// [PROF-MEMORY-TIMELINE]: `peak_bytes` is the peak since tracking began, so a +/// release and a collection lower the live bytes but never the peak. +#[tokio::test(flavor = "multi_thread")] +async fn a_release_and_a_collection_never_lower_the_peak() { + let mut h = harness().await; + let _ = h.fixture.send("grow 300"); + let retained = h.fixture.send("blob 16"); + assert_eq!(count(&retained, "blobs"), 16, "{retained}"); + let session = start(&h).await; + let before = snapshot(&h, &session).await; + let released = h.fixture.send("release all"); + assert_eq!(count(&released, "blobs"), 0, "{released}"); + let _ = call(&h.service, methods::MEMORY_GC_COLLECT, session.clone()).await; + let step = call(&h.service, methods::MEMORY_SNAPSHOT, session.clone()).await; + let outcome = &completed(&step)["outcome"]; + let after = &outcome["snapshot"]; + let bytes = |snapshot: &Value, key: &str| snapshot[key].as_i64().unwrap_or(0); + assert!( + bytes(after, "current_bytes") < bytes(&before, "current_bytes"), + "the release freed live bytes: {before} then {after}" + ); + assert!( + bytes(after, "peak_bytes") >= bytes(&before, "peak_bytes"), + "the peak never falls: {before} then {after}" + ); + assert!( + bytes(after, "peak_bytes") >= bytes(after, "current_bytes"), + "the peak is never below the live bytes: {after}" + ); + let peaks: Vec = outcome["timeline"] + .as_array() + .expect("timeline") + .iter() + .map(|point| point["peak_bytes"].as_i64().unwrap_or(0)) + .collect(); + assert!( + peaks.windows(2).all(|pair| pair[1] >= pair[0]), + "the timeline's peak never falls: {peaks:?}" + ); + stop(&h, &session).await; +} diff --git a/crates/lsp-profiler/src/session.rs b/crates/lsp-profiler/src/session.rs index 7ebaa89..59da251 100644 --- a/crates/lsp-profiler/src/session.rs +++ b/crates/lsp-profiler/src/session.rs @@ -41,6 +41,13 @@ impl CpuSession { .as_secs_f64() } + /// True once a requested duration has elapsed: the session ended by itself + /// and only its recording remains, kept for the next `stop` ([PROF-CPU-SESSIONS]). + fn ended(&self) -> bool { + self.limit + .is_some_and(|limit| self.started_at.elapsed() >= limit) + } + /// Seconds one batch represents when the backend gives no weight. fn weight(&self) -> f64 { 1.0 / f64::from(u32::try_from(self.sample_rate).unwrap_or(u32::MAX).max(1)) @@ -224,7 +231,9 @@ impl CpuSessionManager { hotspots: &HotspotConfig, ) -> Option { let mut sessions = self.sessions.lock().await; - let session = sessions.get_mut(session_id)?; + let session = sessions + .get_mut(session_id) + .filter(|session| !session.ended())?; session.drain(); let info = session.info(); Some(ProfileProgress { @@ -239,11 +248,12 @@ impl CpuSessionManager { }) } - /// Every active session. + /// Every active session; one past its duration has ended and is not listed. pub async fn list(&self) -> Vec { let mut sessions = self.sessions.lock().await; sessions .values_mut() + .filter(|session| !session.ended()) .map(|session| { session.drain(); session.info() diff --git a/docs/specs/LSP-PROFILER-SPEC.md b/docs/specs/LSP-PROFILER-SPEC.md index 2404add..c2c9d7e 100644 --- a/docs/specs/LSP-PROFILER-SPEC.md +++ b/docs/specs/LSP-PROFILER-SPEC.md @@ -592,6 +592,8 @@ Collection counts (`gc_generations`) and named generation sizes (`generation_siz Snapshot, compare, GC and stop are reachable from the dashboard, the processes view and, in courier mode, the debug toolbar. +Stop ends the server's session (`memory.stop`) before the UI forgets it, so a stopped session refuses every later step and its kept graphs are removed. A site or hot function with no location is listed by name and paints no line. + ### [PROF-UI-REFGRAPH] Retention graph 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. @@ -695,10 +697,7 @@ The crate maps `error_kind` one-to-one onto [PROF-ERRORS]. ### [PROF-DOTNET-CPU] CPU capture -The engine opens an EventPipe session with rundown and a 256 MB buffer. It enables: - -- `Microsoft-DotNETCore-SampleProfiler`, which samples every managed thread each millisecond; -- `Microsoft-Windows-DotNETRuntime` at Informational with keywords `0x14C14FCCBD`: loader, JIT, IL-to-native maps and stacks. +The engine opens an EventPipe session with rundown and a 256 MB buffer. It enables only `Microsoft-DotNETCore-SampleProfiler`, which samples every managed thread each millisecond; the rundown that ends each segment names its methods, modules and IL-to-native maps. It never enables `Microsoft-Windows-DotNETRuntime`: while any session does, the runtime keeps its list of the types it has already logged, so a heap snapshot ([PROF-DOTNET-HEAP-CAPTURE]) taken during the CPU session would report every type an earlier snapshot named as a bare address. Recording runs in segments, each its own session recorded to a temporary `.nettrace` that is deleted with its `.etlx` on every exit path. The first segment lasts 500 ms and each next one doubles, up to 10 s. A segment ends with its rundown, so its frames can be named. The next segment starts before the last one is symbolized, so the gap between them is only the rundown. Ticks therefore stream a segment behind: @@ -819,7 +818,8 @@ The server binds the socket before spawning the helper and accepts one stream. I - Every backend is tested against a real target process the library owns. Consumer fixtures are never copied in. For .NET the targets are `dotnet/fixtures/HotLeakApp` (C#) and its F# twin `dotnet/fixtures/HotLeakAppFs`: a hot loop in `Hot.Spin` (`Hot.spin`), and a leak of `LeakedPayload` held by the static `LeakCache.Items` (the module value `LeakCache.items`). The F# payload is a record holding a union case and a generic record, which are IL shapes C# never emits. The tests assert: - CPU: a hot line inside `Hot.Spin` at its `Program.cs` line, and inside `Hot.spin` at its `Program.fs` line; - - memory: `LeakedPayload` grows across snapshots, reaches Medium then High confidence, and has a retention path ending at root `LeakCache.Items`. In F#, the record, the union case `PayloadKind+Small` and the generic record `Tagged`1[System.Int32]` each anchor at the exact `Program.fs` line that constructs them, and the leak's root is `static items`, with no `@` suffix. + - memory: `LeakedPayload` grows across snapshots, reaches Low, Medium then High confidence, and has a retention path ending at root `LeakCache.Items`. In F#, the record, the union case `PayloadKind+Small` and the generic record `Tagged`1[System.Int32]` each anchor at the exact `Program.fs` line that constructs them, and the leak's root is `static items`, with no `@` suffix. + - the F# fixture's line protocol (`grow`, `payload`, `blob`, `release all`, `work on|off`, `stats`) makes one shape grow at a time. Tests assert the rest of its zoo: `Order` over `CustomerId`, `OrderLine`, `Priority+Express` and a struct record; `Quote` behind a never-evicting cache; never-removed event-handler closures; a two-node reference cycle rooted at `static cycles`; and retained arrays, which anchor at no source line. A release resets the confidence streak, and one 12 MiB growth is Medium. - Every test asserts a specific outcome. A call that only proves something returned is not a test. - The UI library is tested inside VS Code against a mock language server that speaks the wire (`packages/lsp-profiler-ui/src/test/host`): command registration and advertisement, manifest contributions, settings, reactive state and gating, the processes panel, webview CSP and theming, decorations. Server-backed flows (CPU attach to a live process, heap snapshot and diff, retention graph, induced collection, autopilot) are tested in each host's extension suite against that host's real targets; SharpLsp's are `profiler-cpu-e2e.test.ts` and `memory-e2e.test.ts` against `ProfileTarget` and `ProfileLeakTarget`. - [PROF-LSP] conformance is tested at the host boundary: diff --git a/dotnet/LspProfiler.DotNet.Engine/CpuCommand.cs b/dotnet/LspProfiler.DotNet.Engine/CpuCommand.cs index fd96cff..4e90bca 100644 --- a/dotnet/LspProfiler.DotNet.Engine/CpuCommand.cs +++ b/dotnet/LspProfiler.DotNet.Engine/CpuCommand.cs @@ -57,11 +57,7 @@ private static void Record(DiagnosticsClient client, int pid, CpuSegment segment /// One EventPipe recording: a session copied to a temporary trace file. internal sealed class CpuSegment { - /// The runtime events dotnet-trace's cpu-sampling profile enables: loader, JIT, IL maps, stacks. - private const long RuntimeKeywords = 0x14C14FCCBD; - private const string SampleProvider = "Microsoft-DotNETCore-SampleProfiler"; - private const string RuntimeProvider = "Microsoft-Windows-DotNETRuntime"; private readonly EventPipeSession _session; private readonly FileStream _file; @@ -76,14 +72,16 @@ private CpuSegment(EventPipeSession session, string trace) _copy = session.EventStream.CopyToAsync(_file); } - /// Open a segment on ; a failure to attach propagates. + /// + /// Open a segment on ; a failure to attach propagates. Only the sampler + /// is enabled: the segment's rundown names every frame. While any session enables the runtime + /// provider, the runtime keeps the list of types it has already logged, so a heap snapshot taken + /// during a CPU session that enabled it would name only the types no earlier snapshot named. + /// [PROF-DOTNET-CPU] + /// public static CpuSegment Start(DiagnosticsClient client, int pid) { - EventPipeProvider[] providers = - [ - new(SampleProvider, EventLevel.Informational), - new(RuntimeProvider, EventLevel.Informational, RuntimeKeywords), - ]; + EventPipeProvider[] providers = [new(SampleProvider, EventLevel.Informational)]; var trace = Path.Combine(Path.GetTempPath(), $"lsp-profiler-dotnet-{pid}-{Guid.NewGuid():N}.nettrace"); return new CpuSegment(client.StartEventPipeSession(providers, requestRundown: true, circularBufferMB: 256), trace); } diff --git a/packages/lsp-profiler-ui/src/memory-capture.ts b/packages/lsp-profiler-ui/src/memory-capture.ts index cfbf69a..13c93dd 100644 --- a/packages/lsp-profiler-ui/src/memory-capture.ts +++ b/packages/lsp-profiler-ui/src/memory-capture.ts @@ -57,6 +57,7 @@ export const LSP_MEM_CMD = lspTable({ objectsByType: "memory.objectsByType", gcCollect: "memory.gcCollect", ingest: "memory.ingest", + stop: "memory.stop", }); /** tracemalloc traceback depth injected at start. */ @@ -158,6 +159,26 @@ export function isDirectMemorySession(): boolean { /** The running LSP client handle. */ type LspClient = NonNullable; +/** + * End the tracked session on the server ([PROF-MEMORY-SESSION]): it answers no + * further step and its heap graphs are deleted ([PROF-DOTNET-MEMORY] `stop`). + * Call it while the store still holds the session id; a failure is logged, + * because the UI's own tracking ends either way. + */ +export async function endServerSession(store: Store): Promise { + const client = store.client.value; + const memorySessionId = store.profiler.value.memorySessionId; + if (client?.isRunning() !== true || memorySessionId === undefined) { return; } + try { + await client.sendRequest("workspace/executeCommand", { + command: LSP_MEM_CMD.stop, + arguments: [{ memory_session_id: memorySessionId }], + }); + } catch (err: unknown) { + Logger.warn(`[Memory] ${LSP_MEM_CMD.stop} failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + /** Everything one staged memory round-trip needs. */ interface MemoryOperation { readonly store: Store; diff --git a/packages/lsp-profiler-ui/src/memory-profiler.ts b/packages/lsp-profiler-ui/src/memory-profiler.ts index 990037d..058283c 100644 --- a/packages/lsp-profiler-ui/src/memory-profiler.ts +++ b/packages/lsp-profiler-ui/src/memory-profiler.ts @@ -35,6 +35,7 @@ import { memStartTitle, TRACEBACK_DEPTH, captureSnapshotAndDiff, + endServerSession, hasCapturedSnapshot, isDirectMemorySession, lastMemorySnapshot, @@ -91,7 +92,7 @@ function memoryCommandDisposables(store: Store): vscode.Disposable[] { vscode.commands.registerCommand(cmd("memorySnapshot"), async () => handleMemorySnapshot(store)), vscode.commands.registerCommand(cmd("memoryDiff"), async () => handleMemoryDiff(store)), vscode.commands.registerCommand(cmd("memoryGcCollect"), async () => handleMemoryGcCollect(store)), - vscode.commands.registerCommand(cmd("memoryStop"), () => { runMemoryStopCommand(store); }), + vscode.commands.registerCommand(cmd("memoryStop"), async () => runMemoryStopCommand(store)), vscode.commands.registerCommand(cmd("memoryReferences"), async () => handleMemoryReferences(store)), // The status-bar show/hide on session changes is bound in memory-status.ts. vscode.debug.onDidStartDebugSession((session) => { @@ -161,7 +162,7 @@ async function runMemoryStartCommand(store: Store, report: (message: string) => } /** The `.profiler.memoryStop` body: stop tracking and never report nothing. */ -function runMemoryStopCommand(store: Store): void { +async function runMemoryStopCommand(store: Store): Promise { // Stopping must never silently produce nothing: surface whether a snapshot was // captured, or say plainly that none was ([PROF-PY-MEMORY-FINAL], #146). const hadSnapshot = hasCapturedSnapshot(); @@ -173,6 +174,7 @@ function runMemoryStopCommand(store: Store): void { if (debugSessionId !== undefined && finalSnapshotFile !== undefined) { pendingSnapshotCleanup.set(debugSessionId, finalSnapshotFile); } + await endServerSession(store); handleMemoryStop(store); if (hadSnapshot) { void vscode.window.showInformationMessage(`${brand()}: Memory tracking stopped.`); @@ -505,6 +507,7 @@ export function gcSummary(result: MemoryIngestResult): string { */ function finishDirectRunOnEnd(store: Store): void { const last = lastMemorySnapshot(); + void endServerSession(store); handleMemoryStop(store); if (last === undefined) { showActionableToast( diff --git a/packages/lsp-profiler-ui/src/profiler-decorations.ts b/packages/lsp-profiler-ui/src/profiler-decorations.ts index d610210..2b73ba7 100644 --- a/packages/lsp-profiler-ui/src/profiler-decorations.ts +++ b/packages/lsp-profiler-ui/src/profiler-decorations.ts @@ -30,8 +30,10 @@ export interface ProfileHotLine { /** Per-function profiling data from the LSP server. */ export interface ProfileHotFunction { name: string; - file: string; - line: number; + /** Source path; absent for a frame the runtime could not place, such as a framework method ([PROF-DOTNET-CPU-SYMBOLS]). */ + file?: string; + /** 1-based definition line; present exactly when `file` is. */ + line?: number; samples: number; percentage: number; selfPercentage: number; @@ -227,9 +229,16 @@ function buildLineDecorations( } /** Build function-level heat decoration options for a single editor. */ +/** A hot function the runtime placed in source, so the heat map can paint it. */ +type LocatedHotFunction = ProfileHotFunction & { file: string; line: number }; + +function isLocated(func: ProfileHotFunction): func is LocatedHotFunction { + return func.file !== undefined && func.line !== undefined; +} + function buildFuncDecorations( editor: vscode.TextEditor, - funcs: ProfileHotFunction[], + funcs: LocatedHotFunction[], ctx: { lines: ProfileHotLine[] | undefined; optionsByColor: Map }, ): void { const { lines, optionsByColor } = ctx; @@ -268,7 +277,8 @@ export function applyProfileDecorations(result: ProfileResult): void { const keyOf = pathKeyer(); const linesByFile = groupByFile(result.hotLines, keyOf); - const funcsByFile = groupByFile(result.hotFunctions, keyOf); + // An unlocated frame stays in the results table but paints no line. + const funcsByFile = groupByFile(result.hotFunctions.filter(isLocated), keyOf); const editors = vscode.window.visibleTextEditors; for (const editor of editors) { diff --git a/packages/lsp-profiler-ui/src/profiler-flamegraph-html.ts b/packages/lsp-profiler-ui/src/profiler-flamegraph-html.ts index c55b74b..78c8bfb 100644 --- a/packages/lsp-profiler-ui/src/profiler-flamegraph-html.ts +++ b/packages/lsp-profiler-ui/src/profiler-flamegraph-html.ts @@ -434,13 +434,18 @@ function flamegraphScriptRender(): string { const fnBody = document.getElementById('fn-body'); for (const fn of hotFunctions) { const tr = document.createElement('tr'); - tr.onclick = () => vscode.postMessage({ type: 'navigateToSource', file: fn.file, line: fn.line }); + // A frame the runtime could not place (a framework method) has no file: it + // is listed by name, with no link to nowhere. + const placed = fn.file !== undefined; + if (placed) { + tr.onclick = () => vscode.postMessage({ type: 'navigateToSource', file: fn.file, line: fn.line }); + } // fn.name/fn.file are CPython co_name/co_filename from the profiled (possibly // third-party) program — escape before innerHTML. This also renders synthetic // frames like / literally instead of as broken HTML elements. tr.innerHTML = [ '' + escapeHtml(fn.name) + '', - '' + escapeHtml(basename(fn.file)) + ':' + fn.line + '', + placed ? '' + escapeHtml(basename(fn.file)) + ':' + fn.line + '' : '', '' + fn.percentage.toFixed(1) + '%', '' + fn.selfPercentage.toFixed(1) + '%', '
', diff --git a/packages/lsp-profiler-ui/src/test/suite/cpu-runtime-neutral.test.ts b/packages/lsp-profiler-ui/src/test/suite/cpu-runtime-neutral.test.ts new file mode 100644 index 0000000..7130982 --- /dev/null +++ b/packages/lsp-profiler-ui/src/test/suite/cpu-runtime-neutral.test.ts @@ -0,0 +1,52 @@ +// Tests for [PROF-UI] + [PROF-DOTNET-CPU-SYMBOLS]. See docs/specs/LSP-PROFILER-SPEC.md +/** + * CPU UI, RUNTIME-NEUTRAL: a hot function the runtime could not place, such as + * a framework frame (`System.Threading.Thread.StartCallback()`) whose PDB is not + * on this machine, reaches the screens by name only. The adapter used to turn + * the absent location into `""` and line 0, which the results table rendered + * as a `:0` link to nowhere. + */ + +import * as assert from 'assert'; +import { toProfileResult } from '../../wire-adapters'; +import { buildFlamegraphHtml } from '../../profiler-flamegraph-html'; +import type { ProfileReport } from '../../wire-generated'; +import { hostApi } from './test-helpers'; + +const FRAMEWORK_FRAME = 'System.Threading.Thread.StartCallback()'; + +function report(): ProfileReport { + return { + session_id: 'cpu-1', pid: 7, runtime: 'dotnet', duration_secs: 3, total_samples: 300, + user_samples: 290, sample_rate: 100, + hot_functions: [ + { name: FRAMEWORK_FRAME, samples: 290, percentage: 100, self_samples: 0, self_percentage: 0 }, + { name: 'App.Hot.spin', file: '/src/Program.fs', line: 26, samples: 280, percentage: 96.5, self_samples: 280, self_percentage: 96.5 }, + ], + hot_lines: [{ file: '/src/Program.fs', line: 29, samples: 200, percentage: 69 }], + artifacts: {}, + diagnostics: [], + }; +} + +suite('CPU UI — a hot function with no location', () => { + suiteSetup(async () => { await hostApi(); }); + + test('reaches the results by name, with no invented file or line', () => { + const result = toProfileResult(report()); + const [framework, spin] = result.hotFunctions; + assert.strictEqual(framework?.name, FRAMEWORK_FRAME, 'the unlocated frame is still listed'); + assert.strictEqual(framework?.file, undefined, 'no file is invented'); + assert.strictEqual(framework?.line, undefined, 'no line 0 is invented'); + assert.strictEqual(framework?.percentage, 100); + assert.deepStrictEqual([spin?.file, spin?.line], ['/src/Program.fs', 26], 'a located frame keeps its anchor'); + }); + + test('the results table embeds the unlocated frame without a location', () => { + const html = buildFlamegraphHtml(toProfileResult(report())); + assert.ok(html.includes(FRAMEWORK_FRAME), 'the frame is in the table data'); + assert.ok(!html.includes('"file":""'), 'no empty file is embedded'); + assert.ok(!html.includes('"line":0'), 'no line 0 is embedded'); + assert.ok(html.includes('"file":"/src/Program.fs"'), 'a located frame keeps its file'); + }); +}); diff --git a/packages/lsp-profiler-ui/src/wire-adapters.ts b/packages/lsp-profiler-ui/src/wire-adapters.ts index 5235bb8..9370a52 100644 --- a/packages/lsp-profiler-ui/src/wire-adapters.ts +++ b/packages/lsp-profiler-ui/src/wire-adapters.ts @@ -48,8 +48,7 @@ export function toProfileResult(report: ProfileReport): ProfileResult { ...(artifacts.export_error !== undefined ? { exportError: artifacts.export_error } : {}), hotFunctions: report.hot_functions.map((fn) => ({ name: fn.name, - file: fn.file ?? "", - line: fn.line ?? 0, + ...located(fn), samples: fn.samples, percentage: fn.percentage, selfPercentage: fn.self_percentage, @@ -84,10 +83,13 @@ export function toUiProcess(process: WireProcess): ProcessInfo { }; } +/** [PROF-WIRE]: a source location is both fields or neither, never `""` and line 0. */ +function located(entry: { file?: string | null; line?: number | null }): { file?: string; line?: number } { + return entry.file != null && entry.line != null ? { file: entry.file, line: entry.line } : {}; +} + function allocation(site: { file?: string | null; line?: number | null; size_bytes: number; count: number; type_name?: string | null }): Record { - // [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 ?? "" }; + return { ...located(site), size: site.size_bytes, count: site.count, typeName: site.type_name ?? "" }; } function snapshotFields(snapshot: HeapSnapshot, timeline: TimelinePoint[], heapProfilePath: string | null | undefined): Record {