jcp-gateway: meter each call on the upstream server's own process tree #198
Merged
dcharlot
merged 19 commits from 2026-10-09 13:59:30 -04:00
feat/gateway-upstream-energy into main 2026-10-09
jcp-gateway: refuse a gated call sent as a notification, unsent and sealed
All checks were successful
energy meters on real hardware / RAPL and NVML on jetson-hub (pull_request) Successful in 32s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 4s
no-vanity-metrics / WAI public text names no comparison (pull_request) Successful in 4s
no-vanity-metrics / WAI public text names no company or product (pull_request) Successful in 4s
standards workspaces build + test / arl-rs (pull_request) Successful in 1m12s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m58s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m33s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 1m14s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 1m14s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m17s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m13s
standards workspaces build + test / sense-rs (pull_request) Successful in 1m6s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m39s
workflow hygiene / every workflow supersedes its own queue (pull_request) Successful in 4s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 34m16s
standards workspaces build + test / aac-rs (pull_request) Successful in 3m47s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 1m56s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 1m13s
standards workspaces build + test / map-rs (pull_request) Successful in 4m26s
standards workspaces build + test / mesh-rs (pull_request) Successful in 4m45s
standards workspaces build + test / openpay (pull_request) Successful in 20m53s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m10s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 7m41s
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:<tool>`, 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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
jcp-gateway: keep Gateway Send
The boxed upstream tree meter was a bare Box<dyn TreeSpan>, 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 <[email protected]>
sandbox-meter, jcp-gateway: round a tree reading up to a whole microjoule
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 <[email protected]>
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 <[email protected]>
jcp: specify metering a call on another process tree, and when it is whole
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 <[email protected]>
jcp-gateway: test tree-metered calls against an oracle on real servers
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>