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
2 changes: 1 addition & 1 deletion coverage-thresholds.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"threshold": 96
},
"vsix": {
"threshold": 84
"threshold": 86
},
"nvim": {
"threshold": 39
Expand Down
7 changes: 7 additions & 0 deletions crates/basilisk-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ pub mod commands {
pub const MEMORY_OBJECTS_BY_TYPE: &str = "basilisk.memory.objectsByType";
/// Force garbage collection and report what was collected.
pub const MEMORY_GC_COLLECT: &str = "basilisk.memory.gcCollect";
/// Ingest the raw output of a memory injection script run by the editor in
/// the active debug session. The marker in the output (`__BASILISK_MEM__*`)
/// selects the parser; the LSP updates session state, publishes memory
/// diagnostics, and returns the structured result. This is the second leg
/// of the editor-as-courier round-trip (the LSP holds no DAP connection).
pub const MEMORY_INGEST: &str = "basilisk.memory.ingest";

/// Command names advertised via `executeCommandProvider` capabilities.
///
Expand Down Expand Up @@ -126,6 +132,7 @@ pub mod commands {
MEMORY_REFERENCES,
MEMORY_OBJECTS_BY_TYPE,
MEMORY_GC_COLLECT,
MEMORY_INGEST,
];
}

Expand Down
232 changes: 232 additions & 0 deletions crates/basilisk-lsp/src/profiler/cpuprofile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
//! Implements [LSPPROF]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-SPEEDSCOPE
//!
//! Export aggregated CPU samples as a V8 `.cpuprofile` (the Chrome `DevTools`
//! `Profiler.Profile` schema) so VS Code's built-in profile viewer renders it
//! natively (flame chart, bottom-up + left-heavy tables). Same UI as Node.js CPU
//! profiles. See <https://code.visualstudio.com/docs/nodejs/profiling>.
//!
//! All threads are merged into one timeline (Python's GIL serializes execution),
//! producing a single call tree of `nodes` plus the `samples`/`timeDeltas`
//! arrays the viewer needs.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use serde_json::{json, Value};
use tracing::info;

use super::aggregator::{ProfileData, SpeedscopeFrame};

/// One call-tree node while building the profile (`id` = index + 1).
struct CpuNode {
/// Index into `ProfileData::frames`, or `None` for the synthetic root.
frame: Option<usize>,
/// Samples whose leaf is this node.
hit_count: u64,
/// Child node indices.
children: Vec<usize>,
}

/// Build a V8 `Profiler.Profile` (`.cpuprofile`) value from aggregated data.
///
/// `sample_rate` (Hz) yields the per-sample interval as **integer**
/// microseconds (`1_000_000 / rate`), avoiding any float→int cast.
#[must_use]
pub fn build_cpuprofile(data: &ProfileData, sample_rate: u64) -> Value {
// Index 0 is the synthetic root.
let mut nodes = vec![CpuNode {
frame: None,
hit_count: 0,
children: Vec::new(),
}];
let mut child_of: HashMap<(usize, usize), usize> = HashMap::new();
let mut samples: Vec<usize> = Vec::new();
let mut time_deltas: Vec<i64> = Vec::new();
let micros = i64::try_from(1_000_000_u64 / sample_rate.max(1)).unwrap_or(0);

let mut thread_ids: Vec<u64> = data.thread_stacks.keys().copied().collect();
thread_ids.sort_unstable();

for tid in thread_ids {
let Some(stacks) = data.thread_stacks.get(&tid) else {
continue;
};
for stack in stacks {
// Walk root → leaf (stacks are stored root-first), creating nodes.
let mut current = 0usize;
for &frame_idx in stack {
current = if let Some(&existing) = child_of.get(&(current, frame_idx)) {
existing
} else {
let new_idx = nodes.len();
nodes.push(CpuNode {
frame: Some(frame_idx),
hit_count: 0,
children: Vec::new(),
});
if let Some(parent) = nodes.get_mut(current) {
parent.children.push(new_idx);
}
let _ = child_of.insert((current, frame_idx), new_idx);
new_idx
};
}
if let Some(leaf) = nodes.get_mut(current) {
leaf.hit_count += 1;
}
samples.push(current + 1);
time_deltas.push(micros);
}
}

let nodes_json: Vec<Value> = nodes
.iter()
.enumerate()
.map(|(idx, node)| node_to_json(idx, node, &data.frames))
.collect();

json!({
"nodes": nodes_json,
"startTime": 0,
"endTime": time_deltas.iter().sum::<i64>(),
"samples": samples,
"timeDeltas": time_deltas,
})
}

/// Export `ProfileData` to a `.cpuprofile` file in `output_dir`; returns the path.
///
/// # Errors
///
/// Returns an error string if serialization or the file write fails.
pub fn export_cpuprofile(
data: &ProfileData,
session_id: &str,
sample_rate: u64,
output_dir: &Path,
) -> Result<PathBuf, String> {
let profile = build_cpuprofile(data, sample_rate);
let json = serde_json::to_string(&profile)
.map_err(|err| format!("Failed to serialize cpuprofile: {err}"))?;
let path = output_dir.join(format!("basilisk-{session_id}.cpuprofile"));
std::fs::write(&path, json)
.map_err(|err| format!("Failed to write cpuprofile {}: {err}", path.display()))?;
info!(path = %path.display(), "exported cpuprofile");
Ok(path)
}

/// Serialize one node to the `ProfileNode` shape.
fn node_to_json(index: usize, node: &CpuNode, frames: &[SpeedscopeFrame]) -> Value {
json!({
"id": index + 1,
"callFrame": call_frame(node.frame, frames),
"hitCount": node.hit_count,
"children": node.children.iter().map(|&c| c + 1).collect::<Vec<_>>(),
})
}

/// Build a `Runtime.CallFrame` for a node (root or a real frame; lines 0-based).
fn call_frame(frame: Option<usize>, frames: &[SpeedscopeFrame]) -> Value {
match frame.and_then(|idx| frames.get(idx)) {
None => json!({
"functionName": "(root)",
"scriptId": "0",
"url": "",
"lineNumber": -1,
"columnNumber": -1,
}),
Some(frame) => json!({
"functionName": frame.name,
"scriptId": "0",
"url": frame.file,
"lineNumber": (frame.line - 1).max(0),
"columnNumber": 0,
}),
}
}

#[cfg(test)]
mod tests {
use super::*;

fn frame(name: &str, file: &str, line: i32) -> SpeedscopeFrame {
SpeedscopeFrame {
name: name.to_owned(),
file: file.to_owned(),
line,
}
}

#[test]
fn cpuprofile_matches_v8_schema() -> Result<(), String> {
// Two samples, both the single-frame stack [frame 0], at 100 Hz.
let data = ProfileData {
frames: vec![frame("hot_function", "/tmp/app.py", 19)],
thread_stacks: HashMap::from([(1_u64, vec![vec![0], vec![0]])]),
thread_weights: HashMap::from([(1_u64, vec![0.01, 0.01])]),
..ProfileData::default()
};

let profile = build_cpuprofile(&data, 100);

let nodes = profile
.get("nodes")
.and_then(Value::as_array)
.ok_or("missing nodes")?;
assert_eq!(nodes.len(), 2, "root + one frame node");

let leaf = nodes.get(1).ok_or("missing leaf node")?;
assert_eq!(leaf.get("hitCount").and_then(Value::as_u64), Some(2));
let call = leaf.get("callFrame").ok_or("missing callFrame")?;
assert_eq!(
call.get("functionName").and_then(Value::as_str),
Some("hot_function")
);
assert_eq!(call.get("url").and_then(Value::as_str), Some("/tmp/app.py"));
assert_eq!(call.get("lineNumber").and_then(Value::as_i64), Some(18));

// The root references the leaf as a child.
let root_children = nodes
.first()
.and_then(|root| root.get("children"))
.and_then(Value::as_array)
.ok_or("missing root children")?;
assert_eq!(root_children.first().and_then(Value::as_u64), Some(2));

// Samples point at the leaf (id 2); 10 ms per sample at 100 Hz.
assert_eq!(
profile.get("samples").and_then(Value::as_array),
Some(&vec![json!(2), json!(2)])
);
assert_eq!(
profile.get("timeDeltas").and_then(Value::as_array),
Some(&vec![json!(10_000), json!(10_000)])
);
assert_eq!(profile.get("endTime").and_then(Value::as_i64), Some(20_000));
Ok(())
}

#[test]
fn export_writes_a_valid_cpuprofile_file() -> Result<(), String> {
let data = ProfileData {
frames: vec![frame("f", "/tmp/a.py", 1)],
thread_stacks: HashMap::from([(1_u64, vec![vec![0]])]),
thread_weights: HashMap::from([(1_u64, vec![0.01])]),
..ProfileData::default()
};
let path = export_cpuprofile(&data, "basilisk-unit-test", 100, &std::env::temp_dir())?;
assert!(
path.extension().is_some_and(|ext| ext == "cpuprofile"),
"path: {}",
path.display()
);
let contents = std::fs::read_to_string(&path).map_err(|err| err.to_string())?;
let parsed: Value = serde_json::from_str(&contents).map_err(|err| err.to_string())?;
assert!(parsed
.get("nodes")
.and_then(Value::as_array)
.is_some_and(|nodes| nodes.len() == 2));
let _ = std::fs::remove_file(&path);
Ok(())
}
}
19 changes: 12 additions & 7 deletions crates/basilisk-lsp/src/profiler/memory/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,18 @@ pub fn generate_allocation_diagnostics(
/// Feeds growths through the `LeakTracker` for confidence scoring:
/// - `BSK-MEM-GROWTH` warnings for all growing allocations
/// - `BSK-MEM-LEAK` for suspected leaks (Medium+ confidence)
///
/// Returns the scored leaks alongside the diagnostics so a caller can both
/// surface the structured leaks (e.g. in a response) and publish the
/// diagnostics from a SINGLE scoring pass — scoring twice would double-count
/// consecutive growths and corrupt confidence.
#[must_use]
pub fn generate_diff_diagnostics(
diff: &MemoryDiff,
leak_tracker: &mut LeakTracker,
) -> DiagnosticsByUri {
let mut result: DiagnosticsByUri = HashMap::new();
) -> (Vec<SuspectedLeak>, DiagnosticsByUri) {
let suspected = leak_tracker.process_growths(&diff.grown_allocations);
let mut result: DiagnosticsByUri = HashMap::new();

for leak in &suspected {
let Ok(uri) = Url::from_file_path(&leak.file) else {
Expand All @@ -108,7 +113,7 @@ pub fn generate_diff_diagnostics(
"generated memory diff diagnostics"
);

result
(suspected, result)
}

/// Parsed uncollectable object from gc collect output.
Expand Down Expand Up @@ -503,7 +508,7 @@ mod tests {
fn diff_diagnostics_with_large_growth() -> Result<(), String> {
let diff = make_diff();
let mut tracker = LeakTracker::new();
let diags = generate_diff_diagnostics(&diff, &mut tracker);
let (_, diags) = generate_diff_diagnostics(&diff, &mut tracker);

let uri = Url::from_file_path("/tmp/cache.py").map_err(|()| "bad URI")?;
let file_diags = diags
Expand Down Expand Up @@ -541,7 +546,7 @@ mod tests {

let _ = generate_diff_diagnostics(&diff, &mut tracker);
let _ = generate_diff_diagnostics(&diff, &mut tracker);
let diags = generate_diff_diagnostics(&diff, &mut tracker);
let (_, diags) = generate_diff_diagnostics(&diff, &mut tracker);

let uri = Url::from_file_path("/tmp/cache.py").map_err(|()| "bad URI")?;
let file_diags = diags.get(&uri).ok_or("expected diagnostics")?;
Expand Down Expand Up @@ -579,7 +584,7 @@ mod tests {
};

let mut tracker = LeakTracker::new();
let diags = generate_diff_diagnostics(&diff, &mut tracker);
let (_, diags) = generate_diff_diagnostics(&diff, &mut tracker);

let uri = Url::from_file_path("/tmp/small.py").map_err(|()| "bad URI")?;
let file_diags = diags.get(&uri).ok_or("expected diagnostics")?;
Expand Down Expand Up @@ -738,7 +743,7 @@ mod tests {
freed_allocations: vec![],
};
let mut tracker = LeakTracker::new();
let diags = generate_diff_diagnostics(&diff, &mut tracker);
let (_, diags) = generate_diff_diagnostics(&diff, &mut tracker);
assert!(diags.is_empty());
}
}
Loading
Loading