Skip to main content

forge_fold_cost/
forge-fold-cost.rs

1//! A forge to measure against, and the client that measures it.
2//!
3//! The cost this instrument exists for is a cost to a PROCESS, so the
4//! measurement is `ps -o time=` on a forge's own CPU time, sampled around a
5//! window of raw websocket work and charged against an idle arm of the same
6//! length. That needs a forge the instrument can watch from outside without
7//! disturbing the one somebody is using, and it needs the client in another
8//! process - a client sharing this one would put its own decoding of a 28 MB
9//! frame inside the number.
10//!
11//! Two roles, and the server is the default:
12//!
13//! ```text
14//! cargo build --release -p forge-server --features testing --bin forge-fold-cost
15//! forge-fold-cost --transcript /tmp/hub.jsonl --breakdown      # serve
16//! forge-fold-cost --measure --pid <that pid> --arm refresh    # measure
17//! ```
18//!
19//! **Build it in release.** The profile moves the headline figure by 7x -
20//! the same transcript and the same command read 1,413 ms for a read in a
21//! debug build and 204 ms in a release one - so every row says which build
22//! its figures came from. That is the MEASURING CLIENT's build, and the
23//! server's is a separate thing a row cannot report: see `client_profile`.
24//!
25//! **Every arm prints its own denominator** - the frames it read, the bytes
26//! they carried, the turns and messages they held - because an arm that
27//! reported a cost without them could not be told from one that had stopped
28//! seeing the conversation at all. The check that matters is running two arms
29//! of the same name against a server before and after a change and finding
30//! the bytes identical: that is what says the instrument was still watching
31//! the conversation rather than having gone quiet.
32//!
33//! **And each arm sends what its name says**, which is not a detail. A
34//! `refresh` is an `unsubscribe` and a `subscribe`, because that is what the
35//! client sends on every update for the open seat; a `subscribe` carries its
36//! own connect; an `idle` arm does nothing at all. An arm that sent a `more`
37//! and called itself a refresh would report on a path the client never takes.
38//!
39//! The seat's conversation is the file `--transcript` names, written in under
40//! the session id the fixture seeds. Nothing here synthesises one: a fold over
41//! invented rows is a fold over rows whose size is a property of this file
42//! rather than of a conversation, which is the one thing a measurement of
43//! per-byte cost cannot afford.
44
45use std::path::{Path, PathBuf};
46use std::sync::Arc;
47use std::time::{Duration, Instant};
48
49use clap::Parser;
50use forge_primitives::SessionSlot;
51use forge_server::surface::ViewSurface;
52use forge_server::testing::Fleet;
53use forge_server::transport::TransportState;
54use forge_server::transport::envelope::{ClientMessage, ServerMessage, Subject};
55use forge_server::transport::serve;
56use futures_util::{SinkExt, StreamExt};
57use tokio_tungstenite::tungstenite::Message;
58
59#[derive(Parser)]
60struct Args {
61    /// A session transcript to serve as the seat's conversation, read whole.
62    #[arg(long)]
63    transcript: Option<PathBuf>,
64    /// The org the fixture declares.
65    #[arg(long, default_value = "Bench")]
66    org: String,
67    /// The project the fixture declares. Its directory is under `--dir`.
68    #[arg(long, default_value = "seat")]
69    project: String,
70    /// Where the fixture's config dir goes. The workspace's store lives under
71    /// it and the transcript is written into it, so a fresh directory gives a
72    /// fresh seat; nothing here clears one, so a rerun over the same directory
73    /// reuses the store it left.
74    #[arg(long, default_value = "/tmp/forge-fold-cost")]
75    dir: PathBuf,
76    /// The port. Fixed rather than ephemeral: a harness that samples `ps`
77    /// needs to find the server by something, and the port is what survives a
78    /// PID nobody wrote down.
79    #[arg(long, default_value_t = 8791)]
80    port: u16,
81    /// Report where the time goes inside one request before serving.
82    #[arg(long)]
83    breakdown: bool,
84    /// Measure a running server rather than serve one.
85    #[arg(long)]
86    measure: bool,
87    /// The server's PID, which is whose CPU time the measurement charges.
88    #[arg(long)]
89    pid: Option<u32>,
90    /// The arm to run, and each one sends what its name says:
91    /// `idle` does nothing and is the baseline; `subscribe` is one connect
92    /// and one subscribe; `more` is an attach then `--asks` pages; and
93    /// `refresh` is an attach then `--asks` subscribe-reloads, each an
94    /// `unsubscribe` and a `subscribe`, which is what the client sends on
95    /// every update.
96    ///
97    /// **A closed set, so a mistyped arm is refused rather than run as
98    /// `more`.** An arm that ran a different workload under the name a
99    /// person typed is the defect this instrument exists to find, and it is
100    /// the same mistake whether the code or the caller makes it.
101    #[arg(long, default_value = "refresh")]
102    arm: Arm,
103    /// How many turns each `more` asks for.
104    #[arg(long, default_value_t = 20)]
105    turns: u32,
106    /// How many asks an arm makes.
107    #[arg(long, default_value_t = 8)]
108    asks: u32,
109    /// How long the idle arm runs, and the wall the charge is scaled against.
110    #[arg(long, default_value_t = 2.0)]
111    seconds: f64,
112}
113
114/// Another process's CPU time, at `ps`'s 10 ms resolution.
115///
116/// **A PID that cannot be read is an error, never a zero.** A stale PID is
117/// the ordinary case - the server prints its PID once and a person pastes it
118/// after a restart - and a zero here is the strongest false positive this
119/// instrument can produce: a full, plausible denominator of bytes and turns
120/// beside "the server cost nothing". The denominator exists so an arm that
121/// stopped seeing the conversation can be told from one that worked; the CPU
122/// half needs the same guard.
123fn cpu_seconds(pid: u32) -> anyhow::Result<f64> {
124    let out = std::process::Command::new("ps")
125        .args(["-p", &pid.to_string(), "-o", "time="])
126        .output()
127        .map_err(|error| anyhow::anyhow!("ps could not be run for pid {pid}: {error}"))?;
128    if !out.status.success() {
129        // `ps` says nothing on stderr for a dead PID, which is its common
130        // case here, so the sentence carries the status rather than a colon
131        // with nothing after it.
132        let said = String::from_utf8_lossy(&out.stderr).trim().to_owned();
133        anyhow::bail!(
134            "ps failed for pid {pid} ({}){}",
135            out.status,
136            if said.is_empty() { String::new() } else { format!(": {said}") },
137        );
138    }
139    let text = String::from_utf8_lossy(&out.stdout);
140    parse_cpu_time(text.trim())
141        .ok_or_else(|| anyhow::anyhow!("ps reported no CPU time for pid {pid}: `{text}`"))
142}
143
144/// `[[dd-]hh:]mm:ss.cc` as seconds, or `None` when it is not that.
145fn parse_cpu_time(text: &str) -> Option<f64> {
146    let (days, rest) = match text.split_once('-') {
147        Some((days, rest)) => (days.parse::<f64>().ok()?, rest),
148        None => (0.0, text),
149    };
150    let mut parts = rest.split(':').map(str::parse::<f64>).collect::<Result<Vec<f64>, _>>().ok()?;
151    if parts.is_empty() {
152        return None;
153    }
154    while parts.len() < 3 {
155        parts.insert(0, 0.0);
156    }
157    Some(days * 86400.0 + parts[0] * 3600.0 + parts[1] * 60.0 + parts[2])
158}
159
160/// What this run was built as, which moves the headline figure by 7x.
161///
162/// Same transcript, same command: a debug build reads 1,413 ms where a
163/// release one reads 204, and a debug CLIENT reads 2,695 ms of wall where a
164/// release one reads 1,250.
165///
166/// **This is the MEASURING CLIENT's build, and it is not the server's.** A
167/// row's `charged_cpu_ms` is the server's CPU, which moves with the server's
168/// own build, and the two can differ: a release client measuring a debug
169/// server reports `client_profile: release` about a server built debug. The
170/// server's build is announced by its own serve line and is not recoverable
171/// from a row - `--pid` is an address, and the greeting carries a protocol
172/// version rather than a build.
173///
174/// **The serve line uses this too, and there the caller IS the server.** It
175/// says which build is listening; a row's `client_profile` says which build
176/// is measuring. Two processes, and they can disagree.
177fn profile() -> &'static str {
178    if cfg!(debug_assertions) { "debug" } else { "release" }
179}
180
181/// This process's resident set, which is what holding a conversation costs.
182fn rss() -> anyhow::Result<u64> {
183    let pid = std::process::id();
184    let out = std::process::Command::new("ps")
185        .args(["-p", &pid.to_string(), "-o", "rss="])
186        .output()
187        .map_err(|error| anyhow::anyhow!("ps could not be run for pid {pid}: {error}"))?;
188    if !out.status.success() {
189        anyhow::bail!("ps failed for this process's rss ({})", out.status);
190    }
191    String::from_utf8_lossy(&out.stdout)
192        .trim()
193        .parse::<u64>()
194        .map_err(|_| anyhow::anyhow!("ps reported no rss for this process"))
195}
196
197/// The workload an arm sends, as a closed set.
198///
199/// **A closed set rather than a string, so a mistyped arm is refused rather
200/// than run as something else.** An arm that ran a different workload under
201/// the name a person typed is the defect this instrument exists to find, and
202/// it is the same mistake whether the code or the caller makes it.
203#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
204enum Arm {
205    /// Nothing at all, and the baseline every other arm is charged against.
206    Idle,
207    /// One connect and one subscribe: the price of arriving.
208    Subscribe,
209    /// An attach, then `--asks` pages.
210    More,
211    /// An attach, then `--asks` subscribe-reloads, each an `unsubscribe` and
212    /// a `subscribe` - what the client sends on every update for the open
213    /// seat.
214    Refresh,
215}
216
217impl Arm {
218    fn name(self) -> &'static str {
219        match self {
220            Self::Idle => "idle",
221            Self::Subscribe => "subscribe",
222            Self::More => "more",
223            Self::Refresh => "refresh",
224        }
225    }
226}
227
228/// What one arm moved, and what it cost.
229///
230/// The denominator is not decoration: it is what tells a reader that the arm
231/// saw a conversation at all.
232#[derive(Default)]
233struct Measured {
234    cpu_ms: f64,
235    wall_ms: f64,
236    frames: u64,
237    bytes: u64,
238    turns: u64,
239    messages: u64,
240    asks: u32,
241    /// The PID this arm was charged to, echoed from `--pid`.
242    ///
243    /// **Echoed rather than verified**: nothing here checks that the process
244    /// owns the port, so a recycled PID is charged and then named as the
245    /// server. It is an address for a reader to check, not a claim.
246    pid: u32,
247}
248
249impl Measured {
250    /// The line one arm reports.
251    ///
252    /// Every row carries the build it was MEASURED WITH, the PID it charged,
253    /// and - on the idle row, which every other arm is charged against - the
254    /// window ASKED FOR rather than the one the clock measured, so a reader
255    /// recomputing the charge from a lifted row gets a number slightly under
256    /// the printed one. The divisor is the idle arm's own wall time, which no
257    /// row prints; the gap is milliseconds normally and wider under load,
258    /// which is when someone is most likely to be recomputing.
259    ///
260    /// **The client's build moves two of the figures, and the charge is only
261    /// as clean as the baseline.** `wall_ms` is the client's own and a debug
262    /// client reads 2,695 ms where a release one reads 1,250 on the same
263    /// server. `charged_cpu_ms` is `cpu - idle_cpu * share`, so where the
264    /// idle arm reads 0.0 the charge is insensitive to that build; where the
265    /// server has background work the baseline is non-zero, `share` moves
266    /// with the client's wall, and the charge moves with it.
267    fn report(&self, arm: &str, idle: &Measured, window: Option<f64>) -> String {
268        let share = if idle.wall_ms > 0.0 { self.wall_ms / idle.wall_ms } else { 0.0 };
269        let window = match window {
270            Some(seconds) => format!(", \"window_s\": {seconds:.1}"),
271            None => String::new(),
272        };
273        format!(
274            "{{\"arm\": \"{arm}\", \"client_profile\": \"{}\", \"charged_pid\": {}, \
275             \"charged_cpu_ms\": {:.1}, \"wall_ms\": {:.1}, \"frames\": {}, \"bytes\": {}, \
276             \"turns\": {}, \"messages\": {}, \"asks\": {}, \"idle_cpu_ms\": {:.1}, \
277             \"idle_share\": {:.3}{window}}}",
278            profile(),
279            self.pid,
280            self.cpu_ms - idle.cpu_ms * share,
281            self.wall_ms,
282            self.frames,
283            self.bytes,
284            self.turns,
285            self.messages,
286            self.asks,
287            idle.cpu_ms,
288            share,
289        )
290    }
291}
292
293/// The messages a page or a snapshot carried, counted the way a client draws
294/// them: one per turn, plus the frames each turn holds.
295fn count(payload: &serde_json::Value) -> (u64, u64) {
296    let Some(turns) = payload.get("turns").and_then(|turns| turns.as_array()) else {
297        return (0, 0);
298    };
299    let messages = turns
300        .iter()
301        .map(|turn| turn.get("messages").and_then(|m| m.as_array()).map_or(0, |m| m.len() as u64))
302        .sum();
303    (turns.len() as u64, messages)
304}
305
306/// A socket with nothing asked on it yet, which the greeting has arrived on.
307///
308/// A whole-conversation snapshot is tens of megabytes, past the client's
309/// default message size: a client that refused it would measure the refusal
310/// rather than the encode.
311async fn a_socket(
312    args: &Args,
313) -> anyhow::Result<
314    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
315> {
316    let url = format!("ws://127.0.0.1:{}/socket", args.port);
317    let config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
318        .max_message_size(None)
319        .max_frame_size(None);
320    let (socket, _) =
321        tokio_tungstenite::connect_async_with_config(url, Some(config), false).await?;
322    Ok(socket)
323}
324
325/// Attach to the seat and take the answer, which is what a connect costs.
326///
327/// Nothing here is counted into an arm: this is the price of arriving, and
328/// the arm it precedes is the price of staying.
329async fn attach(
330    args: &Args,
331    seat: &SessionSlot,
332) -> anyhow::Result<
333    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
334> {
335    let mut socket = a_socket(args).await?;
336    let opening = ClientMessage::Subscribe {
337        what: Subject::Session(seat.clone()),
338        // Not answering: registering as one that can answer parks a turn on
339        // this client's reply, and a measurement must not change the thing it
340        // measures.
341        answering: false,
342        // Not hosting either, for the same reason.
343        review: false,
344    };
345    socket.send(Message::Text(serde_json::to_string(&opening)?.into())).await?;
346    let mut uncounted = Measured::default();
347    let _ = read_one(&mut socket, &mut uncounted).await?;
348    Ok(socket)
349}
350
351/// Ask on a connection already open, reading every frame and counting it.
352async fn work(
353    args: &Args,
354    seat: &SessionSlot,
355    socket: &mut tokio_tungstenite::WebSocketStream<
356        tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
357    >,
358    arm: &mut Measured,
359) -> anyhow::Result<()> {
360    let mut before = None;
361    let asks = if args.arm == Arm::Subscribe { 1 } else { args.asks };
362    for _ in 0..asks {
363        let asked = match args.arm {
364            Arm::Subscribe => ClientMessage::Subscribe {
365                what: Subject::Session(seat.clone()),
366                answering: false,
367                review: false,
368            },
369            // The client's own order: let the seat go, then ask for it again.
370            // Nothing answers an unsubscribe, so the read below waits for the
371            // subscribe's snapshot.
372            Arm::Refresh => {
373                let let_go = ClientMessage::Unsubscribe { what: Subject::Session(seat.clone()) };
374                socket.send(Message::Text(serde_json::to_string(&let_go)?.into())).await?;
375                ClientMessage::Subscribe {
376                    what: Subject::Session(seat.clone()),
377                    answering: false,
378                    review: false,
379                }
380            }
381            Arm::Idle | Arm::More => ClientMessage::More {
382                conversation: seat.clone(),
383                before: before.clone(),
384                turns: args.turns,
385            },
386        };
387        socket.send(Message::Text(serde_json::to_string(&asked)?.into())).await?;
388        if let ServerMessage::Page { cursor, .. } = read_one(socket, arm).await? {
389            before = cursor;
390        }
391    }
392    Ok(())
393}
394
395/// The next answer on the socket, counted into `arm`.
396///
397/// The greeting and anything a live core pushes while this runs are read and
398/// passed over rather than counted: an arm's denominator is what the ask it
399/// made was answered with, not everything the socket happened to say.
400async fn read_one(
401    socket: &mut tokio_tungstenite::WebSocketStream<
402        tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
403    >,
404    arm: &mut Measured,
405) -> anyhow::Result<ServerMessage> {
406    loop {
407        let Some(message) = socket.next().await else {
408            anyhow::bail!("the server stopped talking before it answered");
409        };
410        let Message::Text(text) = message? else {
411            continue;
412        };
413        match serde_json::from_str::<ServerMessage>(&text)? {
414            answer @ (ServerMessage::Snapshot { .. } | ServerMessage::Page { .. }) => {
415                arm.frames += 1;
416                arm.bytes += text.len() as u64;
417                let payload = match &answer {
418                    ServerMessage::Snapshot { data, .. } => data["conversation"].clone(),
419                    ServerMessage::Page { turns, .. } => serde_json::json!({ "turns": turns }),
420                    _ => serde_json::Value::Null,
421                };
422                let (turns, messages) = count(&payload);
423                arm.turns += turns;
424                arm.messages += messages;
425                return Ok(answer);
426            }
427            ServerMessage::Error { what, why, .. } => anyhow::bail!("{what} refused: {why}"),
428            // The greeting, and anything a live core pushes while this runs.
429            // Skipped rather than returned: a caller waiting for its answer
430            // would otherwise take the greeting as one and read every later
431            // answer a frame behind.
432            _ => {}
433        }
434    }
435}
436
437async fn measure(args: &Args) -> anyhow::Result<()> {
438    let Some(pid) = args.pid else {
439        anyhow::bail!("--measure wants --pid, which is whose CPU time the arms are charged to");
440    };
441    let seat = SessionSlot::lead(&args.org, &args.project);
442
443    let mut idle = Measured { pid, ..Measured::default() };
444    let started = Instant::now();
445    let before = cpu_seconds(pid)?;
446    tokio::time::sleep(Duration::from_secs_f64(args.seconds)).await;
447    idle.wall_ms = started.elapsed().as_secs_f64() * 1000.0;
448    idle.cpu_ms = (cpu_seconds(pid)? - before) * 1000.0;
449
450    // Nothing to do for `idle`: the bracket above is the whole arm, and it is
451    // the baseline every other arm is charged against rather than a workload
452    // of its own. Its window goes on the row because every other arm is
453    // charged against it and the row cannot be read without one.
454    if args.arm == Arm::Idle {
455        println!(
456            "{}",
457            Measured { pid, ..Measured::default() }.report(
458                Arm::Idle.name(),
459                &idle,
460                Some(args.seconds)
461            )
462        );
463        return Ok(());
464    }
465
466    // `subscribe` is the price of ARRIVING, so its socket is opened inside
467    // the bracket - a connect outside it would price a subscribe on a socket
468    // somebody else had already opened. Every other arm measures the price of
469    // staying, so its attach is taken before the bracket: charging a connect
470    // to a refresh would report arriving as staying.
471    let mut attached = match args.arm {
472        Arm::Subscribe => None,
473        _ => Some(attach(args, &seat).await?),
474    };
475    let mut arm = Measured {
476        asks: if args.arm == Arm::Subscribe { 1 } else { args.asks },
477        pid,
478        ..Measured::default()
479    };
480    let started = Instant::now();
481    let before = cpu_seconds(pid)?;
482    let mut socket = match attached.take() {
483        Some(socket) => socket,
484        None => a_socket(args).await?,
485    };
486    work(args, &seat, &mut socket, &mut arm).await?;
487    arm.wall_ms = started.elapsed().as_secs_f64() * 1000.0;
488    arm.cpu_ms = (cpu_seconds(pid)? - before) * 1000.0;
489
490    println!("{}", arm.report(args.arm.name(), &idle, None));
491    Ok(())
492}
493
494#[tokio::main]
495async fn main() -> anyhow::Result<()> {
496    let args = Args::parse();
497    if args.measure {
498        return measure(&args).await;
499    }
500    let Some(transcript) = args.transcript.as_deref() else {
501        anyhow::bail!("serving wants --transcript, or --measure to watch another server");
502    };
503    serve_one(&args, transcript).await
504}
505
506async fn serve_one(args: &Args, transcript: &Path) -> anyhow::Result<()> {
507    let rows = std::fs::read_to_string(transcript)?;
508    let rows: Vec<&str> = rows.lines().filter(|row| !row.trim().is_empty()).collect();
509    let bytes = rows.iter().map(|row| row.len() + 1).sum::<usize>();
510
511    std::fs::create_dir_all(&args.dir)?;
512    let fleet = Fleet::in_dir(&args.dir, &[(&args.org, &[args.project.as_str()])])
513        .map_err(anyhow::Error::msg)?;
514    fleet.start(&args.org, &args.project).map_err(anyhow::Error::msg)?;
515    fleet.seed_transcript(&args.org, &args.project, "lead", &rows).map_err(anyhow::Error::msg)?;
516    let seat = SessionSlot::lead(&args.org, &args.project);
517
518    // The conditions and the denominator, on stdout before the socket opens.
519    // A reader who sees no conversation on the wire cannot tell "the fix
520    // works" from "the file was not there" - and a reader who does not know
521    // the profile cannot tell a 200 ms figure from a 1,400 ms one.
522    println!(
523        "built {}; seeded {} rows / {} bytes as {}/{}",
524        profile(),
525        rows.len(),
526        bytes,
527        args.org,
528        args.project
529    );
530
531    let surface = fleet.surface();
532    if args.breakdown {
533        println!("{}", breakdown(&surface, &seat, &args.dir.join(&args.project))?);
534    }
535
536    let state = Arc::new(TransportState::for_workspace(&fleet.workspace()));
537    // The seat's conversation, put where a `Connected` would have left it.
538    //
539    // **The transport does not read a transcript**, so a server left to
540    // itself here would answer every arm from an empty conversation and
541    // report the cheapest result of all. The fixture reads the transcript raw
542    // and holds what it read - the state a client attaching to a running seat
543    // meets.
544    fleet
545        .hold_conversation(&state, &args.org, &args.project, "lead")
546        .map_err(anyhow::Error::msg)?;
547    let addr = std::net::SocketAddr::from(([127, 0, 0, 1], args.port));
548    let listener = tokio::net::TcpListener::bind(addr).await?;
549    println!("pid {} listening on ws://{addr}/socket for {seat:?}", std::process::id());
550    let updates = state.surface.subscribe_mirror();
551    serve(state, listener, updates).await?;
552    Ok(())
553}
554
555/// Where the CONVERSATION's time goes inside one request, taken once before
556/// any client attaches: a fix that removes one of these three is worth what
557/// that one costs and no more.
558///
559/// **These three are not the whole request.** A subscribe also runs the git
560/// scan, the file-index walk, the reviews, the MCP read, the process walk and
561/// the rest, so the sum here is a SHARE of a request rather than its cost -
562/// and the share moves with the transcript and the machine, which is why the
563/// printed line says so rather than quoting a percentage.
564fn breakdown(surface: &Arc<ViewSurface>, seat: &SessionSlot, cwd: &Path) -> anyhow::Result<String> {
565    let fleet = rss()?;
566
567    let started = Instant::now();
568    let read = surface.conversation(seat, cwd);
569    let read_ms = started.elapsed().as_millis();
570    let holding = rss()?;
571
572    let started = Instant::now();
573    let rendered = forge_server::transcript::render(&read.messages, false);
574    let fold_ms = started.elapsed().as_millis();
575
576    let started = Instant::now();
577    let turns = forge_server::transport::wire::all_turns(&read.messages, &rendered);
578    let encode_ms = started.elapsed().as_millis();
579
580    Ok(format!(
581        "breakdown: read {read_ms} ms, fold {fold_ms} ms, encode {encode_ms} ms over {} \
582         messages (the CONVERSATION's share of a request, not the request: the git scan, the \
583         file-index walk, the reviews, the MCP read and the process walk are not in these \
584         three); resident {fleet} KiB with the fleet, {holding} KiB holding the read, {} KiB \
585         holding read + fold + encode ({} turns)",
586        read.messages.len(),
587        rss()?,
588        turns.len(),
589    ))
590}
591
592#[cfg(test)]
593mod tests {
594    use clap::ValueEnum;
595
596    use super::Arm;
597
598    /// An arm reports under the name it is ASKED FOR.
599    ///
600    /// **`name()` is a hand-written match and the report prints what it
601    /// says**, so a variant renamed without it would run one workload and
602    /// report another under the name a person typed - the defect this
603    /// instrument exists to find, made by the instrument. The census is the
604    /// enum's own, so a variant added is covered the day it lands.
605    #[test]
606    fn every_arm_reports_under_the_name_it_is_asked_for() {
607        for arm in Arm::value_variants() {
608            let asked =
609                arm.to_possible_value().expect("every arm has a name").get_name().to_owned();
610            assert_eq!(
611                arm.name(),
612                asked,
613                "the name this arm reports under is not the one a caller asks for",
614            );
615        }
616    }
617}