From e2b4337126ebbd18cfecb8cb55b4011dffbbc8a1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 08:46:07 -0400 Subject: [PATCH 01/19] sandbox-meter: meter the CPU energy of another live process tree jcp-gateway meters each relayed MCP call with PowerMeter::detect(), which is machine-wide, and on macOS 27 every unprivileged machine-wide CPU rail is frozen, so every call is sealed as an estimate. The kernel's per-process CPU energy count (proc_pid_rusage RUSAGE_INFO_V6 ri_energy_nj) is readable, live, for any process of this user, so the upstream server and its descendants can be read directly. TreeMeter walks the tree from a root pid (proc_listchildpids, which returns a count and truncates silently, grown and retried), reads each member twice around its run state, and applies a pure rule over the start and stop snapshots. Every way the figure is seen to fall short is a gap: a descendant reaped in the span (its energy is in no counter), a member gone, or older than the span and missed at its start, a member exited in the span (its GPU time is lost ~2 ms after exit), GPU time spent by the tree or a GPU client lost, a member that ever held in-process Neural Engine model memory (lifetime maximum, which nothing can reset), a member unreadable or still running at an end. The root gone or unreadable, or a zero advance, is no reading at all. Settling uses the kernel's run state, not CPU time: read from another process, a spinning thread's CPU time moved only in whole 10 ms quanta, the same folds as its energy, so a "CPU unchanged over 500 us" rule read a child that kept computing after its reply as settled and stale in 60 of 60 runs. Zero running threads (pti_numrunning) between two reads that agree was settled and exact in 58 of 58. A zombie child reaped in the span moves its reaper's reaped-descendant time by exactly its own and its reaped CPU time (5 of 5 to the tick), so such a reap is no gap. The GPU half ports jouleclaw-energy's I/O Registry client reader, telling a client with no AppUsage apart from a zero time, and shares ioreport's CoreFoundation declarations. Usage moves into tree.rs, built everywhere; read_usage returns the errno so EPERM (another user) and ESRCH (gone) are told apart; the rusage layout test pins every field read. Co-Authored-By: Claude Opus 5.5 --- .../crates/sandbox-meter/src/lib.rs | 3 +- .../sandbox-meter/src/probe/gpu_clients.rs | 268 ++++ .../sandbox-meter/src/probe/ioreport.rs | 29 +- .../crates/sandbox-meter/src/probe/mod.rs | 16 + .../crates/sandbox-meter/src/probe/process.rs | 77 +- .../crates/sandbox-meter/src/probe/tree.rs | 1177 +++++++++++++++++ 6 files changed, 1530 insertions(+), 40 deletions(-) create mode 100644 sandbox/sandbox-rs/crates/sandbox-meter/src/probe/gpu_clients.rs create mode 100644 sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs index 3b273cd8a..0d36cc479 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/lib.rs @@ -17,7 +17,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..5da94573e --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/gpu_clients.rs @@ -0,0 +1,268 @@ +//! 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 API context, whose `accumulatedGPUTime` is the GPU time +//! that context 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), +//! and an idle client's time does not move. But 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, + /// 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"); + 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..990d3f47f 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,13 @@ //! ([`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 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 +61,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. +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..dc25a7481 --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs @@ -0,0 +1,1177 @@ +//! 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: a GPU client time that did not move, and no process 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 read zero on every +//! unprivileged counter: the model memory is 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 what was SEEN missing: 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. 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. 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. +//! +//! **The rule** ([`combine`], pure, so its tests drive every case without a +//! process) is in [`TreeGap`]'s variants, one per way a figure can be +//! missing, 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. 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. The rule +/// does not depend on it (the run state, not an interval, decides); 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)>, +} + +impl Snapshot { + fn member(&self, pid: u32) -> Option<&Member> { + self.members.iter().find(|m| m.pid == pid) + } + + /// 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`] is short of the tree's whole energy over the +/// span, as seen. A reading with none is whole for its domain (see the +/// module docs): 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) or too little (at the stop). Its figure is counted. + Unsettled { pid: u32, 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 CPU energy + /// is read and counted; but 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 `ns` of GPU time over the span. Its energy + /// is not measured here, and it is not zero. + Gpu { ns: u64 }, + /// The tree's GPU time 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)` — a client of that member 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::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, so its GPU time is unknown"), + TreeGap::Gpu { ns } => write!(f, "the tree spent {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, "a GPU client of pid {pid} vanished or lost its time") + } + 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 for its domain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreeReading { + /// The figure, µJ: [`cpu_nj`](Self::cpu_nj) rounded down. + 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 be short of the tree's whole energy. + pub gaps: Vec, +} + +impl TreeReading { + /// Whether nothing was seen missing: no gap. Whole for the reading's + /// domain only (see the module docs). + 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, from every client of a `tree` pid: +/// one present at both ends (by registry ID) its advance, one new at the end +/// its whole time. The pids of clients whose time is unknown — 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) — come back beside it: never read as a decrease. +pub(super) fn gpu_spent(start: &[GpuClient], end: &[GpuClient], tree: impl Fn(u32) -> bool) -> (u64, Vec) { + let mut ns: u64 = 0; + let mut unknown: Vec = Vec::new(); + for c in start.iter().filter(|c| tree(c.pid)) { + let advance = match (c.gpu_ns, end.iter().find(|e| e.id == c.id).map(|e| e.gpu_ns)) { + // Present at both ends, with no time at either: it spent none. + (None, Some(None)) => Some(0), + (Some(_), Some(None)) => None, + (before, Some(Some(after))) => after.checked_sub(before.unwrap_or(0)), + (_, None) => None, + }; + match advance { + Some(d) => ns = ns.saturating_add(d), + None if !unknown.contains(&c.pid) => unknown.push(c.pid), + None => {} + } + } + for e in end.iter().filter(|e| tree(e.pid) && !start.iter().any(|c| c.id == e.id)) { + ns = ns.saturating_add(e.gpu_ns.unwrap_or(0)); + } + (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 }); + } + } + + // 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, and read, 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 / 1_000, cpu_nj, processes: pids.len(), pids, wall, gaps }) +} + +/// The CPU energy of a live process tree rooted at another process: see the +/// module docs 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, Instant}; + + #[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. + 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: `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()); + } + 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. + Ok(None) => 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 + } + + /// Walks every [`SETTLE_POLL`](super::SETTLE_POLL) until every member is + /// settled, or `bound` has passed: the last walk. + pub(super) fn settled_walk(root: u32, bound: Duration) -> Snapshot { + let deadline = Instant::now() + bound; + loop { + let snap = walk(root); + if snap.members.iter().all(|m| m.settled) || Instant::now() >= deadline { + return snap; + } + std::thread::sleep(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 down. + #[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, 8, 2)); + assert_eq!(r.pids, [ROOT, 101]); + } + + /// 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 + /// count is final and readable), with its GPU time unknown. 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); + } + + /// 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: an idle client of the tree adds + /// nothing; one that advanced adds its advance; one new at the stop adds + /// its whole time; another process's clients are not looked at, even + /// vanished; a client of the tree that vanished, lost its `AppUsage`, + /// fell to zero or went back is unknown, never a decrease; one with no + /// time at either end spent none. + #[test] + fn gpu_time_is_counted_by_client_and_a_lost_client_is_unknown() { + let tree = |pid: u32| pid == ROOT || pid == 101; + let idle = [client(1, ROOT, Some(5_000))]; + assert_eq!(gpu_spent(&idle, &idle, tree), (0, vec![])); + 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![])); + 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![])); + 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])); + } + + /// 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"); + } +} -- 2.54.0 (Apple Git-157) From 8f15c1b67cd025778ca37666196fef0e04ea70e1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 08:54:19 -0400 Subject: [PATCH 02/19] sandbox-meter: test the tree meter against an oracle on real process trees tests/tree_energy.rs spawns servers of its own and meters request/response round trips with a TreeMeter. The oracle shares no code with the meter: the tree from ps, each member's proc_pid_rusage read into a raw buffer at the SDK's offsets, read once the oracle has seen every member off-core at two reads 20 ms apart whose energy agrees. A server waiting for its next request does not move its count, so a reading must equal the oracle's advance to the nanojoule: a single awk from a bare echo (~10-26 uJ) to ~10 ms of CPU; a shell and its awk worker; a request that starts a child and a grandchild (counted whole); a reply followed by a few milliseconds of work, read right after the reply at both ends (a stale stop reads less, a stale start more); a perl parent reaping a child that had exited before the span (no gap). A shell that forks and reaps a worker per request carries exactly one gap, ReapedDescendant, with the worker's CPU time to the nanosecond. 152 readings over 8 runs on a host at load ~150 matched the oracle exactly. The first born-in-span design (awk replying, then starting a sleep via system()) read 1.05 mJ short of a later oracle while every member was off-core: settling makes a count exact at its instant, not the tree's work finished. The module docs now say so, and the test's tree finishes its work before its reply. The tree module is public so its docs (what a reading covers) render. CI's macOS energy step now also requires the tests' "tree meter:" line, the proof that these platform-gated tests ran. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/standards-build.yml | 3 + .../crates/sandbox-meter/src/probe/mod.rs | 4 +- .../crates/sandbox-meter/src/probe/tree.rs | 20 +- .../crates/sandbox-meter/tests/tree_energy.rs | 394 ++++++++++++++++++ 4 files changed, 414 insertions(+), 7 deletions(-) create mode 100644 sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs diff --git a/.github/workflows/standards-build.yml b/.github/workflows/standards-build.yml index ecb3ef3b3..c522725a6 100644 --- a/.github/workflows/standards-build.yml +++ b/.github/workflows/standards-build.yml @@ -400,3 +400,6 @@ 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" 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 990d3f47f..b2100ab2d 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs @@ -63,8 +63,8 @@ mod nvml; 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. -mod tree; +// 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. diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs index dc25a7481..f74c31283 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs @@ -76,7 +76,16 @@ //! matters at both ends: a stale start reading over-counts the span, a stale //! end reading under-counts it. //! -//! **The rule** ([`combine`], pure, so its tests drive every case without a +//! Settling makes each member's count exact at the instant it is read; it +//! does not 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, and what it does after the stop belongs to no span. +//! 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 when the work it meters is done — after the +//! reply — and the tree's work after that is not this span's. +//! +//! **The rule** (`combine`, pure, so its tests drive every case without a //! process) is in [`TreeGap`]'s variants, one per way a figure can be //! missing, 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 @@ -253,7 +262,8 @@ impl std::fmt::Display for SpanEnd { /// One way a [`TreeReading`] is short of the tree's whole energy over the /// span, as seen. A reading with none is whole for its domain (see the -/// module docs): it does not say the tree handed no work outside itself. +/// [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 @@ -361,7 +371,7 @@ pub enum TreeError { } /// One span of a process tree's CPU energy: the figure and what it is short -/// of. See the module docs for its domain. +/// 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 down. @@ -384,7 +394,7 @@ pub struct TreeReading { impl TreeReading { /// Whether nothing was seen missing: no gap. Whole for the reading's - /// domain only (see the module docs). + /// domain only (see the [module docs](self)). pub fn is_whole(&self) -> bool { self.gaps.is_empty() } @@ -537,7 +547,7 @@ pub(super) fn combine( } /// The CPU energy of a live process tree rooted at another process: see the -/// module docs for what a reading covers and how it is taken. +/// [module docs](self) for what a reading covers and how it is taken. #[derive(Debug, Clone)] pub struct TreeMeter { root: u32, 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..a62f5f075 --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs @@ -0,0 +1,394 @@ +//! 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::{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 / 1_000); + 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 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"); + } +} + +/// 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))); +} -- 2.54.0 (Apple Git-157) From 62bd7b36dda8ffcf319ade830957d3ecf7a59cce Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 09:11:38 -0400 Subject: [PATCH 03/19] jcp-gateway: meter each gated call on the upstream server's own process tree The gateway metered every relayed call with PowerMeter::detect(), which is machine-wide; on macOS 27 every unprivileged machine-wide CPU rail is frozen, so every call was sealed as an estimate, and where a rail is live the figure is the machine's, not the server's. sandbox-meter's TreeMeter reads the kernel's per-process CPU energy count for every process of the upstream server's tree, so the call can be read on the work's own process. Upstream gains pid() (default None: the mocks, and any upstream with no local process). The gateway builds a TreeMeter for that pid on the first gated call, or at startup through call_meter(), and again only when the upstream names another pid: the meter pins the root's identity, so a pid that comes to name another process reads as gone, never as the server. The tree span encloses the machine span, so the machine fallback's figure (an estimate's wall time included) stays the call's alone. Grading: a tree reading with no gap is HwShunt/Whole only when the operator has declared the server self-contained (no work handed to a process outside its tree), which no reader can see; any other reading is HwShunt/Partial and is debited all the same; a span with no reading falls back to the machine meter exactly as before. The plaintext _meta adds energy_domain and, for a Partial tree figure, energy_partial_because (the gap kinds, and not-declared-self-contained); the sealed receipt format is unchanged, and the plaintext provenance and coverage still equal the sealed ones. The binary takes --upstream-self-contained and its startup line names the meter that applies. Unit tests drive every reading shape through the pure grading and through the whole relay path with a canned tree meter, sealing included. Co-Authored-By: Claude Opus 5.5 --- .../crates/jcp-gateway/src/bin/jcp_gateway.rs | 44 +- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 600 +++++++++++++++++- 2 files changed, 607 insertions(+), 37 deletions(-) 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..36708dd53 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,6 +11,15 @@ //! `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. +//! +//! 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}; @@ -31,7 +40,9 @@ fn sealed_grade(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Option, } @@ -45,6 +56,10 @@ impl Upstream for ChildServer { self.stdout.read_line(&mut line)?; Ok(line.trim_end().to_string()) } + + fn pid(&self) -> Option { + Some(self.child.id()) + } } fn main() -> std::io::Result<()> { @@ -52,6 +67,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 +75,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,7 +95,9 @@ 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); } @@ -87,7 +109,7 @@ fn main() -> std::io::Result<()> { let mut up = ChildServer { stdin: child.stdin.take().expect("child stdin"), stdout: BufReader::new(child.stdout.take().expect("child stdout")), - _child: child, + child, }; let grant_refs: Vec<(&str, u64)> = grants.iter().map(|(t, b)| (t.as_str(), *b)).collect(); @@ -95,10 +117,18 @@ fn main() -> std::io::Result<()> { 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 = diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 4cfc9410b..fad6c6a4b 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -11,11 +11,28 @@ //! `(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 time, 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. +//! - 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 @@ -42,16 +59,74 @@ 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 sandbox_meter::{Measurement, PowerMeter, 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. pub trait Upstream { fn call(&mut self, request_line: &str) -> std::io::Result; + + /// 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. +trait TreeSpan { + /// Run `call` once, inside a span of the tree, and read the span. + fn span(&self, call: &mut dyn FnMut()) -> Result; +} + +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,9 +136,14 @@ 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 of the call, 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. @@ -142,7 +222,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 @@ -192,6 +293,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 +347,84 @@ 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 read zero on every + /// unprivileged counter. 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, &self.upstream) { + (Some(p), Some((q, Some(_)))) if p == *q => 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. @@ -346,19 +520,39 @@ 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, when the + // work it meters is done. + self.prepare_upstream_meter(up.pid()); + let (response, measurement, reading) = { + let tree = self.upstream.as_ref().and_then(|(_, t)| t.as_deref()); + 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. + let metered = grade_call(reading.as_ref(), self.upstream_self_contained, &measurement); + let cost = metered.cost; let cost_uj = cost.uj(); // Phase 3: debit the metered joules (and the tainted sub-budget, if the @@ -387,7 +581,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, }; @@ -687,7 +881,18 @@ 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. +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 +913,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 +1016,103 @@ 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"; + +/// 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 an upstream-tree figure is `Partial`: each [`gap_kind`] seen, once, + /// in the order the meter reported them, then + /// [`NOT_DECLARED_SELF_CONTAINED`] where it applies. Empty for a `Whole` + /// figure and for a machine-wide one. + 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. +fn gap_kind(gap: &TreeGap) -> &'static str { + match gap { + TreeGap::Unreadable { .. } => "unreadable", + TreeGap::Unlisted { .. } => "unlisted", + TreeGap::Unsettled { .. } => "unsettled", + 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 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. +/// +/// The tree figure is [`TreeReading::cpu_uj`], the meter's nanojoules +/// rounded down to the receipt's unit, so a reading under 1 µJ would seal +/// as 0 µJ, still `HwShunt`: the reading is real and the unit is the +/// receipt's. None is expected: a bare request/response round trip read +/// 3.6–18.5 µJ in 14 of 14 (M5 Max, macOS 27.0.1). +fn grade_call(tree: Option<&Result>, self_contained: bool, machine: &Measurement) -> Metered { + let Some(Ok(r)) = tree else { return Metered::machine(machine) }; + 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 cost = if because.is_empty() { Energy::measured(r.cpu_uj) } else { Energy::measured_partial(r.cpu_uj) }; + Metered { cost, domain: EnergyDomain::UpstreamTree, partial_because: because } +} + fn deny_reason_of(d: &jcp_runtime::Decision) -> String { match &d.verdict { jcp_schema::Verdict::Deny(r) => r.label().to_string(), @@ -889,6 +1195,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 +1220,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"); } @@ -964,10 +1274,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 @@ -1406,6 +1721,231 @@ 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 / 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"]), + ( + Some(Ok(reading(9_870, gaps))), + false, + Some(("HwShunt", Some("Partial"))), + tree, + vec!["reaped-descendant", "gpu", "unsettled", 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); + let want = match (&tree, grade) { + (Some(Ok(r)), Some((_, None))) => Energy::measured(r.cpu_uj), + (Some(Ok(r)), Some((_, Some(_)))) => Energy::measured_partial(r.cpu_uj), + _ => 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}"); + } + } + + /// 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_uj, "the tree's figure is the reading's"); + 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()); + } + + /// 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::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}; -- 2.54.0 (Apple Git-157) From ba523f293b7a5517aaa1efb83eb91777a9acb4c3 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 09:15:27 -0400 Subject: [PATCH 04/19] jcp-gateway: test tree-metered calls against an oracle on real servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/upstream_tree.rs relays tools/call through Gateway::relay to real MCP-ish servers it spawns: awk answering each JSON-RPC line itself, and a shell answering each with an awk worker it forks and reaps before it replies. The oracle shares no code with the meter or the gateway: the server's proc_pid_rusage into a raw buffer at the SDK's offsets (energy at 336, reaped-descendant time at 96 and 104), read before and after each relay once the server is quiet by pti_numrunning at byte 88 of a raw PROC_PIDTASKINFO buffer. A server blocked on its next request does not move its count, so each call's figure must be the oracle's advance rounded down to the µJ, and it was: 99 of 99 readings over 9 runs at load ~150. A declared single-process server is sealed HwShunt and whole; the same server undeclared is HwShunt/Partial with not-declared-self-contained; the reaping shell is HwShunt/Partial with reaped-descendant even when declared, its figure the shell's own advance; an upstream naming a pid this process may not read (launchd) is metered machine-wide. Each call's plaintext grade equals its sealed one, and each receipt log verifies. The jcp-rs matrix entry runs on Linux, where no tree meter exists, so the macos-energy-backend job gains a jcp-gateway step that fails unless the test printed its `upstream tree:` line. libc is a dev-dependency for the oracle. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/standards-build.yml | 16 + jcp/jcp-rs/Cargo.lock | 1 + jcp/jcp-rs/crates/jcp-gateway/Cargo.toml | 5 + .../crates/jcp-gateway/tests/upstream_tree.rs | 358 ++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs diff --git a/.github/workflows/standards-build.yml b/.github/workflows/standards-build.yml index c522725a6..9e9f7d9fe 100644 --- a/.github/workflows/standards-build.yml +++ b/.github/workflows/standards-build.yml @@ -403,3 +403,19 @@ jobs: # 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/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..b750299d4 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml +++ b/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml @@ -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/tests/upstream_tree.rs b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs new file mode 100644 index 000000000..099daead3 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs @@ -0,0 +1,358 @@ +//! 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 down to +//! the receipt's microjoules. That is the tolerance — exact to the µJ: the +//! figure is at most the oracle's advance and less than 1 µJ under 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 / 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 / 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 / 1_000, "the shell's own advance, to the µJ"); + } + 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); +} -- 2.54.0 (Apple Git-157) From 5c743e8900fc421d06041c4a5a2ceea5f2b4651e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 09:18:51 -0400 Subject: [PATCH 05/19] jcp: specify metering a call on another process tree, and when it is whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §5.1 rung 3 covered only the metering process's own threads. It now also covers the same per-process count read for every process of another tree the meter can read, the process the work runs in: its domain (the advance of members present at both ends plus the whole count of members born in the span, not DRAM), that each end MUST be read with no thread of a member on a core or queued, and every seen shortfall that makes the figure Partial (a descendant reaped or a member exited in the span, a member gone or older than the span and unread at its start, a member unreadable, unlisted or still running, GPU time, in-process Neural Engine memory). An empty list is not enough for Whole: an implementation MUST NOT grade it Whole without the operator's declaration that the process hands no work outside its tree, because no reader of the tree can see that. The jcp README's jcp-gateway paragraph, the gateway's crate description, the sandbox README and sandbox-meter's crate and probe docs say the same of the tree meter and of the gateway's --upstream-self-contained. Co-Authored-By: Claude Opus 5.5 --- jcp/README.md | 2 +- jcp/SPEC.md | 43 +++++++++++++++++++ jcp/jcp-rs/crates/jcp-gateway/Cargo.toml | 2 +- sandbox/README.md | 2 +- .../crates/sandbox-meter/src/lib.rs | 8 ++++ .../crates/sandbox-meter/src/probe/mod.rs | 7 ++- 6 files changed, 60 insertions(+), 4 deletions(-) diff --git a/jcp/README.md b/jcp/README.md index 46923dc3c..47b04edc8 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 time, 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..fa7aeab32 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -625,6 +625,49 @@ 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); and the span SHOULD end after the work's reply, when the work + it meters is done. The figure is `Partial` (§5.2) when the meter sees + any of these over the span, each a part of the tree's energy the figure + does not hold: + - 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 spent and read + before the span; + - a member that exited inside the span: its count is final and is + counted, but 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), whose + children could not be listed, or that was still running at an end; + - GPU time spent by a member, 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/crates/jcp-gateway/Cargo.toml b/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml index b750299d4..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 diff --git a/sandbox/README.md b/sandbox/README.md index 6fb5f3f1f..5a79766d3 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 lists beside the figure every way it was seen to fall short of the tree's whole energy (a descendant reaped or exited in the span, GPU time, in-process Neural Engine memory, a member unreadable or still running); 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 0d36cc479..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}; 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 b2100ab2d..95d9eb377 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs @@ -37,7 +37,12 @@ //! [`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. +//! 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 -- 2.54.0 (Apple Git-157) From df1161418d85913c04668fe2f9570a8ccc87331b Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:06:12 -0400 Subject: [PATCH 06/19] jcp-gateway: send notifications without waiting for a reply The binary's ChildServer::call writes a line and then blocks reading a reply, and relay forwarded a client notification with up.call(line). An MCP server answers no notification, and every client sends notifications/initialized right after initialize, so the binary hung on the first one and never read another line. A client's response to a request the server made (a line with no method) is answered with nothing too. Upstream gains notify(), which sends a line and waits for nothing; its default goes through call() so in-process upstreams keep working, and ChildServer overrides it to write and flush only. relay sends both kinds of line with it. A unit test uses an upstream whose call() panics, and a new integration test runs the real binary in front of an awk server that answers only lines carrying an id: initialize, notifications/initialized, then calls, each answered. Co-Authored-By: Claude Opus 5.5 --- .../crates/jcp-gateway/src/bin/jcp_gateway.rs | 7 + jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 66 ++++++++- .../crates/jcp-gateway/tests/stdio_binary.rs | 131 ++++++++++++++++++ 3 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 jcp/jcp-rs/crates/jcp-gateway/tests/stdio_binary.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 36708dd53..7d8a47e87 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 @@ -57,6 +57,13 @@ impl Upstream for ChildServer { Ok(line.trim_end().to_string()) } + /// Written and flushed, and nothing read: the server sends no reply. + fn notify(&mut self, line: &str) -> std::io::Result<()> { + self.stdin.write_all(line.as_bytes())?; + self.stdin.write_all(b"\n")?; + self.stdin.flush() + } + fn pid(&self) -> Option { Some(self.child.id()) } diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index fad6c6a4b..bb8986a8b 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -67,10 +67,26 @@ 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 @@ -148,7 +164,9 @@ pub enum Relayed { /// A `tools/call` the gateway refused; the upstream was never contacted. /// `response` is a JSON-RPC error to hand back to the client. 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, } @@ -439,9 +457,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)?; @@ -1583,6 +1603,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(); 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..cb6f04b5a --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-gateway/tests/stdio_binary.rs @@ -0,0 +1,131 @@ +//! 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}})); + } +} -- 2.54.0 (Apple Git-157) From ec22ef9051c5b2e192cd26195a23a4ddb2ebf568 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:10:06 -0400 Subject: [PATCH 07/19] sandbox-meter, jcp-gateway: round a tree reading up to a whole microjoule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tree figure was the meter's nanojoules floored to microjoules, so a reading of 1 to 999 nJ sealed "HwShunt 0 µJ" and debited nothing: a measured call presented as free. A budget's enforcement point never debits less than was read. TreeReading::cpu_uj is now cpu_nj rounded up, and the gateway seals and debits cpu_nj.div_ceil(1000) itself, whatever µJ figure a reading carries; the error stays under one unit of the receipt's resolution, always over. The integration tests' oracle equality is now cost_uj == oracle_nj.div_ceil(1000), and new unit tests pin 1, 999, 1000 and 1001 nJ at both layers. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 47 +++++++++++++++---- .../crates/jcp-gateway/tests/upstream_tree.rs | 13 ++--- .../crates/sandbox-meter/src/probe/tree.rs | 23 +++++++-- .../crates/sandbox-meter/tests/tree_energy.rs | 2 +- 4 files changed, 64 insertions(+), 21 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index bb8986a8b..930685d0d 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -1113,11 +1113,11 @@ fn gap_kind(gap: &TreeGap) -> &'static str { /// or unreadable, or the tree's count not advanced): the machine-wide /// span, exactly as [`energy_of`] grades it. /// -/// The tree figure is [`TreeReading::cpu_uj`], the meter's nanojoules -/// rounded down to the receipt's unit, so a reading under 1 µJ would seal -/// as 0 µJ, still `HwShunt`: the reading is real and the unit is the -/// receipt's. None is expected: a bare request/response round trip read -/// 3.6–18.5 µJ in 14 of 14 (M5 Max, macOS 27.0.1). +/// 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) -> Metered { let Some(Ok(r)) = tree else { return Metered::machine(machine) }; let mut because: Vec<&'static str> = Vec::new(); @@ -1129,7 +1129,8 @@ fn grade_call(tree: Option<&Result>, self_contained: boo if !self_contained { because.push(NOT_DECLARED_SELF_CONTAINED); } - let cost = if because.is_empty() { Energy::measured(r.cpu_uj) } else { Energy::measured_partial(r.cpu_uj) }; + 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 } } @@ -1783,7 +1784,7 @@ mod tests { /// 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 / 1_000, cpu_nj, processes: 1, pids: vec![4242], wall: Duration::from_millis(2), gaps } + 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. @@ -1858,8 +1859,8 @@ mod tests { for (tree, declared, grade, domain, because) in tree_shapes() { let m = grade_call(tree.as_ref(), declared, &est); let want = match (&tree, grade) { - (Some(Ok(r)), Some((_, None))) => Energy::measured(r.cpu_uj), - (Some(Ok(r)), Some((_, Some(_)))) => Energy::measured_partial(r.cpu_uj), + (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); @@ -1881,6 +1882,32 @@ mod tests { } } + /// 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); + 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 @@ -1927,7 +1954,7 @@ mod tests { 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_uj, "the tree's figure is the reading's"); + 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}"); } diff --git a/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs index 099daead3..7509dca4f 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs @@ -13,9 +13,10 @@ //! 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 down to -//! the receipt's microjoules. That is the tolerance — exact to the µJ: the -//! figure is at most the oracle's advance and less than 1 µJ under it. +//! 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 @@ -264,7 +265,7 @@ fn a_declared_self_contained_server_is_read_whole_on_its_own_process() { 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 / 1_000, "the figure against the oracle's advance, to the µJ"); + assert_eq!(c.cost_uj, oracle.div_ceil(1_000), "the figure against the oracle's advance, to the µJ"); } the_log_verifies(&g); } @@ -293,7 +294,7 @@ fn an_undeclared_server_is_read_partial_for_want_of_the_declaration() { 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 / 1_000, "the figure against the oracle's advance, to the µJ"); + assert_eq!(c.cost_uj, oracle.div_ceil(1_000), "the figure against the oracle's advance, to the µJ"); } the_log_verifies(&g); } @@ -326,7 +327,7 @@ fn a_server_that_reaps_a_worker_per_call_is_partial_even_when_declared() { 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 / 1_000, "the shell's own advance, to the µJ"); + assert_eq!(c.cost_uj, oracle.div_ceil(1_000), "the shell's own advance, to the µJ"); } the_log_verifies(&g); } diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs index f74c31283..b129ccfe5 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs @@ -374,7 +374,9 @@ pub enum TreeError { /// 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 down. + /// 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 @@ -543,7 +545,7 @@ pub(super) fn combine( if cpu_nj == 0 { return Err(TreeError::NotAdvanced { gaps }); } - Ok(TreeReading { cpu_uj: cpu_nj / 1_000, cpu_nj, processes: pids.len(), pids, wall, gaps }) + Ok(TreeReading { cpu_uj: cpu_nj.div_ceil(1_000), cpu_nj, processes: pids.len(), pids, wall, gaps }) } /// The CPU energy of a live process tree rooted at another process: see the @@ -844,17 +846,30 @@ mod tests { } /// A root and a child present at both ends: the sum of their advances, - /// whole. The µJ figure is the nJ figure rounded down. + /// 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, 8, 2)); + 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] diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs index a62f5f075..f256345ef 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs @@ -212,7 +212,7 @@ fn a_single_process_server_reads_whole_and_exactly() { 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 / 1_000); + assert_eq!(r.cpu_uj, r.cpu_nj.div_ceil(1_000), "the µJ figure is the nJ figure rounded up"); before = after; } } -- 2.54.0 (Apple Git-157) From 79baf409721be80699a70b5dd3cf3bc621e8b992 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:10:44 -0400 Subject: [PATCH 08/19] jcp-gateway: keep Gateway Send The boxed upstream tree meter was a bare Box, which carries no auto traits, so Gateway stopped being Send: an embedder that moves a gateway to a thread, or holds it across an await on a multithreaded executor, no longer compiled. TreeMeter is plain data, so the bound costs nothing. TreeSpan now requires Send, and a compile-time assertion that Gateway is Send fails the build if a field takes it away again. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 930685d0d..5501250f3 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -133,12 +133,20 @@ impl std::fmt::Display for CallMeter { /// 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. -trait TreeSpan { +/// 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 -- 2.54.0 (Apple Git-157) From 00a0e52964d16e79615a753591d6d797a6c899fd Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:12:41 -0400 Subject: [PATCH 09/19] jcp-gateway: meter a call on the tree of the pid the upstream names now prepare_upstream_meter(None) keeps the meter built for an earlier pid, and relay then used that meter without comparing it with up.pid(). So once a tree meter existed for pid P, every call to an upstream that named no process was read on P's tree and sealed HwShunt, "upstream-process-tree", for a process the upstream no longer claimed, while call_meter() reported the machine meter and the Upstream::pid contract promised machine-wide. relay and call_meter now choose the meter with one function, tree_for(), which returns the meter only when it was built for the pid named now. A relay-level test switches pid() to None and back and checks energy_domain on each call. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 54 +++++++++++++++++++++--- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 5501250f3..112292e6a 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -286,6 +286,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 @@ -433,8 +445,8 @@ impl Gateway { pub fn call_meter(&mut self, up: &impl Upstream) -> CallMeter { let pid = up.pid(); self.prepare_upstream_meter(pid); - match (pid, &self.upstream) { - (Some(p), Some((q, Some(_)))) if p == *q => CallMeter::UpstreamTree { pid: p }, + match (pid, tree_for(&self.upstream, pid)) { + (Some(p), Some(_)) => CallMeter::UpstreamTree { pid: p }, _ if self.meter.is_hardware() => CallMeter::MachineHardware, _ => CallMeter::Estimator, } @@ -555,10 +567,13 @@ impl Gateway { // 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, when the - // work it meters is done. - self.prepare_upstream_meter(up.pid()); + // work it meters is done. 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 = self.upstream.as_ref().and_then(|(_, t)| t.as_deref()); + 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))))); @@ -2010,6 +2025,35 @@ mod tests { assert!(g.upstream.is_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] -- 2.54.0 (Apple Git-157) From 58a25c67a7c9958c99bd735889ca5e68e0dc27dc Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:14:32 -0400 Subject: [PATCH 10/19] sandbox-meter: read a failed child listing as a failure, not as childless children() took a negative count as the only failure, but libproc's proc_listchildpids returns 0, not -1, when the listing fails, leaving errno set. A denied listing therefore read exactly like a childless process: the walk recorded no Unlisted gap, so TreeGap::Unlisted could never fire, and a tree whose listing was denied read as its root alone, whole. Measured under sandbox-exec with (deny process-info-listpids): every listing 0 with EPERM, while each process's usage stayed readable; a missing or childless pid returns 0 with errno 0. errno is now cleared before the call and a count of 0 with errno set is an error. The test runs this test binary again under that sandbox and meters a perl root relaying to an awk worker: each reading names the root alone, is not whole, and carries Unlisted (EPERM) for the root at both ends. Co-Authored-By: Claude Opus 5.5 --- .../crates/sandbox-meter/src/probe/tree.rs | 18 ++++- .../crates/sandbox-meter/tests/tree_energy.rs | 68 ++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs index b129ccfe5..ecf7efd70 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs @@ -679,18 +679,34 @@ mod sys { /// 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. + /// 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); diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs index f256345ef..c02fcf90e 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs @@ -19,7 +19,7 @@ #![cfg(all(target_os = "macos", target_arch = "aarch64"))] -use sandbox_meter::{TreeGap, TreeMeter, TreeReading}; +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}; @@ -355,6 +355,72 @@ fn a_reply_with_a_tail_of_work_is_read_once_the_tail_is_done() { } } +/// 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 -- 2.54.0 (Apple Git-157) From 771933602a5f00dd08356d0bca4f0973341ffcf5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:20:55 -0400 Subject: [PATCH 11/19] sandbox-meter, jcp-gateway: confirm each end's walk with a second one A walk reads a parent, then lists and reads its children. A descendant born in the span that exits and is reaped by its parent between the two was in the stop walk neither as a member (gone by its listing, or ESRCH at its read, dropped silently) nor in the parent's reaped-descendant time (read before the reap); the parent, blocked in wait, read as settled, so the reading had no gap and a declared server's call was sealed HwShunt, Whole while holding a small fraction of its CPU energy. Reproduced here with a perl server whose worker writes the reply itself and is reaped after it, 60 idle children beside it: whole without the worker by the second of 40 round trips in each of 5 runs. A walk is now accepted only when every member is settled and it agrees with the walk before it on the tree's shape: the same processes (pid and start time), parents, reaped-descendant and exit times, unreadable and unlisted members, and no listed child gone by its read (a new vanished flag). None of these returns to a value it left, so a reap between two agreeing walks is impossible, and the next agreeing walk holds the reap, which the reading then names. A tree still changing when the bound runs out is a new gap, TreeGap::Changing, sent as "tree-changing". This also makes the zombie-at-start credit sound: a start zombie absent from a confirmed stop walk was reaped before it, and its reaper's read holds it. Tests: pure tests of the agreement and of the new gap; a real-process meter test with that server (40 round trips, never whole, each naming the worker as Exited or the server's ReapedDescendant); and a gateway test with a declared server of the same shape, never sealed whole. SPEC 5.1 requires the confirming walk and lists the new gap. Co-Authored-By: Claude Opus 5.5 --- jcp/SPEC.md | 17 +- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 2 + .../crates/jcp-gateway/tests/upstream_tree.rs | 51 +++++ .../crates/sandbox-meter/src/probe/tree.rs | 189 +++++++++++++++++- .../crates/sandbox-meter/tests/tree_energy.rs | 63 ++++++ 5 files changed, 309 insertions(+), 13 deletions(-) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index fa7aeab32..ab8c952f9 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -636,10 +636,16 @@ Recommended ladder, best first: 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); and the span SHOULD end after the work's reply, when the work - it meters is done. The figure is `Partial` (§5.2) when the meter sees - any of these over the span, each a part of the tree's energy the figure - does not hold: + (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, when the work it meters is done. The figure is + `Partial` (§5.2) when the meter sees any of these over the span, each a + part of the tree's energy the figure does not hold: - 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 spent and read @@ -654,6 +660,9 @@ Recommended ladder, best first: separated, so none of it is counted; - a member that could not be read (a process of another user), whose children could not be listed, or that was still running at an end; + - 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; - GPU time spent by a member, or GPU time that could not be read; - a member that has held Neural Engine model memory in-process. diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 112292e6a..2db0eb039 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -1112,6 +1112,7 @@ fn gap_kind(gap: &TreeGap) -> &'static str { TreeGap::Unreadable { .. } => "unreadable", TreeGap::Unlisted { .. } => "unlisted", TreeGap::Unsettled { .. } => "unsettled", + TreeGap::Changing { .. } => "tree-changing", TreeGap::PredatesSpan { .. } => "predates-span", TreeGap::Gone { .. } => "gone", TreeGap::EnergyWentBack { .. } => "energy-went-back", @@ -2063,6 +2064,7 @@ mod tests { TreeGap::Unreadable { pid: 1, errno: 1, end }, TreeGap::Unlisted { pid: 1, errno: 1, end }, TreeGap::Unsettled { pid: 1, end }, + TreeGap::Changing { end }, TreeGap::PredatesSpan { pid: 1 }, TreeGap::Gone { pid: 1 }, TreeGap::EnergyWentBack { pid: 1 }, diff --git a/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs index 7509dca4f..fa3c21b13 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs @@ -332,6 +332,57 @@ fn a_server_that_reaps_a_worker_per_call_is_partial_even_when_declared() { 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" | "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); +} + /// 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. diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs index ecf7efd70..74a2a649c 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs @@ -76,6 +76,26 @@ //! 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 each member's count exact at the instant it is read; it //! does not 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 @@ -120,8 +140,10 @@ use std::time::{Duration, Instant}; /// 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. The rule -/// does not depend on it (the run state, not an interval, decides); it only +/// 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. @@ -211,6 +233,16 @@ pub(super) struct Snapshot { 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 { @@ -218,6 +250,40 @@ impl Snapshot { 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 { + fn shape(s: &Snapshot) -> (Vec<(u32, u64, u32, u64, u64)>, Vec<(u32, i32)>, Vec<(u32, i32)>) { + let mut members: Vec<_> = s + .members + .iter() + .map(|m| (m.pid, m.usage.start_abstime, m.parent, m.usage.reaped_descendants_cpu, m.usage.exit_abstime)) + .collect(); + members.sort_unstable(); + let (mut unreadable, mut unlisted) = (s.unreadable.clone(), s.unlisted.clone()); + unreadable.sort_unstable(); + unlisted.sort_unstable(); + (members, unreadable, unlisted) + } + !self.vanished && !other.vanished && shape(self) == shape(other) + } + /// 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) @@ -276,8 +342,17 @@ pub enum TreeGap { /// 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) or too little (at the stop). Its figure is counted. + /// the start) or too little (at the stop). Its figure is counted. 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. @@ -331,6 +406,9 @@ impl std::fmt::Display for TreeGap { 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") } @@ -470,6 +548,10 @@ pub(super) fn combine( 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 @@ -764,8 +846,15 @@ mod sys { 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. - Ok(None) => continue, + // 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) { @@ -776,16 +865,38 @@ mod sys { snap } - /// Walks every [`SETTLE_POLL`](super::SETTLE_POLL) until every member is - /// settled, or `bound` has passed: the last walk. + /// Walks 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 a [`SETTLE_POLL`](super::SETTLE_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 settled_walk(root: u32, bound: Duration) -> Snapshot { let deadline = Instant::now() + bound; + let mut prev: Option = None; + let mut walks = 0u32; loop { let snap = walk(root); - if snap.members.iter().all(|m| m.settled) || Instant::now() >= deadline { + walks += 1; + let quiet = snap.quiet(); + if quiet && prev.as_ref().is_some_and(|p| p.agrees_with(&snap)) { return snap; } - std::thread::sleep(super::SETTLE_POLL); + 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(super::SETTLE_POLL); + } } } @@ -1034,6 +1145,66 @@ mod tests { 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"); + } + + /// 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`. diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs index c02fcf90e..05c812579 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs @@ -250,6 +250,69 @@ fn a_server_that_reaps_its_worker_is_not_whole() { } } +/// 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. -- 2.54.0 (Apple Git-157) From db4048e42f947a3400c124d4e14a9353240d8fee Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:23:13 -0400 Subject: [PATCH 12/19] sandbox-meter: test the settle loop on given walks The loop that accepts a walk only once the next one agrees lived inside the macOS-only sys module, so only real processes exercised it, and the reap-race test mostly hits the vanished-child half of the rule (a child listed and gone by its read), not the half where the child is reaped before it is even listed. The loop is now settle(), over any source of walks, and sys::settled_walk feeds it real walks. Pure tests drive it: a quiet walk that missed a reap is not accepted until the next walk reads the reap and the one after confirms it; a walk that saw a child vanish confirms nothing; a member still running is waited out; and a tree that keeps changing is returned unconfirmed at the bound, after two walks even with no bound. Co-Authored-By: Claude Opus 5.5 --- .../crates/sandbox-meter/src/probe/tree.rs | 135 +++++++++++++----- 1 file changed, 102 insertions(+), 33 deletions(-) diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs index 74a2a649c..4cef64995 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs @@ -630,6 +630,41 @@ pub(super) fn combine( 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)] @@ -715,7 +750,7 @@ mod sys { use super::{settled, GpuClient, Member, Snapshot, Threads}; use crate::probe::process::read_usage; use std::collections::HashSet; - use std::time::{Duration, Instant}; + use std::time::Duration; #[repr(C)] struct Timebase { @@ -865,39 +900,10 @@ mod sys { snap } - /// Walks 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 a [`SETTLE_POLL`](super::SETTLE_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. + /// The tree from `root`, walked until it is settled and confirmed + /// ([`settle`](super::settle)). pub(super) fn settled_walk(root: u32, bound: Duration) -> Snapshot { - let deadline = Instant::now() + bound; - let mut prev: Option = None; - let mut walks = 0u32; - loop { - let snap = walk(root); - 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(super::SETTLE_POLL); - } - } + super::settle(|| walk(root), bound, super::SETTLE_POLL) } /// Every GPU client now; `None` if the registry could not be read, and @@ -1189,6 +1195,69 @@ mod tests { 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. -- 2.54.0 (Apple Git-157) From 3fdbd6d0d0ab8d31f89587c7c593861e99a2cc22 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:29:40 -0400 Subject: [PATCH 13/19] sandbox-meter, jcp-gateway: a GPU client whose time did not move is unknown use The reading took an unchanged GPU client time as proof the tree used no GPU. A client's AppUsage holds one entry per live command queue, and a released queue's entry leaves with its time: a request on a command queue created and released inside the span (5.2 ms of GPU work by its command buffer's own timestamps) moved its client's time by 0, so gpu_spent returned (0, []) and a declared Metal server's call was sealed HwShunt, Whole with its GPU energy unmeasured. gpu_spent now reports, besides the recorded time (a lower bound, carried as Gpu), every tree pid that holds a GPU client at either end and recorded no GPU time over the span as GpuUnknown, as well as one whose client was lost: only a tree with no GPU client reads whole. The docs in tree.rs, gpu_clients.rs, the gateway, SPEC 5.1 and both READMEs say so. The GPU half had no real-process test, so a blinded reader (a misspelt time key reads every client as 0) passed every suite. A Metal compute server, built at test time with the system C compiler, now backs a meter test (persistent-queue work must read as Gpu time > 0; transient-queue and CPU-only requests must carry a GPU gap for the server; none whole) and a gateway test (a declared Metal server is sealed Partial with "gpu" or "gpu-unknown" on every call). The registry test also requires some client to have recorded GPU time. Co-Authored-By: Claude Opus 5.5 --- jcp/README.md | 2 +- jcp/SPEC.md | 6 +- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 4 +- .../crates/jcp-gateway/tests/upstream_tree.rs | 88 ++++++++++++ sandbox/README.md | 2 +- .../sandbox-meter/src/probe/gpu_clients.rs | 24 +++- .../crates/sandbox-meter/src/probe/tree.rs | 134 ++++++++++++------ .../crates/sandbox-meter/tests/tree_energy.rs | 114 +++++++++++++++ 8 files changed, 321 insertions(+), 53 deletions(-) diff --git a/jcp/README.md b/jcp/README.md index 47b04edc8..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. 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 time, 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-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 ab8c952f9..d29fee461 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -663,7 +663,11 @@ Recommended ladder, best first: - 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; - - GPU time spent by a member, or GPU time that could not be read; + - 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 diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 2db0eb039..99d951b56 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -19,8 +19,8 @@ //! 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 time, no in-process Neural -//! Engine memory, every member read and gone quiet at both ends) AND +//! 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 diff --git a/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs index fa3c21b13..207dc5f2e 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs @@ -383,6 +383,94 @@ fn a_worker_reaped_after_writing_the_reply_never_leaves_a_call_whole() { 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. diff --git a/sandbox/README.md b/sandbox/README.md index 5a79766d3..f73f7a58b 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. 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 lists beside the figure every way it was seen to fall short of the tree's whole energy (a descendant reaped or exited in the span, GPU time, in-process Neural Engine memory, a member unreadable or still running); 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). +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 lists beside the figure every way it was seen to fall short of the tree's whole energy (a descendant reaped or exited in the span, a GPU client held, in-process Neural Engine memory, a member unreadable or still running); 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/probe/gpu_clients.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/gpu_clients.rs index 5da94573e..7834d4b1a 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/gpu_clients.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/gpu_clients.rs @@ -9,15 +9,22 @@ //! 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 API context, whose `accumulatedGPUTime` is the GPU time -//! that context has spent, in ns. This is public IOKit API, read +//! 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), -//! and an idle client's time does not move. But GPU time is LOST AT EXIT: +//! 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 @@ -250,7 +257,8 @@ mod tests { } /// On this host the registry walks, every client is a distinct entry, - /// and this test process — which never opened the GPU — holds none. + /// 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(); @@ -262,6 +270,12 @@ mod tests { 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/tree.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs index 4cef64995..516746d2d 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs @@ -13,8 +13,10 @@ //! 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: a GPU client time that did not move, and no process that ever -//! held Neural Engine model memory. It does NOT cover, and cannot see: +//! 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 @@ -116,7 +118,14 @@ //! 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. The Neural +//! 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 @@ -380,13 +389,18 @@ pub enum TreeGap { /// 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 `ns` of GPU time over the span. Its energy - /// is not measured here, and it is not zero. + /// 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 time 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)` — a client of that member vanished, lost - /// its time or went back. + /// 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, @@ -418,10 +432,10 @@ impl std::fmt::Display for TreeGap { 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, so its GPU time is unknown"), - TreeGap::Gpu { ns } => write!(f, "the tree spent {ns} ns of GPU time, whose energy is not measured"), + 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, "a GPU client of pid {pid} vanished or lost its time") + 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") @@ -492,31 +506,56 @@ fn check_root(root: (u32, u64), s: &Snapshot) -> Result<(), TreeError> { } } -/// The tree's GPU time over the span, from every client of a `tree` pid: -/// one present at both ends (by registry ID) its advance, one new at the end -/// its whole time. The pids of clients whose time is unknown — 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) — come back beside it: never read as a decrease. +/// 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 ns: u64 = 0; - let mut unknown: Vec = Vec::new(); - for c in start.iter().filter(|c| tree(c.pid)) { - let advance = match (c.gpu_ns, end.iter().find(|e| e.id == c.id).map(|e| e.gpu_ns)) { - // Present at both ends, with no time at either: it spent none. - (None, Some(None)) => Some(0), - (Some(_), Some(None)) => None, - (before, Some(Some(after))) => after.checked_sub(before.unwrap_or(0)), - (_, None) => None, - }; - match advance { - Some(d) => ns = ns.saturating_add(d), - None if !unknown.contains(&c.pid) => unknown.push(c.pid), - None => {} + 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); } } - for e in end.iter().filter(|e| tree(e.pid) && !start.iter().any(|c| c.id == e.id)) { - ns = ns.saturating_add(e.gpu_ns.unwrap_or(0)); + 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) } @@ -1340,30 +1379,39 @@ mod tests { GpuClient { id, pid, gpu_ns } } - /// The GPU accounting, client by client: an idle client of the tree adds - /// nothing; one that advanced adds its advance; one new at the stop adds - /// its whole time; another process's clients are not looked at, even - /// vanished; a client of the tree that vanished, lost its `AppUsage`, - /// fell to zero or went back is unknown, never a decrease; one with no - /// time at either end spent none. + /// 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_lost_client_is_unknown() { + 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(&idle, &idle, tree), (0, vec![])); + 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![])); + 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![])); + 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 diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs index 05c812579..cdf75aae3 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/tree_energy.rs @@ -521,3 +521,117 @@ fn a_child_that_exited_before_the_span_and_is_reaped_in_it_is_no_gap() { 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); + } + } +} -- 2.54.0 (Apple Git-157) From fb91899026031898ac9247ae64fc506dc5eb8722 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:35:01 -0400 Subject: [PATCH 14/19] jcp-gateway: end a call at its own response, and relay what comes before it ChildServer::call returned the first line the server wrote. During a tools/call an MCP server may first send notifications (progress, log messages) or requests of its own (sampling); the gateway took that line as the reply, so the tree span stopped before the call's work, the stop walk found the server blocked and settled, and a declared server's call was sealed HwShunt, Whole holding a sliver of it. The real response then answered the next call, sealed Whole with the previous call's work and debited from that call's grant, possibly another tool's. The binary now reads the server's stdout on a thread 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 request's id and hands any other response to the client. The client's stdin is read on a thread too, so a client's answer to a server's mid-call request reaches the server while the call is open instead of deadlocking behind it. As a defence for any Upstream, relay grades a figure Partial, tree or machine, with "reply-not-the-response" in energy_partial_because, unless the returned line is the JSON-RPC response with the call's id and a result or an error. A unit test covers each kind of wrong line; a binary test runs the real gateway in front of an awk server that sends a progress notification and a sampling request inside each call and waits for the client's answer. Co-Authored-By: Claude Opus 5.5 --- .../crates/jcp-gateway/src/bin/jcp_gateway.rs | 135 +++++++++++---- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 156 +++++++++++++++--- .../crates/jcp-gateway/tests/stdio_binary.rs | 61 +++++++ 3 files changed, 297 insertions(+), 55 deletions(-) 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 7d8a47e87..3a68061fb 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 @@ -12,6 +12,13 @@ //! `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 @@ -22,9 +29,11 @@ //! `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. @@ -38,30 +47,81 @@ 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 { /// 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: ChildStdin, - stdout: BufReader, + 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<()> { - self.stdin.write_all(line.as_bytes())?; - self.stdin.write_all(b"\n")?; - self.stdin.flush() + to_server(&self.stdin, line) } fn pid(&self) -> Option { @@ -108,16 +168,12 @@ fn main() -> std::io::Result<()> { 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, - }; + 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]); @@ -141,14 +197,32 @@ fn main() -> std::io::Result<()> { 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 @@ -160,19 +234,16 @@ 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()?; + 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 99d951b56..913777240 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -58,7 +58,7 @@ 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 jcp_schema::{Capability, Caveat, ContentId, Coverage, Did, Energy, FlowFacts, Grant, SignedGrant}; use sandbox_meter::{Measurement, PowerMeter, TreeError, TreeGap, TreeMeter, TreeReading}; use sandbox_schema::DeviceClass; use serde_json::{json, Value}; @@ -593,8 +593,11 @@ impl Gateway { // 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. - let metered = grade_call(reading.as_ref(), self.upstream_self_contained, &measurement); + // 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(); @@ -933,7 +936,9 @@ fn jcp_meta(params: &Value) -> Option<&Value> { /// `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. +/// 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 { @@ -1083,6 +1088,34 @@ impl EnergyDomain { /// 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 { @@ -1090,10 +1123,12 @@ struct Metered { cost: Energy, /// Where it was read. domain: EnergyDomain, - /// Why an upstream-tree figure is `Partial`: each [`gap_kind`] seen, once, - /// in the order the meter reported them, then - /// [`NOT_DECLARED_SELF_CONTAINED`] where it applies. Empty for a `Whole` - /// figure and for a machine-wide one. + /// 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>, } @@ -1135,27 +1170,44 @@ fn gap_kind(gap: &TreeGap) -> &'static str { /// 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. +/// 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) -> Metered { - let Some(Ok(r)) = tree else { return Metered::machine(machine) }; - let mut because: Vec<&'static str> = Vec::new(); - for kind in r.gaps.iter().map(gap_kind) { - if !because.contains(&kind) { - because.push(kind); +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); } - 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 } fn deny_reason_of(d: &jcp_runtime::Decision) -> String { @@ -1881,7 +1933,7 @@ mod tests { 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); + 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)), @@ -1916,7 +1968,7 @@ mod tests { 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); + 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)); } @@ -2026,6 +2078,64 @@ mod tests { 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 diff --git a/jcp/jcp-rs/crates/jcp-gateway/tests/stdio_binary.rs b/jcp/jcp-rs/crates/jcp-gateway/tests/stdio_binary.rs index cb6f04b5a..8677d904e 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/tests/stdio_binary.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/tests/stdio_binary.rs @@ -129,3 +129,64 @@ fn the_binary_serves_calls_after_the_initialized_notification() { 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"]); + } +} -- 2.54.0 (Apple Git-157) From fb68d61090fce4cc84f8513b9cbb2ebf13d3f847 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:42:03 -0400 Subject: [PATCH 15/19] sandbox-meter, jcp-gateway: say which way a gap errs, and where deferred work lands SPEC 5.1, the sandbox README and the TreeGap and TreeReading.gaps docs called every gap a part of the tree's energy the figure does not hold, and 5.2 reads Partial as "remainder unmeasured", a lower bound. But a member still running at the START was read stale-low, so its advance includes up to a quantum per running thread spent just before the span: the one gap by which the figure can exceed the span's energy, sealed Partial and debited. The docs now say so, and the wire names the end: energy_partial_because carries "unsettled-at-start" or "unsettled-at-stop" instead of one "unsettled", so a client can tell a possible over-count from a shortfall. The figure is still counted either way: a budget's enforcement point does not drop a member's whole advance, which would under-debit. The tree docs also said a server's work after the stop "belongs to no span" and the gateway's Phase 2 comment that the span stops "when the work it meters is done". Back-to-back spans read all the tree spends inside them: work a server defers past one reply, or does on a timer, lands in the next call's span, and a server waiting on a timer reads as settled at its start. The tree module docs, the gateway docs and SPEC 5.1 now say that a whole reading means nothing was seen missing from the tree's energy over the span, not that all of it was the span's own work. Co-Authored-By: Claude Opus 5.5 --- jcp/SPEC.md | 24 +++++-- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 46 ++++++++++---- .../crates/jcp-gateway/tests/upstream_tree.rs | 2 +- sandbox/README.md | 2 +- .../crates/sandbox-meter/src/probe/tree.rs | 63 ++++++++++++------- 5 files changed, 93 insertions(+), 44 deletions(-) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index d29fee461..d8207227f 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -643,9 +643,15 @@ Recommended ladder, best first: 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, when the work it meters is done. The figure is - `Partial` (§5.2) when the meter sees any of these over the span, each a - part of the tree's energy the figure does not hold: + 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 spent and read @@ -658,8 +664,16 @@ Recommended ladder, best first: - 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), whose - children could not be listed, or that was still running at an end; + - 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; diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 913777240..63ad3df14 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -20,11 +20,16 @@ //! 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`]), +//! 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. +//! 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. @@ -59,7 +64,7 @@ use jcp_receipt::{FlowSeal, ReceiptLog}; use jcp_runtime::Runtime; use jcp_schema::flow::{FlowCeiling, Readers}; use jcp_schema::{Capability, Caveat, ContentId, Coverage, Did, Energy, FlowFacts, Grant, SignedGrant}; -use sandbox_meter::{Measurement, PowerMeter, TreeError, TreeGap, TreeMeter, TreeReading}; +use sandbox_meter::{Measurement, PowerMeter, SpanEnd, TreeError, TreeGap, TreeMeter, TreeReading}; use sandbox_schema::DeviceClass; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -167,7 +172,9 @@ pub enum Relayed { /// 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 of the call, and is never presented as its total. + /// 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. @@ -566,8 +573,10 @@ impl Gateway { // 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, when the - // work it meters is done. The tree meter is chosen as `call_meter` + // 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(); @@ -1142,11 +1151,19 @@ impl Metered { /// 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 { .. } => "unsettled", + 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", @@ -1166,8 +1183,10 @@ fn gap_kind(gap: &TreeGap) -> &'static str { /// - 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 it is debited all -/// the same, as every allowed call's figure is (SPEC §5); +/// `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; @@ -1905,13 +1924,13 @@ mod tests { (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"]), + (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", NOT_DECLARED_SELF_CONTAINED], + 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![]), @@ -2174,6 +2193,7 @@ mod tests { 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 }, diff --git a/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs index 207dc5f2e..cc950cac0 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/tests/upstream_tree.rs @@ -371,7 +371,7 @@ fn a_worker_reaped_after_writing_the_reply_never_leaves_a_call_whole() { 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" | "tree-changing"))), + because.iter().any(|k| matches!(k.as_str(), Some("reaped-descendant" | "exited" | "unsettled-at-start" | "unsettled-at-stop" | "tree-changing"))), "{}", c.meta ); diff --git a/sandbox/README.md b/sandbox/README.md index f73f7a58b..33a5eed3c 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. 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 lists beside the figure every way it was seen to fall short of the tree's whole energy (a descendant reaped or exited in the span, a GPU client held, in-process Neural Engine memory, a member unreadable or still running); 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). +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 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/probe/tree.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs index 516746d2d..e4a6c6598 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs @@ -30,14 +30,18 @@ //! 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 what was SEEN missing: 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. 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. +//! 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`, @@ -101,18 +105,24 @@ //! Settling makes each member's count exact at the instant it is read; it //! does not 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, and what it does after the stop belongs to no span. -//! 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 when the work it meters is done — after the -//! reply — and the tree's work after that is not this span's. +//! 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 be -//! missing, 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). +//! 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 @@ -335,10 +345,12 @@ impl std::fmt::Display for SpanEnd { } } -/// One way a [`TreeReading`] is short of the tree's whole energy over the -/// span, as seen. 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. +/// 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 @@ -351,7 +363,9 @@ pub enum TreeGap { /// 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) or too little (at the stop). Its figure is counted. The + /// 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 }, @@ -482,7 +496,8 @@ pub struct TreeReading { pub pids: Vec, /// From the start walk settling to the stop walk settling. pub wall: Duration, - /// Every way the figure was seen to be short of the tree's whole energy. + /// 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, } -- 2.54.0 (Apple Git-157) From 746a6b606ae54f24e8108ff2a0e1dc270d1cfaba Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:43:22 -0400 Subject: [PATCH 16/19] sandbox-meter, jcp-gateway: say what an exited member's record lacks, and what a delegating client reads SPEC 5.1, TreeGap::Exited (doc and Display) and the settling paragraph said an exited member's count is final and counted, with only its GPU time unknown, and that settling makes each member's count exact. A zombie's record holds its complete CPU time but not the energy of its last time on a core, which is folded only when a thread leaves a core and never for an exiting thread: measured, a child that ran 3.3 ms of CPU and exited at once left 0 to 0.32 mJ, some exactly 0 nJ, against about 1.5 mJ per CPU-ms for the same work in a process that blocks first. The figure was already Partial for it; the stated reason now says the energy is short too. The tree module docs and Gateway::declare_upstream_self_contained said a client whose model inference ran in a system daemon "read zero on every unprivileged counter". Its Neural Engine footprint reads zero, but its own CPU count, the one the tree meter sums, advanced tens of millijoules per request: the failure the declaration guards against is a plausible nonzero figure, not a zero. Both now say what was measured. Co-Authored-By: Claude Opus 5.5 --- jcp/SPEC.md | 11 +++-- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 12 +++--- .../crates/sandbox-meter/src/probe/tree.rs | 43 +++++++++++++------ 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index d8207227f..62bdd8ed5 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -654,10 +654,13 @@ Recommended ladder, best first: 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 spent and read - before the span; - - a member that exited inside the span: its count is final and is - counted, but its GPU time is lost at exit, so its GPU use is unknown; + 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; diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 63ad3df14..b75971f0e 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -413,11 +413,13 @@ impl Gateway { /// 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 read zero on every - /// unprivileged counter. 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 + /// `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) { diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs index e4a6c6598..e0d89a4fb 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs @@ -21,9 +21,11 @@ //! - 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 read zero on every -//! unprivileged counter: the model memory is charged to a system service of -//! another user, unreadable here; +//! (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 @@ -75,7 +77,8 @@ //! 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. The meter walks again, every +//! 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 @@ -102,8 +105,15 @@ //! With this rule, 240 round trips of the same server in 6 runs named the //! worker in every reading. //! -//! Settling makes each member's count exact at the instant it is read; it -//! does not make the tree's work finished. A member waiting on I/O in the +//! 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 @@ -398,10 +408,12 @@ pub enum TreeGap { /// 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 CPU energy - /// is read and counted; but 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. + /// 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 @@ -445,7 +457,10 @@ impl std::fmt::Display for TreeGap { 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, so its GPU time is unknown"), + 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) } => { @@ -612,7 +627,7 @@ pub(super) fn combine( // 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, and read, before the span. + // spent before the span. let reaped_before = |reaper: u32| -> u64 { start .members @@ -1159,8 +1174,8 @@ mod tests { } /// A member that exited in the span and is not yet reaped is counted (its - /// count is final and readable), with its GPU time unknown. One that had - /// exited before the span is no gap. + /// 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))]); -- 2.54.0 (Apple Git-157) From b78c6299a460dae97fa4a67ea55db43eff177128 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:44:11 -0400 Subject: [PATCH 17/19] sandbox-meter: compare a walk's shape piece by piece Clippy flagged the one-tuple shape in Snapshot::agrees_with as too complex a type. The members, the unreadable and the unlisted lists are now compared one after the other; the rule is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../crates/sandbox-meter/src/probe/tree.rs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs index e0d89a4fb..0a0a9973c 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/tree.rs @@ -298,19 +298,26 @@ impl Snapshot { /// 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 { - fn shape(s: &Snapshot) -> (Vec<(u32, u64, u32, u64, u64)>, Vec<(u32, i32)>, Vec<(u32, i32)>) { - let mut members: Vec<_> = s + // 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(); - members.sort_unstable(); - let (mut unreadable, mut unlisted) = (s.unreadable.clone(), s.unlisted.clone()); - unreadable.sort_unstable(); - unlisted.sort_unstable(); - (members, unreadable, unlisted) + v.sort_unstable(); + v } - !self.vanished && !other.vanished && shape(self) == shape(other) + 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. -- 2.54.0 (Apple Git-157) From 451987e1cb9da7a35d4731999d8ad2d536946d4b Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:46:29 -0400 Subject: [PATCH 18/19] sandbox: say the tree meter confirms each end with a second walk The README described each end as read once no thread is on a core; it is also accepted only when a second walk finds the same processes, parents, reaped and exit times, which is what keeps a child reaped mid-walk from vanishing without a gap. Co-Authored-By: Claude Opus 5.5 --- sandbox/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sandbox/README.md b/sandbox/README.md index 33a5eed3c..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. 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 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). +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. -- 2.54.0 (Apple Git-157) From 08be85834daf31b7147b6283e8a003ee578851c0 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 10:49:10 -0400 Subject: [PATCH 19/19] jcp-gateway: refuse a gated call sent as a notification, unsent and sealed A tools/call, resources/read or prompts/get with no `id` is a JSON-RPC notification: its result and its receipt could reach no one, and no reply bounds the span it would be metered over. The binary now matches replies by id, so such a call waited forever. It is refused before anything else and never reaches the server, nothing is sent back, and the attempt is sealed as a denial on a frame whose action is `notification:`, which no grant carries. The ungranted-tool trick (decide against another tool's grant) would have ALLOWED a granted tool's frame, so it is not used here. Co-Authored-By: Claude Opus 5.5 --- .../crates/jcp-gateway/src/bin/jcp_gateway.rs | 5 +- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 67 ++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) 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 3a68061fb..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 @@ -239,7 +239,10 @@ fn main() -> std::io::Result<()> { } Relayed::Denied { response, reason } => { eprintln!("jcp-gateway: DENIED ({reason})"); - to_client(&response)?; + // 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() { diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index b75971f0e..36308c1dd 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -177,7 +177,8 @@ pub enum Relayed { /// 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 line the server answers with nothing — a client notification (no /// `id`), or the client's response to a request the server made (no @@ -516,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). @@ -697,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(); @@ -1367,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. -- 2.54.0 (Apple Git-157)