diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index 440c6aa..52b30ef 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -550,6 +550,7 @@ or removing a command fails until the table changes in the same commit. | `processLifecycle.resolve` | Decide how each process or resource of one Lithe instance ends | | `processLifecycle.inventory` | Group a process snapshot of one Lithe instance into terminals, Agent tasks and background services | | `processLifecycle.settle` | Turn confirmation answers into what ends, what is kept and the updated settings | +| `processMemory.summarize` | Sum a process table's memory by part of one Lithe instance: the host, language servers, plugin services, system helpers and other child processes | | `markdown.render` | Renders and sanitizes shared Markdown | | `markdown.outline` | Outlines the headings of shared Markdown as a document-symbol tree | | `markdown.resolveReference` | Decides what a link or an image of a rendered Markdown document points at | @@ -5079,6 +5080,45 @@ scope-token ownership. Fixtures live in Fixtures live in `rust/fixtures/process-lifecycle/settle-v1.json`. +`processMemory.summarize` accepts `{ hostProcessId, processes, roles, entryLimit }` +and attributes every process the host could read to one part of the instance, +so every host shows the same numbers for the same table. Each process row is +`{ processId, parentProcessId, responsibleProcessId?, startTime?, name, bytes }`. +`bytes` is the memory the host charges to the process; every row of one request +uses the same measure. `responsibleProcessId` is the process the operating system +holds responsible, which macOS reports for XPC services. `startTime` is in +microseconds since 1970; a reported parent that started after its child is a +reused process ID and is not followed. `roles` are +`{ processId, kind: "languageServer" | "pluginService", name }` for the processes +the host started for that role; a role naming the host is ignored, and the first +role listed for a process ID is used. `entryLimit` defaults to 20 and is capped +at 200. + +Every process counts at most once: + +- The host is the `host` part. +- A process whose parent chain reaches a role process before the host belongs to + that role's part. A language server a plugin service started is therefore + counted as a language server, with the processes below it. +- A process whose parent chain reaches the host first is a `childProcess`, + grouped under the host's direct child it descends from. +- A process outside the host's tree whose `responsibleProcessId` is the host, + such as a WebKit process of a web view, is a `systemHelper`, grouped under the + topmost such process. When the host was started from a terminal, macOS holds + the terminal responsible, so these helpers are not the host's. +- Other processes are left out. The processes of a cycle of parent links are + decided as if none of them had a parent. + +It returns `{ totalBytes, categories, entries, omitted }`. `categories` lists +`host`, `languageServer`, `pluginService`, `systemHelper` and `childProcess` in +that order, each `{ kind, bytes, processCount }`, empty ones included. `entries` +are the groups other than the host, `{ kind, name, processId, bytes, +processCount }`, largest first, with ties ordered by part, name and process ID. +`name` is the role name, else the process name, shortened to 80 characters. +`omitted` is `{ count, bytes }` of the groups past the limit. A table without the +host or with a repeated process ID is `invalid_request`. Fixtures live in +`rust/fixtures/process-lifecycle/memory-summary-v1.json`. + ## Local IDE capability broker `ideHost.control` accepts `{action, arguments}` and delegates to the native diff --git a/rust/crates/core/src/protocol/command.rs b/rust/crates/core/src/protocol/command.rs index 3774c2b..784fcf3 100644 --- a/rust/crates/core/src/protocol/command.rs +++ b/rust/crates/core/src/protocol/command.rs @@ -124,6 +124,8 @@ pub enum CoreCommand { ProcessLifecycleInventory, /// Turns a plan and the user's answers into what ends (`processLifecycle.settle`). ProcessLifecycleSettle, + /// Sums a process table's memory by part of the instance (`processMemory.summarize`). + ProcessMemorySummarize, /// Renders and sanitizes shared Markdown (`markdown.render`). MarkdownRender, /// Outlines the headings of shared Markdown as document symbols (`markdown.outline`). @@ -1193,6 +1195,7 @@ impl CoreCommand { "processLifecycle.resolve" => Some(Self::ProcessLifecycleResolve), "processLifecycle.inventory" => Some(Self::ProcessLifecycleInventory), "processLifecycle.settle" => Some(Self::ProcessLifecycleSettle), + "processMemory.summarize" => Some(Self::ProcessMemorySummarize), "markdown.render" => Some(Self::MarkdownRender), "markdown.outline" => Some(Self::MarkdownOutline), "markdown.resolveReference" => Some(Self::MarkdownResolveReference), @@ -1623,6 +1626,10 @@ mod tests { CoreCommand::parse("processLifecycle.settle"), Some(CoreCommand::ProcessLifecycleSettle) )); + assert!(matches!( + CoreCommand::parse("processMemory.summarize"), + Some(CoreCommand::ProcessMemorySummarize) + )); } #[test] diff --git a/rust/crates/core/src/runtime/dispatcher.rs b/rust/crates/core/src/runtime/dispatcher.rs index 971d6de..1204443 100644 --- a/rust/crates/core/src/runtime/dispatcher.rs +++ b/rust/crates/core/src/runtime/dispatcher.rs @@ -425,6 +425,23 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon ), } } + CoreCommand::ProcessMemorySummarize => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| error.to_string()) + .and_then(|request| { + lithe_process::memory::summarize(&request).map_err(|error| error.to_string()) + }) { + Ok(summary) => CoreResponse::success( + id, + serde_json::to_value(summary).expect("process memory summary should encode"), + ), + Err(details) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid process memory request") + .with_details(details), + ), + } + } CoreCommand::Editor(command) => editor::dispatch(command, id, parsed.payload, document), CoreCommand::WorkbenchChromeLayoutReduce => { rules_response(id, "workbench.chromeLayout.reduce", parsed.payload) diff --git a/rust/crates/core/src/runtime/request_trace.rs b/rust/crates/core/src/runtime/request_trace.rs index af7440f..9fe5e3c 100644 --- a/rust/crates/core/src/runtime/request_trace.rs +++ b/rust/crates/core/src/runtime/request_trace.rs @@ -195,6 +195,8 @@ pub(crate) fn is_hot(method: &str) -> bool { method, "workspace.filterChanges" | "lsp.workspaceFilesChanged" | "git.classifyMetadataChanges" ) + // The status bar sums the instance's memory every few seconds while it is shown. + || method == "processMemory.summarize" } /// Explicit host provenance wins; Git provenance covers hosts that send only that. @@ -270,6 +272,7 @@ mod tests { "workspace.classifyVisibility", "git.classifyMetadataChanges", "debug.transport.handle", + "processMemory.summarize", ] { assert!(is_hot(method), "{method}"); } diff --git a/rust/crates/core/src/tests/mod.rs b/rust/crates/core/src/tests/mod.rs index 5f7bb6a..6c17ae0 100644 --- a/rust/crates/core/src/tests/mod.rs +++ b/rust/crates/core/src/tests/mod.rs @@ -45,6 +45,7 @@ mod marketplace_plugins; mod plugin_editor_resources; mod plugin_run_debug; mod plugin_settings_documents; +mod process_memory; mod project; mod project_generators; mod project_tree; diff --git a/rust/crates/core/src/tests/process_memory.rs b/rust/crates/core/src/tests/process_memory.rs new file mode 100644 index 0000000..f27b7a4 --- /dev/null +++ b/rust/crates/core/src/tests/process_memory.rs @@ -0,0 +1,33 @@ +//! `processMemory.summarize` against the shared memory summary fixture. + +use serde_json::{json, Value}; + +fn run(request: &Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"command": "processMemory.summarize", "payload": request}).to_string(), + )) + .unwrap() +} + +#[test] +fn shared_memory_summary_fixture_runs_every_case() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/process-lifecycle/memory-summary-v1.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = run(&case["request"]); + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "case {}", case["name"]); + } + for case in fixture["invalid"].as_array().unwrap() { + let response = run(&case["request"]); + assert_eq!(response["ok"], false, "case {}: {response}", case["name"]); + assert_eq!( + response["error"]["code"], "invalid_request", + "case {}", + case["name"] + ); + } +} diff --git a/rust/crates/process/Cargo.toml b/rust/crates/process/Cargo.toml index e077134..f24fbc4 100644 --- a/rust/crates/process/Cargo.toml +++ b/rust/crates/process/Cargo.toml @@ -3,7 +3,7 @@ name = "lithe-process" version = "0.1.0" edition = "2021" license = "Apache-2.0" -description = "Child-process lifecycle and inventory decisions shared by every Lithe host" +description = "Child-process lifecycle, inventory and memory attribution decisions shared by every Lithe host" [dependencies] serde = { version = "1.0", features = ["derive"] } diff --git a/rust/crates/process/src/lib.rs b/rust/crates/process/src/lib.rs index cdf7c38..c2dc343 100644 --- a/rust/crates/process/src/lib.rs +++ b/rust/crates/process/src/lib.rs @@ -1,5 +1,6 @@ -//! Child-process lifecycle decisions and inventory classification shared by -//! the platform process supervisors. +//! Child-process lifecycle decisions, inventory classification and memory +//! attribution shared by the platform process supervisors. pub mod inventory; pub mod lifecycle; +pub mod memory; diff --git a/rust/crates/process/src/memory.rs b/rust/crates/process/src/memory.rs new file mode 100644 index 0000000..640bee4 --- /dev/null +++ b/rust/crates/process/src/memory.rs @@ -0,0 +1,589 @@ +//! Attribution of a process table to the parts of one Lithe instance, for the +//! memory readout of the status bar. +//! +//! Hosts read every process's parent, start time, responsible process and +//! memory, and name the processes they started for a role: language servers +//! and plugin services. Which process belongs to which part, and the sums, are +//! decided here so every host shows the same numbers for the same table: +//! +//! - the host process is a part of its own; +//! - a process whose parent chain reaches a role process first belongs to that +//! role, so a language server that a plugin service started counts once, as a +//! language server, together with everything below it; +//! - a process whose parent chain reaches the host first is a child process, +//! grouped under the host's direct child it descends from; +//! - a process outside the host's tree is a system helper when the operating +//! system holds the host responsible for it, as macOS does for the WebKit +//! processes of a web view, grouped under the topmost such process; +//! - every other process is not the instance's and is left out. +//! +//! Each process counts once. Fixtures live in +//! `rust/fixtures/process-lifecycle/memory-summary-v1.json`. + +use std::collections::HashMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// Entries returned when the request does not give a limit. +const DEFAULT_ENTRY_LIMIT: usize = 20; +/// Most entries one summary returns, whatever the request asks. +const MAX_ENTRY_LIMIT: usize = 200; +/// Longest entry name before it is shortened with an ellipsis. +const NAME_CHARACTERS: usize = 80; + +/// One row of the host's process table. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryProcess { + /// Operating-system process ID. + pub process_id: i64, + /// Parent process ID as the operating system reports it. + pub parent_process_id: i64, + /// Process the operating system holds responsible for this one, on systems + /// that have that notion. + #[serde(default)] + pub responsible_process_id: Option, + /// Start time in microseconds since 1970. When a process and its reported + /// parent both have one and the parent started later, the parent ID was + /// reused and the link is ignored. + #[serde(default)] + pub start_time: Option, + /// Short process name. + #[serde(default)] + pub name: String, + /// Memory charged to the process, in bytes. Every row uses the same measure. + pub bytes: u64, +} + +/// What the host started a process for. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum MemoryRoleKind { + /// A language server of an open project. + LanguageServer, + /// The backend process of a plugin. + PluginService, +} + +/// A process the host started for a role. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryRole { + /// Operating-system process ID of the role's process. + pub process_id: i64, + /// What the process was started for. + pub kind: MemoryRoleKind, + /// Name shown for the role; empty shows the process name. + #[serde(default)] + pub name: String, +} + +/// Request of `processMemory.summarize`. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryRequest { + /// Process ID of the host itself; it must be one of `processes`. + pub host_process_id: i64, + /// Every process the host could read, in any order. + pub processes: Vec, + /// Processes the host started for a role. A role naming the host is + /// ignored, and the first role listed for a process ID is the one used. + #[serde(default)] + pub roles: Vec, + /// Most entries to return; defaults to 20 and is capped at 200. + #[serde(default)] + pub entry_limit: Option, +} + +/// Part of the instance a process's memory counts in, in the order the +/// summary lists them. +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub enum MemoryKind { + /// The host process. + Host, + /// Language servers and the processes below them. + LanguageServer, + /// Plugin services and the processes below them. + PluginService, + /// Processes the operating system started on the host's behalf outside + /// its process tree. + SystemHelper, + /// Other processes of the host's tree, such as terminal shells and tools. + ChildProcess, +} + +impl MemoryKind { + const ALL: [MemoryKind; 5] = [ + MemoryKind::Host, + MemoryKind::LanguageServer, + MemoryKind::PluginService, + MemoryKind::SystemHelper, + MemoryKind::ChildProcess, + ]; +} + +impl From for MemoryKind { + fn from(kind: MemoryRoleKind) -> Self { + match kind { + MemoryRoleKind::LanguageServer => MemoryKind::LanguageServer, + MemoryRoleKind::PluginService => MemoryKind::PluginService, + } + } +} + +/// Sum of one part. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MemoryCategory { + /// The part. + pub kind: MemoryKind, + /// Bytes of every process in the part. + pub bytes: u64, + /// Number of processes in the part. + pub process_count: usize, +} + +/// One process of the instance together with the processes counted under it. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MemoryEntry { + /// Part the entry counts in; never `host`. + pub kind: MemoryKind, + /// Role name, else the process name, shortened to one row. + pub name: String, + /// Process ID of the entry's top process. + pub process_id: i64, + /// Bytes of the top process and the processes below it. + pub bytes: u64, + /// Number of processes in the entry, the top one included. + pub process_count: usize, +} + +/// Entries past the limit. +#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct OmittedEntries { + /// Number of entries left out. + pub count: usize, + /// Bytes of the entries left out. + pub bytes: u64, +} + +/// Response of `processMemory.summarize`. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MemorySummary { + /// Bytes of every process of the instance. + pub total_bytes: u64, + /// Every part, in the order of [`MemoryKind`], empty ones included. + pub categories: Vec, + /// Entries other than the host, largest first; ties go by part, then + /// name, then process ID. + pub entries: Vec, + /// Entries past the limit. + pub omitted: OmittedEntries, +} + +/// Why a process table cannot be summarized. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MemoryError { + /// The host process is not in the table. + HostMissing(i64), + /// Two rows have the same process ID. + DuplicateProcess(i64), +} + +impl fmt::Display for MemoryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MemoryError::HostMissing(process_id) => { + write!(formatter, "host process {process_id} is not in the table") + } + MemoryError::DuplicateProcess(process_id) => { + write!(formatter, "process {process_id} appears more than once") + } + } + } +} + +impl std::error::Error for MemoryError {} + +/// Where one process counts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Owner { + /// Not the instance's. + Unowned, + /// The host process. + Host, + /// Counted in `kind`, under the process at position `root`. + Part { kind: MemoryKind, root: usize }, +} + +/// Sums the memory of every process of the host's instance by part and by +/// top process (`processMemory.summarize`). +pub fn summarize(request: &MemoryRequest) -> Result { + let processes = &request.processes; + let mut positions = HashMap::with_capacity(processes.len()); + for (position, process) in processes.iter().enumerate() { + if positions.insert(process.process_id, position).is_some() { + return Err(MemoryError::DuplicateProcess(process.process_id)); + } + } + let host = request.host_process_id; + if !positions.contains_key(&host) { + return Err(MemoryError::HostMissing(host)); + } + let mut roles: HashMap = HashMap::new(); + for role in &request.roles { + if role.process_id != host { + roles.entry(role.process_id).or_insert(role); + } + } + let owners = Attribution { + processes, + positions: &positions, + roles: &roles, + host, + } + .owners(); + + let mut categories = MemoryKind::ALL.map(|kind| MemoryCategory { + kind, + bytes: 0, + process_count: 0, + }); + let mut groups: HashMap = HashMap::new(); + let mut total_bytes = 0u64; + for (process, owner) in processes.iter().zip(&owners) { + let kind = match *owner { + Owner::Unowned => continue, + Owner::Host => MemoryKind::Host, + Owner::Part { kind, root } => { + let entry = groups.entry(root).or_insert_with(|| MemoryEntry { + kind, + name: entry_name(&processes[root], roles.get(&processes[root].process_id)), + process_id: processes[root].process_id, + bytes: 0, + process_count: 0, + }); + entry.bytes = entry.bytes.saturating_add(process.bytes); + entry.process_count += 1; + kind + } + }; + let category = &mut categories[kind as usize]; + category.bytes = category.bytes.saturating_add(process.bytes); + category.process_count += 1; + total_bytes = total_bytes.saturating_add(process.bytes); + } + + let mut entries: Vec = groups.into_values().collect(); + entries.sort_by(|a, b| { + b.bytes + .cmp(&a.bytes) + .then(a.kind.cmp(&b.kind)) + .then_with(|| a.name.cmp(&b.name)) + .then(a.process_id.cmp(&b.process_id)) + }); + let limit = request + .entry_limit + .unwrap_or(DEFAULT_ENTRY_LIMIT) + .min(MAX_ENTRY_LIMIT) + .min(entries.len()); + let rest = entries.split_off(limit); + let omitted = OmittedEntries { + count: rest.len(), + bytes: rest + .iter() + .fold(0u64, |sum, entry| sum.saturating_add(entry.bytes)), + }; + Ok(MemorySummary { + total_bytes, + categories: categories.to_vec(), + entries, + omitted, + }) +} + +/// The role name, else the process name, short enough for one row. +fn entry_name(process: &MemoryProcess, role: Option<&&MemoryRole>) -> String { + let name = role + .map(|role| role.name.as_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(&process.name); + if name.chars().count() > NAME_CHARACTERS { + let shortened: String = name.chars().take(NAME_CHARACTERS - 1).collect(); + format!("{shortened}…") + } else { + name.to_string() + } +} + +/// The table and the facts that decide where each process counts. +struct Attribution<'a> { + processes: &'a [MemoryProcess], + positions: &'a HashMap, + roles: &'a HashMap, + host: i64, +} + +impl Attribution<'_> { + /// Where every process counts, by position. + /// + /// Each walk climbs parent links from a process until it reaches a + /// process already decided, the host, a role, or a process without a + /// usable parent, then decides the climbed processes from the top down. + /// A walk that comes back to a process it climbed has found a cycle of + /// parent links, which only reused process IDs without start times can + /// produce; the processes of the cycle are decided as if none had a + /// parent, so the result does not depend on where the walk started. + fn owners(&self) -> Vec { + let count = self.processes.len(); + let mut owners: Vec> = vec![None; count]; + let mut climbing = vec![false; count]; + let mut path = Vec::new(); + for start in 0..count { + if owners[start].is_some() { + continue; + } + path.clear(); + let mut current = start; + let mut above = Owner::Unowned; + loop { + if let Some(owner) = owners[current] { + above = owner; + break; + } + if climbing[current] { + let at = path + .iter() + .position(|&position| position == current) + .expect("a climbed process is on the path"); + for &member in &path[at..] { + owners[member] = Some(self.owner(member, Owner::Unowned)); + climbing[member] = false; + } + path.truncate(at); + above = owners[current].expect("cycle members are decided"); + break; + } + climbing[current] = true; + path.push(current); + if self.decides_itself(current) { + break; + } + match self.parent(current) { + Some(parent) => current = parent, + None => break, + } + } + for &member in path.iter().rev() { + above = self.owner(member, above); + owners[member] = Some(above); + climbing[member] = false; + } + } + owners + .into_iter() + .map(|owner| owner.expect("every process is decided")) + .collect() + } + + /// Whether the process at `position` is the host or a role, whose place + /// does not depend on its parent. + fn decides_itself(&self, position: usize) -> bool { + let process_id = self.processes[position].process_id; + process_id == self.host || self.roles.contains_key(&process_id) + } + + /// Where the process at `position` counts, given where its parent counts. + fn owner(&self, position: usize, above: Owner) -> Owner { + let process = &self.processes[position]; + if process.process_id == self.host { + return Owner::Host; + } + if let Some(role) = self.roles.get(&process.process_id) { + return Owner::Part { + kind: role.kind.into(), + root: position, + }; + } + match above { + Owner::Host => Owner::Part { + kind: MemoryKind::ChildProcess, + root: position, + }, + Owner::Part { .. } => above, + Owner::Unowned if process.responsible_process_id == Some(self.host) => Owner::Part { + kind: MemoryKind::SystemHelper, + root: position, + }, + Owner::Unowned => Owner::Unowned, + } + } + + /// Position of the parent of the process at `position`: none when the + /// parent is not in the table, is the process itself, or started after it. + fn parent(&self, position: usize) -> Option { + let process = &self.processes[position]; + let &parent = self.positions.get(&process.parent_process_id)?; + if parent == position { + return None; + } + if let (Some(child_start), Some(parent_start)) = + (process.start_time, self.processes[parent].start_time) + { + if parent_start > child_start { + return None; + } + } + Some(parent) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Fixture { + schema_version: u32, + cases: Vec, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct FixtureCase { + name: String, + request: serde_json::Value, + expected: serde_json::Value, + } + + fn process(process_id: i64, parent_process_id: i64, bytes: u64) -> MemoryProcess { + MemoryProcess { + process_id, + parent_process_id, + responsible_process_id: None, + start_time: None, + name: format!("p{process_id}"), + bytes, + } + } + + // Every host replays this file, so a rule change must update it. + #[test] + fn shared_fixture_summaries_match_in_any_table_order() { + let fixture: Fixture = serde_json::from_str(include_str!( + "../../../fixtures/process-lifecycle/memory-summary-v1.json" + )) + .expect("fixture should parse"); + assert_eq!(fixture.schema_version, 1); + for case in fixture.cases { + let mut request: MemoryRequest = + serde_json::from_value(case.request).expect("fixture request should decode"); + for ordering in ["original", "reversed", "rotated"] { + match ordering { + "reversed" => request.processes.reverse(), + "rotated" if !request.processes.is_empty() => request.processes.rotate_left(1), + _ => {} + } + let summary = summarize(&request).expect("fixture case should summarize"); + assert_eq!( + serde_json::to_value(summary).expect("summary should encode"), + case.expected, + "fixture case {} ({ordering} table)", + case.name + ); + } + } + } + + #[test] + fn a_table_without_the_host_or_with_a_repeated_process_is_rejected() { + let missing = MemoryRequest { + host_process_id: 10, + processes: vec![process(11, 1, 5)], + roles: Vec::new(), + entry_limit: None, + }; + assert_eq!(summarize(&missing), Err(MemoryError::HostMissing(10))); + let repeated = MemoryRequest { + host_process_id: 10, + processes: vec![process(10, 1, 5), process(11, 10, 5), process(11, 10, 6)], + roles: Vec::new(), + entry_limit: None, + }; + assert_eq!(summarize(&repeated), Err(MemoryError::DuplicateProcess(11))); + } + + #[test] + fn a_deep_chain_is_decided_without_recursion() { + let depth = 100_000; + let mut processes = vec![process(1, 0, 1)]; + processes.extend((2..=depth).map(|process_id| process(process_id, process_id - 1, 1))); + processes.reverse(); + let summary = summarize(&MemoryRequest { + host_process_id: 1, + processes, + roles: Vec::new(), + entry_limit: Some(1), + }) + .expect("chain should summarize"); + assert_eq!(summary.total_bytes, depth as u64); + assert_eq!(summary.entries.len(), 1); + assert_eq!(summary.entries[0].process_id, 2); + assert_eq!(summary.entries[0].process_count, depth as usize - 1); + } + + #[test] + fn sums_saturate_instead_of_overflowing() { + let summary = summarize(&MemoryRequest { + host_process_id: 1, + processes: vec![process(1, 0, u64::MAX), process(2, 1, 1)], + roles: Vec::new(), + entry_limit: None, + }) + .expect("table should summarize"); + assert_eq!(summary.total_bytes, u64::MAX); + } + + #[test] + fn long_names_are_shortened() { + let mut long = process(2, 1, 1); + long.name = "x".repeat(200); + let summary = summarize(&MemoryRequest { + host_process_id: 1, + processes: vec![process(1, 0, 1), long], + roles: Vec::new(), + entry_limit: None, + }) + .expect("table should summarize"); + let name = &summary.entries[0].name; + assert_eq!(name.chars().count(), NAME_CHARACTERS); + assert!(name.ends_with('…')); + } + + #[test] + fn a_larger_limit_is_capped() { + let mut processes = vec![process(1, 0, 1)]; + processes.extend((2..=251).map(|process_id| process(process_id, 1, 1))); + let summary = summarize(&MemoryRequest { + host_process_id: 1, + processes, + roles: Vec::new(), + entry_limit: Some(10_000), + }) + .expect("table should summarize"); + assert_eq!(summary.entries.len(), MAX_ENTRY_LIMIT); + assert_eq!( + summary.omitted, + OmittedEntries { + count: 50, + bytes: 50 + } + ); + } +} diff --git a/rust/fixtures/process-lifecycle/memory-summary-v1.json b/rust/fixtures/process-lifecycle/memory-summary-v1.json new file mode 100644 index 0000000..7f69d1c --- /dev/null +++ b/rust/fixtures/process-lifecycle/memory-summary-v1.json @@ -0,0 +1,845 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "name": "every part of an instance the system started", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 0, + "parentProcessId": 0, + "responsibleProcessId": 0, + "name": "kernel_task", + "bytes": 1000 + }, + { + "processId": 1, + "parentProcessId": 0, + "responsibleProcessId": 1, + "name": "launchd", + "bytes": 20 + }, + { + "processId": 100, + "parentProcessId": 1, + "responsibleProcessId": 100, + "name": "Lithe", + "bytes": 300 + }, + { + "processId": 200, + "parentProcessId": 100, + "responsibleProcessId": 100, + "name": "alpha-ls", + "bytes": 80 + }, + { + "processId": 201, + "parentProcessId": 200, + "responsibleProcessId": 100, + "name": "alpha-ls-worker", + "bytes": 10 + }, + { + "processId": 300, + "parentProcessId": 100, + "responsibleProcessId": 100, + "name": "beta-service", + "bytes": 50 + }, + { + "processId": 310, + "parentProcessId": 300, + "responsibleProcessId": 100, + "name": "beta-ls", + "bytes": 40 + }, + { + "processId": 311, + "parentProcessId": 310, + "responsibleProcessId": 100, + "name": "beta-ls-worker", + "bytes": 5 + }, + { + "processId": 320, + "parentProcessId": 300, + "responsibleProcessId": 100, + "name": "beta-helper", + "bytes": 7 + }, + { + "processId": 400, + "parentProcessId": 100, + "responsibleProcessId": 100, + "name": "zsh", + "bytes": 6 + }, + { + "processId": 401, + "parentProcessId": 400, + "responsibleProcessId": 100, + "name": "git", + "bytes": 4 + }, + { + "processId": 500, + "parentProcessId": 1, + "responsibleProcessId": 100, + "name": "com.apple.WebKit.WebContent", + "bytes": 60 + }, + { + "processId": 501, + "parentProcessId": 1, + "responsibleProcessId": 100, + "name": "com.apple.WebKit.GPU", + "bytes": 25 + }, + { + "processId": 600, + "parentProcessId": 1, + "responsibleProcessId": 600, + "name": "Finder", + "bytes": 90 + } + ], + "roles": [ + { + "processId": 200, + "kind": "languageServer", + "name": "Alpha Language" + }, + { + "processId": 300, + "kind": "pluginService", + "name": "Beta Service" + }, + { + "processId": 310, + "kind": "languageServer", + "name": "Beta Language" + } + ] + }, + "expected": { + "totalBytes": 587, + "categories": [ + { + "kind": "host", + "bytes": 300, + "processCount": 1 + }, + { + "kind": "languageServer", + "bytes": 135, + "processCount": 4 + }, + { + "kind": "pluginService", + "bytes": 57, + "processCount": 2 + }, + { + "kind": "systemHelper", + "bytes": 85, + "processCount": 2 + }, + { + "kind": "childProcess", + "bytes": 10, + "processCount": 2 + } + ], + "entries": [ + { + "kind": "languageServer", + "name": "Alpha Language", + "processId": 200, + "bytes": 90, + "processCount": 2 + }, + { + "kind": "systemHelper", + "name": "com.apple.WebKit.WebContent", + "processId": 500, + "bytes": 60, + "processCount": 1 + }, + { + "kind": "pluginService", + "name": "Beta Service", + "processId": 300, + "bytes": 57, + "processCount": 2 + }, + { + "kind": "languageServer", + "name": "Beta Language", + "processId": 310, + "bytes": 45, + "processCount": 2 + }, + { + "kind": "systemHelper", + "name": "com.apple.WebKit.GPU", + "processId": 501, + "bytes": 25, + "processCount": 1 + }, + { + "kind": "childProcess", + "name": "zsh", + "processId": 400, + "bytes": 10, + "processCount": 2 + } + ], + "omitted": { + "count": 0, + "bytes": 0 + } + } + }, + { + "name": "a host started from a terminal is not responsible for its helpers", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 1, + "parentProcessId": 0, + "responsibleProcessId": 1, + "name": "launchd", + "bytes": 20 + }, + { + "processId": 40, + "parentProcessId": 1, + "responsibleProcessId": 40, + "name": "Terminal", + "bytes": 100 + }, + { + "processId": 50, + "parentProcessId": 40, + "responsibleProcessId": 40, + "name": "zsh", + "bytes": 5 + }, + { + "processId": 100, + "parentProcessId": 50, + "responsibleProcessId": 40, + "name": "Lithe", + "bytes": 200 + }, + { + "processId": 101, + "parentProcessId": 100, + "responsibleProcessId": 40, + "name": "tool", + "bytes": 30 + }, + { + "processId": 500, + "parentProcessId": 1, + "responsibleProcessId": 40, + "name": "com.apple.WebKit.WebContent", + "bytes": 70 + } + ] + }, + "expected": { + "totalBytes": 230, + "categories": [ + { + "kind": "host", + "bytes": 200, + "processCount": 1 + }, + { + "kind": "languageServer", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "pluginService", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "systemHelper", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "childProcess", + "bytes": 30, + "processCount": 1 + } + ], + "entries": [ + { + "kind": "childProcess", + "name": "tool", + "processId": 101, + "bytes": 30, + "processCount": 1 + } + ], + "omitted": { + "count": 0, + "bytes": 0 + } + } + }, + { + "name": "a parent that started after its child is a reused process ID", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 1, + "parentProcessId": 0, + "startTime": 0, + "name": "init", + "bytes": 20 + }, + { + "processId": 100, + "parentProcessId": 1, + "startTime": 1000, + "name": "Lithe", + "bytes": 50 + }, + { + "processId": 700, + "parentProcessId": 100, + "startTime": 500, + "name": "p700", + "bytes": 40 + }, + { + "processId": 701, + "parentProcessId": 100, + "startTime": 2000, + "name": "p701", + "bytes": 8 + }, + { + "processId": 702, + "parentProcessId": 100, + "startTime": 1000, + "name": "p702", + "bytes": 2 + }, + { + "processId": 703, + "parentProcessId": 701, + "name": "p703", + "bytes": 1 + } + ] + }, + "expected": { + "totalBytes": 61, + "categories": [ + { + "kind": "host", + "bytes": 50, + "processCount": 1 + }, + { + "kind": "languageServer", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "pluginService", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "systemHelper", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "childProcess", + "bytes": 11, + "processCount": 3 + } + ], + "entries": [ + { + "kind": "childProcess", + "name": "p701", + "processId": 701, + "bytes": 9, + "processCount": 2 + }, + { + "kind": "childProcess", + "name": "p702", + "processId": 702, + "bytes": 2, + "processCount": 1 + } + ], + "omitted": { + "count": 0, + "bytes": 0 + } + } + }, + { + "name": "a cycle of parent links is decided as if it had no parent", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 100, + "parentProcessId": 1, + "responsibleProcessId": 100, + "name": "Lithe", + "bytes": 10 + }, + { + "processId": 800, + "parentProcessId": 801, + "responsibleProcessId": 100, + "name": "p800", + "bytes": 3 + }, + { + "processId": 801, + "parentProcessId": 800, + "name": "p801", + "bytes": 4 + }, + { + "processId": 802, + "parentProcessId": 801, + "name": "p802", + "bytes": 5 + }, + { + "processId": 803, + "parentProcessId": 800, + "name": "p803", + "bytes": 6 + } + ] + }, + "expected": { + "totalBytes": 19, + "categories": [ + { + "kind": "host", + "bytes": 10, + "processCount": 1 + }, + { + "kind": "languageServer", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "pluginService", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "systemHelper", + "bytes": 9, + "processCount": 2 + }, + { + "kind": "childProcess", + "bytes": 0, + "processCount": 0 + } + ], + "entries": [ + { + "kind": "systemHelper", + "name": "p800", + "processId": 800, + "bytes": 9, + "processCount": 2 + } + ], + "omitted": { + "count": 0, + "bytes": 0 + } + } + }, + { + "name": "roles naming the host, a repeated process or a gone process", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 100, + "parentProcessId": 1, + "name": "Lithe", + "bytes": 50 + }, + { + "processId": 200, + "parentProcessId": 100, + "name": "gamma-service", + "bytes": 20 + }, + { + "processId": 300, + "parentProcessId": 100, + "name": "delta-ls", + "bytes": 30 + } + ], + "roles": [ + { + "processId": 100, + "kind": "languageServer", + "name": "Host" + }, + { + "processId": 200, + "kind": "pluginService", + "name": "First" + }, + { + "processId": 200, + "kind": "languageServer", + "name": "Second" + }, + { + "processId": 999, + "kind": "languageServer", + "name": "Gone" + }, + { + "processId": 300, + "kind": "languageServer", + "name": "" + } + ] + }, + "expected": { + "totalBytes": 100, + "categories": [ + { + "kind": "host", + "bytes": 50, + "processCount": 1 + }, + { + "kind": "languageServer", + "bytes": 30, + "processCount": 1 + }, + { + "kind": "pluginService", + "bytes": 20, + "processCount": 1 + }, + { + "kind": "systemHelper", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "childProcess", + "bytes": 0, + "processCount": 0 + } + ], + "entries": [ + { + "kind": "languageServer", + "name": "delta-ls", + "processId": 300, + "bytes": 30, + "processCount": 1 + }, + { + "kind": "pluginService", + "name": "First", + "processId": 200, + "bytes": 20, + "processCount": 1 + } + ], + "omitted": { + "count": 0, + "bytes": 0 + } + } + }, + { + "name": "entries past the limit are summed, ties go by part, name and process ID", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 100, + "parentProcessId": 1, + "name": "Lithe", + "bytes": 1 + }, + { + "processId": 201, + "parentProcessId": 100, + "name": "b", + "bytes": 5 + }, + { + "processId": 202, + "parentProcessId": 100, + "name": "a", + "bytes": 5 + }, + { + "processId": 203, + "parentProcessId": 100, + "name": "a", + "bytes": 5 + }, + { + "processId": 204, + "parentProcessId": 100, + "name": "c", + "bytes": 3 + }, + { + "processId": 205, + "parentProcessId": 100, + "name": "d", + "bytes": 2 + }, + { + "processId": 206, + "parentProcessId": 100, + "name": "z", + "bytes": 5 + } + ], + "roles": [ + { + "processId": 206, + "kind": "languageServer", + "name": "z" + } + ], + "entryLimit": 3 + }, + "expected": { + "totalBytes": 26, + "categories": [ + { + "kind": "host", + "bytes": 1, + "processCount": 1 + }, + { + "kind": "languageServer", + "bytes": 5, + "processCount": 1 + }, + { + "kind": "pluginService", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "systemHelper", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "childProcess", + "bytes": 20, + "processCount": 5 + } + ], + "entries": [ + { + "kind": "languageServer", + "name": "z", + "processId": 206, + "bytes": 5, + "processCount": 1 + }, + { + "kind": "childProcess", + "name": "a", + "processId": 202, + "bytes": 5, + "processCount": 1 + }, + { + "kind": "childProcess", + "name": "a", + "processId": 203, + "bytes": 5, + "processCount": 1 + } + ], + "omitted": { + "count": 3, + "bytes": 10 + } + } + }, + { + "name": "a limit of zero returns only the sums", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 100, + "parentProcessId": 1, + "name": "Lithe", + "bytes": 7 + }, + { + "processId": 200, + "parentProcessId": 100, + "name": "tool", + "bytes": 3 + } + ], + "entryLimit": 0 + }, + "expected": { + "totalBytes": 10, + "categories": [ + { + "kind": "host", + "bytes": 7, + "processCount": 1 + }, + { + "kind": "languageServer", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "pluginService", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "systemHelper", + "bytes": 0, + "processCount": 0 + }, + { + "kind": "childProcess", + "bytes": 3, + "processCount": 1 + } + ], + "entries": [], + "omitted": { + "count": 1, + "bytes": 3 + } + } + } + ], + "invalid": [ + { + "name": "the host is not in the table", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 1, + "parentProcessId": 0, + "name": "launchd", + "bytes": 20 + } + ] + } + }, + { + "name": "a process appears twice", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 100, + "parentProcessId": 1, + "name": "Lithe", + "bytes": 5 + }, + { + "processId": 200, + "parentProcessId": 100, + "name": "p200", + "bytes": 1 + }, + { + "processId": 200, + "parentProcessId": 100, + "name": "p200", + "bytes": 2 + } + ] + } + }, + { + "name": "an unknown role", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 100, + "parentProcessId": 1, + "name": "Lithe", + "bytes": 5 + } + ], + "roles": [ + { + "processId": 100, + "kind": "debugger", + "name": "x" + } + ] + } + }, + { + "name": "a process without bytes", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 100, + "parentProcessId": 1, + "name": "Lithe" + } + ] + } + }, + { + "name": "negative bytes", + "request": { + "hostProcessId": 100, + "processes": [ + { + "processId": 100, + "parentProcessId": 1, + "name": "Lithe", + "bytes": -1 + } + ] + } + } + ] +}