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}