diff --git a/.github/workflows/standards-build.yml b/.github/workflows/standards-build.yml index ecb3ef3b3..9e9f7d9fe 100644 --- a/.github/workflows/standards-build.yml +++ b/.github/workflows/standards-build.yml @@ -400,3 +400,22 @@ jobs: log=$(mktemp) cargo test -p sandbox-meter -- --nocapture 2>&1 | tee "$log" grep -q 'ioreport liveness:' "$log" + # The tree meter's real-process tests (tests/tree_energy.rs) are + # gated to Apple Silicon macOS too; each prints this line. + grep -q 'tree meter:' "$log" + + # jcp-gateway meters each gated call on the upstream server's own + # process tree with that meter. The jcp-rs matrix entry runs on Linux, + # where there is no tree meter and every call is metered machine-wide, + # so its real-process test (tests/upstream_tree.rs, Apple Silicon macOS + # only) runs nowhere else. Each call it meters on a real server's tree + # prints this line (the machine-wide fallback test prints another), so + # the grep fails if a cfg or a filter left none of them to run. + - name: jcp-gateway (upstream process tree) + shell: bash + working-directory: jcp/jcp-rs + run: | + set -o pipefail + log=$(mktemp) + cargo test -p jcp-gateway -- --nocapture 2>&1 | tee "$log" + grep -q 'upstream tree:' "$log" diff --git a/jcp/README.md b/jcp/README.md index 46923dc3c..8f70235b3 100644 --- a/jcp/README.md +++ b/jcp/README.md @@ -46,7 +46,7 @@ jcp/ - **`jcp-intercept`** — **attested interceptor chains**: a composable, ordered pipeline of transforms *around* each model and tool call — the `before_model` / `after_model` / `before_tool` seams an agent's middleware stack runs, composed onion-order (forward in, reverse out) and stopping at the first refusal or human-approval hold. An interceptor may inspect or **rewrite** the request (redact a secret before it reaches the model, trim stale context), **refuse** it, or **hold** it for a human. The difference from the incumbent's arbitrary in-process hooks: every step seals an **`intercept/1`** JCR-1 receipt carrying the request digest **before and after** the step (so a redaction or a trim is provable — the content itself never sealed, only its hash), the seam, the outcome, and the **metered joules** at their honest provenance, hash-chained — the exact transform pipeline that produced a model request becomes reconstructable and verifiable by anyone, and a step cannot run un-sealed. Three reference interceptors — `Redact` (a content transform), `CallLimit` (a guardrail), `Approve` (a human-in-the-loop hold) — each reuse a primitive the family already holds rather than reinventing it. - **`jcp-efficiency`** — the **energy-efficiency spec**, surfaced as a sealed receipt. The dark-factory economics turn on four figures — **joules per accepted token, tokens per second, value per token, and the tokens/joules/dollars spent per COMPLETED, VERIFIED unit of work** — and the incumbents publish, at best, a *rate* (cost per token) that can't tell you whether the spend bought a result. An **`efficiency/1`** JCR-1 receipt records all four at honest provenance, hash-chained, with the one thing the rates omit: the **outcome anchor** (`completed` + a verdict). A measurement that didn't complete is spend that bought nothing, and the receipt says so. `aggregate` then splits value-delivered from total spend across a chain — charging *every* attempt (including the failed ones) against the results actually delivered — so `tokens_per_completed_unit` / `uj_per_completed_unit` / `value_per_joule` become the honest efficiency a "cost per token" number is designed to hide. Integers and strings only (JCR-1 forbids bools/floats); the rate figures recompute from the sealed inputs on read. - **`jcp-energy`** — the metering ladder (SPEC §5.1): RAPL package counters (Linux powercap, wraparound-corrected, `HwShunt`, only while every package counter is live — `rapl_status()`), NVML GPU energy behind the `nvml` feature (`HwShunt` and `Partial`, since it does not read the CPU's share; only while live — `nvml_status()`), the macOS kernel's per-process CPU energy count (`proc_pid_rusage`'s `ri_energy_nj`; `HwShunt`, whole for in-process CPU work, only while live — `process_energy_status()`; it covers this process's threads, not children, the GPU or DRAM, so a span that reaps a child process or ends with one is `Partial`; it is live on macOS 27.0.1 where IOReport's CPU rails are not), Apple Silicon IOReport (`HwShunt`, only while every CPU energy rail, each die's on a multi-die part, is live — on macOS 27.0.1 that rail is republished in lumps, so there the rung is declined and `ioreport_status()` says why; `Meter::detect_explained()` names the rung selected and why each one above it was declined), a time×calibrated-power model (`ModelBased`, always available), and a constant per-op estimator (`Estimator`). A vendor counter's reading is real but vendor-opaque and relayed by the platform, and it counts as a reading only while the counter is live (SPEC §5). Measured when the platform exposes it, estimated when it doesn't — and the provenance always says which; a hardware backend that fails mid-reading degrades honestly to the model. -- **`jcp-gateway`** — the **energy-native MCP gateway**: a JCP enforcement point that sits in front of an MCP server so an existing client/server pair keeps its transport while every `tools/call` becomes capability-scoped, joule-budgeted, and sealed. The novel mechanic is two-phase metered enforcement: the call is *authorized* before it runs (no debit, because the cost isn't yet known), the upstream call is **metered** by the sibling Sandbox standard's energy probe (Apple Silicon IOReport, RAPL, NVML — vendor energy counters, whose readings are real but vendor-opaque and relayed by the platform, and which count as readings only while the counter is live for the work, SPEC §5; otherwise the figure is an estimate and is graded as one), and the metered joules are debited from the grant — so the budget that stops a runaway agent is denominated in joules, not a token estimate. Every decision, allow and deny, seals into a **hash-chained receipt log**: excise, reorder, or splice a receipt and the chain breaks at that point. A denied call never reaches the server (the live 2025–26 MCP exploits land on tool *output*, which the gateway frames as untrusted `data` that can never exercise a capability), and the `jcp-gateway` binary runs it over stdio in front of any MCP-server subprocess. This is the chokepoint the AI-gateway field occupies, on the two axes nobody there holds: the unit of account is the joule, and the audit is cryptographic and tamper-evident rather than a dollar figure and a span. +- **`jcp-gateway`** — the **energy-native MCP gateway**: a JCP enforcement point that sits in front of an MCP server so an existing client/server pair keeps its transport while every `tools/call` becomes capability-scoped, joule-budgeted, and sealed. The novel mechanic is two-phase metered enforcement: the call is *authorized* before it runs (no debit, because the cost isn't yet known), the upstream call is **metered** by the sibling Sandbox standard's energy probe (Apple Silicon IOReport, RAPL, NVML — vendor energy counters, whose readings are real but vendor-opaque and relayed by the platform, and which count as readings only while the counter is live for the work, SPEC §5; otherwise the figure is an estimate and is graded as one), and the metered joules are debited from the grant — so the budget that stops a runaway agent is denominated in joules, not a token estimate. Where the upstream server is a local process whose CPU energy the host's kernel counts live (macOS `proc_pid_rusage`), each call is read on that server's own process tree, the work's own process rather than the machine (SPEC §5.1, rung 3): sealed `HwShunt`, and `Whole` only when nothing was seen missing from the tree (no descendant reaped or exited in the span, no GPU client held, no in-process Neural Engine memory, every member read and quiet at both ends) and the operator has declared, with `--upstream-self-contained`, that the server hands no work to a process outside its tree; otherwise `Partial`, with the response's `_meta` naming why. Every decision, allow and deny, seals into a **hash-chained receipt log**: excise, reorder, or splice a receipt and the chain breaks at that point. A denied call never reaches the server (the live 2025–26 MCP exploits land on tool *output*, which the gateway frames as untrusted `data` that can never exercise a capability), and the `jcp-gateway` binary runs it over stdio in front of any MCP-server subprocess. This is the chokepoint the AI-gateway field occupies, on the two axes nobody there holds: the unit of account is the joule, and the audit is cryptographic and tamper-evident rather than a dollar figure and a span. - **`jcp-wire`** — the `jcp/1` wire binding (SPEC §11): 4-byte length prefix + canonical det-CBOR over any byte stream, with TCP and Unix-socket endpoints and a client. The decoder rejects non-canonical bodies, so a message has exactly one wire form. Requests carry the full delegation chain and are **caller-signed** — the endpoint authenticates from the request itself and denies an impersonation `bad-signature`, sealed like any other decision. Responses carry the deterministic decision and the sealed receipt. - **`jcp-threshold`** — threshold actors (SPEC §2.2): a `did:key` whose private key is split k-of-n with FROST (RFC 9591); the group key is an ordinary Ed25519 key, so a 2-of-3 team issues, holds, and signs wire requests with nothing else in JCP changing. Shares come from a trusted dealer or **distributed key generation** — with DKG the group's private key never exists anywhere, not even momentarily. The two-agent rule for irreversible actions, refused by arithmetic rather than policy. - **`jcp-channel`** — receipt-settled budget channels (SPEC §4.5, the OPTIONAL profile): N enforcement points spend optimistically against one grant and settle by exchanging signed statements of cumulative spend — forged, stale, and regressing statements refused; between settlements the exposure is bounded by the window, and after settlement any overdraft is provable from the signed statements. Where bounded overdraft is unacceptable, `shard()` remains the baseline. diff --git a/jcp/SPEC.md b/jcp/SPEC.md index abf33f0fa..62bdd8ed5 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -625,6 +625,79 @@ Recommended ladder, best first: SoC-wide counter because it attributes the work's own CPU energy rather than the machine's. A span over which it did not advance is metered by a lower rung. + + The same count, read for every process of ANOTHER process tree the + metering process can read — the process the work runs in, such as the + server a gateway relays a call to, and its descendants — meters that + work → `HwShunt`. Its domain is the CPU energy of the tree's processes + over the span (as above, not DRAM): the advance of every process present + at both ends, plus the whole count of every process born inside the + span. A process is identified by its pid and its start time, so a reused + pid is another process. The count is folded only when a thread leaves a + core, so at each end of the span a member's count MUST be read with none + of its threads on a core or queued for one, or the member is a gap + (below). A walk of the tree reads a parent before it lists and reads + the parent's children, so a child that exits and is reaped in between + is in that walk neither as a member nor in its parent's reaped time; + the walk at each end MUST therefore be confirmed by another, one after + the other, that agrees with it on the tree's processes, each one's + parent, reaped-descendant CPU time and exit time, with no listed child + gone by its read, or that end is a gap (below). The span SHOULD end + after the work's reply. The figure is all the tree spent inside the + span, whatever for: work the process defers past an earlier reply, or + does on a timer of its own, is read in whichever span is open when it + runs, and work after this span's stop is in no figure of this span. So + `Whole` says that nothing was seen missing from the tree's energy over + the span, not that all of it was spent on the span's own work. The + figure is `Partial` (§5.2) when the meter sees any of these over the + span; each is energy of the tree's over the span that the figure does + not hold, but for the one noted: + - a descendant reaped inside the span (the reaper's reaped-descendant + CPU time moved; a reaped process's energy is in no counter), unless + it had already exited at the start, its energy then all spent before + the span; + - a member that exited inside the span: its record is final and is + counted, but it lacks the energy of the member's last time on a core + (an exiting thread's last slice is never folded into it: up to all of + a short-lived process's energy), and its GPU time is lost at exit, so + its GPU use is unknown; + - a member present at the start and gone at the stop (reaped, or moved + out of the tree): what it spent in the span before it went is not in + the figure; + - a member present at the stop that started before the span and was not + read at its start: its energy from before the span cannot be + separated, so none of it is counted; + - a member that could not be read (a process of another user), or + whose children could not be listed; + - a member still running at an end: at the stop its reading lacks up to + a scheduler quantum of energy per running thread; at the START its + reading is short by as much, so the figure may hold up to that much + energy spent just BEFORE the span. This one can make the figure + exceed the tree's energy over the span, so a `Partial` tree figure + with it is not a lower bound, and an implementation that names the + gaps beside the figure SHOULD name the end (the reference gateway: + `unsettled-at-start`, `unsettled-at-stop`); + - an end whose walk no agreeing walk confirmed before the meter stopped + waiting: the tree kept changing, and a process that came and went may + be missing; + - a member holding a GPU client at either end: GPU time its client + recorded, or GPU use the reader cannot rule out, because a GPU + client's recorded time is per live command queue and a queue + created and released inside the span takes its time with it; or GPU + time that could not be read; + - a member that has held Neural Engine model memory in-process. + + An implementation MUST NOT grade a tree's figure `Whole` on an empty + list alone. `Whole` further requires the operator's declaration that the + process hands no work to a process outside its tree — a system service, + on-device model inference run by a system daemon, a database server, a + remote API — because a reader of the tree cannot see such delegation: + that work's energy is charged to the process that does it, which may be + another user's and unreadable. Without the declaration the figure is + `Partial`. A span whose root is gone or unreadable at either end, or + whose figure did not advance, is metered by a lower rung. The reference + gateway (`jcp-gateway`) meters each gated call this way where the + upstream server is a local process it can read. 4. **Apple IOReport** (the Energy Model group) — the SoC energy counters → `HwShunt`, selected only while every CPU energy rail (one per die on a multi-die part) is live by the rule above. A span over which no CPU rail diff --git a/jcp/jcp-rs/Cargo.lock b/jcp/jcp-rs/Cargo.lock index 8c5a1f0f6..a6a58343d 100644 --- a/jcp/jcp-rs/Cargo.lock +++ b/jcp/jcp-rs/Cargo.lock @@ -629,6 +629,7 @@ dependencies = [ "jcp-receipt", "jcp-runtime", "jcp-schema", + "libc", "sandbox-meter", "sandbox-schema", "serde_json", diff --git a/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml b/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml index 43a595e91..1710ed11b 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml +++ b/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jcp-gateway" -description = "The energy-native MCP gateway: a JCP enforcement point in front of an MCP server. Every tools/call is gated by a capability grant, the upstream call is metered (a hardware reading where the host exposes a live energy counter, an estimate where it does not, each sealed as what it is), the metered joules are debited from the grant's budget, and the decision seals into a hash-chained receipt log. The chokepoint position the AI-gateway field occupies — denominated in joules, with a tamper-evident audit, not dollars and a span." +description = "The energy-native MCP gateway: a JCP enforcement point in front of an MCP server. Every tools/call is gated by a capability grant, the upstream call is metered (on the upstream server's own process tree where the host's kernel counts per-process CPU energy, and otherwise machine-wide: a hardware reading where the host exposes a live energy counter, an estimate where it does not, each sealed as what it is), the metered joules are debited from the grant's budget, and the decision seals into a hash-chained receipt log. The chokepoint position the AI-gateway field occupies — denominated in joules, with a tamper-evident audit, not dollars and a span." version.workspace = true edition.workspace = true license.workspace = true @@ -21,6 +21,11 @@ sandbox-schema = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-schema" ed25519-dalek = { workspace = true } serde_json = { workspace = true } +[dev-dependencies] +# tests/upstream_tree.rs's oracle reads the upstream server's energy count +# with proc_pid_rusage into a raw buffer, sharing no code with the meter. +libc = "0.2" + [[bin]] name = "jcp-gateway" path = "src/bin/jcp_gateway.rs" diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/bin/jcp_gateway.rs b/jcp/jcp-rs/crates/jcp-gateway/src/bin/jcp_gateway.rs index 49290cd2d..31b0aed53 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/bin/jcp_gateway.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/bin/jcp_gateway.rs @@ -2,8 +2,8 @@ //! MCP server subprocess. //! //! ```bash -//! jcp-gateway --server --grant = [--grant …] -- \ -//! [args…] +//! jcp-gateway --server --grant = [--grant …] \ +//! [--upstream-self-contained] -- [args…] //! ``` //! //! The gateway speaks newline-delimited JSON-RPC with its own client on @@ -11,11 +11,29 @@ //! `tools/call` is gated, metered, debited, and sealed; receipts stream to //! `JCP_GATEWAY_RECEIPTS` (default `./jcp-gateway-receipts.cose`, length- //! prefixed envelopes) and a one-line summary per call goes to stderr. +//! +//! What the server writes that is not a response — a notification, or a +//! request of its own in the middle of a call — goes straight to the +//! client, and the client's answer to such a request straight back to the +//! server, while the call stays open. A call ends at the response carrying +//! its own id, and its span with it. A client notification is sent on +//! without waiting for anything back. +//! +//! Each call is metered on the server's own process tree where this host's +//! kernel counts per-process CPU energy live (macOS), and machine-wide +//! otherwise; the startup line says which. `--upstream-self-contained` is +//! the operator's declaration that the server hands no work to a process +//! outside its tree (no system service, no model inference in a system +//! daemon, no database server, no remote API): the meter cannot see that, +//! and without the declaration every reading of the tree is sealed +//! `Partial`. use ed25519_dalek::SigningKey; -use jcp_gateway::{Gateway, Relayed, Upstream}; +use jcp_gateway::{is_response, message_id, Gateway, Relayed, Upstream}; use std::io::{BufRead, BufReader, Write}; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::sync::{Arc, Mutex}; /// The energy grade a verified receipt seals: its `energy_provenance`, plus /// `energy_coverage` when that is `Partial`. `None` if it does not verify. @@ -29,21 +47,85 @@ fn sealed_grade(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Option std::io::Result<()> { + let mut out = std::io::stdout().lock(); + writeln!(out, "{line}")?; + out.flush() +} + +/// One line to the server, whole and flushed: the relay loop and the +/// client's reader both write to it. +fn to_server(stdin: &Mutex, line: &str) -> std::io::Result<()> { + let mut w = stdin.lock().unwrap_or_else(|e| e.into_inner()); + w.write_all(line.as_bytes())?; + w.write_all(b"\n")?; + w.flush() +} + /// The upstream MCP server as a child process speaking JSON-RPC over stdio. +/// +/// A server writes more than responses on its stdout: in the middle of a +/// call it may send notifications (`notifications/progress`, +/// `notifications/message`) and requests of its own (`sampling/createMessage`) +/// before the call's response. So a thread reads the server's stdout and +/// routes each line: a response (an `id` and no `method`) to the call +/// waiting for it, everything else straight to the client. A call returns +/// the response carrying its own request's id — the line its metered span +/// ends at — and hands any other response to the client as written. struct ChildServer { - _child: Child, - stdin: ChildStdin, - stdout: BufReader, + /// Held, and never waited on while the gateway serves: an unreaped child + /// keeps its pid, so the pid the meter reads stays this server's. + child: Child, + stdin: Arc>, + /// The server's responses, in the order it wrote them; closed when its + /// stdout closes. + responses: Receiver, +} + +impl ChildServer { + fn new(mut child: Child) -> ChildServer { + let stdin = Arc::new(Mutex::new(child.stdin.take().expect("child stdin"))); + let stdout = BufReader::new(child.stdout.take().expect("child stdout")); + let (tx, responses) = mpsc::channel(); + std::thread::spawn(move || { + for line in stdout.lines() { + let Ok(line) = line else { break }; + if is_response(&line) { + if tx.send(line).is_err() { + break; + } + } else if to_client(&line).is_err() { + break; + } + } + }); + ChildServer { child, stdin, responses } + } } impl Upstream for ChildServer { fn call(&mut self, request_line: &str) -> std::io::Result { - self.stdin.write_all(request_line.as_bytes())?; - self.stdin.write_all(b"\n")?; - self.stdin.flush()?; - let mut line = String::new(); - self.stdout.read_line(&mut line)?; - Ok(line.trim_end().to_string()) + to_server(&self.stdin, request_line)?; + let id = message_id(request_line); + loop { + // The server closed its stdout: no response is coming. + let Ok(line) = self.responses.recv() else { return Ok(String::new()) }; + match &id { + Some(id) if message_id(&line).as_ref() != Some(id) => to_client(&line)?, + _ => return Ok(line), + } + } + } + + /// Written and flushed, and nothing read: the server sends no reply. + fn notify(&mut self, line: &str) -> std::io::Result<()> { + to_server(&self.stdin, line) + } + + fn pid(&self) -> Option { + Some(self.child.id()) } } @@ -52,6 +134,7 @@ fn main() -> std::io::Result<()> { let mut server = "mcp".to_string(); let mut grants: Vec<(String, u64)> = Vec::new(); let mut cmd: Vec = Vec::new(); + let mut self_contained = false; let mut i = 1; while i < args.len() { match args[i].as_str() { @@ -59,6 +142,10 @@ fn main() -> std::io::Result<()> { server = args.get(i + 1).cloned().unwrap_or_default(); i += 2; } + "--upstream-self-contained" => { + self_contained = true; + i += 1; + } "--grant" => { if let Some((t, b)) = args.get(i + 1).and_then(|s| s.split_once('=')) { if let Ok(budget) = b.parse() { @@ -75,43 +162,67 @@ fn main() -> std::io::Result<()> { } } if cmd.is_empty() { - eprintln!("usage: jcp-gateway --server --grant = … -- …"); + eprintln!( + "usage: jcp-gateway --server --grant = … [--upstream-self-contained] -- …" + ); std::process::exit(2); } - let mut child = Command::new(&cmd[0]) + let child = Command::new(&cmd[0]) .args(&cmd[1..]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn()?; - let mut up = ChildServer { - stdin: child.stdin.take().expect("child stdin"), - stdout: BufReader::new(child.stdout.take().expect("child stdout")), - _child: child, - }; + let mut up = ChildServer::new(child); let grant_refs: Vec<(&str, u64)> = grants.iter().map(|(t, b)| (t.as_str(), *b)).collect(); let steward_sk = SigningKey::from_bytes(&[0x5e; 32]); let steward_pk = steward_sk.verifying_key().to_bytes(); let steward = format!("did:jcp:gateway:{server}"); let mut gw = Gateway::new(&server, &grant_refs, steward_sk); + if self_contained { + gw.declare_upstream_self_contained(); + } + // Which meter reads each call, decided now: building the tree meter pays + // the per-process counter's liveness probe once, here rather than on the + // first call. Each call's own grade is on its line below, read back from + // its sealed receipt. + let call_meter = gw.call_meter(&up); eprintln!( - "jcp-gateway: server={server} tools={:?} metering={}", + "jcp-gateway: server={server} tools={:?} metering={call_meter} upstream-self-contained={}", gw.tools(), - if gw.metering_is_hardware() { "hardware" } else { "estimator" } + if gw.upstream_self_contained() { "declared" } else { "not declared" }, ); let receipts_path = std::env::var("JCP_GATEWAY_RECEIPTS").unwrap_or_else(|_| "./jcp-gateway-receipts.cose".into()); - let stdin = std::io::stdin(); - let mut stdout = std::io::stdout(); - let mut prev_receipts = 0usize; - for line in stdin.lock().lines() { - let line = line?; - if line.trim().is_empty() { - continue; + // The client's lines are read on a thread of their own. A response of + // the client's (an `id` and no `method`), answering a request the server + // made in the middle of a call, goes straight to the server: the relay + // loop below is then waiting on that very call, and the server will not + // answer the call until it has its answer. Every other line is relayed, + // in order, by the loop. + let (tx, client_lines) = mpsc::channel::(); + let server_stdin = Arc::clone(&up.stdin); + std::thread::spawn(move || { + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + if is_response(&line) { + if to_server(&server_stdin, &line).is_err() { + break; + } + } else if tx.send(line).is_err() { + break; + } } + }); + + let mut prev_receipts = 0usize; + for line in client_lines { match gw.relay(&line, &mut up)? { Relayed::Allowed { response, cost_uj, budget_remaining_uj } => { // How the joules were obtained, read back from the receipt the @@ -123,19 +234,19 @@ fn main() -> std::io::Result<()> { .unwrap_or_else(|| "unverified".into()); eprintln!("jcp-gateway: allowed, {cost_uj} µJ [{grade}], {budget_remaining_uj} µJ left"); if !response.is_empty() { - writeln!(stdout, "{response}")?; - stdout.flush()?; + to_client(&response)?; } } Relayed::Denied { response, reason } => { eprintln!("jcp-gateway: DENIED ({reason})"); - writeln!(stdout, "{response}")?; - stdout.flush()?; + // Empty for a call sent as a notification: it gets no reply. + if !response.is_empty() { + to_client(&response)?; + } } Relayed::PassedThrough(resp) => { if !resp.is_empty() { - writeln!(stdout, "{resp}")?; - stdout.flush()?; + to_client(&resp)?; } } Relayed::Notified => {} diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 4cfc9410b..36308c1dd 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -11,11 +11,33 @@ //! `(server, tool)`, so a grant for `read_file` cannot authorize `shell`. //! 2. The call is authorized *before* it runs ([`Runtime::authorize`]) — but //! nothing is debited yet, because the cost isn't known until it runs. -//! 3. The upstream call is **metered** ([`sandbox_meter::PowerMeter`]): a -//! hardware reading where this host exposes a live energy counter, and the -//! meter's wall-clock estimate where it does not. The figure keeps the -//! provenance of the span it came from — `HwShunt` only for a reading, -//! `Estimator` for an estimate — in the ledger and in the receipt. +//! 3. The upstream call is **metered**, on the upstream server's own process +//! tree where this host can read it, and machine-wide where it cannot: +//! - Where the upstream is a local process this gateway may read +//! ([`Upstream::pid`]) and the host's kernel counts each process's CPU +//! energy live ([`sandbox_meter::TreeMeter`]: macOS), the figure is the +//! CPU energy of that server and its descendants over the call, the +//! work's own process rather than the machine — `HwShunt`. It is +//! `Whole` only when the meter saw nothing missing (no descendant +//! reaped or exited in the span, no GPU client held, no in-process +//! Neural Engine memory, every member read and gone quiet at both +//! ends) AND the operator has declared that the server hands no work to +//! a process outside its tree ([`Gateway::declare_upstream_self_contained`]), +//! which no reader of the tree can see. Otherwise it is `Partial`: its +//! remainder is unmeasured, not zero — and where a member was still +//! running at the start (`unsettled-at-start`), it may also hold up to a +//! scheduler quantum per running thread from just before the call. +//! Whole or not, it is the tree's energy over the call, whatever the +//! tree spent it on: work the server deferred past an earlier reply is +//! read in the call open when it runs. +//! - Otherwise ([`sandbox_meter::PowerMeter`]): a hardware reading where +//! this host exposes a live machine-wide energy counter, and the +//! meter's wall-clock estimate where it does not. +//! +//! Either way the figure keeps the provenance and coverage of the span it +//! came from — `HwShunt` only for a reading, `Estimator` for an estimate, +//! `Partial` for a reading of part of the call — in the ledger and in the +//! receipt. //! 4. The metered cost is debited from the grant ([`Runtime::charge`]); when //! the budget is exhausted the next call is denied. //! 5. Every decision — allow and deny — seals into a **hash-chained receipt @@ -41,17 +63,99 @@ use jcp_grant::issue; use jcp_receipt::{FlowSeal, ReceiptLog}; use jcp_runtime::Runtime; use jcp_schema::flow::{FlowCeiling, Readers}; -use jcp_schema::{Capability, Caveat, ContentId, Did, Energy, FlowFacts, Grant, SignedGrant}; -use sandbox_meter::{Measurement, PowerMeter}; +use jcp_schema::{Capability, Caveat, ContentId, Coverage, Did, Energy, FlowFacts, Grant, SignedGrant}; +use sandbox_meter::{Measurement, PowerMeter, SpanEnd, TreeError, TreeGap, TreeMeter, TreeReading}; use sandbox_schema::DeviceClass; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; +use std::time::Duration; /// The upstream MCP server, abstracted so the gateway's policy is testable /// without spawning a process. `call` sends one JSON-RPC request line and -/// returns the server's response line. +/// returns the server's response line; `notify` sends a line the server +/// answers with nothing. pub trait Upstream { fn call(&mut self, request_line: &str) -> std::io::Result; + + /// Send one line the server sends no reply to, and return once it is + /// sent, without waiting for anything back: a client notification (no + /// `id`, such as the `notifications/initialized` every client sends + /// right after `initialize`), or the client's response to a request the + /// server made. A server answers neither, so an implementation that + /// waits for a reply here waits forever. + /// + /// The default sends it through [`call`](Self::call) and discards what + /// comes back, which suits an in-process server that returns at once; + /// an implementation over a real transport (a child's stdio) MUST + /// override it to write without reading. + fn notify(&mut self, line: &str) -> std::io::Result<()> { + self.call(line).map(|_| ()) + } + + /// The pid of the process that serves [`call`](Self::call), when it is a + /// process on this host: the root of the tree a gated call is metered on + /// ([`Gateway::call_meter`]). `None`, the default, when there is no such + /// process to read — a server in this process, a mock, a remote + /// endpoint — and every gated call is then metered machine-wide. + /// + /// The pid must stay this server's for as long as it is named: an + /// implementation that spawned the server keeps it unreaped (holding its + /// `Child`, never waiting on it while it serves), so the pid cannot name + /// another process. The meter pins the root's identity besides, so a pid + /// that comes to name another process reads as a server that is gone, + /// never as the server. + fn pid(&self) -> Option { + None + } +} + +/// Which meter reads a gated call's energy ([`Gateway::call_meter`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CallMeter { + /// The upstream server's own process tree, rooted at `pid`: the kernel's + /// per-process CPU energy count, read for every member + /// ([`sandbox_meter::TreeMeter`]). A call whose tree span gives no + /// reading (the root gone or unreadable, or the tree's count not + /// advanced) is metered machine-wide instead. + UpstreamTree { pid: u32 }, + /// The machine-wide meter, on a live hardware energy counter. + MachineHardware, + /// The machine-wide meter's wall-clock estimate: this host exposes no + /// live machine-wide energy counter. + Estimator, +} + +impl std::fmt::Display for CallMeter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CallMeter::UpstreamTree { pid } => write!(f, "upstream process tree (pid {pid})"), + CallMeter::MachineHardware => f.write_str("machine hardware"), + CallMeter::Estimator => f.write_str("estimator"), + } + } +} + +/// A meter of the upstream server's process tree over one call. +/// [`TreeMeter`] is the one the gateway builds; the tests give canned ones, +/// so every shape a reading takes is driven through the whole relay path, +/// sealing included. `Send`, because a [`Gateway`] holds one boxed and a +/// gateway is moved to a thread, or held across an `await`, by embedders: +/// a bare trait object would take `Send` away from it. +trait TreeSpan: Send { + /// Run `call` once, inside a span of the tree, and read the span. + fn span(&self, call: &mut dyn FnMut()) -> Result; +} + +/// A [`Gateway`] is `Send`: this stops compiling if a field takes it away. +const _: () = { + const fn send() {} + send::(); +}; + +impl TreeSpan for TreeMeter { + fn span(&self, call: &mut dyn FnMut()) -> Result { + self.measure(call).1 + } } /// What the gateway did with one client message. @@ -61,14 +165,24 @@ pub enum Relayed { /// the response. PassedThrough(String), /// A gated call the gateway authorized, metered, and forwarded. - /// `cost_uj` is the figure debited from the grant: a hardware reading or - /// the meter's estimate. The sealed receipt's `energy_provenance`, and the - /// same label beside the figure in the response's `_meta`, say which. + /// `cost_uj` is the figure debited from the grant: a reading of the + /// upstream server's own process tree, a machine-wide hardware reading, + /// or the machine meter's estimate. The sealed receipt's + /// `energy_provenance` and `energy_coverage` say how it was obtained and + /// whether it covers the whole call; the response's `_meta` carries the + /// same two labels beside the figure, and where it was read + /// (`energy_domain`). A `Partial` figure is debited all the same, as what + /// was read over the call (short of the call's energy, or, with + /// `unsettled-at-start` among its reasons, possibly a little over it), + /// and is never presented as its total. Allowed { response: String, cost_uj: u64, budget_remaining_uj: u64 }, /// A `tools/call` the gateway refused; the upstream was never contacted. - /// `response` is a JSON-RPC error to hand back to the client. + /// `response` is a JSON-RPC error to hand back to the client, or empty + /// when the call came as a notification (no `id`), which gets no reply. Denied { response: String, reason: String }, - /// A client notification (no `id`) forwarded fire-and-forget. + /// A line the server answers with nothing — a client notification (no + /// `id`), or the client's response to a request the server made (no + /// `method`) — sent with [`Upstream::notify`], which waits for no reply. Notified, } @@ -142,7 +256,28 @@ pub struct Gateway { /// `detect_in_process()`: the metered work is the upstream MCP server, a /// separate process, and this process's own energy counter does not cover /// a child process — over a tool call it would read the gateway waiting. + /// + /// It meters the flow scan, and every gated call the upstream tree meter + /// gives no reading of: an upstream with no process to read, a host + /// whose per-process count is not live (every host but macOS), a root + /// this process may not read, or a span whose tree reading failed. On + /// Linux it is the only meter. meter: PowerMeter, + /// The upstream server's process-tree meter: the pid it was built for, + /// and the meter, `None` where [`TreeMeter::for_process`] gives none. + /// Built on the first gated call (or [`Gateway::call_meter`]) for the pid + /// the upstream names, and built again only when it names another — a + /// server restarted under a new pid. Not when the pid stays the same: + /// the meter pinned the root's identity, so a pid that came to name + /// another process reads as a server gone, and the call is metered + /// machine-wide rather than on a stranger. + upstream: Option<(u32, Option>)>, + /// The operator has declared that the upstream server hands no work to a + /// process outside its own tree ([`Gateway::declare_upstream_self_contained`]). + upstream_self_contained: bool, + /// How long the upstream tree meter waits, at each end of a call, for + /// the tree to go quiet ([`Gateway::set_upstream_settle_bound`]). + upstream_settle: Duration, log: ReceiptLog, now: u64, /// Signs re-issued grants when a flow ceiling is attached to a tool. The same @@ -159,6 +294,18 @@ pub struct Gateway { witnesses: BTreeMap, } +/// The tree meter that reads a call to an upstream naming `pid`: the one +/// built for that pid, and none when the upstream names no process (its +/// calls are then metered machine-wide, as [`Upstream::pid`] says) or one +/// no meter was built for. A meter kept from an earlier pid is never used +/// for a call to an upstream that no longer names it. +fn tree_for(upstream: &Option<(u32, Option>)>, pid: Option) -> Option<&dyn TreeSpan> { + match (pid, upstream) { + (Some(p), Some((q, Some(t)))) if p == *q => Some(t.as_ref()), + _ => None, + } +} + impl Gateway { /// Build a gateway for `server` granting each `(tool, budget_uj)` a /// capability with that joule ceiling. The consumer is its own grant @@ -192,6 +339,9 @@ impl Gateway { tool_schemas: BTreeMap::new(), caller: did, meter: PowerMeter::detect(), + upstream: None, + upstream_self_contained: false, + upstream_settle: sandbox_meter::probe::SETTLE_BOUND, log: ReceiptLog::new(steward_sk, format!("did:jcp:gateway:{server}")), now: 0, issuer_sk: issuer, @@ -243,14 +393,86 @@ impl Gateway { self.log.entries() } - /// Whether the active meter has a hardware energy source (vs. the - /// estimator alone). This says what the meter can read, not what a call - /// did: a span whose CPU counter held is estimated even on a hardware - /// meter, and each call's receipt carries its own span's provenance. + /// Whether the MACHINE-WIDE meter has a hardware energy source (vs. the + /// estimator alone). It meters the flow scan, and a gated call only where + /// the upstream server's process tree gives no reading, so this says + /// nothing about a call metered on that tree: [`call_meter`](Self::call_meter) + /// says which meter reads a call. And it says what the meter can read, + /// not what a call did: a span whose CPU counter held is estimated even + /// on a hardware meter, and each call's receipt carries its own span's + /// provenance. pub fn metering_is_hardware(&self) -> bool { self.meter.is_hardware() } + /// Declare that the upstream server does its work inside its own process + /// tree and hands none of it to a process outside it: no system service + /// over XPC, no model inference in a system daemon, no database server, + /// no remote API. Off by default. + /// + /// The tree meter cannot see delegation. Work a server hands to another + /// process is charged to that process, and a process of another user is + /// not even readable here: on-device model inference run by a system + /// daemon drew about 1 W of Neural Engine power (M5 Max, macOS 27.0.1, + /// `sudo powermetrics`) while its client showed no Neural Engine + /// footprint and its own CPU count advanced only by its own share, tens + /// of millijoules per request — a plausible, nonzero figure with nothing + /// in it to show the rest. A reading with nothing seen missing therefore + /// says only that nothing was missing inside the tree, and only the + /// operator, who knows what the server is, can say there is nothing + /// outside it. With this declaration such a reading is sealed + /// `Whole`; without it every tree reading is sealed `Partial`, and the + /// response's `_meta` says `not-declared-self-contained`. + pub fn declare_upstream_self_contained(&mut self) { + self.upstream_self_contained = true; + } + + /// Whether the operator declared the upstream server self-contained + /// ([`declare_upstream_self_contained`](Self::declare_upstream_self_contained)). + pub fn upstream_self_contained(&self) -> bool { + self.upstream_self_contained + } + + /// How long the upstream tree meter waits, at each end of a call, for + /// every process of the tree to go quiet (default + /// [`sandbox_meter::probe::SETTLE_BOUND`]). A process still running when + /// it runs out is a gap, so the call is sealed `Partial`; a longer bound + /// trades latency on a loaded host for fewer of those. Takes effect from + /// the next call. + pub fn set_upstream_settle_bound(&mut self, bound: Duration) { + self.upstream_settle = bound; + // Built again, with this bound, for whichever pid the next call names. + self.upstream = None; + } + + /// Which meter reads a gated call to `up`: the upstream server's own + /// process tree where `up` names a process ([`Upstream::pid`]) this host + /// can read with a live per-process energy count, and the machine-wide + /// meter otherwise. Builds the tree meter if it is not built yet, which + /// the first time in a process pays the counter's liveness probe (about + /// 300 ms of CPU on this thread): call it at startup to keep that off the + /// first call. + pub fn call_meter(&mut self, up: &impl Upstream) -> CallMeter { + let pid = up.pid(); + self.prepare_upstream_meter(pid); + match (pid, tree_for(&self.upstream, pid)) { + (Some(p), Some(_)) => CallMeter::UpstreamTree { pid: p }, + _ if self.meter.is_hardware() => CallMeter::MachineHardware, + _ => CallMeter::Estimator, + } + } + + /// Build the upstream tree meter for `pid`, unless it is built for it. + fn prepare_upstream_meter(&mut self, pid: Option) { + let Some(pid) = pid else { return }; + if self.upstream.as_ref().is_some_and(|(p, _)| *p == pid) { + return; + } + let meter = TreeMeter::for_process(pid) + .map(|m| Box::new(m.with_settle_bound(self.upstream_settle)) as Box); + self.upstream = Some((pid, meter)); + } + /// Relay one client message to the upstream under policy. The single entry /// point: classifies the message, gates `tools/call`, meters and debits, /// seals the receipt, and returns what to send the client. @@ -265,9 +487,11 @@ impl Gateway { let params = msg.get("params").cloned().unwrap_or(Value::Null); let Some((action, args)) = gated_action(method, ¶ms) else { - if id.is_none() { - // A notification: forward fire-and-forget, no response. - let _ = up.call(line); + if id.is_none() || msg.get("method").is_none() { + // A notification (no `id`), or the client's response to a + // request the server made (no `method`): the server sends + // nothing back, so it is sent without waiting for a reply. + up.notify(line)?; return Ok(Relayed::Notified); } let response = up.call(line)?; @@ -293,6 +517,14 @@ impl Gateway { let tool = action.as_str(); let args_bytes = serde_json::to_vec(&args).unwrap_or_default(); + // A gated method sent as a notification (no `id`) cannot be answered: + // its result and its receipt could reach no one, and no reply bounds + // the span it would be metered over. So it is refused before anything + // else and never reaches the server, and the attempt is sealed. + if id.is_none() { + return Ok(self.deny_notification(tool, &args_bytes)); + } + // No grant for this tool ⇒ deny. We still SEAL the denial, using any // held grant as the frame's grant so the runtime's audience check // fails it (the attempt is recorded, not silently dropped). @@ -346,19 +578,47 @@ impl Gateway { }); } - // Phase 2: run the upstream call, metered. - let (response, measurement) = { - let mut response = String::new(); - let (res, m) = self.meter.measure(|| up.call(line)); - if let Ok(r) = res { - response = r; - } - (response, m) + // Phase 2: run the upstream call, metered twice: on the upstream + // server's own process tree where it can be read, and machine-wide, + // the fallback. The tree span encloses the machine span, so the + // machine figure (an estimate's wall time included) is the call's + // alone and never the tree meter's settling at either end; and the + // tree meter reads other processes, so this one's metering is no + // part of its figure. The tree span stops after the reply: what the + // server does after it is not in this figure, but in that of + // whichever call is open when it runs, since a span reads all the + // tree spends inside it. The tree meter is chosen as `call_meter` + // reports it: the one built for the pid the upstream names NOW, and + // none when it names none, whatever was built for an earlier pid. + let pid = up.pid(); + self.prepare_upstream_meter(pid); + let (response, measurement, reading) = { + let tree = tree_for(&self.upstream, pid); + let meter = &mut self.meter; + let mut ran = None; + let reading = tree.map(|t| t.span(&mut || ran = Some(meter.measure(|| up.call(line))))); + let (res, m, reading) = match ran { + Some((res, m)) => (res, m, reading), + // No tree meter (or one that never ran the call, whose + // reading is then of no call): the machine meter alone. + None => { + let (res, m) = meter.measure(|| up.call(line)); + (res, m, None) + } + }; + (res.unwrap_or_default(), m, reading) }; - // The call's cost as what it is: a hardware reading, or the meter's - // estimate when this host exposes no live counter or the span's CPU - // counter held. Either way it is debited, so a runaway is enforced. - let cost = energy_of(&measurement); + // The call's cost as what it is: a reading of the upstream's tree, + // whole only where nothing was seen missing and the operator declared + // the server self-contained; else a machine-wide hardware reading, or + // the machine meter's estimate when this host exposes no live counter + // or the span's CPU counter held. Either way it is debited, so a + // runaway is enforced. And either way it is `Partial` when the line + // the upstream returned is not this call's response: the span then + // ended before the call's work did. + let the_response = is_the_response(&response, id); + let metered = grade_call(reading.as_ref(), self.upstream_self_contained, &measurement, the_response); + let cost = metered.cost; let cost_uj = cost.uj(); // Phase 3: debit the metered joules (and the tainted sub-budget, if the @@ -387,7 +647,7 @@ impl Gateway { // receives evidence for its own request rather than having to trust // that the gateway logged something somewhere. let response = match self.log.entries().last() { - Some(r) => attach_receipt(&response, r, &cost, decision.budget_remaining_uj), + Some(r) => attach_receipt(&response, r, &metered, decision.budget_remaining_uj), None => response, }; @@ -446,6 +706,30 @@ impl Gateway { self.deny_sealed(tool, id, "tool schema changed since it was granted") } + /// Refuse a gated method sent as a notification, and seal the attempt. + /// + /// A grant covers the call as a request; a notification is not that form + /// of it. The sealed frame names the tool's own resource with the action + /// `notification:`, which no grant carries, so the runtime denies it + /// on the grant for the tool where one is held (`action-not-granted`), or + /// on any other held grant (`wrong-resource`) — never through the trick + /// [`deny_sealed`](Self::deny_sealed) uses, which would ALLOW a granted + /// tool's frame. Nothing is sent back: a notification gets no reply. + fn deny_notification(&mut self, tool: &str, args: &[u8]) -> Relayed { + let reason = "gated method sent as a notification (no id)".to_string(); + let anchor = self.grants.get(tool).or_else(|| self.grants.values().next()).cloned(); + if let Some(anchor) = anchor { + let grant = anchor.grant.clone(); + let mut frame = mcp_call_frame(grant.id(), &self.server, tool, args); + frame.action = format!("notification:{tool}"); + let d = self.runtime.authorize(&frame, &anchor, self.caller, self.now); + if !d.is_allow() { + self.log.append(&frame, &grant, &d); + } + } + Relayed::Denied { response: String::new(), reason } + } + /// Seal a refusal and build the client error, whatever the reason. fn deny_sealed(&mut self, tool: &str, id: Option<&Value>, why: &str) -> Relayed { let reason = why.to_string(); @@ -687,7 +971,20 @@ fn jcp_meta(params: &Value) -> Option<&Value> { /// the sealed receipt carries. A client that reads only the plaintext therefore /// never takes an estimate for a reading, and never has to decode the receipt /// to find out which it got. -fn attach_receipt(response: &str, receipt: &[u8], cost: &Energy, remaining_uj: u64) -> String { +/// +/// Two more keys say where the figure came from, in plaintext only: the +/// sealed receipt's format is unchanged, so they are the gateway's account +/// of the figure and not evidence. `energy_domain` is `upstream-process-tree` +/// for a reading of the upstream server's own process tree and `machine` for +/// the machine-wide meter's figure; and a `Partial` tree figure carries +/// `energy_partial_because`, the kinds of what was missing from it +/// ([`gap_kind`], and `not-declared-self-contained` where the operator made +/// no declaration), so a client can tell a server that reaped a worker from +/// one whose operator said nothing. Any figure, tree or machine, carries +/// `reply-not-the-response` there when the line the upstream returned was +/// not the call's own response. +fn attach_receipt(response: &str, receipt: &[u8], metered: &Metered, remaining_uj: u64) -> String { + let cost = &metered.cost; let Ok(mut v) = serde_json::from_str::(response) else { return response.to_string(); }; @@ -708,6 +1005,10 @@ fn attach_receipt(response: &str, receipt: &[u8], cost: &Energy, remaining_uj: u if !cost.is_whole() { jcp["energy_coverage"] = json!(cost.coverage.label()); } + jcp["energy_domain"] = json!(metered.domain.label()); + if !metered.partial_because.is_empty() { + jcp["energy_partial_because"] = json!(metered.partial_because); + } meta.insert(JCP_META_NS.to_string(), jcp); v.to_string() } @@ -807,6 +1108,162 @@ fn energy_of(m: &Measurement) -> Energy { } } +/// Where a gated call's figure was read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EnergyDomain { + /// The upstream server's own process tree ([`TreeMeter`]). + UpstreamTree, + /// The machine-wide meter ([`PowerMeter`]). + Machine, +} + +impl EnergyDomain { + /// The `energy_domain` label in a response's `_meta`. + fn label(self) -> &'static str { + match self { + EnergyDomain::UpstreamTree => "upstream-process-tree", + EnergyDomain::Machine => "machine", + } + } +} + +/// Why a reading of the upstream's tree is `Partial` although nothing was +/// seen missing from it: the operator has not declared that the server hands +/// no work outside its tree. +const NOT_DECLARED_SELF_CONTAINED: &str = "not-declared-self-contained"; + +/// Why a call's figure is `Partial`, whichever meter read it: the line the +/// upstream returned is not the JSON-RPC response to the call — a +/// notification, a request of the server's, a response to another id — so +/// the span stopped before the call's work was done, and whatever the +/// server did after it is not in the figure. +const REPLY_NOT_THE_RESPONSE: &str = "reply-not-the-response"; + +/// Whether `line` is a JSON-RPC response: an object that carries an `id` +/// and no `method`. A request carries both, and a notification a `method` +/// and no `id`. The stdio binary routes the lines it relays by this. +pub fn is_response(line: &str) -> bool { + serde_json::from_str::(line).is_ok_and(|v| v.is_object() && v.get("id").is_some() && v.get("method").is_none()) +} + +/// The `id` a JSON-RPC message carries, if it is an object with one. +pub fn message_id(line: &str) -> Option { + serde_json::from_str::(line).ok()?.get("id").cloned() +} + +/// Whether `response` is THE response to a request whose id is `id`: a +/// JSON-RPC response with that id and a `result` or an `error`. A request +/// with no id has none. +fn is_the_response(response: &str, id: Option<&Value>) -> bool { + let Some(id) = id else { return false }; + let Ok(v) = serde_json::from_str::(response) else { return false }; + v.get("method").is_none() && (v.get("result").is_some() || v.get("error").is_some()) && v.get("id") == Some(id) +} + +/// A gated call's metered cost, and where it came from. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Metered { + /// The figure debited from the grant and sealed in the receipt. + cost: Energy, + /// Where it was read. + domain: EnergyDomain, + /// Why the figure is `Partial`: for an upstream-tree figure each + /// [`gap_kind`] seen, once, in the order the meter reported them, then + /// [`NOT_DECLARED_SELF_CONTAINED`] where it applies; for any figure, + /// then, [`REPLY_NOT_THE_RESPONSE`] where it applies. Empty for a + /// `Whole` figure, and for a machine-wide one otherwise (one `Partial` + /// for its meter's own reason, a GPU counter alone, carries none). + partial_because: Vec<&'static str>, +} + +impl Metered { + /// The machine-wide meter's span, graded by [`energy_of`]. + fn machine(m: &Measurement) -> Metered { + Metered { cost: energy_of(m), domain: EnergyDomain::Machine, partial_because: Vec::new() } + } +} + +/// A [`TreeGap`]'s kind, as `energy_partial_because` names it. The match has +/// no wildcard, so a kind of gap the meter adds cannot reach the wire +/// unnamed: this stops compiling instead. +/// +/// A member still running at an end is named by its end, because the two +/// err in opposite directions: at the stop its reading lacks what it spent +/// since its last fold, so the figure is short; at the START its reading is +/// short by as much, so the figure may hold up to a quantum per running +/// thread of energy spent BEFORE the call — the one kind by which a +/// `Partial` figure can exceed the call's energy rather than fall short. +fn gap_kind(gap: &TreeGap) -> &'static str { + match gap { + TreeGap::Unreadable { .. } => "unreadable", + TreeGap::Unlisted { .. } => "unlisted", + TreeGap::Unsettled { end: SpanEnd::Start, .. } => "unsettled-at-start", + TreeGap::Unsettled { end: SpanEnd::Stop, .. } => "unsettled-at-stop", + TreeGap::Changing { .. } => "tree-changing", + TreeGap::PredatesSpan { .. } => "predates-span", + TreeGap::Gone { .. } => "gone", + TreeGap::EnergyWentBack { .. } => "energy-went-back", + TreeGap::ReapedDescendant { .. } => "reaped-descendant", + TreeGap::Exited { .. } => "exited", + TreeGap::Gpu { .. } => "gpu", + TreeGap::GpuUnknown { .. } => "gpu-unknown", + TreeGap::NeuralEngine { .. } => "neural-engine", + } +} + +/// A gated call's cost from what its two meters read. The upstream tree's +/// reading comes first, being the work's own process rather than the +/// machine: +/// +/// - a reading with no gap, of a server the operator declared +/// self-contained: `HwShunt`, `Whole`; +/// - any other reading — a gap seen, or no declaration: `HwShunt`, +/// `Partial`. Its remainder is unmeasured, not zero (and with a member +/// still running at the start, `unsettled-at-start`, it may hold a little +/// from before the call: it is then no lower bound), and it is debited +/// all the same, as every allowed call's figure is (SPEC §5); +/// - no reading — no tree meter, or the span's [`TreeError`] (the root gone +/// or unreadable, or the tree's count not advanced): the machine-wide +/// span, exactly as [`energy_of`] grades it; +/// - and whichever it is, `Partial` with [`REPLY_NOT_THE_RESPONSE`] unless +/// `the_response`: the line the upstream returned was the call's own +/// response, so both spans ended when the call's work did. +/// +/// The tree figure is the meter's nanojoules rounded UP to the receipt's +/// whole microjoule: this is a budget's enforcement point, and it never +/// debits less than was read. A reading of 1 to 999 nJ seals as 1 µJ, not +/// as a measured 0 µJ that would debit nothing; the figure is over the +/// reading by less than one unit of the receipt's resolution. +fn grade_call( + tree: Option<&Result>, + self_contained: bool, + machine: &Measurement, + the_response: bool, +) -> Metered { + let mut metered = match tree { + Some(Ok(r)) => { + let mut because: Vec<&'static str> = Vec::new(); + for kind in r.gaps.iter().map(gap_kind) { + if !because.contains(&kind) { + because.push(kind); + } + } + if !self_contained { + because.push(NOT_DECLARED_SELF_CONTAINED); + } + let uj = r.cpu_nj.div_ceil(1_000); + let cost = if because.is_empty() { Energy::measured(uj) } else { Energy::measured_partial(uj) }; + Metered { cost, domain: EnergyDomain::UpstreamTree, partial_because: because } + } + _ => Metered::machine(machine), + }; + if !the_response { + metered.cost.coverage = Coverage::Partial; + metered.partial_because.push(REPLY_NOT_THE_RESPONSE); + } + metered +} + fn deny_reason_of(d: &jcp_runtime::Decision) -> String { match &d.verdict { jcp_schema::Verdict::Deny(r) => r.label().to_string(), @@ -889,6 +1346,8 @@ mod tests { let grade = meta["energy_provenance"].as_str().expect("energy_provenance beside cost_uj"); assert_eq!(Some(grade), sealed.get("energy_provenance").and_then(Value::as_str), "{meta} vs {sealed}"); assert_eq!(meta.get("energy_coverage"), sealed.get("energy_coverage"), "{meta} vs {sealed}"); + // The mock names no process, so the call is metered machine-wide. + assert_eq!(meta["energy_domain"].as_str(), Some("machine"), "{meta}"); eprintln!("gateway _meta: hardware={} {meta}", g.metering_is_hardware()); if !g.metering_is_hardware() { assert_eq!(grade, "Estimator", "an estimate labelled as something else: {meta}"); @@ -912,12 +1371,14 @@ mod tests { (Energy::measured_partial(3_000), "HwShunt", Some("Partial")), (Energy::modeled(700), "ModelBased", None), ] { - let v: Value = serde_json::from_str(&attach_receipt(ok, b"r", &cost, 9)).unwrap(); + let v: Value = serde_json::from_str(&attach_receipt(ok, b"r", &machine(cost), 9)).unwrap(); let meta = v.pointer(&ptr).expect("the jcp _meta block"); assert_eq!(meta["cost_uj"].as_u64(), Some(cost.uj()), "{meta}"); assert!(meta.get("measured_uj").is_none(), "{meta}"); assert_eq!(meta["energy_provenance"].as_str(), Some(provenance), "{meta}"); assert_eq!(meta.get("energy_coverage").and_then(Value::as_str), coverage, "{meta}"); + assert_eq!(meta["energy_domain"].as_str(), Some("machine"), "{meta}"); + assert!(meta.get("energy_partial_because").is_none(), "{meta}"); assert_eq!(meta["budget_remaining_uj"].as_u64(), Some(9)); assert_eq!(v["result"]["ok"].as_bool(), Some(true), "the upstream payload is untouched"); } @@ -939,6 +1400,38 @@ mod tests { assert_eq!(up.calls, 0, "upstream still never contacted"); } + /// A gated call sent as a notification could get neither its result nor + /// its receipt, and no reply would bound its span: it is refused unsent, + /// with nothing sent back, and the attempt is sealed as a denial — for a + /// granted tool too, whose own grant must not be what lets it through. + #[test] + fn a_gated_call_sent_as_a_notification_is_refused_unsent_and_sealed() { + let steward_pub = SigningKey::from_bytes(&[7u8; 32]).verifying_key().to_bytes(); + let mut g = gw(); + let mut up = MockServer { calls: 0 }; + for (tool, label) in [("read_file", "action-not-granted"), ("shell", "wrong-resource")] { + let line = json!({"jsonrpc":"2.0","method":"tools/call", + "params":{"name":tool,"arguments":{"q":"x"}}}) + .to_string(); + let before = g.receipts().len(); + let Relayed::Denied { response, reason } = g.relay(&line, &mut up).unwrap() else { + panic!("a notification-shaped call to `{tool}` was not refused") + }; + assert!(response.is_empty(), "a notification got a reply: {response}"); + assert!(reason.contains("notification"), "{reason}"); + assert_eq!(g.receipts().len(), before + 1, "the attempt on `{tool}` was not sealed"); + let core = jcp_receipt::extract(g.receipts().last().unwrap(), "did:jcp:gateway:files", &steward_pub).unwrap(); + assert_eq!(core.verdict, "deny", "`{tool}`"); + assert_eq!(core.deny_reason.as_deref(), Some(label), "`{tool}`"); + assert_eq!(core.cost_uj, 0); + } + assert_eq!(up.calls, 0, "the server was contacted"); + assert_eq!( + jcp_receipt::verify_log(g.receipts(), "did:jcp:gateway:files", &steward_pub), + Ok(g.receipts().len()) + ); + } + /// The witness moves to MCP's namespaced `_meta`, and the flat key a client /// may still be sending keeps working — a silent behaviour change at an /// enforcement point is worse than a legacy key. @@ -964,10 +1457,15 @@ mod tests { #[test] fn a_response_the_gateway_cannot_parse_is_returned_untouched() { let junk = "not json at all"; - assert_eq!(attach_receipt(junk, b"r", &Energy::estimator(1), 2), junk); + assert_eq!(attach_receipt(junk, b"r", &machine(Energy::estimator(1)), 2), junk); // Valid JSON, but no `result` object to extend. let err = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"x"}}"#; - assert_eq!(attach_receipt(err, b"r", &Energy::estimator(1), 2), err); + assert_eq!(attach_receipt(err, b"r", &machine(Energy::estimator(1)), 2), err); + } + + /// A machine-wide figure, as [`Metered::machine`] makes one from a span. + fn machine(cost: Energy) -> Metered { + Metered { cost, domain: EnergyDomain::Machine, partial_because: Vec::new() } } /// Base64 is RFC 4648 with padding — pinned against known vectors, because a @@ -1268,6 +1766,42 @@ mod tests { assert_eq!(up.calls, 4); } + /// A server sends nothing back for a notification, or for the client's + /// response to a request the server made, so the gateway sends each with + /// `notify` and never waits for a reply: this upstream's `call` fails + /// the test if either reaches it. `notifications/initialized` is the + /// one every client sends right after `initialize`. + #[test] + fn a_line_the_server_never_answers_is_sent_without_waiting_for_a_reply() { + #[derive(Default)] + struct NoReplies { + notified: Vec, + } + impl Upstream for NoReplies { + fn call(&mut self, line: &str) -> std::io::Result { + panic!("waited for a reply the server never sends: {line}"); + } + fn notify(&mut self, line: &str) -> std::io::Result<()> { + self.notified.push(line.to_string()); + Ok(()) + } + } + let mut g = gw(); + let mut up = NoReplies::default(); + let lines = [ + json!({"jsonrpc":"2.0","method":"notifications/initialized"}).to_string(), + json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":3}}).to_string(), + // The client's answer to a server's `sampling/createMessage`. + json!({"jsonrpc":"2.0","id":"s-1","result":{"role":"assistant","content":{"type":"text","text":"hi"}}}).to_string(), + json!({"jsonrpc":"2.0","id":"s-2","error":{"code":-1,"message":"declined"}}).to_string(), + ]; + for line in &lines { + assert_eq!(g.relay(line, &mut up).unwrap(), Relayed::Notified, "{line}"); + } + assert_eq!(up.notified, lines, "each sent upstream once, untouched"); + assert!(g.receipts().is_empty(), "nothing gated, nothing sealed"); + } + #[test] fn ungranted_tool_is_denied_without_contacting_upstream() { let mut g = gw(); @@ -1406,6 +1940,346 @@ mod tests { } } + // ── a call metered on the upstream server's own process tree ───────────── + + use sandbox_meter::SpanEnd; + + /// A tree reading of `cpu_nj` with `gaps`, as the meter builds one. + fn reading(cpu_nj: u64, gaps: Vec) -> TreeReading { + TreeReading { cpu_uj: cpu_nj.div_ceil(1_000), cpu_nj, processes: 1, pids: vec![4242], wall: Duration::from_millis(2), gaps } + } + + /// A tree meter that runs the call and returns the reading it was given. + struct Canned(Result); + impl TreeSpan for Canned { + fn span(&self, call: &mut dyn FnMut()) -> Result { + call(); + self.0.clone() + } + } + + /// The mock server, naming a process as its own. + struct Named { + inner: MockServer, + pid: Option, + } + impl Upstream for Named { + fn call(&mut self, line: &str) -> std::io::Result { + self.inner.call(line) + } + fn pid(&self) -> Option { + self.pid + } + } + + /// Every shape a call's tree reading takes, whether the operator + /// declared the server self-contained, and what the call is then sealed + /// as: its provenance, its coverage (`None`: whole), where it was read, + /// and why it is partial. The machine-wide figure of the error shapes is + /// the fallback span's, whatever this host's machine meter is. + #[allow(clippy::type_complexity)] + fn tree_shapes() -> Vec<(Option>, bool, Option<(&'static str, Option<&'static str>)>, &'static str, Vec<&'static str>)> { + let reaped = |pid| TreeGap::ReapedDescendant { pid, cpu: Duration::from_micros(800) }; + let gaps = vec![reaped(4242), TreeGap::Gpu { ns: 12_000 }, reaped(4243), TreeGap::Unsettled { pid: 4243, end: SpanEnd::Stop }]; + let tree = "upstream-process-tree"; + vec![ + // Nothing seen missing, and the operator declared the server + // self-contained: the one shape sealed whole. + (Some(Ok(reading(1_234_567, vec![]))), true, Some(("HwShunt", None)), tree, vec![]), + // Nothing seen missing, and no declaration: delegation outside + // the tree is not ruled out. + (Some(Ok(reading(1_234_567, vec![]))), false, Some(("HwShunt", Some("Partial"))), tree, vec![NOT_DECLARED_SELF_CONTAINED]), + // Gaps seen: partial however the server is declared, each kind + // named once, in the order seen. + (Some(Ok(reading(9_870, gaps.clone()))), true, Some(("HwShunt", Some("Partial"))), tree, vec!["reaped-descendant", "gpu", "unsettled-at-stop"]), + ( + Some(Ok(reading(9_870, gaps))), + false, + Some(("HwShunt", Some("Partial"))), + tree, + vec!["reaped-descendant", "gpu", "unsettled-at-stop", NOT_DECLARED_SELF_CONTAINED], + ), + // No reading: the machine-wide span, as it would be with no tree. + (Some(Err(TreeError::NotAdvanced { gaps: vec![TreeGap::Gone { pid: 9 }] })), true, None, "machine", vec![]), + (Some(Err(TreeError::RootGone { pid: 4242 })), true, None, "machine", vec![]), + (Some(Err(TreeError::RootUnreadable { pid: 4242, errno: 1 })), false, None, "machine", vec![]), + (None, true, None, "machine", vec![]), + ] + } + + /// The grading, pure: a tree reading is `Whole` only with no gap AND the + /// operator's declaration, and `Partial` otherwise, its figure the + /// reading's either way; a span with no reading is the machine-wide + /// span's figure exactly as `energy_of` grades it. Every figure obeys + /// the provenance coupling, and the plaintext attached to a response + /// says what the figure is, where it was read and why it is partial. + #[test] + fn a_tree_reading_is_whole_only_with_no_gap_and_the_operators_declaration() { + let est = span(vec![rec(DeviceClass::Cpu, None, 60_000, Grade::Estimator)]); + let ok = r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#; + let ptr = format!("/result/_meta/{}", JCP_META_NS.replace('/', "~1")); + for (tree, declared, grade, domain, because) in tree_shapes() { + let m = grade_call(tree.as_ref(), declared, &est, true); + let want = match (&tree, grade) { + (Some(Ok(r)), Some((_, None))) => Energy::measured(r.cpu_nj.div_ceil(1_000)), + (Some(Ok(r)), Some((_, Some(_)))) => Energy::measured_partial(r.cpu_nj.div_ceil(1_000)), + _ => energy_of(&est), + }; + eprintln!("gateway grade: declared={declared} {tree:?} -> {:?} {} {:?}", m.cost, m.domain.label(), m.partial_because); + assert_eq!(m.cost, want, "{tree:?} declared={declared}"); + m.cost.check_provenance().unwrap(); + assert_eq!(m.domain.label(), domain); + assert_eq!(m.partial_because, because); + + let v: Value = serde_json::from_str(&attach_receipt(ok, b"r", &m, 0)).unwrap(); + let meta = v.pointer(&ptr).expect("the jcp _meta block"); + assert_eq!(meta["cost_uj"].as_u64(), Some(want.uj()), "{meta}"); + assert_eq!(meta["energy_provenance"].as_str(), Some(want.provenance.jcr1_label()), "{meta}"); + let coverage = (!want.is_whole()).then(|| want.coverage.label()); + assert_eq!(meta.get("energy_coverage").and_then(Value::as_str), coverage, "{meta}"); + assert_eq!(meta["energy_domain"].as_str(), Some(domain), "{meta}"); + let listed: Option> = + meta.get("energy_partial_because").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()); + assert_eq!(listed, (!because.is_empty()).then_some(because), "{meta}"); + } + } + + /// A measured call is never sealed or debited as 0 µJ: the tree's + /// nanojoules are rounded UP to the receipt's microjoule, whatever µJ + /// figure the reading itself carries, so the enforcement point never + /// debits less than was read, and is over by less than one unit. + #[test] + fn a_tree_reading_is_rounded_up_to_a_whole_microjoule() { + let est = span(vec![rec(DeviceClass::Cpu, None, 60_000, Grade::Estimator)]); + for (nj, uj) in [(1, 1), (999, 1), (1_000, 1), (1_001, 2), (2_000_001, 2_001)] { + let r = TreeReading { cpu_uj: nj / 1_000, ..reading(nj, vec![]) }; + for declared in [true, false] { + let m = grade_call(Some(&Ok(r.clone())), declared, &est, true); + assert_eq!(m.cost.uj(), uj, "{nj} nJ, declared={declared}"); + assert_eq!(m.cost.measured_uj, Some(uj)); + } + } + // Through the relay: what is debited is what is sealed. + let mut g = gw(); + g.declare_upstream_self_contained(); + g.upstream = Some((4242, Some(Box::new(Canned(Ok(TreeReading { cpu_uj: 0, ..reading(999, vec![]) })))))); + let mut up = Named { inner: MockServer { calls: 0 }, pid: Some(4242) }; + let Relayed::Allowed { cost_uj, budget_remaining_uj, .. } = g.relay(&call("read_file", 1), &mut up).unwrap() else { + panic!("expected Allowed") + }; + assert_eq!((cost_uj, budget_remaining_uj), (1, 50_000_000 - 1)); + } + + /// The same shapes through the whole relay path: the call runs once, + /// inside the tree's span; what is debited, sealed and attached is the + /// graded figure; and the plaintext provenance and coverage equal the + /// sealed ones, for a tree figure and for the fallback alike. + #[test] + fn every_tree_reading_shape_seals_what_the_plaintext_says() { + let steward_pub = SigningKey::from_bytes(&[7u8; 32]).verifying_key().to_bytes(); + let ptr = format!("/result/_meta/{}", JCP_META_NS.replace('/', "~1")); + for (tree, declared, grade, domain, _) in tree_shapes() { + let mut g = gw(); + if declared { + g.declare_upstream_self_contained(); + } + let mut up = Named { inner: MockServer { calls: 0 }, pid: Some(4242) }; + if let Some(t) = tree.clone() { + g.upstream = Some((4242, Some(Box::new(Canned(t))))); + assert_eq!(g.call_meter(&up), CallMeter::UpstreamTree { pid: 4242 }); + } else { + g.upstream = Some((4242, None)); + } + let Relayed::Allowed { response, cost_uj, budget_remaining_uj } = g.relay(&call("read_file", 1), &mut up).unwrap() else { + panic!("expected Allowed") + }; + assert_eq!(up.inner.calls, 1, "the call ran exactly once"); + assert_eq!(budget_remaining_uj, 50_000_000 - cost_uj, "debited by the figure"); + + let sealed = jcr1_payload(g.receipts().last().unwrap(), "did:jcp:gateway:files", &steward_pub); + let v: Value = serde_json::from_str(&response).unwrap(); + let meta = v.pointer(&ptr).expect("the jcp _meta block"); + let mut shown = meta.clone(); + if let Some(o) = shown.as_object_mut() { + o.remove("receipt_b64"); + } + eprintln!( + "gateway relay: declared={declared} {:?} -> sealed {} {}; _meta {shown}", + tree.as_ref().map(|t| t.as_ref().map(|r| &r.gaps)), + sealed["energy_provenance"], + sealed.get("energy_coverage").unwrap_or(&Value::Null), + ); + assert_eq!(sealed.get("cost_uj").and_then(Value::as_u64), Some(cost_uj), "{sealed}"); + assert_eq!(meta["cost_uj"].as_u64(), Some(cost_uj), "{meta}"); + assert_eq!(meta.get("energy_provenance"), sealed.get("energy_provenance"), "{meta} vs {sealed}"); + assert_eq!(meta.get("energy_coverage"), sealed.get("energy_coverage"), "{meta} vs {sealed}"); + assert_eq!(meta["energy_domain"].as_str(), Some(domain), "{meta}"); + match (&tree, grade) { + (Some(Ok(r)), Some((provenance, coverage))) => { + assert_eq!(cost_uj, r.cpu_nj.div_ceil(1_000), "the tree's figure is the reading's, rounded up"); + assert_eq!(sealed.get("energy_provenance").and_then(Value::as_str), Some(provenance), "{sealed}"); + assert_eq!(sealed.get("energy_coverage").and_then(Value::as_str), coverage, "{sealed}"); + } + _ if !g.metering_is_hardware() => { + assert_eq!(sealed.get("energy_provenance").and_then(Value::as_str), Some("Estimator"), "{sealed}"); + } + _ => {} + } + assert_eq!(jcp_receipt::verify_log(g.receipts(), "did:jcp:gateway:files", &steward_pub), Ok(g.receipts().len())); + } + } + + /// The tree meter is the one built for the pid the upstream names now: + /// kept while it names the same one (or none, which meters machine-wide + /// and drops nothing), built afresh when it names another, and none for + /// a pid this process cannot read, whose calls are metered machine-wide. + #[test] + fn the_upstream_tree_meter_follows_the_pid_the_upstream_names() { + let mut g = gw(); + let machine = if g.metering_is_hardware() { CallMeter::MachineHardware } else { CallMeter::Estimator }; + assert_eq!(g.call_meter(&MockServer { calls: 0 }), machine, "a mock names no process"); + assert!(g.upstream.is_none(), "and builds no tree meter"); + + g.upstream = Some((4242, Some(Box::new(Canned(Ok(reading(5_000, vec![]))))))); + let mut up = Named { inner: MockServer { calls: 0 }, pid: Some(4242) }; + assert_eq!(g.call_meter(&up), CallMeter::UpstreamTree { pid: 4242 }); + up.pid = None; + assert_eq!(g.call_meter(&up), machine); + up.pid = Some(4242); + assert_eq!(g.call_meter(&up), CallMeter::UpstreamTree { pid: 4242 }, "naming none dropped nothing"); + + // No process has this pid (it is no `pid_t`): no meter, on any host. + up.pid = Some(u32::MAX); + assert_eq!(g.call_meter(&up), machine); + assert!(matches!(&g.upstream, Some((p, None)) if *p == u32::MAX), "built afresh for the new pid"); + let Relayed::Allowed { response, .. } = g.relay(&call("read_file", 1), &mut up).unwrap() else { + panic!("expected Allowed") + }; + let v: Value = serde_json::from_str(&response).unwrap(); + let meta = v.pointer(&format!("/result/_meta/{}", JCP_META_NS.replace('/', "~1"))).unwrap(); + assert_eq!(meta["energy_domain"].as_str(), Some("machine"), "{meta}"); + + // A new settle bound applies from the next call: the meter is built again. + g.set_upstream_settle_bound(Duration::from_millis(5)); + assert!(g.upstream.is_none()); + } + + /// A call's figure is `Partial` whichever meter read it when the line + /// the upstream returned is not the call's own response: a notification + /// (`notifications/progress` mid-call), a response to another id, a + /// server's request. The span stopped there, before the call's work was + /// done. Only the response with the call's id, carrying a `result` or + /// an `error`, leaves a figure whole. + #[test] + fn a_figure_is_partial_when_the_upstreams_line_is_not_the_calls_response() { + struct Says(String); + impl Upstream for Says { + fn call(&mut self, _: &str) -> std::io::Result { + Ok(self.0.clone()) + } + fn pid(&self) -> Option { + Some(4242) + } + } + let steward_pub = SigningKey::from_bytes(&[7u8; 32]).verifying_key().to_bytes(); + let ok = |id: Value| json!({"jsonrpc":"2.0","id":id,"result":{"ok":true}}).to_string(); + let lines = [ + (ok(json!(1)), true), + (json!({"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"failed"}}).to_string(), true), + (json!({"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":1,"progress":1}}).to_string(), false), + (ok(json!(2)), false), + (ok(json!("1")), false), + (json!({"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{}}).to_string(), false), + (String::new(), false), + ]; + for tree in [true, false] { + for (line, whole) in &lines { + let mut g = gw(); + g.declare_upstream_self_contained(); + let canned: Option> = tree.then(|| Box::new(Canned(Ok(reading(5_000_000, vec![])))) as Box); + g.upstream = Some((4242, canned)); + let Relayed::Allowed { response, .. } = g.relay(&call("read_file", 1), &mut Says(line.clone())).unwrap() else { + panic!("expected Allowed") + }; + let sealed = jcr1_payload(g.receipts().last().unwrap(), "did:jcp:gateway:files", &steward_pub); + let coverage = sealed.get("energy_coverage").and_then(Value::as_str); + eprintln!("gateway reply check: tree={tree} {line:?} -> {} {coverage:?}", sealed["energy_provenance"]); + if *whole && tree { + assert_eq!(coverage, None, "the call's own response: {sealed}"); + } else if !whole { + assert_eq!(coverage, Some("Partial"), "not the call's response, yet {sealed}"); + } + if let Ok(v) = serde_json::from_str::(&response) { + if let Some(meta) = v.pointer(&format!("/result/_meta/{}", JCP_META_NS.replace('/', "~1"))) { + let because = meta.get("energy_partial_because").and_then(Value::as_array).cloned().unwrap_or_default(); + assert_eq!(because.contains(&json!(REPLY_NOT_THE_RESPONSE)), !whole, "{meta}"); + } + } + } + } + assert!(is_response(&ok(json!(1))) && is_response(&lines[1].0)); + assert!(!is_response(&lines[2].0) && !is_response(&lines[5].0) && !is_response("not json") && !is_response("[1]")); + assert_eq!((message_id(&ok(json!("s-1"))), message_id(&lines[2].0)), (Some(json!("s-1")), None)); + } + + /// A call is metered on the tree meter `call_meter` reports for it: an + /// upstream that names no process now is metered machine-wide, even + /// with a meter kept from the pid it named before, and is metered on + /// that tree again once it names that pid again. + #[test] + fn a_call_is_metered_on_the_tree_of_the_pid_named_now_or_machine_wide() { + let mut g = gw(); + let machine = if g.metering_is_hardware() { CallMeter::MachineHardware } else { CallMeter::Estimator }; + g.declare_upstream_self_contained(); + g.upstream = Some((4242, Some(Box::new(Canned(Ok(reading(5_000_000, vec![]))))))); + let mut up = Named { inner: MockServer { calls: 0 }, pid: Some(4242) }; + let domain = |g: &mut Gateway, up: &mut Named, id: i64| { + let Relayed::Allowed { response, .. } = g.relay(&call("read_file", id), up).unwrap() else { + panic!("expected Allowed") + }; + let v: Value = serde_json::from_str(&response).unwrap(); + let meta = v.pointer(&format!("/result/_meta/{}", JCP_META_NS.replace('/', "~1"))).unwrap().clone(); + (meta["energy_domain"].as_str().unwrap().to_string(), meta["cost_uj"].as_u64().unwrap()) + }; + assert_eq!(g.call_meter(&up), CallMeter::UpstreamTree { pid: 4242 }); + assert_eq!(domain(&mut g, &mut up, 1), ("upstream-process-tree".to_string(), 5_000)); + up.pid = None; + assert_eq!(g.call_meter(&up), machine); + assert_eq!(domain(&mut g, &mut up, 2).0, "machine", "a call to an upstream naming no process, read on a tree"); + up.pid = Some(4242); + assert_eq!(domain(&mut g, &mut up, 3).0, "upstream-process-tree", "the meter kept for 4242 serves it again"); + assert_eq!(up.inner.calls, 3, "each call ran once"); + } + + /// Each kind of gap has its own name on the wire, and none is the + /// operator's missing declaration. + #[test] + fn every_gap_kind_has_a_distinct_wire_name() { + let end = SpanEnd::Start; + let all = [ + TreeGap::Unreadable { pid: 1, errno: 1, end }, + TreeGap::Unlisted { pid: 1, errno: 1, end }, + TreeGap::Unsettled { pid: 1, end }, + TreeGap::Unsettled { pid: 1, end: SpanEnd::Stop }, + TreeGap::Changing { end }, + TreeGap::PredatesSpan { pid: 1 }, + TreeGap::Gone { pid: 1 }, + TreeGap::EnergyWentBack { pid: 1 }, + TreeGap::ReapedDescendant { pid: 1, cpu: Duration::from_millis(1) }, + TreeGap::Exited { pid: 1 }, + TreeGap::Gpu { ns: 1 }, + TreeGap::GpuUnknown { pid: None }, + TreeGap::NeuralEngine { pid: 1, bytes: 1 }, + ]; + let kinds: BTreeSet<&str> = all.iter().map(gap_kind).collect(); + assert_eq!(kinds.len(), all.len(), "{kinds:?}"); + assert!(!kinds.contains(NOT_DECLARED_SELF_CONTAINED)); + assert_eq!(gap_kind(&TreeGap::GpuUnknown { pid: Some(3) }), "gpu-unknown"); + for k in kinds { + assert!(!k.is_empty() && k.chars().all(|c| c.is_ascii_lowercase() || c == '-'), "{k}"); + } + } + // ── the end-to-end attack the whole feature exists to stop ─────────────── use jcp_schema::flow::{Integrity, Label, Origins}; diff --git a/jcp/jcp-rs/crates/jcp-gateway/tests/stdio_binary.rs b/jcp/jcp-rs/crates/jcp-gateway/tests/stdio_binary.rs new file mode 100644 index 000000000..8677d904e --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-gateway/tests/stdio_binary.rs @@ -0,0 +1,192 @@ +//! The `jcp-gateway` binary itself, over stdio, in front of a real server +//! process: the way an MCP client runs it. Each test drives the binary with +//! newline-delimited JSON-RPC on its stdin and reads its stdout on a thread +//! of its own, so a gateway that stops answering fails the test at a +//! deadline instead of hanging it. +//! +//! The server is awk, answering only the lines that carry an `id`, as an MCP +//! server answers requests and never notifications. It runs on every host +//! this crate builds on. + +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::time::Duration; + +/// How long a test waits for one line from the gateway: far past anything a +/// call takes, on a host at a load of 14 to 260. +const LINE_DEADLINE: Duration = Duration::from_secs(120); + +/// An MCP-ish server in awk: a request (a line with an `id`) gets one +/// response carrying that id; a line without one gets nothing. +const ANSWERS_REQUESTS_ONLY: &str = r#"/"id"/ { + id = "null" + if (match($0, /"id":[0-9]+/)) id = substr($0, RSTART + 5, RLENGTH - 5) + if ($0 ~ /"method":"initialize"/) printf "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{}}}\n", id + else printf "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"done\"}]}}\n", id + fflush() +}"#; + +/// The gateway binary, running, with its server. +struct Gateway { + child: Child, + stdin: ChildStdin, + lines: Receiver, + stderr: Receiver, +} + +impl Gateway { + fn spawn(name: &str, server: &[&str], extra: &[&str]) -> Gateway { + let receipts = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")) + .join(format!("jcp-gateway-{name}-{}.cose", std::process::id())); + let _ = std::fs::remove_file(&receipts); + let mut child = Command::new(env!("CARGO_BIN_EXE_jcp-gateway")) + .args(["--server", name, "--grant", "echo=1000000000"]) + .args(extra) + .arg("--") + .args(server) + .env("JCP_GATEWAY_RECEIPTS", &receipts) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn the jcp-gateway binary"); + let stdin = child.stdin.take().unwrap(); + let (tx, lines) = mpsc::channel(); + let out = child.stdout.take().unwrap(); + std::thread::spawn(move || { + for line in BufReader::new(out).lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + let (etx, stderr) = mpsc::channel(); + let mut err = child.stderr.take().unwrap(); + std::thread::spawn(move || { + let mut all = String::new(); + let _ = err.read_to_string(&mut all); + let _ = etx.send(all); + }); + Gateway { child, stdin, lines, stderr } + } + + fn send(&mut self, msg: &Value) { + writeln!(self.stdin, "{msg}").expect("write to the gateway"); + self.stdin.flush().expect("flush to the gateway"); + } + + /// The next line the gateway writes, parsed; fails the test, with the + /// gateway's stderr, if none comes by the deadline. + fn next(&mut self, waiting_for: &str) -> Value { + match self.lines.recv_timeout(LINE_DEADLINE) { + Ok(line) => serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON ({e}): {line}")), + Err(_) => { + let _ = self.child.kill(); + let _ = self.child.wait(); + let stderr = self.stderr.recv_timeout(Duration::from_secs(5)).unwrap_or_default(); + panic!("no line from the gateway while waiting for {waiting_for}; its stderr:\n{stderr}") + } + } + } +} + +impl Drop for Gateway { + fn drop(&mut self) { + // Its server reads the gateway's pipe, so it sees EOF and exits too. + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn call(id: i64) -> Value { + json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"echo","arguments":{}}}) +} + +/// The handshake every MCP client opens with — `initialize`, then the +/// `notifications/initialized` notification, which the server answers with +/// nothing — and then calls. The gateway must send the notification on +/// without waiting for a reply, or it never reads another line. +#[test] +fn the_binary_serves_calls_after_the_initialized_notification() { + let mut g = Gateway::spawn("smoke", &["/usr/bin/awk", ANSWERS_REQUESTS_ONLY], &[]); + g.send(&json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}})); + let init = g.next("the initialize response"); + assert_eq!(init["id"], json!(1), "{init}"); + assert!(init["result"]["capabilities"]["extensions"]["science.transaction/jcp"].is_object(), "{init}"); + g.send(&json!({"jsonrpc":"2.0","method":"notifications/initialized"})); + for id in 2..5 { + g.send(&call(id)); + let r = g.next(&format!("the response to call {id}")); + assert_eq!(r["id"], json!(id), "{r}"); + assert_eq!(r["result"]["content"][0]["text"], json!("done"), "{r}"); + let meta = &r["result"]["_meta"]["science.transaction/jcp"]; + assert!(meta["receipt_b64"].is_string(), "each call carries its sealed receipt: {r}"); + eprintln!("stdio binary: call {id}: cost_uj {} [{}] domain {}", meta["cost_uj"], meta["energy_provenance"], meta["energy_domain"]); + // A notification between calls is sent on, unanswered, too. + g.send(&json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}})); + } +} + +/// An MCP-ish server in awk that, inside each `tools/call`, first sends the +/// client a `notifications/progress` and a request of its own +/// (`sampling/createMessage`, id `s`), waits for the client's +/// answer, and only then sends the call's response, saying whether it got +/// the answer. Every other request gets a plain response; notifications get +/// nothing. +const TALKS_MID_CALL: &str = r#"/"method":"tools\/call"/ { + id = "null" + if (match($0, /"id":[0-9]+/)) id = substr($0, RSTART + 5, RLENGTH - 5) + printf "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":%s,\"progress\":1}}\n", id + printf "{\"jsonrpc\":\"2.0\",\"id\":\"s%s\",\"method\":\"sampling/createMessage\",\"params\":{\"maxTokens\":1}}\n", id + fflush() + answer = "" + getline answer + got = (answer ~ ("\"id\":\"s" id "\"") && answer ~ /"result"/) ? "sampled" : "unanswered" + printf "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"%s\"}]}}\n", id, got + fflush() + next +} +/"id"/ { + id = "null" + if (match($0, /"id":[0-9]+/)) id = substr($0, RSTART + 5, RLENGTH - 5) + printf "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{}}}\n", id + fflush() +}"#; + +/// A server that talks in the middle of a call: the gateway hands its +/// notification and its request to the client as they come, carries the +/// client's answer back to the server while the call is still open, and +/// returns, as the call's response, the line with the call's own id — so +/// each call gets its own response, sealed, and the next call is not handed +/// the previous one's. Under `--upstream-self-contained` the awk server is +/// one process; whatever its figure is sealed as, it is the response with +/// the call's id that ends its span. +#[test] +fn the_binary_relays_what_a_server_sends_in_the_middle_of_a_call() { + let mut g = Gateway::spawn("chatty", &["/usr/bin/awk", TALKS_MID_CALL], &["--upstream-self-contained"]); + g.send(&json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}})); + assert_eq!(g.next("the initialize response")["id"], json!(1)); + g.send(&json!({"jsonrpc":"2.0","method":"notifications/initialized"})); + for id in 2..5 { + g.send(&call(id)); + let progress = g.next(&format!("call {id}'s progress notification")); + assert_eq!(progress["method"], json!("notifications/progress"), "{progress}"); + assert_eq!(progress["params"]["progressToken"], json!(id), "{progress}"); + let ask = g.next(&format!("call {id}'s sampling request")); + assert_eq!(ask["method"], json!("sampling/createMessage"), "{ask}"); + let ask_id = ask["id"].clone(); + assert_eq!(ask_id, json!(format!("s{id}")), "{ask}"); + g.send(&json!({"jsonrpc":"2.0","id":ask_id,"result":{"role":"assistant","content":{"type":"text","text":"ok"}}})); + let r = g.next(&format!("the response to call {id}")); + assert_eq!(r["id"], json!(id), "each call gets its own response: {r}"); + assert_eq!(r["result"]["content"][0]["text"], json!("sampled"), "the server got the client's answer mid-call: {r}"); + let meta = &r["result"]["_meta"]["science.transaction/jcp"]; + assert!(meta["receipt_b64"].is_string(), "{r}"); + let because = meta["energy_partial_because"].as_array().cloned().unwrap_or_default(); + assert!(!because.contains(&json!("reply-not-the-response")), "the span ended at the call's own response: {meta}"); + eprintln!("stdio binary: chatty call {id}: cost_uj {} [{}] domain {} because {because:?}", meta["cost_uj"], meta["energy_provenance"], meta["energy_domain"]); + } +} diff --git a/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs new file mode 100644 index 000000000..cc950cac0 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs @@ -0,0 +1,498 @@ +//! The gateway metering real calls on the upstream server's own process +//! tree. Each test spawns an MCP-ish server of its own (a process group of +//! its own, killed at the end) that answers one JSON-RPC `tools/call` per +//! line, burning the loop count its arguments name before it replies, and +//! relays `tools/call`s to it through [`Gateway::relay`]. +//! +//! The oracle shares no code with the meter or the gateway: the server's +//! `proc_pid_rusage(pid, RUSAGE_INFO_V6)` read into a raw buffer and taken +//! at the SDK's byte offsets (energy at 336, reaped-descendant CPU time at +//! 96 and 104), read before and after each relay once the server is quiet +//! on the oracle's own terms: no thread on a core or queued +//! (`proc_pidinfo(PROC_PIDTASKINFO)` into a raw buffer, `pti_numrunning` at +//! byte 88) at two reads 20 ms apart whose energy agrees. A server blocked +//! waiting for its next request does not move its count, so between the +//! meter's reads and the oracle's nothing is spent: the call's figure must +//! be the oracle's advance across the relay, in nanojoules rounded UP to +//! the receipt's microjoules (an enforcement point never debits less than +//! was read). That is the tolerance — exact to the µJ: the figure is at +//! least the oracle's advance and less than 1 µJ over it. +//! +//! What a call is sealed as is read back from its receipt, verified, and +//! compared with the plaintext beside the response. The tests log what they +//! saw (`upstream tree:`), and run one at a time (a lock) so one test's +//! server is not loading the host under another's. + +#![cfg(all(target_os = "macos", target_arch = "aarch64"))] + +use ed25519_dalek::SigningKey; +use jcp_gateway::{CallMeter, Gateway, Relayed, Upstream, JCP_META_NS}; +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::process::CommandExt; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +static ONE_AT_A_TIME: Mutex<()> = Mutex::new(()); + +fn alone() -> MutexGuard<'static, ()> { + ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// How long the gateway's tree meter may wait for the server to go quiet in +/// these tests: far past the default bound, because the host runs at a load +/// of 14 to 260 and a reply's tail can wait that long for a core. A server +/// still running after it would be a gap and the call `Partial`, failing a +/// test that expects it whole, which is the point: a whole reading is never +/// a stale one. +const TEST_SETTLE: Duration = Duration::from_secs(2); + +/// The JSON-RPC result for the request line in `l`: its id, and its +/// `arguments.n` as a loop count burned before replying. +const REPLY: &str = r#"id = "null"; n = 0 + if (match(l, /"id":[0-9]+/)) id = substr(l, RSTART + 5, RLENGTH - 5) + if (match(l, /"n":[0-9]+/)) n = substr(l, RSTART + 4, RLENGTH - 4) + 0 + for (i = 0; i < n; i++) x += i + printf "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"burned %d\"}]}}\n", id, n"#; + +const STEWARD: &str = "did:jcp:gateway:burner"; + +fn steward_sk() -> SigningKey { + SigningKey::from_bytes(&[0x2c; 32]) +} + +/// `pid`'s `rusage_info_v6`, raw: `proc_pid_rusage(pid, 6)` into a buffer +/// larger than the struct's 464 bytes, as 64-bit words. +fn oracle_rusage(pid: i32) -> Option<[u64; 64]> { + let mut buf = [0u64; 64]; + // SAFETY: `buf` is writable and larger than `rusage_info_v6`. + let rc = unsafe { libc::proc_pid_rusage(pid, 6, buf.as_mut_ptr() as *mut libc::rusage_info_t) }; + (rc == 0).then_some(buf) +} + +fn energy_nj(ru: &[u64; 64]) -> u64 { + ru[336 / 8] +} + +fn reaped_cpu_ticks(ru: &[u64; 64]) -> u64 { + ru[96 / 8] + ru[104 / 8] +} + +/// `pti_numrunning` of `pid`: `proc_pidinfo(pid, PROC_PIDTASKINFO = 4)` into +/// a raw 96-byte buffer, the int32 at byte 88. +fn oracle_running(pid: i32) -> Option { + let mut buf = [0u8; 96]; + // SAFETY: `buf` is writable for the 96 bytes passed. + let n = unsafe { libc::proc_pidinfo(pid, 4, 0, buf.as_mut_ptr().cast(), 96) }; + (n == 96).then(|| i32::from_ne_bytes(buf[88..92].try_into().unwrap())) +} + +/// `pid`'s rusage once it is quiet by the oracle's rule: not running or +/// queued at two reads 20 ms apart, with the same energy at both. Panics +/// after 20 s. +fn quiet(pid: i32) -> [u64; 64] { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let first = oracle_rusage(pid).expect("oracle read"); + let before = oracle_running(pid) == Some(0); + std::thread::sleep(Duration::from_millis(20)); + let second = oracle_rusage(pid).expect("oracle read"); + let after = oracle_running(pid) == Some(0); + if before && after && energy_nj(&first) == energy_nj(&second) { + return second; + } + assert!(Instant::now() < deadline, "the oracle never saw {pid} quiet"); + } +} + +/// An MCP-ish server: a child in a process group of its own, one JSON-RPC +/// request per line on its stdin and one response per line on its stdout, +/// killed with its whole group on drop. It names its pid to the gateway +/// (`named`, normally its own), and is never waited on while it serves, so +/// its pid stays its own. +struct Server { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + named: Option, +} + +impl Server { + fn spawn(program: &str, args: &[&str]) -> Server { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .process_group(0) + .spawn() + .expect("spawn the server"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let named = Some(child.id()); + Server { child, stdin, stdout, named } + } + + /// awk answering every request itself: a single-process server. + fn single_process() -> Server { + Server::spawn("/usr/bin/awk", &[&format!("{{ l = $0; {REPLY}; fflush() }}")]) + } + + /// A shell answering each request with an awk worker it forks, waits on + /// and reaps before it replies: every call reaps a descendant. + fn reaping_a_worker_per_call() -> Server { + Server::spawn( + "/bin/sh", + &[ + "-c", + r#"while IFS= read -r l; do r=$(L="$l" /usr/bin/awk "$1"); printf '%s\n' "$r"; done"#, + "sh", + &format!(r#"BEGIN {{ l = ENVIRON["L"]; {REPLY} }}"#), + ], + ) + } + + fn pid(&self) -> i32 { + self.child.id() as i32 + } +} + +impl Upstream for Server { + fn call(&mut self, line: &str) -> std::io::Result { + writeln!(self.stdin, "{line}")?; + self.stdin.flush()?; + let mut reply = String::new(); + self.stdout.read_line(&mut reply)?; + Ok(reply.trim_end().to_string()) + } + + fn pid(&self) -> Option { + self.named + } +} + +impl Drop for Server { + fn drop(&mut self) { + // SAFETY: signals this server's own process group. + unsafe { libc::kill(-self.pid(), libc::SIGKILL) }; + let _ = self.child.wait(); + } +} + +/// A gateway granting `burn` a budget no test reaches. +fn gateway(declared: bool) -> Gateway { + let mut g = Gateway::new("burner", &[("burn", 50_000_000_000)], steward_sk()); + g.set_upstream_settle_bound(TEST_SETTLE); + if declared { + g.declare_upstream_self_contained(); + } + g +} + +fn burn(id: i64, n: u64) -> String { + json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"burn","arguments":{"n":n}}}).to_string() +} + +/// What one allowed call came back as. +struct Call { + cost_uj: u64, + /// The call's sealed receipt, verified and decoded. + sealed: Value, + /// The plaintext beside the response, without the receipt's bytes. + meta: Value, +} + +fn relay(g: &mut Gateway, up: &mut Server, id: i64, n: u64) -> Call { + let Relayed::Allowed { response, cost_uj, .. } = g.relay(&burn(id, n), up).expect("relay") else { + panic!("the call was not allowed") + }; + let pubkey = steward_sk().verifying_key().to_bytes(); + let core = jcp_receipt::jcr1::verify_cose_sign1(g.receipts().last().unwrap(), STEWARD, &pubkey).expect("the receipt verifies"); + let sealed: Value = jcp_schema::from_canonical_cbor(&core).expect("the receipt decodes"); + let response: Value = serde_json::from_str(&response).expect("a JSON response"); + let mut meta = response.pointer(&format!("/result/_meta/{}", JCP_META_NS.replace('/', "~1"))).expect("the jcp _meta").clone(); + meta.as_object_mut().unwrap().remove("receipt_b64"); + assert_eq!(response["id"].as_i64(), Some(id), "the server's own response, relayed: {response}"); + assert_eq!(response["result"]["content"][0]["text"].as_str(), Some(format!("burned {n}").as_str()), "{response}"); + assert_eq!(sealed["cost_uj"].as_u64(), Some(cost_uj), "{sealed}"); + assert_eq!(meta["cost_uj"].as_u64(), Some(cost_uj), "{meta}"); + // The invariant: the plaintext grade is the sealed grade. + assert_eq!(meta.get("energy_provenance"), sealed.get("energy_provenance"), "{meta} vs {sealed}"); + assert_eq!(meta.get("energy_coverage"), sealed.get("energy_coverage"), "{meta} vs {sealed}"); + Call { cost_uj, sealed, meta } +} + +fn grade(c: &Call) -> String { + match c.sealed.get("energy_coverage").and_then(Value::as_str) { + Some(coverage) => format!("{}, {coverage}", c.sealed["energy_provenance"].as_str().unwrap_or("?")), + None => c.sealed["energy_provenance"].as_str().unwrap_or("?").to_string(), + } +} + +fn the_log_verifies(g: &Gateway) { + let pubkey = steward_sk().verifying_key().to_bytes(); + assert_eq!(jcp_receipt::verify_log(g.receipts(), STEWARD, &pubkey), Ok(g.receipts().len())); +} + +/// A single-process server whose operator declared it self-contained: each +/// call, from a bare round trip to a few milliseconds of CPU, is sealed +/// `HwShunt` and whole, read on the server's own process tree, and its +/// figure is the oracle's advance across the relay, exact to the µJ. +#[test] +fn a_declared_self_contained_server_is_read_whole_on_its_own_process() { + let _one = alone(); + let mut s = Server::single_process(); + let pid = s.pid(); + let mut g = gateway(true); + assert_eq!( + g.call_meter(&s), + CallMeter::UpstreamTree { pid: pid as u32 }, + "a child of this process, on a host whose per-process counter is live (every host this runs on)" + ); + for (rep, n) in [0u64, 20_000, 150_000, 300_000, 0].into_iter().enumerate() { + let before = energy_nj(&quiet(pid)); + let c = relay(&mut g, &mut s, rep as i64, n); + let oracle = energy_nj(&quiet(pid)) - before; + eprintln!( + "upstream tree: declared single process, loop {n} rep {rep}: {} µJ [{}], oracle {oracle} nJ, _meta {}", + c.cost_uj, + grade(&c), + c.meta + ); + assert!(oracle > 0, "the server's count advanced over the call"); + assert_eq!(c.sealed["energy_provenance"].as_str(), Some("HwShunt"), "{}", c.sealed); + assert!(c.sealed.get("energy_coverage").is_none(), "whole: {}", c.sealed); + assert_eq!(c.meta["energy_domain"].as_str(), Some("upstream-process-tree"), "{}", c.meta); + assert!(c.meta.get("energy_partial_because").is_none(), "{}", c.meta); + assert_eq!(c.cost_uj, oracle.div_ceil(1_000), "the figure against the oracle's advance, to the µJ"); + } + the_log_verifies(&g); +} + +/// The same server with no declaration: the same reading, to the µJ, but +/// sealed `Partial`, because nothing in the tree can show that the server +/// hands no work to a process outside it; the plaintext says that is why. +#[test] +fn an_undeclared_server_is_read_partial_for_want_of_the_declaration() { + let _one = alone(); + let mut s = Server::single_process(); + let pid = s.pid(); + let mut g = gateway(false); + assert_eq!(g.call_meter(&s), CallMeter::UpstreamTree { pid: pid as u32 }); + for (rep, n) in [0u64, 150_000, 300_000].into_iter().enumerate() { + let before = energy_nj(&quiet(pid)); + let c = relay(&mut g, &mut s, rep as i64, n); + let oracle = energy_nj(&quiet(pid)) - before; + eprintln!( + "upstream tree: undeclared single process, loop {n} rep {rep}: {} µJ [{}], oracle {oracle} nJ, _meta {}", + c.cost_uj, + grade(&c), + c.meta + ); + assert_eq!(c.sealed["energy_provenance"].as_str(), Some("HwShunt"), "{}", c.sealed); + assert_eq!(c.sealed["energy_coverage"].as_str(), Some("Partial"), "{}", c.sealed); + assert_eq!(c.meta["energy_domain"].as_str(), Some("upstream-process-tree"), "{}", c.meta); + assert_eq!(c.meta["energy_partial_because"], json!(["not-declared-self-contained"]), "{}", c.meta); + assert_eq!(c.cost_uj, oracle.div_ceil(1_000), "the figure against the oracle's advance, to the µJ"); + } + the_log_verifies(&g); +} + +/// A server that answers each call with a worker it forks and reaps (a +/// shell loop running awk) is `Partial` even when declared: the worker's +/// energy is in no counter once it is reaped. The figure is the shell's own +/// advance, exactly, and the plaintext names the reaped descendant as why. +#[test] +fn a_server_that_reaps_a_worker_per_call_is_partial_even_when_declared() { + let _one = alone(); + let mut s = Server::reaping_a_worker_per_call(); + let pid = s.pid(); + let mut g = gateway(true); + assert_eq!(g.call_meter(&s), CallMeter::UpstreamTree { pid: pid as u32 }); + for (rep, n) in [20_000u64, 200_000, 200_000].into_iter().enumerate() { + let before = quiet(pid); + let c = relay(&mut g, &mut s, rep as i64, n); + let after = quiet(pid); + let oracle = energy_nj(&after) - energy_nj(&before); + let reaped = reaped_cpu_ticks(&after) - reaped_cpu_ticks(&before); + eprintln!( + "upstream tree: declared shell reaping a worker, loop {n} rep {rep}: {} µJ [{}], oracle (the shell's own) {oracle} nJ, reaped {reaped} ticks, _meta {}", + c.cost_uj, + grade(&c), + c.meta + ); + assert!(reaped > 0, "setup: the worker ran and was reaped in the call"); + assert_eq!(c.sealed["energy_provenance"].as_str(), Some("HwShunt"), "{}", c.sealed); + assert_eq!(c.sealed["energy_coverage"].as_str(), Some("Partial"), "{}", c.sealed); + assert_eq!(c.meta["energy_domain"].as_str(), Some("upstream-process-tree"), "{}", c.meta); + assert_eq!(c.meta["energy_partial_because"], json!(["reaped-descendant"]), "{}", c.meta); + assert_eq!(c.cost_uj, oracle.div_ceil(1_000), "the shell's own advance, to the µJ"); + } + the_log_verifies(&g); +} + +/// A declared server whose worker writes the reply ITSELF, keeps computing +/// for a few milliseconds and exits, and is reaped by the server after the +/// reply has gone (perl, with 60 idle children beside it, so a walk of its +/// tree takes long enough for the reap to land inside one). The worker was +/// born in the call and spent energy in it, and is in no counter once +/// reaped, so no call is sealed whole — not even one whose stop walk read +/// the server before the reap and looked for the worker after it. +#[test] +fn a_worker_reaped_after_writing_the_reply_never_leaves_a_call_whole() { + let _one = alone(); + let mut s = Server::spawn( + "/usr/bin/perl", + &[ + "-e", + r#"$| = 1; + for (1 .. 60) { if (fork() == 0) { sleep 1000 while 1 } } + while (my $l = ) { + my ($id) = $l =~ /"id":(\d+)/; my ($n) = $l =~ /"n":(\d+)/; $n //= 0; + my $p = fork(); + if ($p == 0) { + print qq({"jsonrpc":"2.0","id":$id,"result":{"content":[{"type":"text","text":"burned $n"}]}}\n); + my $x = 0; $x += $_ for 1 .. $n; exit 0 + } + waitpid($p, 0); + }"#, + ], + ); + let pid = s.pid(); + let mut g = gateway(true); + assert_eq!(g.call_meter(&s), CallMeter::UpstreamTree { pid: pid as u32 }); + let _ = relay(&mut g, &mut s, 0, 0); + let mut reasons: Vec = Vec::new(); + for rep in 1..31 { + let c = relay(&mut g, &mut s, rep, 150_000); + eprintln!("upstream tree: declared server, worker reaped after its reply, rep {rep}: {} µJ [{}], _meta {}", c.cost_uj, grade(&c), c.meta); + assert_eq!(c.sealed["energy_provenance"].as_str(), Some("HwShunt"), "{}", c.sealed); + assert_eq!(c.sealed["energy_coverage"].as_str(), Some("Partial"), "a call whose worker is in no reading sealed whole: {}", c.meta); + let because = c.meta["energy_partial_because"].as_array().expect("why it is partial"); + assert!( + because.iter().any(|k| matches!(k.as_str(), Some("reaped-descendant" | "exited" | "unsettled-at-start" | "unsettled-at-stop" | "tree-changing"))), + "{}", + c.meta + ); + reasons.extend(because.iter().filter_map(Value::as_str).map(str::to_string)); + } + reasons.sort(); + reasons.dedup(); + eprintln!("upstream tree: declared server, worker reaped after its reply: reasons seen {reasons:?}"); + the_log_verifies(&g); +} + +/// A Metal compute server speaking the tests' JSON-RPC, built from this +/// source when the test first runs: each request's `arguments.n` picks what +/// it does before it replies — 0 nothing on the GPU, 1 a compute kernel on +/// the command queue it keeps, 2 the same kernel on a queue made for the +/// request and released after it — and it replies `burned `. +const METAL_SERVER: &str = r##"#import +#include +#include +#include +static NSString *src = @"#include \nusing namespace metal;\n" + "kernel void burn(device float *o [[buffer(0)]], uint i [[thread_position_in_grid]]) {\n" + " float x = float(i); for (int k = 0; k < 20000; k++) { x = x * 1.0000001f + 0.5f; } o[i] = x; }\n"; +static long field(const char *line, const char *key) { + const char *p = strstr(line, key); + return p ? strtol(p + strlen(key), NULL, 10) : 0; +} +int main(void) { + @autoreleasepool { + id d = MTLCreateSystemDefaultDevice(); + if (!d) return 1; + NSError *err = nil; + id lib = [d newLibraryWithSource:src options:nil error:&err]; + if (!lib) return 1; + id ps = [d newComputePipelineStateWithFunction:[lib newFunctionWithName:@"burn"] error:&err]; + id kept = [d newCommandQueue]; + id buf = [d newBufferWithLength:sizeof(float) * 65536 options:MTLResourceStorageModeShared]; + char line[4096]; + while (fgets(line, sizeof line, stdin)) { + long rid = field(line, "\"id\":"), n = field(line, "\"n\":"); + if (n == 1 || n == 2) { + @autoreleasepool { + id q = n == 2 ? [d newCommandQueue] : kept; + id b = [q commandBuffer]; + id e = [b computeCommandEncoder]; + [e setComputePipelineState:ps]; + [e setBuffer:buf offset:0 atIndex:0]; + [e dispatchThreads:MTLSizeMake(65536, 1, 1) threadsPerThreadgroup:MTLSizeMake(256, 1, 1)]; + [e endEncoding]; + [b commit]; + [b waitUntilCompleted]; + } + } + printf("{\"jsonrpc\":\"2.0\",\"id\":%ld,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"burned %ld\"}]}}\n", rid, n); + fflush(stdout); + } + } + return 0; +} +"##; + +/// A declared server that holds a GPU client is never sealed whole, whatever +/// a call did: its kept queue's work is recorded GPU time ("gpu"), and a +/// call whose work ran on a queue released inside it, or that did no GPU +/// work, leaves the server's recorded time unmoved, which does not show it +/// used none ("gpu-unknown"). The meter waits only 200 ms for the server to +/// go quiet: a Metal process keeps threads that wake on their own, and a +/// server still running at an end is a reason of its own. +#[test] +fn a_declared_server_holding_a_gpu_client_is_never_sealed_whole() { + let _one = alone(); + let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")); + let source = dir.join("upstream_tree_metal_server.m"); + let binary = dir.join(format!("upstream_tree_metal_server-{}", std::process::id())); + std::fs::write(&source, METAL_SERVER).expect("write the Metal server's source"); + let out = Command::new("/usr/bin/cc") + .args(["-fobjc-arc", "-O2", "-framework", "Foundation", "-framework", "Metal", "-o"]) + .arg(&binary) + .arg(&source) + .output() + .expect("run the C compiler"); + assert!(out.status.success(), "the Metal server did not build:\n{}", String::from_utf8_lossy(&out.stderr)); + let mut s = Server::spawn(binary.to_str().unwrap(), &[]); + let pid = s.pid(); + let mut g = gateway(true); + g.set_upstream_settle_bound(Duration::from_millis(200)); + assert_eq!(g.call_meter(&s), CallMeter::UpstreamTree { pid: pid as u32 }); + for (rep, n) in [1u64, 2, 0, 2, 1, 0].into_iter().enumerate() { + let c = relay(&mut g, &mut s, rep as i64, n); + eprintln!("upstream tree: declared Metal server, mode {n} rep {rep}: {} µJ [{}], _meta {}", c.cost_uj, grade(&c), c.meta); + assert_eq!(c.sealed["energy_provenance"].as_str(), Some("HwShunt"), "{}", c.sealed); + assert_eq!(c.sealed["energy_coverage"].as_str(), Some("Partial"), "a GPU-holding server sealed whole: {}", c.meta); + let because: Vec<&str> = c.meta["energy_partial_because"].as_array().expect("why").iter().filter_map(Value::as_str).collect(); + let want = if n == 1 { "gpu" } else { "gpu-unknown" }; + assert!(because.contains(&want) || because.contains(&"gpu"), "{want} not among {because:?}"); + } + the_log_verifies(&g); +} + +/// An upstream naming a process this one may not read (launchd, another +/// user's) gets no tree meter, and its calls are metered machine-wide, +/// sealed as that meter grades them. +#[test] +fn an_upstream_naming_a_process_this_one_cannot_read_is_metered_machine_wide() { + // SAFETY: no preconditions. + if unsafe { libc::geteuid() } == 0 { + eprintln!("upstream fallback: running as root, launchd is readable; nothing to check"); + return; + } + let _one = alone(); + let mut s = Server::single_process(); + s.named = Some(1); + let mut g = gateway(true); + let machine = if g.metering_is_hardware() { CallMeter::MachineHardware } else { CallMeter::Estimator }; + assert_eq!(g.call_meter(&s), machine); + let c = relay(&mut g, &mut s, 1, 20_000); + eprintln!("upstream fallback: pid 1 named: {} µJ [{}], _meta {}", c.cost_uj, grade(&c), c.meta); + assert_eq!(c.meta["energy_domain"].as_str(), Some("machine"), "{}", c.meta); + assert!(c.meta.get("energy_partial_because").is_none(), "{}", c.meta); + if !g.metering_is_hardware() { + assert_eq!(c.sealed["energy_provenance"].as_str(), Some("Estimator"), "{}", c.sealed); + } + the_log_verifies(&g); +} diff --git a/sandbox/README.md b/sandbox/README.md index 6fb5f3f1f..7edcd240a 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -6,7 +6,7 @@ Sandbox is the **isolated-execution substrate** of the Transaction Science open Every other sandbox in the field isolates a **CPU process** — a container, a microVM, a Linux process pinned to a core. The accelerator, where present, is an opaque passthrough that nobody meters and nobody attests. But agentic and AI work does not run on a CPU. It runs across a **fabric** — CPU, GPU, NPU, TPU, photonic, quantum. So in this standard the unit of isolation is not a process; it is a **heterogeneous unit of work**, and the accelerator is a first-class citizen of the isolation boundary: a capability you grant, a resource you meter in joules, and a line item you attest. -A Sandbox run carries exactly four guarantees: **isolation** (the unit runs at the lowest tier of an eight-rung ladder, S0–S7, that satisfies its needs, and cannot reach past it), **capability** (every resource the unit touches — each device class included — was explicitly granted; ungranted access is denied), **metering** (the run emits a signed energy receipt with per-device-class joule attribution and an honest provenance tag on every reading — and the reference meter reads vendor energy counters: Apple Silicon's energy model via IOReport, Intel/AMD RAPL via powercap, NVIDIA's total-energy counter via NVML. A reading is tagged `HwShunt` only while its counter is demonstrably live: each vendor counter is used only if every rail of it that the meter sums into one figure keeps advancing while the meter's probe burns CPU — in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms — every IOReport CPU energy rail (the rail itself, one per die on a multi-die part), every RAPL package and every NVML GPU; and a span across which the CPU counter held is never a reading. A counter that is absent, or present but not live — frozen, or republished in lumps, as the IOReport CPU rails are on macOS 27 — yields no hardware-graded figure: the span degrades to the labeled `Estimator` (wall-clock × TDP), never a fabricated measurement, and `ioreport_status()`, `rapl_status()` and `nvml_status()` say why a host is estimated. For work on the meter's own process there is one more source, kept out of the default meter: on macOS, the CPU energy the kernel attributes to the process's own threads (`proc_pid_rusage`), read through `PowerMeter::detect_in_process()` under the same liveness rule — never for a subprocess, which it does not cover; a single-process child's own figure is read from the exited child instead. Because a live counter reports the whole SoC, the meter can **attribute** it to a single process tree by CPU-time apportionment — tagged `ModelBased`, since the reading is a measurement but the split is a model), and **attestation** (a signed statement of *what image ran, at what tier, under what grants, on what hardware, for what cost*, with a deterministic-replay journal hash where one was produced). +A Sandbox run carries exactly four guarantees: **isolation** (the unit runs at the lowest tier of an eight-rung ladder, S0–S7, that satisfies its needs, and cannot reach past it), **capability** (every resource the unit touches — each device class included — was explicitly granted; ungranted access is denied), **metering** (the run emits a signed energy receipt with per-device-class joule attribution and an honest provenance tag on every reading — and the reference meter reads vendor energy counters: Apple Silicon's energy model via IOReport, Intel/AMD RAPL via powercap, NVIDIA's total-energy counter via NVML. A reading is tagged `HwShunt` only while its counter is demonstrably live: each vendor counter is used only if every rail of it that the meter sums into one figure keeps advancing while the meter's probe burns CPU — in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms — every IOReport CPU energy rail (the rail itself, one per die on a multi-die part), every RAPL package and every NVML GPU; and a span across which the CPU counter held is never a reading. A counter that is absent, or present but not live — frozen, or republished in lumps, as the IOReport CPU rails are on macOS 27 — yields no hardware-graded figure: the span degrades to the labeled `Estimator` (wall-clock × TDP), never a fabricated measurement, and `ioreport_status()`, `rapl_status()` and `nvml_status()` say why a host is estimated. For work on the meter's own process there is one more source, kept out of the default meter: on macOS, the CPU energy the kernel attributes to the process's own threads (`proc_pid_rusage`), read through `PowerMeter::detect_in_process()` under the same liveness rule — never for a subprocess, which it does not cover; a single-process child's own figure is read from the exited child instead. For work in another live process of this user — a server, and its descendants — `TreeMeter` reads that same count for every process of its tree over a span, each end read once no thread of the tree is on a core and confirmed by a second walk that finds the tree's processes unchanged, and lists beside the figure every way it was seen to differ from the tree's energy over the span — short of it (a descendant reaped or exited in the span, a GPU client held, in-process Neural Engine memory, a member unreadable or still running at the stop), or, for a member still running at the start, holding some from just before the span; whether the tree hands work to a process outside it is not visible to any reader, so that is for the caller's operator to declare, never the reading's to claim. Because a live counter reports the whole SoC, the meter can **attribute** it to a single process tree by CPU-time apportionment — tagged `ModelBased`, since the reading is a measurement but the split is a model), and **attestation** (a signed statement of *what image ran, at what tier, under what grants, on what hardware, for what cost*, with a deterministic-replay journal hash where one was produced). The substrate is intentionally **ownerless**: no protocol-level token, permissive licensing on spec text and reference code, and conformance vectors so any implementer can verify canonicity without a trust relationship with the authors. A conforming host runs a unit, enforces its grants, meters its energy, and seals its attestation **without contacting any single steward's servers as a condition of operation**. Transaction Science is one steward: it publishes the spec, ships a reference implementation, and operates commercial services around the substrate — not its proprietor. diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs index 3b273cd8a..fb2fc6604 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs @@ -5,6 +5,14 @@ //! (`measured + estimated`, integer microjoules), every reading carrying a //! [`Provenance`] tag. It seals as a [`jcr1`] envelope — the family's shared //! signed-receipt format. +//! +//! The readings come from [`probe`]: a [`PowerMeter`] over the host's +//! machine-wide energy counters (or its estimator where none is live), the +//! same meter over this process's own CPU energy count +//! ([`PowerMeter::detect_in_process`]), and a [`TreeMeter`] over that count +//! read for every process of another live process tree — a server this +//! process spawned, and its descendants — with every way the tree's figure +//! was seen to fall short listed beside it ([`TreeGap`]). use ed25519_dalek::{SigningKey, VerifyingKey}; use sandbox_schema::{ContentId, DeviceClass, Tier}; @@ -17,7 +25,8 @@ pub mod probe; pub use contention::{StorageActivity, StorageMeterError}; pub use probe::{ ioreport_status, nvml_status, process_energy_status, rapl_status, run_attributed, Attribution, - CounterStatus, IoReportStatus, Measurement, PowerMeter, CPU_RAIL_LIVENESS_BUDGET, + CounterStatus, IoReportStatus, Measurement, PowerMeter, SpanEnd, TreeError, TreeGap, TreeMeter, + TreeReading, TreeStart, CPU_RAIL_LIVENESS_BUDGET, }; /// The receipt's JCR-1 profile tag (SPEC §6.4). diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/gpu_clients.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/gpu_clients.rs new file mode 100644 index 000000000..7834d4b1a --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/gpu_clients.rs @@ -0,0 +1,282 @@ +//! Every GPU client's accumulated GPU time, from the I/O Registry (Apple +//! Silicon macOS): the evidence a [`TreeMeter`](super::TreeMeter) reads to +//! say whether a process tree used the GPU over a span. Ported from +//! jouleclaw-energy's `apple_gpu`, with one change: a client with no +//! `AppUsage` is told apart from one whose time is zero, because a client +//! LOSES its `AppUsage` when its process exits. +//! +//! Each `IOAccelerator` service (on Apple Silicon one, `AGXAccelerator…`) has +//! one user client per connection a process opened to the GPU, as a child in +//! the `IOService` plane (`AGXDeviceUserClient`). Each carries +//! `IOUserClientCreator` (`"pid N, name"`) and `AppUsage`, an array with one +//! dictionary per live command queue (its `CommandQueueCount` matched the +//! entries), whose `accumulatedGPUTime` is the GPU time that queue has +//! spent, in ns. This is public IOKit API, read +//! unprivileged; it is what `ioreg -r -c IOAccelerator -l` prints. A process +//! can hold several clients. A CPU-only process has none. +//! +//! **What a reading can and cannot say** (measured on an M5 Max, macOS +//! 27.0.1, unprivileged): a Metal child's time is live, with no read lag (a +//! read right after its work and one 200 ms later agreed in 11 of 11 runs). +//! But a client's time is NOT the process's GPU use: a command queue's entry +//! leaves `AppUsage`, with its time, when the queue is released, so a +//! request run on a queue created and released between two reads (5.2 ms of +//! GPU work by its command buffer's own timestamps) moved its client's time +//! by 0, as did one on a device opened and closed between them. A client +//! whose time did not move may have done GPU work; only a process with no +//! client is known to have done none. And GPU time is LOST AT EXIT: +//! about 2 ms after its process exits a client has no `AppUsage` (its time +//! reads as none), and by about 9 ms the client is gone from the registry — +//! while the process itself may live on for as long as nobody reaps it. So a +//! client whose time went back, to zero or to none, or that vanished, is a +//! client whose time is unknown, never a decrease; the caller's rule +//! ([`tree`](super::tree)) treats it so. One walk costs about 1 ms (63 ms at +//! worst seen, on a host at load ~200). +//! +//! The CoreFoundation calls `ioreport` already binds are its declarations, +//! not new ones; this module adds the type-ID and number calls and IOKit. + +#![allow(non_snake_case)] + +use super::ioreport::{ + CFArrayGetCount, CFArrayGetValueAtIndex, CFArrayRef, CFDictionaryGetValue, CFDictionaryRef, CFIndex, + CFMutableDictionaryRef, CFRelease, CFStringCreateWithCString, CFStringGetCString, CFStringRef, CFTypeRef, + KCF_STRING_ENCODING_UTF8, +}; +use super::tree::GpuClient; +use std::ffi::{c_char, c_void, CStr}; + +type CFTypeID = usize; +type IoObject = libc::mach_port_t; + +/// `kCFNumberSInt64Type`. +const K_CFNUMBER_SINT64: CFIndex = 4; +/// `kIOMainPortDefault` is `MACH_PORT_NULL`. +const MAIN_PORT_DEFAULT: libc::mach_port_t = 0; +const KERN_SUCCESS: libc::kern_return_t = 0; + +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFGetTypeID(cf: CFTypeRef) -> CFTypeID; + fn CFStringGetTypeID() -> CFTypeID; + fn CFArrayGetTypeID() -> CFTypeID; + fn CFDictionaryGetTypeID() -> CFTypeID; + fn CFNumberGetTypeID() -> CFTypeID; + fn CFNumberGetValue(number: CFTypeRef, the_type: CFIndex, value_ptr: *mut c_void) -> bool; +} + +#[link(name = "IOKit", kind = "framework")] +extern "C" { + fn IOServiceMatching(name: *const c_char) -> CFMutableDictionaryRef; + fn IOServiceGetMatchingServices( + main_port: libc::mach_port_t, + matching: CFDictionaryRef, + existing: *mut IoObject, + ) -> libc::kern_return_t; + fn IOIteratorNext(iterator: IoObject) -> IoObject; + fn IOObjectRelease(object: IoObject) -> libc::kern_return_t; + fn IORegistryEntryGetChildIterator(entry: IoObject, plane: *const c_char, iterator: *mut IoObject) + -> libc::kern_return_t; + fn IORegistryEntryGetRegistryEntryID(entry: IoObject, id: *mut u64) -> libc::kern_return_t; + fn IORegistryEntryCreateCFProperty( + entry: IoObject, + key: CFStringRef, + allocator: *const c_void, + options: u32, + ) -> CFTypeRef; +} + +/// An owned CF object, released on drop. +struct Cf(CFTypeRef); + +impl Drop for Cf { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: `self.0` is a CF object this process owns (a Create/Copy + // result), released once. + unsafe { CFRelease(self.0) } + } + } +} + +/// An owned I/O Kit object, released on drop. +struct Io(IoObject); + +impl Drop for Io { + fn drop(&mut self) { + if self.0 != 0 { + // SAFETY: `self.0` is an object reference this process owns, + // released once. + unsafe { IOObjectRelease(self.0) }; + } + } +} + +impl Io { + /// The next object of an iterator, or `None` at its end. + fn next_of(iterator: &Io) -> Option { + // SAFETY: `iterator` is a live iterator this process owns. + let o = unsafe { IOIteratorNext(iterator.0) }; + (o != 0).then_some(Io(o)) + } +} + +fn cfstr(s: &CStr) -> Option { + // SAFETY: `s` is a NUL-terminated C string for the duration of the call. + let r = unsafe { CFStringCreateWithCString(std::ptr::null(), s.as_ptr() as *const u8, KCF_STRING_ENCODING_UTF8) }; + (!r.is_null()).then_some(Cf(r)) +} + +/// `entry`'s property `key`, owned, if it is set and of the CF type `type_id`. +fn property(entry: &Io, key: &Cf, type_id: CFTypeID) -> Option { + // SAFETY: `entry` is a live registry entry and `key` a live CFString; the + // result is a new reference this process owns (wrapped at once). + let v = Cf(unsafe { IORegistryEntryCreateCFProperty(entry.0, key.0, std::ptr::null(), 0) }); + // SAFETY: `v.0` is a live CF object when non-null. + (!v.0.is_null() && unsafe { CFGetTypeID(v.0) } == type_id).then_some(v) +} + +/// A CFString's text, copied out of it. +fn text(s: &Cf) -> Option { + let mut buf = [0u8; 256]; + // SAFETY: `s.0` is a live CFString and `buf` a writable buffer of the size + // passed. + let ok = unsafe { CFStringGetCString(s.0, buf.as_mut_ptr(), buf.len() as CFIndex, KCF_STRING_ENCODING_UTF8) }; + if !ok { + return None; + } + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + Some(String::from_utf8_lossy(&buf[..end]).into_owned()) +} + +/// The pid in an `IOUserClientCreator` value, `"pid 172, WindowServer"`. +pub(super) fn creator_pid(creator: &str) -> Option { + let rest = creator.strip_prefix("pid ")?; + let digits = rest.split(',').next()?.trim(); + digits.parse().ok() +} + +/// The `accumulatedGPUTime` of every `AppUsage` entry, summed, in ns; `None` +/// if an entry is not a dictionary or its time is not a 64-bit integer. +fn app_usage_ns(usage: &Cf, time_key: &Cf) -> Option { + let mut sum: u64 = 0; + // SAFETY: `usage` is a live CFArray (its type was checked); its values and + // their values are borrowed from it for the duration of this call. + unsafe { + for i in 0..CFArrayGetCount(usage.0 as CFArrayRef) { + let entry = CFArrayGetValueAtIndex(usage.0 as CFArrayRef, i); + if entry.is_null() || CFGetTypeID(entry) != CFDictionaryGetTypeID() { + return None; + } + let t = CFDictionaryGetValue(entry as CFDictionaryRef, time_key.0); + if t.is_null() { + // An entry that has not recorded GPU time yet. + continue; + } + if CFGetTypeID(t) != CFNumberGetTypeID() { + return None; + } + let mut ns: i64 = 0; + if !CFNumberGetValue(t, K_CFNUMBER_SINT64, &mut ns as *mut i64 as *mut c_void) || ns < 0 { + return None; + } + sum = sum.checked_add(ns as u64)?; + } + } + Some(sum) +} + +/// Every GPU client in the registry now: each `IOAccelerator`'s children in +/// the `IOService` plane that carry an `IOUserClientCreator`, with their +/// accumulated GPU time — `None` for a client with no `AppUsage`, which has +/// spent none yet or (its process having exited) has lost it. `None` overall +/// if the registry could not be walked, or a client's creator, usage or ID +/// could not be read: a reading that silently left a client out would call +/// its GPU time zero. +pub(super) fn gpu_clients() -> Option> { + let creator_key = cfstr(c"IOUserClientCreator")?; + let usage_key = cfstr(c"AppUsage")?; + let time_key = cfstr(c"accumulatedGPUTime")?; + // SAFETY: plain type-ID getters. + let (string_t, array_t) = unsafe { (CFStringGetTypeID(), CFArrayGetTypeID()) }; + + // SAFETY: `IOServiceMatching` returns a new dictionary, which + // `IOServiceGetMatchingServices` consumes whatever it returns. + let services = unsafe { + let matching = IOServiceMatching(c"IOAccelerator".as_ptr()); + if matching.is_null() { + return None; + } + let mut it: IoObject = 0; + if IOServiceGetMatchingServices(MAIN_PORT_DEFAULT, matching as CFDictionaryRef, &mut it) != KERN_SUCCESS { + return None; + } + Io(it) + }; + let mut out = Vec::new(); + while let Some(accelerator) = Io::next_of(&services) { + let mut it: IoObject = 0; + // SAFETY: `accelerator` is a live registry entry; the iterator is ours. + if unsafe { IORegistryEntryGetChildIterator(accelerator.0, c"IOService".as_ptr(), &mut it) } != KERN_SUCCESS { + return None; + } + let children = Io(it); + while let Some(child) = Io::next_of(&children) { + let Some(creator) = property(&child, &creator_key, string_t) else { + // Not a user client. + continue; + }; + let pid = creator_pid(&text(&creator)?)?; + let gpu_ns = match property(&child, &usage_key, array_t) { + Some(usage) => Some(app_usage_ns(&usage, &time_key)?), + None => None, + }; + let mut id: u64 = 0; + // SAFETY: `child` is a live registry entry; `id` is writable. + if unsafe { IORegistryEntryGetRegistryEntryID(child.0, &mut id) } != KERN_SUCCESS { + return None; + } + out.push(GpuClient { id, pid, gpu_ns }); + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_creator_names_its_pid() { + assert_eq!(creator_pid("pid 172, WindowServer"), Some(172)); + assert_eq!(creator_pid("pid 99917, Google Chrome He"), Some(99_917)); + assert_eq!(creator_pid("pid 5"), Some(5)); + assert_eq!(creator_pid("172, WindowServer"), None); + assert_eq!(creator_pid("pid x, y"), None); + assert_eq!(creator_pid("pid -3, y"), None); + } + + /// On this host the registry walks, every client is a distinct entry, + /// some client has recorded GPU time, and this test process — which + /// never opened the GPU — holds none. + #[test] + fn the_registry_lists_gpu_clients_once_each() { + let started = std::time::Instant::now(); + let clients = gpu_clients().expect("the I/O Registry's GPU clients"); + let took = started.elapsed(); + let mut ids: Vec = clients.iter().map(|c| c.id).collect(); + ids.sort_unstable(); + ids.dedup(); + let with_usage = clients.iter().filter(|c| c.gpu_ns.is_some()).count(); + eprintln!("gpu clients: {} (distinct ids {}, {with_usage} with AppUsage) in {took:?}", clients.len(), ids.len()); + assert!(!clients.is_empty(), "no GPU client at all: WindowServer holds some on any Mac with a display"); + // The time key read: a misspelt key reads every client as having + // spent nothing, and a reader that sees no GPU time anywhere is blind. + assert!( + clients.iter().any(|c| c.gpu_ns.is_some_and(|ns| ns > 0)), + "no client has recorded any GPU time, not even the display server's" + ); + assert_eq!(ids.len(), clients.len()); + assert!(!clients.iter().any(|c| c.pid == std::process::id()), "a CPU-only test process holds a GPU client"); + } +} diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/ioreport.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/ioreport.rs index e5d7ce670..b14f025cf 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/ioreport.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/ioreport.rs @@ -51,29 +51,32 @@ use std::ptr; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; -type CFTypeRef = *const c_void; -type CFStringRef = *const c_void; -type CFDictionaryRef = *const c_void; -type CFMutableDictionaryRef = *mut c_void; -type CFArrayRef = *const c_void; +// The CoreFoundation types and calls are shared with `gpu_clients`, the I/O +// Registry reader built under the same configuration, so one declaration of +// each CF symbol serves both. +pub(super) type CFTypeRef = *const c_void; +pub(super) type CFStringRef = *const c_void; +pub(super) type CFDictionaryRef = *const c_void; +pub(super) type CFMutableDictionaryRef = *mut c_void; +pub(super) type CFArrayRef = *const c_void; type CFAllocatorRef = *const c_void; -type CFIndex = isize; +pub(super) type CFIndex = isize; type IOReportSubscriptionRef = *mut c_void; -const KCF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; +pub(super) const KCF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; #[link(name = "CoreFoundation", kind = "framework")] extern "C" { - fn CFStringCreateWithCString( + pub(super) fn CFStringCreateWithCString( alloc: CFAllocatorRef, c_str: *const u8, encoding: u32, ) -> CFStringRef; - fn CFStringGetCString(s: CFStringRef, buf: *mut u8, len: CFIndex, encoding: u32) -> bool; - fn CFDictionaryGetValue(d: CFDictionaryRef, key: CFTypeRef) -> CFTypeRef; - fn CFArrayGetCount(a: CFArrayRef) -> CFIndex; - fn CFArrayGetValueAtIndex(a: CFArrayRef, idx: CFIndex) -> CFTypeRef; - fn CFRelease(t: CFTypeRef); + pub(super) fn CFStringGetCString(s: CFStringRef, buf: *mut u8, len: CFIndex, encoding: u32) -> bool; + pub(super) fn CFDictionaryGetValue(d: CFDictionaryRef, key: CFTypeRef) -> CFTypeRef; + pub(super) fn CFArrayGetCount(a: CFArrayRef) -> CFIndex; + pub(super) fn CFArrayGetValueAtIndex(a: CFArrayRef, idx: CFIndex) -> CFTypeRef; + pub(super) fn CFRelease(t: CFTypeRef); } #[link(name = "IOReport", kind = "dylib")] diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs index c21c7f2f5..95d9eb377 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs @@ -32,6 +32,18 @@ //! ([`process_energy_status`]), through [`PowerMeter::detect_in_process`]. It //! does not cover child processes, so it is never the meter for a subprocess. //! +//! The same per-process count, read for every member of ANOTHER live process +//! tree (a server this process spawned, and its descendants), is a +//! [`TreeMeter`]: that tree's CPU energy over a span, with every way the +//! figure was seen to fall short of the tree's whole energy listed beside it +//! (a descendant reaped in the span, GPU or Neural Engine use, a member that +//! would not go quiet). macOS only, and only where the count is live. A +//! reading with nothing listed is whole for that tree only: work the tree +//! hands to a process outside it is charged to that process, and no reader +//! of the tree sees it, so whether there is any is for the caller to have +//! declared (jcp-gateway takes it as the operator's +//! `--upstream-self-contained`), never for the reading to claim. +//! //! A probe can degrade but it can never lie: if the hardware path fails //! mid-span, the reading comes back `Estimator`, not a zero dressed as a //! measurement. The same holds when the CPU energy counter did not advance @@ -54,6 +66,15 @@ mod nvml; // `detect_in_process()`. #[cfg(target_os = "macos")] mod process; +// The CPU energy of another live process tree, read the same way for every +// member. The rule and its types are built and tested everywhere; only macOS +// walks a tree. Public for its module docs: what a reading covers. +pub mod tree; +pub use tree::{SpanEnd, TreeError, TreeGap, TreeMeter, TreeReading, TreeStart, SETTLE_BOUND, SETTLE_POLL}; +// Every GPU client's GPU time from the I/O Registry: the GPU evidence of a +// tree reading. Shares `ioreport`'s CoreFoundation bindings. +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +mod gpu_clients; // Built everywhere: its zone listing, reads and liveness run take the // powercap root as a path, so a caller's [`RaplPackages::live_in`] and the // tests run them on fake powercap trees on any platform. Only Linux opens the diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/process.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/process.rs index 097c92bce..7f6d3e39f 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/process.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/process.rs @@ -24,9 +24,13 @@ //! //! The same read, on a child that has exited but not yet been reaped, gives //! that child's own energy: [`child_reading`] is the rule -//! [`run_attributed`](super::run_attributed) applies to it. +//! [`run_attributed`](super::run_attributed) applies to it. And the same read +//! of any same-uid process, live or exited-unreaped, is what the +//! [`TreeMeter`](super::TreeMeter) walks a process tree with +//! ([`read_usage`]). use super::liveness::{counter_admits, counter_run, counter_status_of, counter_tick, gated, RailWatch, Sample, PROBE_TICK}; +use super::tree::Usage; use super::{CounterStatus, Probe, ProbeError, CPU_RAIL_LIVENESS_BUDGET}; use sandbox_schema::DeviceClass; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -37,8 +41,9 @@ const RUSAGE_INFO_V6: libc::c_int = 6; /// `struct rusage_info_v6` from the macOS 27 SDK's ``, field /// for field. Apple appends to this struct in new versions and never -/// reorders a published one; the layout is pinned by a test (464 bytes, -/// `ri_energy_nj` at byte 336, as the SDK's own `offsetof` gives them). +/// reorders a published one; the layout is pinned by a test (464 bytes, and +/// the byte offset of every field [`read_usage`] reads, as the SDK's own +/// `offsetof` gives them). #[repr(C)] #[derive(Clone, Copy)] struct RusageInfoV6 { @@ -96,25 +101,19 @@ struct RusageInfoV6 { ri_reserved: [u64; 6], } -/// What this module reads of one process's `rusage_info_v6`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct Usage { - /// `ri_energy_nj`: the CPU energy the kernel attributes to the process's - /// own threads, cumulative, in nanojoules. - pub(super) energy_nj: u64, - /// `ri_user_time + ri_system_time`: the process's own CPU time (Mach - /// absolute-time units; only compared with zero here). - pub(super) own_cpu: u64, - /// `ri_child_user_time + ri_child_system_time`: the CPU time of the - /// descendants the process reaped (same units). Their energy is in no - /// field at all. - pub(super) reaped_descendants_cpu: u64, -} - /// One `proc_pid_rusage(pid, RUSAGE_INFO_V6)` read: of a live process, or of -/// an exited child that has not yet been reaped. `None` if the call fails +/// an exited process that has not yet been reaped. `None` if the call fails /// (no such process, or not one this process may read). pub(super) fn usage_of(pid: libc::pid_t) -> Option { + read_usage(pid).ok() +} + +/// [`usage_of`], with the call's `errno` when it fails: `EPERM` for a process +/// of another user (listing it needs no privilege; reading it does), `ESRCH` +/// for one that does not exist or was reaped. A [`TreeMeter`](super::TreeMeter) +/// tells the two apart: the first is a member it cannot read, the second a +/// member that is gone. +pub(super) fn read_usage(pid: libc::pid_t) -> Result { // SAFETY: an all-zero `RusageInfoV6` is a valid value of a struct of // plain integers. let mut info: RusageInfoV6 = unsafe { std::mem::zeroed() }; @@ -123,10 +122,16 @@ pub(super) fn usage_of(pid: libc::pid_t) -> Option { let rc = unsafe { libc::proc_pid_rusage(pid, RUSAGE_INFO_V6, &mut info as *mut RusageInfoV6 as *mut libc::rusage_info_t) }; - (rc == 0).then(|| Usage { + if rc != 0 { + return Err(std::io::Error::last_os_error().raw_os_error().unwrap_or(0)); + } + Ok(Usage { energy_nj: info.ri_energy_nj, own_cpu: info.ri_user_time.saturating_add(info.ri_system_time), reaped_descendants_cpu: info.ri_child_user_time.saturating_add(info.ri_child_system_time), + start_abstime: info.ri_proc_start_abstime, + exit_abstime: info.ri_proc_exit_abstime, + neural_lifetime_max: info.ri_lifetime_max_neural_footprint, }) } @@ -227,26 +232,46 @@ mod tests { /// The struct is the SDK's: the sizes and offsets the macOS 27 SDK's own /// `sizeof`/`offsetof` give (measured with a C program against - /// ``). + /// ``), for every field [`read_usage`] reads. #[test] fn the_rusage_struct_is_the_sdks_layout() { + use std::mem::offset_of; assert_eq!(std::mem::size_of::(), 464); - assert_eq!(std::mem::offset_of!(RusageInfoV6, ri_energy_nj), 336); - assert_eq!(std::mem::offset_of!(RusageInfoV6, ri_penergy_nj), 344); - assert_eq!(std::mem::offset_of!(RusageInfoV6, ri_child_user_time), 96); + assert_eq!(offset_of!(RusageInfoV6, ri_user_time), 16); + assert_eq!(offset_of!(RusageInfoV6, ri_system_time), 24); + assert_eq!(offset_of!(RusageInfoV6, ri_proc_start_abstime), 80); + assert_eq!(offset_of!(RusageInfoV6, ri_proc_exit_abstime), 88); + assert_eq!(offset_of!(RusageInfoV6, ri_child_user_time), 96); + assert_eq!(offset_of!(RusageInfoV6, ri_child_system_time), 104); + assert_eq!(offset_of!(RusageInfoV6, ri_energy_nj), 336); + assert_eq!(offset_of!(RusageInfoV6, ri_penergy_nj), 344); + assert_eq!(offset_of!(RusageInfoV6, ri_lifetime_max_neural_footprint), 376); } /// This process can read itself, and a pid that does not exist reads - /// nothing. + /// nothing. A live process has a start time and no exit time. #[test] fn this_process_reads_itself_and_a_missing_pid_reads_nothing() { let me = usage_of(unsafe { libc::getpid() }).expect("this process reads itself"); assert!(me.own_cpu > 0, "{me:?}"); + assert!(me.start_abstime > 0 && me.exit_abstime == 0, "{me:?}"); assert_eq!(usage_of(-7), None); } + /// The errno tells a process of another user from one that is gone: + /// `launchd` (root) is listed to anyone and read by no one else; a pid + /// past the kernel's range names no process. + #[test] + fn a_read_names_why_it_failed() { + // SAFETY: no preconditions. + if unsafe { libc::geteuid() } != 0 { + assert_eq!(read_usage(1), Err(libc::EPERM), "launchd read by a non-root user"); + } + assert_eq!(read_usage(99_999_999), Err(libc::ESRCH)); + } + fn child(energy_nj: u64, reaped_descendants_cpu: u64) -> Usage { - Usage { energy_nj, own_cpu: 1_000_000, reaped_descendants_cpu } + Usage { energy_nj, own_cpu: 1_000_000, reaped_descendants_cpu, ..Usage::default() } } /// A single-process child's own energy is a reading only while the diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs new file mode 100644 index 000000000..0a0a9973c --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs @@ -0,0 +1,1543 @@ +//! The CPU energy of ANOTHER live process tree on macOS: a process this one +//! names by pid (an MCP server a gateway spawned, say) and its descendants, +//! each read with `proc_pid_rusage(pid, RUSAGE_INFO_V6)` — the kernel's own +//! count of the CPU energy of that process's threads (`ri_energy_nj`), the +//! counter [`PowerMeter::detect_in_process`](super::PowerMeter::detect_in_process) +//! reads for this process. Readable for any process of this user, live or +//! exited-unreaped; a process of another user is listed but not read +//! (`EPERM`). A [`TreeMeter`] exists only where that counter is live on this +//! host ([`process_energy_status`](super::process_energy_status)). +//! +//! **The domain, exactly.** A [`TreeReading`]'s figure is the CPU energy of +//! the processes in the tree over the span: every process present at both +//! ends (its advance) and every process born inside the span (its whole +//! count). Beside it, the reading claims an exact zero for the tree's GPU +//! time and for in-process Neural Engine use, and only where the evidence +//! says so: no member holding a GPU client at either end (a client whose +//! recorded time did not move is no such evidence: see below), and no +//! member that ever held Neural Engine model memory. It does NOT cover, and +//! cannot see: +//! +//! - work the tree hands to a process outside it — a system service over +//! XPC, a database server, a remote API. On-device model inference run by +//! a system daemon drew about 1 W of Neural Engine power on an M5 Max +//! (macOS 27.0.1, `sudo powermetrics`) while its client showed no Neural +//! Engine footprint and its own CPU count advanced only by its own share, +//! tens of millijoules per request: a plausible figure, with nothing in it +//! to show the rest. The model's memory and work are charged to a system +//! service of another user, unreadable here; +//! - DRAM, or any other rail: this is the CPU cores' energy as the kernel +//! attributes it to threads; +//! - a descendant that left the tree before the start was walked (a daemon +//! that double-forked away is reparented to `launchd`, pid 1, and is no +//! longer anyone's descendant here). +//! +//! So a reading's [`gaps`](TreeReading::gaps) say how its figure was SEEN to +//! differ from the tree's energy over the span: a process that came or went +//! in a way whose energy cannot be read, a GPU or Neural Engine figure that +//! is unknown or not zero, a member that could not be read or would not go +//! quiet. Each but one means the figure lacks energy; the one, +//! [`TreeGap::Unsettled`] at the start, means it may also hold up to a +//! quantum per running thread of energy spent just BEFORE the span, so a +//! figure with gaps is not always a lower bound. An empty list +//! ([`TreeReading::is_whole`]) means nothing was seen missing, not that the +//! tree did no work elsewhere: whether the tree hands work outside itself is +//! something only its operator can declare, and that declaration belongs to +//! the caller (a gateway's configuration), never to this reading. +//! +//! **A snapshot** walks the tree from the root: for each process, one read, +//! its run state, a second read, then its children (`proc_listchildpids`, +//! which returns a COUNT of pids and truncates silently, so a full buffer is +//! grown and listed again; a pid that lists none is childless or gone, which +//! the read tells apart). A process is identified by `(pid, +//! ri_proc_start_abstime)`: the start time is fixed per process and distinct +//! (mach ticks, increasing in creation order), so a reused pid is a +//! different process. From each read the snapshot keeps the energy, the +//! process's own CPU time, the CPU time of descendants it reaped +//! (`ri_child_user_time + ri_child_system_time`), its start and exit times +//! (an exit time is a zombie: exited, not yet reaped) and its lifetime +//! maximum Neural Engine footprint. One process costs about 15 µs, nearly all +//! of it the listing, which scans the host's process table. +//! +//! **Settling.** `ri_energy_nj` is folded into the count only when a thread +//! leaves the core (a context switch, or its quantum expiring, about every +//! 10 ms of its CPU): a read while a thread runs misses what it has spent +//! since its last fold. And so, measured here, is the CPU time: read from +//! another process, `ri_user_time + ri_system_time` of a thread spinning +//! without a break moved only in steps of one quantum (10.0 ms; M5 Max, +//! macOS 27.0.1, 2000 read pairs), so two reads a short interval apart can +//! agree while a thread is on a core. A rule of "CPU time unchanged over +//! 500 µs" read a child that kept computing 3–20 ms after its reply as +//! settled, and stale, in 60 of 60 runs. What does tell is the kernel's run +//! state: `proc_pidinfo(PROC_PIDTASKINFO)`'s `pti_numrunning`, the threads +//! on a core or queued for one. A process is settled at a snapshot when +//! that is zero between two reads that agree in every field: a thread on a +//! core at the first read and off it by the run-state read must have left +//! the core in between, and leaving folds its time and energy, so the +//! second read would differ. The same runs read with that rule were settled +//! and exact in 58 of 58 (the other 2 still running at 50 ms on a host at +//! load ~200). A zombie has no task to ask and no thread to run: it is +//! settled when its two reads agree — its record is final then, though not +//! complete (below). The meter walks again, every +//! [`SETTLE_POLL`], until every member is settled, for at most +//! [`SETTLE_BOUND`] (or the meter's own bound); a member still not settled +//! then is a gap ([`TreeGap::Unsettled`]), not a silent stale figure. It +//! matters at both ends: a stale start reading over-counts the span, a stale +//! end reading under-counts it. +//! +//! **Confirming a walk.** A walk with every member settled is still not one +//! instant's picture of the tree: it reads a parent, then lists and reads +//! its children, and a child that exits and is reaped in between is in the +//! walk neither as a member (gone by its listing or its read) nor as its +//! parent's reaped time (read before the reap) — gone without a trace, its +//! energy missing from a figure with nothing seen missing. Measured: a perl +//! server forking a worker per request that writes the reply itself, keeps +//! computing and is reaped after the reply, with 60 idle children beside it +//! to lengthen each walk, read whole without its worker by the second of 40 +//! round trips in each of 5 runs: ~0.6 mJ, the server and its idle +//! children alone, with no gap. So a walk is accepted only when it agrees with the +//! walk before it on the tree's shape — the same processes, parents, +//! reaped-descendant times and exit times, and no listed child gone by its +//! read (`Snapshot::agrees_with`): a reap between the two moves the +//! parent's reaped time, a birth or exit changes the members, and the next +//! agreeing walk then holds the reap and the reading names it. A tree that +//! does not stop changing before the bound is a gap ([`TreeGap::Changing`]). +//! With this rule, 240 round trips of the same server in 6 runs named the +//! worker in every reading. +//! +//! Settling makes a live member's count exact at the instant it is read. +//! An exited member's record is not: the kernel folds a thread's energy +//! when the thread leaves a core, and an exiting thread's last time on a +//! core never reaches the record, while its CPU time does. Measured: a +//! child that ran 3.3 ms of CPU and exited at once left a record of 0 to +//! 0.32 mJ, some exactly 0 nJ, where the same work in a process that blocks +//! before it exits reads about 1.5 mJ per CPU-ms. So an exited member is a +//! gap ([`TreeGap::Exited`]) for its energy as well as its GPU time. Nor +//! does settling make the tree's work finished. A member waiting on I/O in the +//! middle of its work, or a process still being started, is on no core and +//! reads as settled. Measured: a tree still starting a helper after its +//! reply was read, with every member off-core, 1.05 mJ short of a read +//! taken once it had gone quiet. So a span is stopped after the reply, and +//! what the tree does after the stop is not in this span's figure — but it +//! is not in no span's either: a span reads everything the tree spends +//! inside it, whatever for. Work a server defers past one reply, or does on +//! a timer of its own, is read in whichever span is open when it runs (a +//! gateway's next call), and a member waiting on a timer reads as settled +//! at that span's start. A whole reading says nothing was seen missing from +//! the tree's energy over its span, not that all of that energy was spent +//! on the span's own work. +//! +//! **The rule** (`combine`, pure, so its tests drive every case without a +//! process) is in [`TreeGap`]'s variants, one per way a figure can differ +//! from the tree's energy, and [`TreeError`], for a span with no reading at +//! all: the root gone or unreadable at either end, or a summed advance of +//! zero (even a bare request/response round trip reads microjoules here, +//! 3.6–18.5 µJ in 14 of 14, so zero is no reading of it). +//! +//! **GPU and Neural Engine.** The GPU half is every GPU client's +//! accumulated GPU time from the I/O Registry (`gpu_clients`, Apple Silicon +//! only), read before the start walk and after the end walk so its window +//! holds the CPU window; any GPU time spent by a tree process is a gap, +//! because its energy is not measured, and that is not zero. Nor is a +//! client's recorded time that did not move evidence of none spent: the +//! time is kept per live command queue and goes with the queue when it is +//! released, so work on a queue created and released inside the span (5.2 +//! ms of it, by its command buffer's own timestamps, measured here) leaves +//! no trace. A member that holds a GPU client at either end and recorded +//! no GPU time over the span is therefore a gap too, its GPU use unknown; +//! only a tree with no GPU client at either end reads whole. The Neural +//! Engine half is `ri_lifetime_max_neural_footprint`: it went from 0 to the +//! model's size whenever an in-process Core ML model was resident on the +//! Neural Engine, and stayed 0 in every control (CPU, Metal, Core ML on the +//! CPU or GPU). It is a lifetime maximum — the current footprint drops to 0 +//! within a second of release, and the interval maximum can be reset by any +//! process of this user, the monitored one included — so a process that +//! ever held such memory is a gap for every span after, and one that hands +//! its model to a system daemon is not seen at all (see the domain above). + +// Off macOS no tree can be walked: the types and the rule are built, and +// tested, for the rule's sake alone. +#![cfg_attr(not(target_os = "macos"), allow(dead_code))] + +use super::CounterStatus; +use std::time::{Duration, Instant}; + +/// How long a [`TreeMeter`] keeps walking a tree, at each end of a span, +/// for every member to go quiet ([`TreeMeter::with_settle_bound`] changes +/// it). A reply's tail of a few milliseconds of CPU settles in one or two +/// walks; this bounds what a member that never goes quiet (a busy +/// background thread) adds to each end of every span, at the price of a gap. +pub const SETTLE_BOUND: Duration = Duration::from_millis(50); + +/// The pause between two walks while a member is not yet settled, or two +/// walks disagree (a walk with every member settled is confirmed by walking +/// again at once). The rule does not depend on it (the run state and the +/// walks' agreement, not an interval, decide); it only +/// paces the polling: a walk of a small tree costs tens of microseconds, so +/// a quarter of a millisecond keeps the meter's own thread mostly idle while +/// it waits and adds at most that much to the moment the tree went quiet. +pub const SETTLE_POLL: Duration = Duration::from_micros(250); + +/// What a tree snapshot reads of one process's `rusage_info_v6`, and what +/// [`process`](super::process) reads of this process or an exited child. +/// CPU times are in mach ticks (24 MHz on Apple Silicon: 2,409,625 ticks for +/// 100 ms of one thread's CPU), as are the start and exit times, on the +/// clock `mach_absolute_time` reads. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(super) struct Usage { + /// `ri_energy_nj`: the CPU energy the kernel attributes to the process's + /// own threads, cumulative, in nanojoules. + pub(super) energy_nj: u64, + /// `ri_user_time + ri_system_time`: the process's own CPU time. + pub(super) own_cpu: u64, + /// `ri_child_user_time + ri_child_system_time`: the CPU time of the + /// descendants the process reaped, and of theirs. Their energy is in no + /// field at all. + pub(super) reaped_descendants_cpu: u64, + /// `ri_proc_start_abstime`: fixed per process, distinct between + /// processes, so `(pid, start_abstime)` names one process. + pub(super) start_abstime: u64, + /// `ri_proc_exit_abstime`: 0 while the process lives; set once it has + /// exited and is waiting to be reaped (a zombie). + pub(super) exit_abstime: u64, + /// `ri_lifetime_max_neural_footprint`: the most Neural Engine model + /// memory the process ever held, bytes. Never reset. + pub(super) neural_lifetime_max: u64, +} + +/// A process's threads at one instant, from `proc_pidinfo(PROC_PIDTASKINFO)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Threads { + /// `pti_numrunning`: this many threads were on a core or queued for one. + Running(u32), + /// The process has no task to ask (`ESRCH`): it has exited. + NoTask, + /// The call failed for another reason. + Unknown, +} + +/// Whether one process's reading is complete: two reads that agree in every +/// field with, between them, no thread on a core or queued for one — or, for +/// a process that has exited (it has no task), two agreeing reads of a +/// process whose exit time is set. A thread on a core at the first read and +/// off it at the run-state read left the core in between, and leaving folds +/// its CPU time and energy, so the second read would differ. +pub(super) fn settled(first: &Usage, threads: Threads, second: &Usage) -> bool { + if first != second { + return false; + } + match threads { + Threads::Running(n) => n == 0, + Threads::NoTask => second.exit_abstime != 0, + Threads::Unknown => false, + } +} + +/// One process of a tree snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Member { + pub(super) pid: u32, + /// The process whose child listing named it (0 for the root): the one + /// that reaps it, unless that one exits first. + pub(super) parent: u32, + /// Its second read. + pub(super) usage: Usage, + /// Whether its reading was complete ([`settled`]). + pub(super) settled: bool, +} + +/// One walk of a tree. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(super) struct Snapshot { + /// `mach_absolute_time()` just before the walk began: a process whose + /// start time is at or after the start snapshot's was born in the span. + pub(super) taken: u64, + /// The mach timebase (`numer`, `denom`: ticks × numer / denom = ns), for + /// the CPU time a gap reports. + pub(super) timebase: (u32, u32), + /// Every member read: alive, or exited and not yet reaped. + pub(super) members: Vec, + /// Members listed and not readable, with the read's `errno` (`EPERM`: a + /// process of another user). + pub(super) unreadable: Vec<(u32, i32)>, + /// Members whose children could not be listed, with the `errno`. + pub(super) unlisted: Vec<(u32, i32)>, + /// A process was listed as a member's child and was gone, or was + /// another process, by the time it was read: the tree changed while + /// this walk read it. + pub(super) vanished: bool, + /// No walk was confirmed by the one after it ([`Snapshot::agrees_with`]) + /// before the settle bound ran out, and this, the last, is returned all + /// the same: the tree kept changing, so it is not known to hold every + /// process the tree had ([`TreeGap::Changing`], or + /// [`TreeGap::Unsettled`] where a member was still running). + pub(super) unconfirmed: bool, +} + +impl Snapshot { + fn member(&self, pid: u32) -> Option<&Member> { + self.members.iter().find(|m| m.pid == pid) + } + + /// Every member read settled, and no listed process gone by its read. + pub(super) fn quiet(&self) -> bool { + !self.vanished && self.members.iter().all(|m| m.settled) + } + + /// Whether `self` and `other`, two walks one after the other, read the + /// tree with the same shape: the same processes (pid and start time), + /// each listed by the same parent, with the same reaped-descendants time + /// and exit time, and the same members unreadable and unlisted; and + /// neither saw a listed process vanish. None of these ever returns to a + /// value it left (a process that leaves the tree never rejoins it, a + /// reused pid has another start time, the times only grow), so two + /// walks that agree on them read a tree that did not change from the + /// first's read of each member to the second's: no process was born, + /// exited, was reaped or left the tree in between, and none slipped + /// between one member's read and its children's. The members' own + /// counts are not compared: each is exact at its own settled read, + /// whenever another member is read. + pub(super) fn agrees_with(&self, other: &Snapshot) -> bool { + // Each member's pid, start time, parent, reaped time and exit time. + fn members(s: &Snapshot) -> Vec<(u32, u64, u32, u64, u64)> { + let mut v: Vec<_> = s + .members + .iter() + .map(|m| (m.pid, m.usage.start_abstime, m.parent, m.usage.reaped_descendants_cpu, m.usage.exit_abstime)) + .collect(); + v.sort_unstable(); + v + } + fn sorted(v: &[(u32, i32)]) -> Vec<(u32, i32)> { + let mut v = v.to_vec(); + v.sort_unstable(); + v + } + !self.vanished + && !other.vanished + && members(self) == members(other) + && sorted(&self.unreadable) == sorted(&other.unreadable) + && sorted(&self.unlisted) == sorted(&other.unlisted) + } + + /// The member that is the same process as `m`: same pid, same start time. + fn same_process(&self, m: &Member) -> Option<&Member> { + self.member(m.pid).filter(|e| e.usage.start_abstime == m.usage.start_abstime) + } + + fn cpu(&self, ticks: u64) -> Duration { + let (numer, denom) = self.timebase; + let ns = u128::from(ticks) * u128::from(numer) / u128::from(denom.max(1)); + Duration::from_nanos(u64::try_from(ns).unwrap_or(u64::MAX)) + } +} + +/// One GPU client in the I/O Registry: a connection a process opened to the +/// GPU. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct GpuClient { + /// The client's registry entry ID, never reused while the host is up. + pub(super) id: u64, + /// The process that opened it. + pub(super) pid: u32, + /// Its accumulated GPU time, ns, summed over its `AppUsage` entries; + /// `None` when it has no `AppUsage` — none spent yet, or lost because + /// its process exited. + pub(super) gpu_ns: Option, +} + +/// Which end of a span a gap was seen at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpanEnd { + Start, + Stop, +} + +impl std::fmt::Display for SpanEnd { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + SpanEnd::Start => "start", + SpanEnd::Stop => "stop", + }) + } +} + +/// One way a [`TreeReading`]'s figure was seen to differ from the tree's +/// whole energy over the span: short of it, for every kind but +/// [`Unsettled`](TreeGap::Unsettled) at the start, which may also put up to +/// a quantum per running thread from before the span into the figure. A +/// reading with none is whole for its domain (see the [module docs](self)): +/// it does not say the tree handed no work outside itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TreeGap { + /// A member was listed but could not be read (`EPERM`: a process of + /// another user, such as a setuid helper). Its energy is not in the + /// figure, and neither, at the start, is a baseline for it. + Unreadable { pid: u32, errno: i32, end: SpanEnd }, + /// A member's children could not be listed: any descendant below it is + /// unseen. + Unlisted { pid: u32, errno: i32, end: SpanEnd }, + /// A member still had a thread on a core, or queued for one, when the + /// meter's settle bound ran out: its reading at that end may lack up to + /// a quantum of energy per thread, so the figure may count too much (at + /// the start: energy spent just before the span, folded inside it) or + /// too little (at the stop). Its figure is counted, so with this gap at + /// the start the figure is no lower bound. The + /// walk at that end was not confirmed by a second one either, so a + /// process born, exited or reaped while it was read may be missing too. + Unsettled { pid: u32, end: SpanEnd }, + /// Every member was quiet at that end, but no two walks in a row agreed + /// on the tree's shape before the settle bound ran out (a process born, + /// exiting, reaped or vanishing between or during them): the walk read + /// there is not known to hold every process, and a descendant that came + /// and went while it was read may be in it neither as a member nor as + /// its reaper's reaped time. + Changing { end: SpanEnd }, + /// A member at the stop that started before the span but was not in the + /// start walk (unreadable then, or missed): its energy from before the + /// span cannot be separated from the span's, so none of it is counted. + PredatesSpan { pid: u32 }, + /// A member at the start that is not in the tree at the stop: it exited + /// and was reaped (the kernel folds its CPU time into the reaper and its + /// energy into nothing), or left the tree (reparented to `launchd` when + /// its parent exited first), or its pid now names another process. + /// Whatever it spent between the start and its exit is not in the + /// figure. Not raised for a process that had already exited at the start. + Gone { pid: u32 }, + /// A member's energy count went backwards between the ends, which the + /// kernel's count never does for one process: its advance is not + /// counted. + EnergyWentBack { pid: u32 }, + /// A member reaped descendants inside the span, whose CPU time (`cpu`) + /// moved into its own reaped-descendants time and whose energy is in no + /// counter: they ran, and their energy is missing. Descendants that had + /// already exited at the start (zombies then) are not counted against + /// it: their energy was spent before the span, and their reap moves the + /// reaper's time by exactly their CPU time. + ReapedDescendant { pid: u32, cpu: Duration }, + /// A member exited inside the span and is not yet reaped. Its record is + /// read and counted, but it is not all the member spent: an exiting + /// thread's last time on a core is never folded into it (up to all of a + /// short-lived process's energy; see the [module docs](self)). And a GPU + /// client loses its time about 2 ms after its process exits and is + /// removed by about 9 ms, so whether it used the GPU is unknown. + Exited { pid: u32 }, + /// The tree's processes spent at least `ns` of GPU time over the span: + /// what their GPU clients recorded, which leaves out any work on a + /// command queue released inside it. Its energy is not measured here, + /// and it is not zero. + Gpu { ns: u64 }, + /// The tree's GPU use is unknown: `None` — the I/O Registry could not be + /// read at an end, or this platform has no reader (anything but Apple + /// Silicon macOS); `Some(pid)` — that member held a GPU client at an + /// end and its clients recorded no GPU time over the span, which does + /// not show it used none (a command queue created and released inside + /// the span takes its recorded time with it), or a client of it + /// vanished, lost its time or went back. + GpuUnknown { pid: Option }, + /// A member has held Neural Engine model memory in-process (`bytes`, its + /// lifetime maximum), so it may have run work on the Neural Engine, + /// whose energy is in no counter this process can read. + NeuralEngine { pid: u32, bytes: u64 }, +} + +impl std::fmt::Display for TreeGap { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TreeGap::Unreadable { pid, errno, end } => { + write!(f, "pid {pid} could not be read at the {end} (errno {errno})") + } + TreeGap::Unlisted { pid, errno, end } => { + write!(f, "pid {pid}'s children could not be listed at the {end} (errno {errno})") + } + TreeGap::Unsettled { pid, end } => { + write!(f, "pid {pid} was still running at the {end}, so its reading may be stale") + } + TreeGap::Changing { end } => { + write!(f, "the tree kept changing at the {end}, so a process that came and went may be missing") + } + TreeGap::PredatesSpan { pid } => { + write!(f, "pid {pid} started before the span but was not read at its start") + } + TreeGap::Gone { pid } => write!(f, "pid {pid} left the tree or exited and was reaped"), + TreeGap::EnergyWentBack { pid } => write!(f, "pid {pid}'s energy count went backwards"), + TreeGap::ReapedDescendant { pid, cpu } => { + write!(f, "pid {pid} reaped descendants that ran {cpu:?} of CPU; their energy is in no counter") + } + TreeGap::Exited { pid } => write!( + f, + "pid {pid} exited in the span: its record lacks its last time on a core, and its GPU time is unknown" + ), + TreeGap::Gpu { ns } => write!(f, "the tree spent at least {ns} ns of GPU time, whose energy is not measured"), + TreeGap::GpuUnknown { pid: None } => f.write_str("the tree's GPU time could not be read"), + TreeGap::GpuUnknown { pid: Some(pid) } => { + write!(f, "pid {pid} held a GPU client whose use over the span is unknown") + } + TreeGap::NeuralEngine { pid, bytes } => { + write!(f, "pid {pid} has held {bytes} bytes of Neural Engine model memory") + } + } + } +} + +/// A span with no reading at all. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum TreeError { + /// The root is not in its tree at an end: it exited and was reaped, or + /// its pid now names another process (a different start time). + #[error("the tree's root, pid {pid}, is gone")] + RootGone { pid: u32 }, + /// The root could not be read at an end. + #[error("the tree's root, pid {pid}, could not be read (errno {errno})")] + RootUnreadable { pid: u32, errno: i32 }, + /// The CPU energy the figure sums did not advance. Even a bare + /// request/response round trip reads microjoules on this counter, so a + /// zero is no reading of the span. `gaps` are the reading's gaps, for + /// the record. + #[error("the tree's CPU energy did not advance over the span")] + NotAdvanced { gaps: Vec }, +} + +/// One span of a process tree's CPU energy: the figure and what it is short +/// of. See the [module docs](self) for its domain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreeReading { + /// The figure, µJ: [`cpu_nj`](Self::cpu_nj) rounded UP to a whole µJ, so + /// a span that read any energy is never 0 µJ and a budget debited by it + /// is never debited less than was read (it is over by under 1 µJ). + pub cpu_uj: u64, + /// The figure as the kernel counts it, nJ: the advance of every process + /// present at both ends with the same identity, plus the whole count of + /// every process born inside the span (and nothing of a gap's process + /// whose figure could not be separated: [`TreeGap::PredatesSpan`], + /// [`TreeGap::EnergyWentBack`]). + pub cpu_nj: u64, + /// How many processes [`cpu_nj`](Self::cpu_nj) sums. + pub processes: usize, + /// Their pids. + pub pids: Vec, + /// From the start walk settling to the stop walk settling. + pub wall: Duration, + /// Every way the figure was seen to differ from the tree's whole energy + /// over the span (see [`TreeGap`]: all but one mean energy is missing). + pub gaps: Vec, +} + +impl TreeReading { + /// Whether nothing was seen missing: no gap. Whole for the reading's + /// domain only (see the [module docs](self)). + pub fn is_whole(&self) -> bool { + self.gaps.is_empty() + } +} + +/// The root at an end: present with its pinned identity, or the error. +fn check_root(root: (u32, u64), s: &Snapshot) -> Result<(), TreeError> { + let (pid, born) = root; + if let Some(m) = s.member(pid) { + return if m.usage.start_abstime == born { Ok(()) } else { Err(TreeError::RootGone { pid }) }; + } + match s.unreadable.iter().find(|(p, _)| *p == pid) { + Some(&(_, errno)) => Err(TreeError::RootUnreadable { pid, errno }), + None => Err(TreeError::RootGone { pid }), + } +} + +/// The tree's GPU time over the span as the registry recorded it, and the +/// tree pids whose GPU use over the span is unknown. +/// +/// A client's recorded time is per live command queue: a queue's +/// `AppUsage` entry, and the time on it, go when the queue is released. +/// Measured (M5 Max, macOS 27.0.1): a request run on a command queue created +/// and released inside the span did 5.2 ms of GPU work by its command +/// buffer's own timestamps and moved its client's recorded time by 0, as +/// did one on a device opened and closed inside it. So a client whose time +/// did not move is no evidence of no GPU use; only a process that held no +/// client at either end is known to have used none. +/// +/// The time is therefore a lower bound: each client of a `tree` pid present +/// at both ends (by registry ID) adds its advance, and one new at the end +/// its whole time. Every tree pid holding a client at either end is unknown +/// unless its clients recorded GPU time over the span (the time then says +/// it used the GPU), and so is one with a client whose time is lost — +/// present at the start and gone at the end, or whose time went back, fell +/// to zero or lost its `AppUsage` (a client loses its time ~2 ms after its +/// process exits): never read as a decrease. +pub(super) fn gpu_spent(start: &[GpuClient], end: &[GpuClient], tree: impl Fn(u32) -> bool) -> (u64, Vec) { + let mut holders: Vec = Vec::new(); + for c in start.iter().chain(end).filter(|c| tree(c.pid)) { + if !holders.contains(&c.pid) { + holders.push(c.pid); + } + } + let mut ns: u64 = 0; + let mut unknown: Vec = Vec::new(); + for pid in holders { + let mut spent: u64 = 0; + let mut lost = false; + for c in start.iter().filter(|c| c.pid == pid) { + match (c.gpu_ns, end.iter().find(|e| e.id == c.id).map(|e| e.gpu_ns)) { + // Present at both ends, with no time recorded at either. + (None, Some(None)) => {} + (before, Some(Some(after))) => match after.checked_sub(before.unwrap_or(0)) { + Some(d) => spent = spent.saturating_add(d), + None => lost = true, + }, + (Some(_), Some(None)) | (_, None) => lost = true, + } + } + for e in end.iter().filter(|e| e.pid == pid && !start.iter().any(|c| c.id == e.id)) { + spent = spent.saturating_add(e.gpu_ns.unwrap_or(0)); + } + ns = ns.saturating_add(spent); + if lost || spent == 0 { + unknown.push(pid); + } + } + (ns, unknown) +} + +/// The rule: a span's reading of the tree rooted at `root` (its pid and +/// start time, pinned when the meter was made) from the settled start and +/// stop snapshots, the GPU clients read before the start walk and after the +/// stop walk (`None`: unreadable, or no reader on this platform) and the +/// span's wall time. Pure, so the tests drive every case. +pub(super) fn combine( + root: (u32, u64), + start: &Snapshot, + end: &Snapshot, + gpu_start: Option<&[GpuClient]>, + gpu_end: Option<&[GpuClient]>, + wall: Duration, +) -> Result { + check_root(root, start)?; + check_root(root, end)?; + + let mut gaps: Vec = Vec::new(); + for (snap, at) in [(start, SpanEnd::Start), (end, SpanEnd::Stop)] { + for &(pid, errno) in &snap.unreadable { + gaps.push(TreeGap::Unreadable { pid, errno, end: at }); + } + for &(pid, errno) in &snap.unlisted { + gaps.push(TreeGap::Unlisted { pid, errno, end: at }); + } + for m in snap.members.iter().filter(|m| !m.settled) { + gaps.push(TreeGap::Unsettled { pid: m.pid, end: at }); + } + // Unconfirmed with a member still running is said by `Unsettled`. + if snap.unconfirmed && snap.members.iter().all(|m| m.settled) { + gaps.push(TreeGap::Changing { end: at }); + } + } + + // The CPU time a reaper's reaped-descendants time may move by with no + // energy lost: the whole CPU time (own and reaped) of each of its + // children that had already exited at the start and was reaped in the + // span. The kernel adds exactly that at the reap; their energy was all + // spent before the span. + let reaped_before = |reaper: u32| -> u64 { + start + .members + .iter() + .filter(|s| s.parent == reaper && s.usage.exit_abstime != 0 && end.same_process(s).is_none()) + .map(|s| s.usage.own_cpu.saturating_add(s.usage.reaped_descendants_cpu)) + .fold(0, u64::saturating_add) + }; + + let mut cpu_nj: u64 = 0; + let mut pids: Vec = Vec::new(); + for e in &end.members { + let u = &e.usage; + let (counted, reaped_in_span) = match start.same_process(e) { + Some(s) => match u.energy_nj.checked_sub(s.usage.energy_nj) { + Some(d) => (Some(d), u.reaped_descendants_cpu.saturating_sub(s.usage.reaped_descendants_cpu)), + None => { + gaps.push(TreeGap::EnergyWentBack { pid: e.pid }); + (None, u.reaped_descendants_cpu.saturating_sub(s.usage.reaped_descendants_cpu)) + } + }, + // Not in the start walk (or its pid named another process then): + // born in the span, or older and missed. + None if u.start_abstime >= start.taken => (Some(u.energy_nj), u.reaped_descendants_cpu), + None => { + gaps.push(TreeGap::PredatesSpan { pid: e.pid }); + (None, 0) + } + }; + if let Some(d) = counted { + cpu_nj = cpu_nj.saturating_add(d); + pids.push(e.pid); + } + let lost = reaped_in_span.saturating_sub(reaped_before(e.pid)); + if lost > 0 { + gaps.push(TreeGap::ReapedDescendant { pid: e.pid, cpu: end.cpu(lost) }); + } + let exited_before = start.same_process(e).is_some_and(|s| s.usage.exit_abstime != 0); + if u.exit_abstime != 0 && !exited_before { + gaps.push(TreeGap::Exited { pid: e.pid }); + } + if u.neural_lifetime_max > 0 { + gaps.push(TreeGap::NeuralEngine { pid: e.pid, bytes: u.neural_lifetime_max }); + } + } + for s in &start.members { + let unreadable_now = end.unreadable.iter().any(|&(p, _)| p == s.pid); + if end.same_process(s).is_none() && s.usage.exit_abstime == 0 && !unreadable_now { + gaps.push(TreeGap::Gone { pid: s.pid }); + } + } + + // The GPU: every client of any process that was a member at either end. + match (gpu_start, gpu_end) { + (Some(a), Some(b)) => { + let in_tree = |pid: u32| start.member(pid).is_some() || end.member(pid).is_some(); + let (ns, unknown) = gpu_spent(a, b, in_tree); + gaps.extend(unknown.into_iter().map(|pid| TreeGap::GpuUnknown { pid: Some(pid) })); + if ns > 0 { + gaps.push(TreeGap::Gpu { ns }); + } + } + _ => gaps.push(TreeGap::GpuUnknown { pid: None }), + } + + if cpu_nj == 0 { + return Err(TreeError::NotAdvanced { gaps }); + } + Ok(TreeReading { cpu_uj: cpu_nj.div_ceil(1_000), cpu_nj, processes: pids.len(), pids, wall, gaps }) +} + +/// Walks (`walk`: production walks the tree; the tests give a sequence) +/// until a walk with every member settled is confirmed by the one before it +/// ([`Snapshot::agrees_with`]), and returns it; or, once `bound` has passed +/// (and after two walks at least), the last walk, marked `unconfirmed`. A +/// quiet walk is confirmed by walking again at once; anything else waits +/// `poll` first. +/// +/// One walk is not enough even with every member settled: a walk reads a +/// parent, then lists and reads its children, and a child that exits and is +/// reaped in between is in that walk neither as a member nor as its +/// parent's reaped time — gone without a trace. The next walk reads the +/// parent after the reap, its reaped time moved, so the two disagree and +/// the meter walks on until two agree. +pub(super) fn settle(mut walk: impl FnMut() -> Snapshot, bound: Duration, poll: Duration) -> Snapshot { + let deadline = Instant::now() + bound; + let mut prev: Option = None; + let mut walks = 0u32; + loop { + let snap = walk(); + walks += 1; + let quiet = snap.quiet(); + if quiet && prev.as_ref().is_some_and(|p| p.agrees_with(&snap)) { + return snap; + } + if walks >= 2 && Instant::now() >= deadline { + return Snapshot { unconfirmed: true, ..snap }; + } + let confirm_at_once = quiet && !prev.as_ref().is_some_and(Snapshot::quiet); + prev = Some(snap); + if !confirm_at_once { + std::thread::sleep(poll); + } + } +} + +/// The CPU energy of a live process tree rooted at another process: see the +/// [module docs](self) for what a reading covers and how it is taken. +#[derive(Debug, Clone)] +pub struct TreeMeter { + root: u32, + /// The root's start time, pinned: a root whose pid later names another + /// process is gone. + root_start: u64, + settle_bound: Duration, +} + +/// The start of a [`TreeMeter`] span: the settled start walk and the GPU +/// clients read before it. +#[derive(Debug, Clone)] +pub struct TreeStart { + snapshot: Snapshot, + gpu: Option>, + started: Instant, +} + +impl TreeMeter { + /// A meter for the tree rooted at `pid`, if this host's per-process + /// energy counter is live ([`process_energy_status`](super::process_energy_status): + /// the same kernel accounting, proved to advance under load in this + /// process; the first call in a process pays that probe, about 300 ms of + /// CPU on this thread) and the root can be read now: a process of this + /// user, alive or exited and not yet reaped. `None` otherwise, and always + /// off macOS, which has no per-process energy counter. + pub fn for_process(pid: u32) -> Option { + Self::given(super::process_energy_status(), pid) + } + + /// [`for_process`](Self::for_process) for a given counter status, which + /// production takes from the cached status and nowhere else. Separate so + /// the gate is tested for every status without probing. + fn given(status: CounterStatus, pid: u32) -> Option { + if status != CounterStatus::Live { + return None; + } + let root_start = sys::identity(pid)?; + Some(TreeMeter { root: pid, root_start, settle_bound: SETTLE_BOUND }) + } + + /// The same meter, waiting up to `bound` at each end for the tree to go + /// quiet (default [`SETTLE_BOUND`]). + pub fn with_settle_bound(self, bound: Duration) -> TreeMeter { + TreeMeter { settle_bound: bound, ..self } + } + + /// The root's pid. + pub fn root(&self) -> u32 { + self.root + } + + /// Begin a span: read the GPU clients, then walk the tree until it is + /// settled (or the bound runs out). + pub fn start(&self) -> TreeStart { + let gpu = sys::gpu_clients(); + let snapshot = sys::settled_walk(self.root, self.settle_bound); + TreeStart { snapshot, gpu, started: Instant::now() } + } + + /// End a span: walk the tree until it is settled (or the bound runs + /// out), then read the GPU clients, and apply the rule. + pub fn stop(&self, start: TreeStart) -> Result { + let end = sys::settled_walk(self.root, self.settle_bound); + let wall = start.started.elapsed(); + let gpu = sys::gpu_clients(); + combine((self.root, self.root_start), &start.snapshot, &end, start.gpu.as_deref(), gpu.as_deref(), wall) + } + + /// Meter `work` (which drives the tree: sends it a request and waits for + /// the reply): its value, and the span's reading. + pub fn measure(&self, work: impl FnOnce() -> T) -> (T, Result) { + let span = self.start(); + let out = work(); + (out, self.stop(span)) + } +} + +#[cfg(target_os = "macos")] +mod sys { + use super::{settled, GpuClient, Member, Snapshot, Threads}; + use crate::probe::process::read_usage; + use std::collections::HashSet; + use std::time::Duration; + + #[repr(C)] + struct Timebase { + numer: u32, + denom: u32, + } + + extern "C" { + // `libc` marks both deprecated in favour of the `mach2` crate; bound + // here directly, as `attribute` binds `mach_host_self`. + fn mach_absolute_time() -> u64; + fn mach_timebase_info(info: *mut Timebase) -> libc::c_int; + } + + fn now() -> u64 { + // SAFETY: no preconditions. + unsafe { mach_absolute_time() } + } + + fn timebase() -> (u32, u32) { + static TB: std::sync::OnceLock<(u32, u32)> = std::sync::OnceLock::new(); + *TB.get_or_init(|| { + let mut tb = Timebase { numer: 0, denom: 0 }; + // SAFETY: `tb` is writable for the call. + let rc = unsafe { mach_timebase_info(&mut tb) }; + if rc == 0 && tb.denom != 0 { + (tb.numer, tb.denom) + } else { + (1, 1) + } + }) + } + + fn errno() -> i32 { + std::io::Error::last_os_error().raw_os_error().unwrap_or(0) + } + + /// The root's start time, if it can be read now. + pub(super) fn identity(pid: u32) -> Option { + read_usage(libc::pid_t::try_from(pid).ok()?).ok().map(|u| u.start_abstime) + } + + /// The pids of `pid`'s children. `proc_listchildpids` returns a COUNT of + /// pids, and truncates silently: a count that fills the buffer is listed + /// again into a larger one. A pid that does not exist lists none, as a + /// childless one does (a count of 0, `errno` 0). + /// + /// It also returns 0, not -1, when the listing FAILS — libproc turns the + /// failed call into a count of 0 and leaves `errno` set — so `errno` is + /// cleared before the call and a count of 0 with `errno` set is the + /// error. Measured: under a sandbox profile that denies + /// `process-info-listpids`, every listing returned 0 with `EPERM`, while + /// each process's usage stayed readable; read as "childless", the tree + /// would be its root alone, with no gap. + fn children(pid: u32) -> Result, i32> { + let Ok(ppid) = libc::pid_t::try_from(pid) else { return Ok(Vec::new()) }; + let mut cap: usize = 16; + loop { + let mut buf: Vec = vec![0; cap]; + let bytes = libc::c_int::try_from(cap * std::mem::size_of::()).map_err(|_| libc::ENOMEM)?; + // SAFETY: `__error` is this thread's `errno`, always writable. + unsafe { *libc::__error() = 0 }; + // SAFETY: `buf` is writable for `bytes` bytes. + let n = unsafe { libc::proc_listchildpids(ppid, buf.as_mut_ptr().cast(), bytes) }; + if n < 0 { + return Err(errno()); + } + if n == 0 { + match errno() { + 0 => return Ok(Vec::new()), + e => return Err(e), + } + } + let n = n as usize; + if n < cap { + buf.truncate(n); + return Ok(buf.into_iter().filter_map(|p| u32::try_from(p).ok()).filter(|&p| p > 0).collect()); + } + if cap >= 1 << 20 { + return Err(libc::ENOMEM); + } + cap *= 4; + } + } + + fn threads(pid: libc::pid_t) -> Threads { + // SAFETY: an all-zero `proc_taskinfo` is a valid struct of integers. + let mut ti: libc::proc_taskinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as libc::c_int; + // SAFETY: `ti` is writable for `size` bytes, the size of the flavor's struct. + let n = unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDTASKINFO, 0, (&mut ti as *mut libc::proc_taskinfo).cast(), size) }; + if n == size { + return u32::try_from(ti.pti_numrunning).map_or(Threads::Unknown, Threads::Running); + } + if errno() == libc::ESRCH { + Threads::NoTask + } else { + Threads::Unknown + } + } + + /// One member: two reads around its run state. `Ok(None)`: it is gone + /// (no such process, or reaped between the reads). + fn member(pid: u32, parent: u32) -> Result, i32> { + let Ok(p) = libc::pid_t::try_from(pid) else { return Ok(None) }; + let read = |p| match read_usage(p) { + Ok(u) => Ok(Some(u)), + Err(libc::ESRCH) => Ok(None), + Err(e) => Err(e), + }; + let Some(first) = read(p)? else { return Ok(None) }; + let running = threads(p); + let Some(second) = read(p)? else { return Ok(None) }; + // The pid named another process by the second read: the first is gone. + if second.start_abstime != first.start_abstime { + return Ok(None); + } + Ok(Some(Member { pid, parent, usage: second, settled: settled(&first, running, &second) })) + } + + /// One walk of the tree from `root`: each process read, then its + /// children listed (a process that cannot be read is still listed, so a + /// same-user descendant below it is found). + pub(super) fn walk(root: u32) -> Snapshot { + let mut snap = Snapshot { taken: now(), timebase: timebase(), ..Snapshot::default() }; + let mut seen: HashSet = HashSet::from([root]); + let mut stack: Vec<(u32, u32)> = vec![(root, 0)]; + while let Some((pid, parent)) = stack.pop() { + match member(pid, parent) { + Ok(Some(m)) => snap.members.push(m), + // Gone: it has no children to list. A listed child gone by + // its read exited and was reaped (or its pid was reused) + // while this walk ran, after its parent was read: the walk + // holds neither it nor, perhaps, its reap. (A root gone is + // the reading's error, not the walk's.) + Ok(None) => { + snap.vanished |= parent != 0; + continue; + } + Err(errno) => snap.unreadable.push((pid, errno)), + } + match children(pid) { + Ok(kids) => stack.extend(kids.into_iter().filter(|k| seen.insert(*k)).map(|k| (k, pid))), + Err(errno) => snap.unlisted.push((pid, errno)), + } + } + snap + } + + /// The tree from `root`, walked until it is settled and confirmed + /// ([`settle`](super::settle)). + pub(super) fn settled_walk(root: u32, bound: Duration) -> Snapshot { + super::settle(|| walk(root), bound, super::SETTLE_POLL) + } + + /// Every GPU client now; `None` if the registry could not be read, and + /// always off Apple Silicon, which has no reader here. + pub(super) fn gpu_clients() -> Option> { + #[cfg(target_arch = "aarch64")] + { + crate::probe::gpu_clients::gpu_clients() + } + #[cfg(not(target_arch = "aarch64"))] + { + None + } + } +} + +#[cfg(not(target_os = "macos"))] +mod sys { + //! No per-process energy counter off macOS: [`TreeMeter::for_process`](super::TreeMeter::for_process) + //! is `None`, so no meter exists to walk with these. + use super::{GpuClient, Snapshot}; + use std::time::Duration; + + pub(super) fn identity(_: u32) -> Option { + None + } + pub(super) fn settled_walk(_: u32, _: Duration) -> Snapshot { + Snapshot::default() + } + pub(super) fn gpu_clients() -> Option> { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The start walk's mach time; a process born at or after it was born in + /// the span. + const T0: u64 = 1_000_000; + const ROOT: u32 = 100; + /// The root's pinned identity. + const ROOT_ID: (u32, u64) = (ROOT, 10); + + fn usage(born: u64, energy_nj: u64) -> Usage { + Usage { energy_nj, own_cpu: energy_nj / 7, start_abstime: born, ..Usage::default() } + } + + fn member(pid: u32, parent: u32, u: Usage) -> Member { + Member { pid, parent, usage: u, settled: true } + } + + fn snap(taken: u64, members: Vec) -> Snapshot { + Snapshot { taken, timebase: (1, 1), members, ..Snapshot::default() } + } + + /// No GPU client anywhere, read at both ends. + const NO_GPU: Option<&[GpuClient]> = Some(&[]); + + fn read(start: &Snapshot, end: &Snapshot) -> Result { + combine(ROOT_ID, start, end, NO_GPU, NO_GPU, Duration::from_millis(3)) + } + + fn whole(r: &Result) -> &TreeReading { + let r = r.as_ref().expect("a reading"); + assert!(r.is_whole(), "{:?}", r.gaps); + r + } + + fn gaps(r: &Result) -> &[TreeGap] { + &r.as_ref().expect("a reading").gaps + } + + /// A root and a child present at both ends: the sum of their advances, + /// whole. The µJ figure is the nJ figure rounded up, so a reading under + /// 1 µJ is 1 µJ, never 0. + #[test] + fn a_tree_present_at_both_ends_reads_the_sum_of_its_advances() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000)), member(101, ROOT, usage(20, 70_000))]); + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 8_600)), member(101, ROOT, usage(20, 75_000))]); + let r = read(&start, &end); + let r = whole(&r); + assert_eq!((r.cpu_nj, r.cpu_uj, r.processes), (8_600, 9, 2)); + assert_eq!(r.pids, [ROOT, 101]); + } + + /// The µJ figure never rounds a reading away: anything read is at least + /// 1 µJ, and a figure is never under its nanojoules. + #[test] + fn the_microjoule_figure_rounds_up() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000))]); + for (advance, uj) in [(1, 1), (999, 1), (1_000, 1), (1_001, 2), (1_999_999, 2_000)] { + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 5_000 + advance))]); + let r = read(&start, &end); + assert_eq!((whole(&r).cpu_nj, whole(&r).cpu_uj), (advance, uj)); + } + } + + /// A process in the stop walk only, started at or after the start walk, + /// was born in the span: its whole count is the span's. + #[test] + fn a_process_born_in_the_span_counts_its_whole_energy() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000))]); + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000)), member(102, ROOT, usage(T0 + 40, 30_000))]); + let r = read(&start, &end); + let r = whole(&r); + assert_eq!((r.cpu_nj, r.processes), (31_000, 2)); + // Born exactly at the start walk's time counts too. + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000)), member(102, ROOT, usage(T0, 30_000))]); + assert_eq!(whole(&read(&start, &end)).cpu_nj, 31_000); + } + + /// A process in the stop walk only that started before the span (missed, + /// or unreadable, at the start) is a gap, and none of it is counted: its + /// pre-span energy cannot be separated. + #[test] + fn an_older_process_seen_only_at_the_stop_is_a_gap_and_not_counted() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000))]); + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000)), member(103, ROOT, usage(T0 - 1, 9_999_000))]); + let r = read(&start, &end); + assert_eq!(gaps(&r), [TreeGap::PredatesSpan { pid: 103 }]); + let r = r.unwrap(); + assert_eq!((r.cpu_nj, r.processes, r.pids.as_slice()), (1_000, 1, &[ROOT][..])); + } + + /// A process at the start and not at the stop is a gap: whatever it + /// spent before its exit is lost. Its start reading is not counted. + #[test] + fn a_process_gone_at_the_stop_is_a_gap() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000)), member(104, ROOT, usage(20, 40_000))]); + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000))]); + let r = read(&start, &end); + assert_eq!(gaps(&r), [TreeGap::Gone { pid: 104 }]); + assert_eq!(r.unwrap().cpu_nj, 1_000); + } + + /// The same pid with a different start time is a different process: the + /// old one is gone, and the new one, born in the span, is counted whole. + #[test] + fn a_reused_pid_is_two_processes() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000)), member(105, ROOT, usage(20, 40_000))]); + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000)), member(105, ROOT, usage(T0 + 50, 2_000))]); + let r = read(&start, &end); + assert_eq!(gaps(&r), [TreeGap::Gone { pid: 105 }]); + let r = r.unwrap(); + assert_eq!((r.cpu_nj, r.processes), (3_000, 2), "the new 105 counted whole, the old one not at all"); + } + + /// A member whose reaped-descendants time moved reaped a descendant that + /// ran in the span, and that descendant's energy is in no counter: a gap + /// carrying the CPU time it ran. A process born in the span that reaped + /// one is the same. + #[test] + fn a_descendant_reaped_in_the_span_is_a_gap() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000))]); + let mut sh = usage(10, 6_000); + sh.reaped_descendants_cpu = 2_400; + let end = Snapshot { timebase: (125, 3), ..snap(T0 + 900, vec![member(ROOT, 0, sh)]) }; + let r = read(&start, &end); + assert_eq!(gaps(&r), [TreeGap::ReapedDescendant { pid: ROOT, cpu: Duration::from_micros(100) }]); + assert_eq!(r.unwrap().cpu_nj, 1_000, "the reaper's own advance is still counted"); + + let mut born = usage(T0 + 5, 3_000); + born.reaped_descendants_cpu = 9; + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000)), member(106, ROOT, born)]); + assert_eq!(gaps(&read(&start, &end)), [TreeGap::ReapedDescendant { pid: 106, cpu: Duration::from_nanos(9) }]); + } + + /// A child that had already exited at the start (a zombie then) and is + /// reaped in the span moves its reaper's reaped-descendants time by + /// exactly its own CPU time and its reaped descendants' (measured: 5 of 5 + /// reaps exact to the tick), and it spent nothing in the span: no gap, and + /// no `Gone`. Anything the reaper's time moved beyond that is a + /// descendant that ran in the span: a gap for the excess. + #[test] + fn a_zombie_at_the_start_reaped_in_the_span_is_no_gap() { + let mut zombie = usage(20, 40_000); + zombie.own_cpu = 391_463; + zombie.reaped_descendants_cpu = 567_642; + zombie.exit_abstime = T0 - 5; + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000)), member(107, ROOT, zombie)]); + let mut reaper = usage(10, 6_000); + reaper.reaped_descendants_cpu = 959_105; + let end = snap(T0 + 900, vec![member(ROOT, 0, reaper)]); + let r = read(&start, &end); + let r = whole(&r); + assert_eq!((r.cpu_nj, r.processes), (1_000, 1)); + + reaper.reaped_descendants_cpu = 959_105 + 77; + let end = snap(T0 + 900, vec![member(ROOT, 0, reaper)]); + assert_eq!(gaps(&read(&start, &end)), [TreeGap::ReapedDescendant { pid: ROOT, cpu: Duration::from_nanos(77) }]); + + // A zombie that is some other member's child explains nothing here. + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000)), member(107, 999, zombie)]); + reaper.reaped_descendants_cpu = 959_105; + let end = snap(T0 + 900, vec![member(ROOT, 0, reaper)]); + assert_eq!(gaps(&read(&start, &end)), [TreeGap::ReapedDescendant { pid: ROOT, cpu: Duration::from_nanos(959_105) }]); + } + + /// A member that exited in the span and is not yet reaped is counted (its + /// record is final and readable, though it lacks its last time on a + /// core), and is a gap. One that had exited before the span is no gap. + #[test] + fn a_process_that_exited_in_the_span_is_counted_with_its_gpu_unknown() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000)), member(108, ROOT, usage(20, 40_000))]); + let mut exited = usage(20, 52_000); + exited.exit_abstime = T0 + 300; + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000)), member(108, ROOT, exited)]); + let r = read(&start, &end); + assert_eq!(gaps(&r), [TreeGap::Exited { pid: 108 }]); + assert_eq!(r.unwrap().cpu_nj, 13_000); + + let mut before = usage(20, 40_000); + before.exit_abstime = T0 - 300; + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000)), member(108, ROOT, before)]); + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000)), member(108, ROOT, before)]); + assert_eq!(whole(&read(&start, &end)).cpu_nj, 1_000); + } + + /// A process's count never goes back; one that did is a gap, its advance + /// not counted, never a negative. + #[test] + fn energy_that_went_back_is_a_gap_never_a_negative() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000)), member(109, ROOT, usage(20, 40_000))]); + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000)), member(109, ROOT, usage(20, 39_000))]); + let r = read(&start, &end); + assert_eq!(gaps(&r), [TreeGap::EnergyWentBack { pid: 109 }]); + assert_eq!(r.unwrap().cpu_nj, 1_000); + } + + /// A member not settled at either end is a gap at that end; its figure + /// still counts (the gap says it may be stale). + #[test] + fn a_member_still_running_at_an_end_is_a_gap() { + let mut start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000))]); + let mut end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000)), member(110, ROOT, usage(T0 + 1, 500))]); + start.members[0].settled = false; + end.members[1].settled = false; + let r = read(&start, &end); + assert_eq!( + gaps(&r), + [TreeGap::Unsettled { pid: ROOT, end: SpanEnd::Start }, TreeGap::Unsettled { pid: 110, end: SpanEnd::Stop }] + ); + assert_eq!(r.unwrap().cpu_nj, 1_500); + } + + /// Two walks confirm each other when they read the tree with the same + /// shape — the same processes, parents, reaped times, exit times, + /// unreadable and unlisted members, and nothing vanished — whatever the + /// members' own counts did in between. + #[test] + fn two_walks_agree_only_on_a_tree_that_did_not_change_shape() { + let a = snap(T0, vec![member(ROOT, 0, usage(10, 5_000)), member(101, ROOT, usage(20, 70_000))]); + let mut b = snap(T0 + 50, vec![member(101, ROOT, usage(20, 71_000)), member(ROOT, 0, usage(10, 5_100))]); + assert!(a.agrees_with(&b), "counts moved, shape the same, order aside"); + assert!(a.agrees_with(&a)); + + let changed = |f: &dyn Fn(&mut Snapshot)| { + let mut c = b.clone(); + f(&mut c); + c + }; + let reaped = changed(&|c| c.members[1].usage.reaped_descendants_cpu = 7); + let exited = changed(&|c| c.members[0].usage.exit_abstime = T0 + 9); + let reparented = changed(&|c| c.members[0].parent = 1); + let reused = changed(&|c| c.members[0].usage.start_abstime = T0 + 20); + let born = changed(&|c| c.members.push(member(102, ROOT, usage(T0 + 30, 9)))); + let gone = changed(&|c| { + c.members.remove(0); + }); + let vanished = changed(&|c| c.vanished = true); + let unreadable = changed(&|c| c.unreadable.push((103, 1))); + let unlisted = changed(&|c| c.unlisted.push((ROOT, 1))); + for (what, c) in [ + ("a reap", reaped), + ("an exit", exited), + ("another parent", reparented), + ("a reused pid", reused), + ("a birth", born), + ("a member gone", gone), + ("a listed child gone by its read", vanished), + ("a member unreadable", unreadable), + ("a listing failed", unlisted), + ] { + assert!(!a.agrees_with(&c) && !c.agrees_with(&a), "{what}"); + } + b.vanished = true; + assert!(!b.agrees_with(&b), "a walk that saw a child vanish confirms nothing, not even itself"); + } + + /// Walks handed out in order, counted. + fn walks(seq: Vec) -> (impl FnMut() -> Snapshot, std::rc::Rc>) { + let taken = std::rc::Rc::new(std::cell::Cell::new(0)); + let n = taken.clone(); + let walk = move || { + let i = n.get(); + n.set(i + 1); + seq[i.min(seq.len() - 1)].clone() + }; + (walk, taken) + } + + /// The settle loop accepts a quiet walk only once the walk before it + /// agrees: a parent read before a reap whose child is gone by the walk's + /// listing (`missed`: quiet, and wrong) is not accepted; the next walk + /// reads the reap (`reaped`), and the one after confirms it. + #[test] + fn a_quiet_walk_is_accepted_only_once_the_next_one_agrees() { + const LONG: Duration = Duration::from_secs(60); + let missed = snap(T0, vec![member(ROOT, 0, usage(10, 5_000))]); + let mut parent = usage(10, 5_000); + parent.reaped_descendants_cpu = 120; + let reaped = snap(T0 + 10, vec![member(ROOT, 0, parent)]); + + let (walk, taken) = walks(vec![missed.clone(), reaped.clone()]); + let got = settle(walk, LONG, Duration::ZERO); + assert_eq!((got.members.clone(), got.unconfirmed, taken.get()), (reaped.members.clone(), false, 3)); + + // A walk that saw a listed child vanish confirms nothing, though the + // next walk has the same shape. + let vanished = Snapshot { vanished: true, ..missed.clone() }; + let (walk, taken) = walks(vec![vanished, missed.clone()]); + assert!(!settle(walk, LONG, Duration::ZERO).unconfirmed); + assert_eq!(taken.get(), 3, "the vanished walk, then two that agree"); + + // A member still running is waited out; the quiet walk after it is + // confirmed by it, the shape being the same (its own count is not + // compared: only the quiet walk's counts are used). + let mut running = missed.clone(); + running.members[0].settled = false; + let (walk, taken) = walks(vec![running, missed.clone()]); + let got = settle(walk, LONG, Duration::ZERO); + assert!(!got.unconfirmed && got.quiet()); + assert_eq!(taken.get(), 2); + } + + /// A tree that never stops changing is walked until the bound has passed + /// (two walks at least, even with no bound at all), and the last walk is + /// returned marked unconfirmed. + #[test] + fn a_tree_that_keeps_changing_is_returned_unconfirmed_at_the_bound() { + let mut n = 0u64; + let changing = || { + n += 1; + let mut u = usage(10, 5_000); + u.reaped_descendants_cpu = n; + snap(T0, vec![member(ROOT, 0, u)]) + }; + let got = settle(changing, Duration::ZERO, Duration::ZERO); + assert!(got.unconfirmed); + assert_eq!(got.members[0].usage.reaped_descendants_cpu, 2, "two walks, the last returned"); + } + + /// A walk returned unconfirmed (the bound ran out with the tree still + /// changing) is a gap at its end when every member was quiet; with a + /// member still running, that member's `Unsettled` says it. + #[test] + fn an_unconfirmed_walk_is_a_gap() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000))]); + let mut end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000))]); + end.unconfirmed = true; + assert_eq!(gaps(&read(&start, &end)), [TreeGap::Changing { end: SpanEnd::Stop }]); + let unconfirmed_start = Snapshot { unconfirmed: true, ..start.clone() }; + let confirmed_end = Snapshot { unconfirmed: false, ..end.clone() }; + assert_eq!(gaps(&read(&unconfirmed_start, &confirmed_end)), [TreeGap::Changing { end: SpanEnd::Start }]); + end.members[0].settled = false; + assert_eq!(gaps(&read(&start, &end)), [TreeGap::Unsettled { pid: ROOT, end: SpanEnd::Stop }]); + } + + /// A member that cannot be read (another user's) or whose children + /// cannot be listed is a gap at that end. One unreadable at the stop is + /// not also `Gone`. + #[test] + fn an_unreadable_or_unlisted_member_is_a_gap() { + let mut start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000)), member(111, ROOT, usage(20, 9_000))]); + start.unlisted.push((ROOT, 5)); + let mut end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000))]); + end.unreadable.push((111, 1)); + let r = read(&start, &end); + assert_eq!( + gaps(&r), + [ + TreeGap::Unlisted { pid: ROOT, errno: 5, end: SpanEnd::Start }, + TreeGap::Unreadable { pid: 111, errno: 1, end: SpanEnd::Stop }, + ] + ); + } + + /// The root gone, replaced (its pid naming a process with another start + /// time) or unreadable at either end is no reading. + #[test] + fn a_root_that_is_gone_or_unreadable_is_no_reading() { + let ok = snap(T0, vec![member(ROOT, 0, usage(10, 5_000))]); + let later = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000))]); + assert!(read(&ok, &later).is_ok()); + let gone = snap(T0 + 900, vec![member(101, ROOT, usage(20, 6_000))]); + assert_eq!(read(&ok, &gone), Err(TreeError::RootGone { pid: ROOT })); + assert_eq!(read(&gone, &later), Err(TreeError::RootGone { pid: ROOT })); + let replaced = snap(T0 + 900, vec![member(ROOT, 0, usage(T0 + 3, 6_000))]); + assert_eq!(read(&ok, &replaced), Err(TreeError::RootGone { pid: ROOT })); + let mut unreadable = snap(T0 + 900, Vec::new()); + unreadable.unreadable.push((ROOT, 1)); + assert_eq!(read(&ok, &unreadable), Err(TreeError::RootUnreadable { pid: ROOT, errno: 1 })); + assert_eq!(read(&unreadable, &later), Err(TreeError::RootUnreadable { pid: ROOT, errno: 1 })); + } + + /// A summed advance of zero is no reading — a tree that ran at all reads + /// microjoules — and the error carries the gaps seen. + #[test] + fn a_tree_whose_energy_did_not_advance_is_no_reading() { + let s = snap(T0, vec![member(ROOT, 0, usage(10, 5_000))]); + assert_eq!(read(&s, &s), Err(TreeError::NotAdvanced { gaps: Vec::new() })); + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 5_000)), member(112, ROOT, usage(5, 70_000))]); + assert_eq!(read(&s, &end), Err(TreeError::NotAdvanced { gaps: vec![TreeGap::PredatesSpan { pid: 112 }] })); + } + + /// A member that has ever held Neural Engine model memory is a gap, with + /// the lifetime maximum — however long ago it held it. + #[test] + fn a_member_that_held_neural_engine_memory_is_a_gap() { + let mut ane = usage(10, 6_000); + ane.neural_lifetime_max = 1_168_000_000; + let mut before = usage(10, 5_000); + before.neural_lifetime_max = 1_168_000_000; + let start = snap(T0, vec![member(ROOT, 0, before)]); + let end = snap(T0 + 900, vec![member(ROOT, 0, ane)]); + let r = read(&start, &end); + assert_eq!(gaps(&r), [TreeGap::NeuralEngine { pid: ROOT, bytes: 1_168_000_000 }]); + assert_eq!(r.unwrap().cpu_nj, 1_000); + } + + fn client(id: u64, pid: u32, gpu_ns: Option) -> GpuClient { + GpuClient { id, pid, gpu_ns } + } + + /// The GPU accounting, client by client: a client of the tree that + /// advanced adds its advance, and one new at the stop its whole time; + /// another process's clients are not looked at, even vanished. A tree + /// process holding a client whose time did not move is UNKNOWN, not idle + /// (a command queue released inside the span takes its time with it), as + /// is one whose client vanished, lost its `AppUsage`, fell to zero or went + /// back — never a decrease. Only a process with no client is known to + /// have used no GPU. + #[test] + fn gpu_time_is_counted_by_client_and_a_client_that_did_not_move_is_unknown() { + let tree = |pid: u32| pid == ROOT || pid == 101; + let idle = [client(1, ROOT, Some(5_000))]; + assert_eq!(gpu_spent(&[], &[], tree), (0, vec![]), "no client: no GPU"); + assert_eq!(gpu_spent(&idle, &idle, tree), (0, vec![ROOT]), "did not move: unknown, not zero"); + assert_eq!(gpu_spent(&idle, &[client(1, ROOT, Some(7_500))], tree), (2_500, vec![])); + assert_eq!(gpu_spent(&[], &[client(2, 101, Some(900))], tree), (900, vec![])); + assert_eq!(gpu_spent(&[], &[client(2, 101, None)], tree), (0, vec![101]), "opened in the span, no time recorded"); + assert_eq!(gpu_spent(&[client(2, 101, Some(900))], &[], tree), (0, vec![101]), "closed in the span"); + assert_eq!(gpu_spent(&[client(3, 7, Some(1))], &[client(4, 7, Some(9_999))], tree), (0, vec![]), "not the tree's"); + assert_eq!(gpu_spent(&idle, &[], tree), (0, vec![ROOT]), "vanished"); + assert_eq!(gpu_spent(&idle, &[client(1, ROOT, None)], tree), (0, vec![ROOT]), "lost its AppUsage"); + assert_eq!(gpu_spent(&idle, &[client(1, ROOT, Some(0))], tree), (0, vec![ROOT]), "fell to zero"); + assert_eq!(gpu_spent(&idle, &[client(1, ROOT, Some(4_999))], tree), (0, vec![ROOT]), "went back"); + assert_eq!(gpu_spent(&[client(1, ROOT, None)], &[client(1, ROOT, None)], tree), (0, vec![ROOT])); + assert_eq!(gpu_spent(&[client(1, ROOT, None)], &[client(1, ROOT, Some(40))], tree), (40, vec![])); + // Several clients of one process: one advanced, one vanished. + let two = [client(1, ROOT, Some(5_000)), client(5, ROOT, Some(1))]; + assert_eq!(gpu_spent(&two, &[client(1, ROOT, Some(6_000))], tree), (1_000, vec![ROOT])); + // One advanced, one idle: the time says it used the GPU. + assert_eq!(gpu_spent(&two, &[client(1, ROOT, Some(6_000)), client(5, ROOT, Some(1))], tree), (1_000, vec![])); + // Two processes, one idle: that one is unknown. + let both = [client(1, ROOT, Some(5_000)), client(6, 101, Some(10))]; + assert_eq!(gpu_spent(&both, &[client(1, ROOT, Some(5_500)), client(6, 101, Some(10))], tree), (500, vec![101])); + } + + /// In a reading: GPU time spent by the tree is a gap with its time (its + /// energy is not measured, and it is not zero); a client lost is GPU + /// unknown; an unreadable registry (or no reader on this platform) at + /// either end is GPU unknown; a GPU client of a process outside the tree + /// is nothing to the reading. + #[test] + fn a_reading_carries_the_trees_gpu_time_as_a_gap() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000))]); + let end = snap(T0 + 900, vec![member(ROOT, 0, usage(10, 6_000)), member(113, ROOT, usage(T0 + 9, 1_000))]); + let at = |a: Option<&[GpuClient]>, b: Option<&[GpuClient]>| { + combine(ROOT_ID, &start, &end, a, b, Duration::from_millis(3)).map(|r| r.gaps) + }; + let other = [client(1, 172, Some(1_000))]; + assert_eq!(at(Some(&other), Some(&[client(1, 172, Some(9_000_000))])), Ok(vec![])); + assert_eq!(at(Some(&other), Some(&[client(1, 172, Some(1_000)), client(2, 113, Some(800))])), Ok(vec![TreeGap::Gpu { ns: 800 }])); + assert_eq!(at(Some(&[client(3, ROOT, Some(50))]), Some(&[])), Ok(vec![TreeGap::GpuUnknown { pid: Some(ROOT) }])); + assert_eq!(at(None, NO_GPU), Ok(vec![TreeGap::GpuUnknown { pid: None }])); + assert_eq!(at(NO_GPU, None), Ok(vec![TreeGap::GpuUnknown { pid: None }])); + } + + /// Several gaps at once are all reported, and the figure sums what can be + /// counted. + #[test] + fn every_gap_seen_is_reported_together() { + let start = snap(T0, vec![member(ROOT, 0, usage(10, 5_000)), member(120, ROOT, usage(20, 1_000))]); + let mut root_end = usage(10, 6_000); + root_end.reaped_descendants_cpu = 50; + let mut ane = usage(T0 + 2, 2_000); + ane.neural_lifetime_max = 4_096; + let end = snap(T0 + 900, vec![member(ROOT, 0, root_end), member(121, ROOT, ane), member(122, 121, usage(3, 1))]); + let r = combine(ROOT_ID, &start, &end, Some(&[]), Some(&[client(9, 121, Some(3))]), Duration::from_millis(3)); + assert_eq!( + gaps(&r), + [ + TreeGap::ReapedDescendant { pid: ROOT, cpu: Duration::from_nanos(50) }, + TreeGap::NeuralEngine { pid: 121, bytes: 4_096 }, + TreeGap::PredatesSpan { pid: 122 }, + TreeGap::Gone { pid: 120 }, + TreeGap::Gpu { ns: 3 }, + ] + ); + let r = r.unwrap(); + assert_eq!((r.cpu_nj, r.processes), (3_000, 2)); + assert!(!r.is_whole()); + for g in &r.gaps { + assert!(!g.to_string().is_empty()); + } + } + + /// A reading is complete only when two agreeing reads bracket a moment + /// with no thread on a core or queued; an exited process (no task) when + /// its two reads agree and its exit time is set. + #[test] + fn a_member_is_settled_only_when_no_thread_runs_between_agreeing_reads() { + let a = usage(10, 5_000); + let mut moved = a; + moved.own_cpu += 1; + let mut zombie = a; + zombie.exit_abstime = 77; + assert!(settled(&a, Threads::Running(0), &a)); + assert!(!settled(&a, Threads::Running(1), &a), "a thread on a core or queued"); + assert!(!settled(&a, Threads::Running(0), &moved), "the reads disagree"); + assert!(settled(&zombie, Threads::NoTask, &zombie)); + assert!(!settled(&a, Threads::NoTask, &a), "no task, yet no exit time"); + assert!(!settled(&a, Threads::Unknown, &a)); + } + + /// The meter exists only for a live counter, and only for a root this + /// process can read now (on macOS; never elsewhere). Nothing here + /// probes: the status is given. + #[test] + fn a_tree_meter_exists_only_for_a_live_counter_and_a_readable_root() { + let me = std::process::id(); + for status in [CounterStatus::Unavailable, CounterStatus::Frozen, CounterStatus::Implausible] { + assert!(TreeMeter::given(status, me).is_none(), "{status:?}"); + } + assert!(TreeMeter::given(CounterStatus::Live, 99_999_999).is_none(), "no such process"); + #[cfg(target_os = "macos")] + { + let m = TreeMeter::given(CounterStatus::Live, me).expect("this process reads itself"); + assert_eq!(m.root(), me); + // SAFETY: no preconditions. + if unsafe { libc::geteuid() } != 0 { + assert!(TreeMeter::given(CounterStatus::Live, 1).is_none(), "launchd is another user's"); + } + } + #[cfg(not(target_os = "macos"))] + assert!(TreeMeter::given(CounterStatus::Live, me).is_none(), "no per-process counter off macOS"); + } +} diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs new file mode 100644 index 000000000..cdf75aae3 --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs @@ -0,0 +1,637 @@ +//! The tree meter on real process trees. Each test spawns a server of its +//! own (a process group of its own, killed at the end), drives it with +//! request/response round trips over pipes, and meters each round trip with +//! a [`TreeMeter`] on the server's pid. +//! +//! The oracle shares no code with the meter: the tree's members from `ps`, +//! each member's `proc_pid_rusage(pid, RUSAGE_INFO_V6)` read into a raw +//! buffer and taken at the SDK's byte offsets (energy at 336, start time at +//! 80, exit time at 88, reaped-descendant CPU time at 96 and 104), read only +//! once the oracle has seen every member quiet on its own terms: no thread on +//! a core or queued (`proc_pidinfo(PROC_PIDTASKINFO)` into a raw buffer, +//! `pti_numrunning` at byte 88) at two reads 20 ms apart whose energy agrees. +//! A server blocked waiting for its next request does not move its count, so +//! the meter's reading of a round trip must EQUAL the oracle's advance across +//! it, to the nanojoule: a settle that read a member still finishing its +//! reply would read less, and one that read a stale start would read more. +//! The tests log what they saw (`tree meter:`), and run one at a time (a +//! lock) so one test's server is not loading the host under another's. + +#![cfg(all(target_os = "macos", target_arch = "aarch64"))] + +use sandbox_meter::{SpanEnd, TreeGap, TreeMeter, TreeReading}; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::process::CommandExt; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +static ONE_AT_A_TIME: Mutex<()> = Mutex::new(()); + +fn alone() -> MutexGuard<'static, ()> { + ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// How long the meter may wait for a tree to go quiet in these tests: far +/// past the production bound, because the host runs at a load of 14 to 260 +/// and a reply's tail can wait that long for a core. A member still running +/// after it would fail the test as a gap, which is the point: a whole +/// reading is never a stale one. +const TEST_SETTLE: Duration = Duration::from_secs(2); + +/// `pid`'s `rusage_info_v6`, raw: `proc_pid_rusage(pid, 6)` into a buffer +/// larger than the struct's 464 bytes, as 64-bit words. +fn oracle_rusage(pid: i32) -> Option<[u64; 64]> { + let mut buf = [0u64; 64]; + // SAFETY: `buf` is writable and larger than `rusage_info_v6`. + let rc = unsafe { libc::proc_pid_rusage(pid, 6, buf.as_mut_ptr() as *mut libc::rusage_info_t) }; + (rc == 0).then_some(buf) +} + +fn energy_nj(ru: &[u64; 64]) -> u64 { + ru[336 / 8] +} + +fn exit_abstime(ru: &[u64; 64]) -> u64 { + ru[88 / 8] +} + +fn reaped_cpu_ticks(ru: &[u64; 64]) -> u64 { + ru[96 / 8] + ru[104 / 8] +} + +/// `pti_numrunning` of `pid`: `proc_pidinfo(pid, PROC_PIDTASKINFO = 4)` into +/// a raw 96-byte buffer, the int32 at byte 88. `None` if the call fails (an +/// exited process has no task). +fn oracle_running(pid: i32) -> Option { + let mut buf = [0u8; 96]; + // SAFETY: `buf` is writable for the 96 bytes passed. + let n = unsafe { libc::proc_pidinfo(pid, 4, 0, buf.as_mut_ptr().cast(), 96) }; + (n == 96).then(|| i32::from_ne_bytes(buf[88..92].try_into().unwrap())) +} + +/// The mach timebase, `(numer, denom)`: ticks × numer / denom = ns. +fn timebase() -> (u64, u64) { + #[repr(C)] + struct Timebase { + numer: u32, + denom: u32, + } + extern "C" { + fn mach_timebase_info(info: *mut Timebase) -> libc::c_int; + } + let mut tb = Timebase { numer: 0, denom: 0 }; + // SAFETY: `tb` is writable for the call. + assert_eq!(unsafe { mach_timebase_info(&mut tb) }, 0); + (u64::from(tb.numer), u64::from(tb.denom)) +} + +/// `pid` and its descendants, from `ps`. +fn ps_tree(root: i32) -> Vec { + let out = Command::new("/bin/ps").args(["-A", "-o", "pid=,ppid="]).output().expect("ps"); + let pairs: Vec<(i32, i32)> = String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| { + let mut it = l.split_whitespace().map(|w| w.parse::()); + Some((it.next()?.ok()?, it.next()?.ok()?)) + }) + .collect(); + let mut tree = vec![root]; + let mut i = 0; + while i < tree.len() { + let p = tree[i]; + tree.extend(pairs.iter().filter(|(_, pp)| *pp == p).map(|(c, _)| *c)); + i += 1; + } + tree +} + +/// Each of `pids`' rusage once every one of them is quiet by the oracle's +/// rule: not running or queued (or exited) at two reads 20 ms apart, with +/// the same energy at both. Panics after 20 s. +fn quiet(pids: &[i32]) -> Vec<[u64; 64]> { + let still = |pid: i32, ru: &[u64; 64]| oracle_running(pid) == Some(0) || exit_abstime(ru) != 0; + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let first: Vec<[u64; 64]> = pids.iter().map(|&p| oracle_rusage(p).expect("oracle read")).collect(); + let before = pids.iter().zip(&first).all(|(&p, ru)| still(p, ru)); + std::thread::sleep(Duration::from_millis(20)); + let second: Vec<[u64; 64]> = pids.iter().map(|&p| oracle_rusage(p).expect("oracle read")).collect(); + let after = pids.iter().zip(&second).all(|(&p, ru)| still(p, ru)); + let same = first.iter().zip(&second).all(|(a, b)| energy_nj(a) == energy_nj(b)); + if before && after && same { + return second; + } + assert!(Instant::now() < deadline, "the oracle never saw {pids:?} quiet"); + } +} + +/// A server: a child in a process group of its own, driven over its stdin +/// and stdout one line at a time, and killed with its whole group on drop. +struct Server { + child: Child, + stdin: ChildStdin, + stdout: BufReader, +} + +impl Server { + fn spawn(program: &str, args: &[&str]) -> Server { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .process_group(0) + .spawn() + .expect("spawn the server"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + Server { child, stdin, stdout } + } + + fn pid(&self) -> i32 { + self.child.id() as i32 + } + + /// One line out, and the reply line back. + fn ask(&mut self, line: &str) -> String { + writeln!(self.stdin, "{line}").expect("send"); + self.stdin.flush().expect("flush"); + self.read_line() + } + + fn read_line(&mut self) -> String { + let mut reply = String::new(); + self.stdout.read_line(&mut reply).expect("reply"); + assert!(!reply.is_empty(), "the server closed its stdout"); + reply.trim_end().to_string() + } + + fn meter(&self) -> TreeMeter { + TreeMeter::for_process(self.pid() as u32) + .expect("a tree meter for a child of this process (the per-process counter is live on this fleet)") + .with_settle_bound(TEST_SETTLE) + } +} + +impl Drop for Server { + fn drop(&mut self) { + // SAFETY: signals this server's own process group. + unsafe { libc::kill(-self.pid(), libc::SIGKILL) }; + let _ = self.child.wait(); + } +} + +fn log(what: &str, rep: usize, r: &TreeReading, oracle_nj: u64) { + eprintln!( + "tree meter: {what} rep {rep}: {} nJ ({} µJ) over {} process(es) {:?}, oracle {oracle_nj} nJ, wall {:?}, gaps {:?}", + r.cpu_nj, r.cpu_uj, r.processes, r.pids, r.wall, r.gaps + ); +} + +/// A single-process server (awk: each request line is a loop count; it +/// burns that loop, replies, flushes and blocks for the next line), metered +/// over round trips from a bare echo to ~10 ms of CPU: each reading is +/// whole (one process, no GPU client, no Neural Engine memory, settled), not +/// zero, and equal to the oracle's advance across the round trip. +#[test] +fn a_single_process_server_reads_whole_and_exactly() { + let _one = alone(); + let mut s = Server::spawn("/usr/bin/awk", &["{ for (i = 0; i < $1; i++) x += i; print x + 0; fflush() }"]); + let meter = s.meter(); + let pid = s.pid(); + let mut before = energy_nj(&quiet(&[pid])[0]); + for (rep, n) in [0u32, 1, 20_000, 100_000, 300_000, 0].into_iter().enumerate() { + let (reply, r) = meter.measure(|| s.ask(&n.to_string())); + let r = r.expect("a reading of the round trip"); + let after = energy_nj(&quiet(&[pid])[0]); + let oracle = after - before; + log(&format!("single process, loop {n}"), rep, &r, oracle); + assert!(!reply.is_empty()); + assert!(r.is_whole(), "{:?}", r.gaps); + assert_eq!((r.processes, r.pids.as_slice()), (1, &[pid as u32][..])); + assert!(r.cpu_nj > 0, "{r:?}"); + assert_eq!(r.cpu_nj, oracle, "the meter's reading against the oracle's advance"); + assert_eq!(r.cpu_uj, r.cpu_nj.div_ceil(1_000), "the µJ figure is the nJ figure rounded up"); + before = after; + } +} + +/// A server that runs each request in a worker it forks and reaps (a shell +/// loop running awk): the worker's energy is in no counter once it is +/// reaped, so each reading carries exactly one gap, `ReapedDescendant` for +/// the shell, with the worker's CPU time to the nanosecond the oracle's +/// reaped-descendant time gives, and is not whole. The shell's own advance +/// is still read, exactly. +#[test] +fn a_server_that_reaps_its_worker_is_not_whole() { + let _one = alone(); + let mut s = Server::spawn( + "/bin/sh", + &["-c", r#"while read l; do /usr/bin/awk "BEGIN { for (i = 0; i < $l; i++) x += i }"; echo ok; done"#], + ); + let meter = s.meter(); + let pid = s.pid(); + let (numer, denom) = timebase(); + let mut before = quiet(&[pid])[0]; + for rep in 0..3 { + let (reply, r) = meter.measure(|| s.ask("200000")); + let r = r.expect("a reading of the shell's own work"); + let after = quiet(&[pid])[0]; + log("shell reaping a worker", rep, &r, energy_nj(&after) - energy_nj(&before)); + assert_eq!(reply, "ok"); + let ticks = reaped_cpu_ticks(&after) - reaped_cpu_ticks(&before); + assert!(ticks > 0, "setup: the worker ran"); + let cpu = Duration::from_nanos(ticks * numer / denom); + assert_eq!(r.gaps, [TreeGap::ReapedDescendant { pid: pid as u32, cpu }]); + assert!(!r.is_whole()); + assert_eq!(r.cpu_nj, energy_nj(&after) - energy_nj(&before), "the shell's own advance"); + before = after; + } +} + +/// A server that answers each request with a worker it forks, which writes +/// the reply ITSELF, keeps computing for a few milliseconds and exits; the +/// server reaps it after the reply has gone (perl, with 60 idle children +/// beside it, so a walk of its tree takes long enough for the reap to land +/// inside one). Born in the span, the worker spent energy in it, and by the +/// time the stop is read it is still running (a gap), exited and unreaped +/// (`Exited`), or reaped (`ReapedDescendant` on the server). So no reading +/// is whole — in particular not one taken by a walk that read the server +/// before the reap and looked for the worker after it, which finds neither +/// the worker nor its reaped time. +#[test] +fn a_worker_reaped_while_its_tree_is_walked_is_never_missed() { + let _one = alone(); + let mut s = Server::spawn( + "/usr/bin/perl", + &[ + "-e", + r#"$| = 1; + for (1 .. 60) { if (fork() == 0) { sleep 1000 while 1 } } + while (my $l = ) { + my $p = fork(); + if ($p == 0) { print "$$\n"; my $x = 0; $x += $_ for 1 .. 150000; exit 0 } + waitpid($p, 0); + }"#, + ], + ); + let root = s.pid() as u32; + // The first round trip runs once all 60 are forked; the tree is quiet + // before the first span. + let _ = s.ask("warm up"); + let tree = ps_tree(s.pid()); + assert_eq!(tree.len(), 61, "setup: the server and its 60 idle children: {tree:?}"); + quiet(&tree); + let meter = s.meter(); + let (mut exited, mut reaped, mut unsettled) = (0, 0, 0); + for rep in 0..40 { + let (reply, r) = meter.measure(|| s.ask("go")); + let worker: u32 = reply.parse().expect("the worker's pid, as its reply"); + let r = r.expect("a reading of the server's tree"); + eprintln!( + "tree meter: worker reaped by its server rep {rep}: {} nJ over {} process(es), worker {worker} {}, gaps {:?}", + r.cpu_nj, + r.processes, + if r.pids.contains(&worker) { "read" } else { "not read" }, + r.gaps + ); + assert!(!r.is_whole(), "a reading without the worker born in the span read whole: {r:?}"); + if r.gaps.iter().any(|g| matches!(g, TreeGap::Unsettled { .. } | TreeGap::Changing { .. })) { + unsettled += 1; + continue; + } + if r.gaps.contains(&TreeGap::Exited { pid: worker }) { + exited += 1; + assert!(r.pids.contains(&worker), "an exited worker's record is counted: {r:?}"); + } else if r.gaps.iter().any(|g| matches!(g, TreeGap::ReapedDescendant { pid, .. } if *pid == root)) { + reaped += 1; + } else { + panic!("the worker is in no gap of a settled reading: {r:?}"); + } + } + eprintln!("tree meter: worker reaped by its server: {exited} read exited, {reaped} reaped, {unsettled} unsettled at the bound"); +} + +/// A two-process tree that lives across the span (a shell waiting on its +/// awk worker, which serves the requests): both are read at both ends, and +/// the reading is whole and equal to the sum of the oracle's two advances. +#[test] +fn a_tree_of_two_long_lived_processes_reads_both() { + let _one = alone(); + let mut s = Server::spawn( + "/bin/sh", + &["-c", r#"/usr/bin/awk "$1"; true"#, "sh", "{ for (i = 0; i < $1; i++) x += i; print x + 0; fflush() }"], + ); + let meter = s.meter(); + // The worker exists once it has answered. + assert_eq!(s.ask("1"), "0"); + let tree = ps_tree(s.pid()); + assert_eq!(tree.len(), 2, "setup: the shell and its awk: {tree:?}"); + let sum = |rus: &[[u64; 64]]| rus.iter().map(energy_nj).sum::(); + let mut before = sum(&quiet(&tree)); + for rep in 0..4 { + let (_, r) = meter.measure(|| s.ask("150000")); + let r = r.expect("a reading"); + let after = sum(&quiet(&tree)); + log("shell and awk", rep, &r, after - before); + assert!(r.is_whole(), "{:?}", r.gaps); + let mut pids = r.pids.clone(); + pids.sort_unstable(); + let mut want: Vec = tree.iter().map(|&p| p as u32).collect(); + want.sort_unstable(); + assert_eq!(pids, want); + assert_eq!(r.cpu_nj, after - before); + before = after; + } +} + +/// A request that makes the tree grow: the shell reads it and starts a +/// second shell, which starts awk; awk burns, replies, and waits on its +/// stdin. A child and a grandchild born inside the span, alive and quiet at +/// its end (the reply comes from the youngest, after both ancestors have +/// done their part). Both are counted whole (they did not exist before it), +/// and the reading is whole and equals the oracle's: the first shell's +/// advance plus the two newcomers' whole counts. +#[test] +fn processes_born_in_the_span_are_counted_whole() { + let _one = alone(); + let mut s = Server::spawn( + "/bin/sh", + &[ + "-c", + r#"read l; /bin/sh -c '/usr/bin/awk "$1"; true' sh2 "$1"; true"#, + "sh", + "BEGIN { for (i = 0; i < 300000; i++) x += i; print x + 0; fflush(); getline line }", + ], + ); + let meter = s.meter(); + let root = s.pid(); + let before = energy_nj(&quiet(&[root])[0]); + assert_eq!(ps_tree(root), [root], "setup: the shell alone before the request"); + let (reply, r) = meter.measure(|| s.ask("go")); + let r = r.expect("a reading"); + assert!(!reply.is_empty()); + let tree = ps_tree(root); + let rus = quiet(&tree); + let oracle = energy_nj(&rus[0]) - before + rus[1..].iter().map(energy_nj).sum::(); + log("shell starting a shell starting awk", 0, &r, oracle); + assert_eq!(tree.len(), 3, "setup: the shell, the second shell and awk: {tree:?}"); + assert!(r.is_whole(), "{:?}", r.gaps); + let mut pids = r.pids.clone(); + pids.sort_unstable(); + let mut want: Vec = tree.iter().map(|&p| p as u32).collect(); + want.sort_unstable(); + assert_eq!(pids, want); + assert_eq!(r.cpu_nj, oracle); +} + +/// A server that keeps computing after it replies (awk: burn, reply, flush, +/// then burn again for a few milliseconds before it reads the next line). +/// Read the moment the reply lands, its count lacks most of that tail — the +/// kernel folds a thread's energy only when it leaves the core — so the +/// meter must wait it out at both ends: the stop right after this round +/// trip's reply, and the start right after the previous round trip's, while +/// that tail still runs. A stale stop would read less than the oracle; a +/// stale start, more. +#[test] +fn a_reply_with_a_tail_of_work_is_read_once_the_tail_is_done() { + let _one = alone(); + let mut s = Server::spawn( + "/usr/bin/awk", + &["{ for (i = 0; i < $1; i++) x += i; print x + 0; fflush(); for (j = 0; j < $2; j++) y += j }"], + ); + let meter = s.meter(); + let pid = s.pid(); + for rep in 0..4 { + // The previous round trip's tail is running as the span opens. + let _ = s.ask("20000 150000"); + let start = meter.start(); + let before = energy_nj(&quiet(&[pid])[0]); + let reply = s.ask("20000 150000"); + let r = meter.stop(start).expect("a reading"); + let after = energy_nj(&quiet(&[pid])[0]); + log("awk with a tail after its reply", rep, &r, after - before); + assert!(!reply.is_empty()); + assert!(r.is_whole(), "{:?}", r.gaps); + assert_eq!(r.cpu_nj, after - before, "the meter's reading against the oracle's advance"); + } +} + +/// Set in the environment of this test binary when it is run again inside a +/// sandbox that denies listing processes (the test below). +const IN_LISTING_SANDBOX: &str = "TREE_ENERGY_IN_LISTING_SANDBOX"; + +/// A sandbox that allows everything but listing processes: each process's +/// usage stays readable, while every `proc_listchildpids` fails. +const DENY_LISTING: &str = "(version 1)(allow default)(deny process-info-listpids)"; + +/// A meter that cannot list a member's children cannot see any descendant +/// below it, so its reading is not whole: it carries `Unlisted` for that +/// member at each end. The listing is made to fail for real: this test +/// binary runs itself again, this test alone, inside a sandbox that denies +/// listing processes, and there meters a perl root relaying each request to +/// an awk worker that does the work — a tree of two, of which the meter can +/// see only the root. +#[test] +fn a_member_whose_children_cannot_be_listed_is_a_gap() { + if std::env::var_os(IN_LISTING_SANDBOX).is_some() { + let mut s = Server::spawn( + "/usr/bin/perl", + &[ + "-e", + r#"use IPC::Open2; $| = 1; + open2(my $out, my $in, "/usr/bin/awk", '{ for (i = 0; i < $1; i++) x += i; print x + 0; fflush() }'); + $in->autoflush(1); + while (my $l = ) { print $in $l; my $r = <$out>; print $r }"#, + ], + ); + assert_eq!(s.ask("1"), "0", "setup: the worker answers"); + let meter = s.meter(); + let root = s.pid() as u32; + for rep in 0..2 { + let (_, r) = meter.measure(|| s.ask("150000")); + let r = r.expect("a reading of the root"); + eprintln!( + "tree meter: listing denied rep {rep}: {} nJ over {} process(es) {:?}, gaps {:?}", + r.cpu_nj, r.processes, r.pids, r.gaps + ); + assert_eq!(r.pids, [root], "the meter saw the root alone"); + assert!(!r.is_whole(), "a tree with its descendants unseen read whole: {:?}", r.gaps); + for end in [SpanEnd::Start, SpanEnd::Stop] { + assert!( + r.gaps.contains(&TreeGap::Unlisted { pid: root, errno: libc::EPERM, end }), + "no Unlisted gap for the root at the {end}: {:?}", + r.gaps + ); + } + } + return; + } + let _one = alone(); + let exe = std::env::current_exe().expect("this test binary"); + let out = Command::new("/usr/bin/sandbox-exec") + .args(["-p", DENY_LISTING]) + .arg(&exe) + .args(["--exact", "a_member_whose_children_cannot_be_listed_is_a_gap", "--nocapture", "--test-threads=1"]) + .env(IN_LISTING_SANDBOX, "1") + .output() + .expect("run this test binary under sandbox-exec"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + eprint!("{stderr}"); + assert!(out.status.success(), "the sandboxed run failed:\n{stdout}\n{stderr}"); + assert!(stdout.contains("1 passed"), "the sandboxed run did not run this test:\n{stdout}"); +} + +/// A child that had already exited at the start (a zombie its parent has +/// not reaped) and is reaped inside the span moves the parent's +/// reaped-descendant time, but by exactly its own CPU time, all spent before +/// the span: no gap. The reading is whole, of the parent alone. +#[test] +fn a_child_that_exited_before_the_span_and_is_reaped_in_it_is_no_gap() { + let _one = alone(); + let mut s = Server::spawn( + "/usr/bin/perl", + &[ + "-e", + r#"$| = 1; my $p = fork(); if ($p == 0) { my $x = 0; $x += $_ for 1 .. 200000; exit 0 } + select(undef, undef, undef, 0.3); print "ready $p\n"; + my $l = ; waitpid($p, 0); print "ok\n"; $l = ;"#, + ], + ); + let ready = s.read_line(); + let zombie: i32 = ready.strip_prefix("ready ").and_then(|p| p.parse().ok()).expect("the child's pid"); + // Wait for the child to have exited (a zombie) before the span opens. + let deadline = Instant::now() + Duration::from_secs(20); + while oracle_rusage(zombie).is_none_or(|ru| exit_abstime(&ru) == 0) { + assert!(Instant::now() < deadline, "setup: the child never exited"); + std::thread::sleep(Duration::from_millis(10)); + } + let meter = s.meter(); + let pid = s.pid(); + let before = quiet(&[pid])[0]; + let (reply, r) = meter.measure(|| s.ask("go")); + let r = r.expect("a reading"); + let after = quiet(&[pid])[0]; + log("perl reaping a child that exited before the span", 0, &r, energy_nj(&after) - energy_nj(&before)); + assert_eq!(reply, "ok"); + assert!(reaped_cpu_ticks(&after) > reaped_cpu_ticks(&before), "setup: the reap moved perl's reaped-descendant time"); + assert!(oracle_rusage(zombie).is_none(), "setup: the child was reaped"); + assert!(r.is_whole(), "{:?}", r.gaps); + assert_eq!((r.processes, r.cpu_nj), (1, energy_nj(&after) - energy_nj(&before))); +} + +/// A Metal compute server for the GPU tests, built from this source when +/// they first run: it prints `ready`, then answers each request line with +/// one line. `persistent` runs a compute kernel on the command queue the +/// server keeps; `transient` runs it on a queue made for the request and +/// released after it; `cpu` does no GPU work. The reply is the GPU time the +/// request's command buffer took by its own timestamps, in ns (0 for `cpu`): +/// an oracle that shares nothing with the meter's I/O Registry reader. +const METAL_SERVER: &str = r##"#import +#include +#include +static NSString *src = @"#include \nusing namespace metal;\n" + "kernel void burn(device float *o [[buffer(0)]], uint i [[thread_position_in_grid]]) {\n" + " float x = float(i); for (int k = 0; k < 20000; k++) { x = x * 1.0000001f + 0.5f; } o[i] = x; }\n"; +int main(void) { + @autoreleasepool { + id d = MTLCreateSystemDefaultDevice(); + if (!d) { printf("no Metal device\n"); fflush(stdout); return 1; } + NSError *err = nil; + id lib = [d newLibraryWithSource:src options:nil error:&err]; + if (!lib) { printf("no library: %s\n", err.localizedDescription.UTF8String); fflush(stdout); return 1; } + id ps = [d newComputePipelineStateWithFunction:[lib newFunctionWithName:@"burn"] error:&err]; + id kept = [d newCommandQueue]; + id buf = [d newBufferWithLength:sizeof(float) * 65536 options:MTLResourceStorageModeShared]; + printf("ready\n"); fflush(stdout); + char line[64]; + while (fgets(line, sizeof line, stdin)) { + if (strncmp(line, "cpu", 3) == 0) { printf("0\n"); fflush(stdout); continue; } + double ns = 0; + @autoreleasepool { + id q = strncmp(line, "transient", 9) == 0 ? [d newCommandQueue] : kept; + id b = [q commandBuffer]; + id e = [b computeCommandEncoder]; + [e setComputePipelineState:ps]; + [e setBuffer:buf offset:0 atIndex:0]; + [e dispatchThreads:MTLSizeMake(65536, 1, 1) threadsPerThreadgroup:MTLSizeMake(256, 1, 1)]; + [e endEncoding]; + [b commit]; + [b waitUntilCompleted]; + ns = (b.GPUEndTime - b.GPUStartTime) * 1e9; + } + printf("%.0f\n", ns); fflush(stdout); + } + } + return 0; +} +"##; + +/// The Metal server, built once per test run into Cargo's scratch directory +/// for integration tests, with the C compiler every macOS Rust build links +/// with. +fn metal_server() -> &'static str { + static BUILT: std::sync::OnceLock = std::sync::OnceLock::new(); + BUILT.get_or_init(|| { + let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")); + let source = dir.join("tree_energy_metal_server.m"); + let binary = dir.join(format!("tree_energy_metal_server-{}", std::process::id())); + std::fs::write(&source, METAL_SERVER).expect("write the Metal server's source"); + let out = Command::new("/usr/bin/cc") + .args(["-fobjc-arc", "-O2", "-framework", "Foundation", "-framework", "Metal", "-o"]) + .arg(&binary) + .arg(&source) + .output() + .expect("run the C compiler"); + assert!(out.status.success(), "the Metal server did not build:\n{}", String::from_utf8_lossy(&out.stderr)); + binary.to_string_lossy().into_owned() + }) +} + +/// A server that holds a GPU client never reads whole, whatever a request +/// did, because its GPU use is not known to be zero: work on its kept queue +/// is recorded and carried as `Gpu` time (the reader sees it), while work +/// on a queue released inside the span leaves its client's recorded time +/// unmoved, as does a request with no GPU work at all, and both are +/// `GpuUnknown` for the server. The reply's command-buffer time is the +/// oracle that the GPU did work. What is tested is the GPU half alone, so +/// the meter waits only 200 ms for the server to go quiet: a Metal process +/// keeps threads that wake on their own, and on this host it is often still +/// running at an end, which is a gap of its own and no part of this test. +#[test] +fn a_server_holding_a_gpu_client_never_reads_whole() { + let _one = alone(); + let mut s = Server::spawn(metal_server(), &[]); + assert_eq!(s.read_line(), "ready", "setup: the Metal server started"); + let meter = TreeMeter::for_process(s.pid() as u32) + .expect("a tree meter for the Metal server") + .with_settle_bound(Duration::from_millis(200)); + let pid = s.pid() as u32; + for (rep, mode) in ["persistent", "transient", "cpu", "transient", "persistent", "cpu", "transient"].into_iter().enumerate() { + let (reply, r) = meter.measure(|| s.ask(mode)); + let gpu_ns: u64 = reply.parse().unwrap_or_else(|_| panic!("the server's reply: {reply}")); + let r = r.expect("a reading of the Metal server"); + eprintln!( + "tree meter: Metal server, {mode} rep {rep}: command buffer {gpu_ns} ns on the GPU; reading {} nJ, gaps {:?}", + r.cpu_nj, r.gaps + ); + assert!(!r.is_whole(), "a server holding a GPU client read whole after a {mode} request: {:?}", r.gaps); + if mode == "cpu" { + assert_eq!(gpu_ns, 0); + } else { + assert!(gpu_ns > 0, "setup: the {mode} request ran on the GPU"); + } + let recorded = r.gaps.iter().find_map(|g| match g { + TreeGap::Gpu { ns } => Some(*ns), + _ => None, + }); + let unknown = r.gaps.contains(&TreeGap::GpuUnknown { pid: Some(pid) }); + if mode == "persistent" { + assert!(recorded.is_some_and(|ns| ns > 0), "the kept queue's work was not seen: {:?}", r.gaps); + } else { + assert!(recorded.is_some() || unknown, "no GPU gap for the server after a {mode} request: {:?}", r.gaps); + } + } +}