Skip to main content

mira/
main.rs

1//! Mira: OTLP in, immutable Arrow blocks out, one binary.
2
3mod alert;
4mod api;
5mod config;
6#[cfg(test)]
7mod e2e;
8mod json;
9mod mcp;
10mod pipeline;
11mod receiver;
12mod telemetry;
13mod term;
14mod tui;
15mod ui;
16mod update;
17
18use std::io::IsTerminal;
19use std::path::{Path, PathBuf};
20use std::sync::atomic::Ordering::Relaxed;
21
22use axum::Router;
23use axum::response::{IntoResponse, Response};
24use axum::routing::get;
25
26use config::Config;
27
28const USAGE: &str = "mira [--config FILE] [--node NAME] [--grpc ADDR] [--http ADDR]
29     [--data-dir PATH] [--retention DURATION]
30     [--max-request-bytes SIZE] [--queue N] [--shards N] [--wal]
31     [--self-telemetry] [--telemetry-interval DURATION]
32     [--alerts FILE] [--version]
33
34mira mira [--config FILE] [--data-dir PATH] [--addr HOST[:PORT]]
35mira update [--version VERSION] [--dry-run]
36
37Flags override the config file, which overrides the defaults. Every value can
38also come from the file via ${env:VAR} — see https://miradb.dev/config/.
39
40`mira mira` opens the terminal UI. With --data-dir it reads a block directory
41in-process and needs no server running; with --addr it queries one over HTTP.
42`mira tui` is the same thing, for anyone who guesses that first.
43
44`mira update` replaces this binary with the latest GitHub release, using the
45same installer as the curl one-liner at https://miradb.dev/install/.";
46
47/// Precedence is flag > file > default. Hand-rolled: the flag set exists only to
48/// override the file, so a parser crate would be more code than the thing it
49/// parses.
50fn load() -> Result<Config, String> {
51    let argv: Vec<String> = std::env::args().skip(1).collect();
52    if argv.iter().any(|a| a == "-h" || a == "--help") {
53        println!("{USAGE}");
54        std::process::exit(0);
55    }
56    if argv.iter().any(|a| a == "-V" || a == "--version") {
57        println!("mira {}", env!("CARGO_PKG_VERSION"));
58        std::process::exit(0);
59    }
60    load_from(argv)
61}
62
63/// [`load`] without the two flags that end the process, so it can be called.
64fn load_from(argv: Vec<String>) -> Result<Config, String> {
65    // The file has to be read first so flags can override it.
66    let mut cfg = match argv.iter().position(|a| a == "--config") {
67        Some(i) => Config::load(Path::new(argv.get(i + 1).ok_or("--config needs a value")?))?,
68        None => Config::default(),
69    };
70
71    let mut it = argv.into_iter();
72    while let Some(flag) = it.next() {
73        let mut value = || it.next().ok_or_else(|| format!("{flag} needs a value"));
74        match flag.as_str() {
75            "--config" => {
76                value()?;
77            }
78            "--node" => cfg.node = value()?,
79            "--grpc" => cfg.grpc = value()?.parse().map_err(|e| format!("--grpc: {e}"))?,
80            "--http" => cfg.http = value()?.parse().map_err(|e| format!("--http: {e}"))?,
81            "--data-dir" => cfg.data_dir = PathBuf::from(value()?),
82            "--retention" => cfg.retention = config::duration(&value()?)?,
83            "--max-request-bytes" => cfg.max_request_bytes = config::bytes(&value()?)?,
84            "--queue" => cfg.queue = config::positive(&value()?)?,
85            "--shards" => cfg.shards = config::whole(&value()?)?,
86            "--telemetry-interval" => cfg.telemetry_interval = config::duration(&value()?)?,
87            "--alerts" => cfg.alerts = Some(PathBuf::from(value()?)),
88            // These two take no value, unlike every other flag here. They are
89            // the settings whose file form has to be able to say `false` — to
90            // turn off what an inherited config turned on — and whose flag form
91            // never does, because a flag is only ever typed to enable something
92            // the file did not.
93            "--wal" => cfg.wal = true,
94            "--self-telemetry" => cfg.self_telemetry = true,
95            other => return Err(format!("unknown flag {other}\n\n{USAGE}")),
96        }
97    }
98    Ok(cfg)
99}
100
101/// Where a `mira mira` invocation should read from.
102///
103/// `--addr` wins if given; otherwise the same `data_dir` the server would use,
104/// so `mira mira --config mira.yaml` looks at exactly the directory that config
105/// writes to.
106fn tui_source(argv: &[String]) -> Result<tui::Source, String> {
107    let mut cfg = match argv.iter().position(|a| a == "--config") {
108        Some(i) => Config::load(Path::new(argv.get(i + 1).ok_or("--config needs a value")?))?,
109        None => Config::default(),
110    };
111    let mut addr = None;
112    let mut it = argv.iter().cloned();
113    while let Some(flag) = it.next() {
114        let mut value = || it.next().ok_or_else(|| format!("{flag} needs a value"));
115        match flag.as_str() {
116            "--config" => {
117                value()?;
118            }
119            "--data-dir" => cfg.data_dir = PathBuf::from(value()?),
120            "--addr" => addr = Some(tui::parse_addr(&value()?)?),
121            other => return Err(format!("unknown flag {other}\n\n{USAGE}")),
122        }
123    }
124    Ok(match addr {
125        Some(a) => tui::Source::Remote(a),
126        None => tui::Source::Local(cfg.data_dir),
127    })
128}
129
130/// The guards `serve_with` runs, for a `mira mira --data-dir` that maps exactly
131/// the same blocks with no server in front of it.
132///
133/// Without them [`mira_core::block::scan`] reads `ENOENT` as an empty directory,
134/// so a typo or a volume that never mounted prints `no rows in this window ·
135/// 0/0 blocks` — indistinguishable from a healthy empty store, and "is the data
136/// gone" is the question this feature exists to answer at 3am. A network
137/// filesystem is worse: it reaches `mmap` and leaves on `SIGBUS`, with nothing
138/// printed at all, where the server would have refused with a paragraph naming
139/// the mount.
140///
141/// `check_writable` is deliberately not here. The TUI writes nothing, and a
142/// read-only mount is the normal way to look at a detached volume.
143///
144/// The two cases with nothing to check are decided here rather than at the call
145/// site, because both of them are rules about this check and not about the
146/// caller. A remote source is somebody else's directory: the server answering
147/// on that address ran these on its own way up, and this process never maps one
148/// of its blocks. And off a terminal `tui::run` refuses a redirected stdin or
149/// stdout on its first line, which outranks a diagnosis of a directory nothing
150/// was going to be drawn from anyway.
151fn check_source(src: &tui::Source, on_a_tty: bool) -> Result<(), Box<dyn std::error::Error>> {
152    let tui::Source::Local(dir) = src else {
153        return Ok(());
154    };
155    if !on_a_tty {
156        return Ok(());
157    }
158    if !dir.is_dir() {
159        return Err(format!(
160            "{} is not a directory. `mira mira --data-dir` reads an existing block \
161             directory in place and creates nothing, so this is a volume that never \
162             mounted, a typo, or the path a different replica writes to. An empty \
163             but real directory is fine and shows no rows.",
164            dir.display()
165        )
166        .into());
167    }
168    // `check_filesystem`'s FUSE arm warns rather than refusing — the magic
169    // number cannot tell gcsfuse from a local overlay, so only a human can — and
170    // this process installs no subscriber, because one stray line lands in the
171    // middle of a frame. So: one stderr subscriber for the length of the call,
172    // before `Term::enter` takes the screen. Routing it rather than reprinting
173    // the sentence keeps the wording in `block.rs`, where the check lives.
174    let to_stderr = tracing_subscriber::fmt()
175        .with_writer(std::io::stderr)
176        .with_ansi(std::io::stderr().is_terminal())
177        .without_time()
178        .finish();
179    tracing::subscriber::with_default(to_stderr, || mira_core::block::check_filesystem(dir))?;
180    Ok(())
181}
182
183fn main() {
184    if let Err(e) = run() {
185        // Returning the error from `main` would print it with `Debug`, which
186        // for `Error::Io` is a struct dump with `Os { code: 13, .. }` in it and
187        // for `Error::NetworkFilesystem` throws away the paragraph explaining
188        // what to do instead. Everything that reaches here is aimed at whoever
189        // typed the command; they need the sentence, not the struct.
190        eprintln!("mira: {e}");
191        std::process::exit(1);
192    }
193}
194
195fn run() -> Result<(), Box<dyn std::error::Error>> {
196    let argv: Vec<String> = std::env::args().skip(1).collect();
197    // `mira mira` is the name; `tui` stays because it is what someone types when
198    // they have not read the usage, and answering that is cheaper than a
199    // "no such flag" they have to think about.
200    // Before the tracing subscriber for the same reason `mira mira` is: the
201    // installer writes its own progress to this terminal, and an `info!` line
202    // interleaved with a `sudo` prompt is a prompt someone does not answer.
203    if argv.first().is_some_and(|a| a == "update") {
204        return update::run(&argv[1..]).map_err(Into::into);
205    }
206    if argv.first().is_some_and(|a| a == "mira" || a == "tui") {
207        if argv.iter().any(|a| a == "-h" || a == "--help") {
208            println!("{USAGE}");
209            return Ok(());
210        }
211        // No tracing subscriber on this path, and no runtime. Both write to the
212        // terminal the TUI has just taken over, and one stray `info!` in the
213        // middle of a frame corrupts the whole screen.
214        let src = tui_source(&argv[1..]).map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
215        // The same two guards `serve_with` runs, for the same reason and before
216        // the same mmap — see `check_source`, which is also where the two cases
217        // it has nothing to say about are written down.
218        let on_a_tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
219        check_source(&src, on_a_tty)?;
220        return tui::run(src).map_err(Into::into);
221    }
222
223    tracing_subscriber::fmt()
224        .with_env_filter(
225            tracing_subscriber::EnvFilter::try_from_default_env()
226                .unwrap_or_else(|_| "mira=info,mira_core=info".into()),
227        )
228        // Colour only for a terminal. `with_ansi` defaults to on and does not
229        // check, so without this every field name in every line reaches a log
230        // file, a collector, or an agent wrapped in escape codes.
231        .with_ansi(std::io::stdout().is_terminal())
232        .init();
233    tokio::runtime::Builder::new_multi_thread()
234        .enable_all()
235        .build()?
236        .block_on(serve())
237}
238
239async fn serve() -> Result<(), Box<dyn std::error::Error>> {
240    let cfg = load().map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
241    serve_with(cfg, shutdown()).await
242}
243
244/// The server proper, with its stop edge passed in rather than taken from the
245/// process. A test can hold that edge; nothing else needs to.
246async fn serve_with(
247    cfg: Config,
248    stop_signal: impl std::future::Future<Output = ()>,
249) -> Result<(), Box<dyn std::error::Error>> {
250    // First, because a rules file that does not parse is a deployment that
251    // believes it is being paged and is not. Nothing has been created, bound or
252    // mapped at this point, so the failure is a message and an exit rather than
253    // a half-started node.
254    let rules = match &cfg.alerts {
255        Some(p) => alert::Rules::load(p)?,
256        None => alert::Rules::off(),
257    };
258
259    // Named, because `File exists (os error 17)` on its own sends whoever reads
260    // it looking for a bug in Mira rather than at the path they passed.
261    std::fs::create_dir_all(&cfg.data_dir).map_err(|e| {
262        format!(
263            "cannot create data directory {}: {e}. Mira makes this path on first \
264             start, so this is a parent that is not writable or something that is \
265             not a directory already sitting there (in Kubernetes: a subPath that \
266             names a file, or a volume mounted readOnly).",
267            cfg.data_dir.display()
268        )
269    })?;
270    // Before anything is mapped. A network mount is not a slow start, it is a
271    // SIGBUS the first time the server hiccups, and by then there is a process
272    // to explain rather than a flag to change.
273    mira_core::block::check_filesystem(&cfg.data_dir)?;
274    mira_core::block::check_writable(&cfg.data_dir)?;
275
276    let node = mira_core::block::node_id(&cfg.node);
277
278    // Both sockets are bound here, before a flusher starts and long before the
279    // line that says which addresses Mira is listening on. tonic binds inside
280    // its own future, so a port still held by the container that is shutting
281    // down used to surface as `mira: transport error` *after* the log line
282    // claiming the address — the most common way a start fails, reported as the
283    // least useful sentence Mira can print.
284    let taken = |addr: std::net::SocketAddr, what: &str, e: std::io::Error| {
285        format!(
286            "cannot bind {addr} for {what}: {e}. Nothing has started yet, so this \
287             is another process on the port — most often the previous instance \
288             still draining (in Kubernetes: a terminationGracePeriodSeconds \
289             shorter than the drain takes, or two replicas sharing a hostPort)."
290        )
291    };
292    let grpc_socket = tonic::transport::server::TcpIncoming::bind(cfg.grpc)
293        .map_err(|e| taken(cfg.grpc, "OTLP/gRPC", e))?
294        // `serve_with_incoming` ignores the builder's TCP settings, and tonic's
295        // default is nodelay on: without this every small export pays a Nagle
296        // delay that `serve` would not have charged it.
297        .with_nodelay(Some(true));
298    let http_socket = tokio::net::TcpListener::bind(cfg.http)
299        .await
300        .map_err(|e| taken(cfg.http, "OTLP/HTTP and the query API", e))?;
301    // The bound addresses, not the requested ones: `--grpc 127.0.0.1:0` is a
302    // real thing to ask for and the "listening" line is the only place the
303    // chosen port is ever written down.
304    let (grpc_addr, http_addr) = (grpc_socket.local_addr()?, http_socket.local_addr()?);
305
306    // Read before `cfg.data_dir` moves into the pipeline config, and before the
307    // uptime clock starts, so the "listening" line below can print what was
308    // actually resolved rather than what was asked for.
309    let data_dir = std::sync::Arc::new(cfg.data_dir.clone());
310    let _ = *START;
311    // Opened before the flushers, because they take a handle to it, and before
312    // anything is served, because replay has to reach the flushers ahead of the
313    // first live export or the sequences interleave.
314    let wal = match cfg.wal {
315        true => Some(std::sync::Arc::new(mira_core::wal::Wal::open(
316            &cfg.data_dir,
317            node,
318        )?)),
319        false => None,
320    };
321    // The node *name*, not the hashed `node` above: a series is labelled with
322    // what an operator typed, and the hash is a filename detail.
323    let node_name = cfg.node.clone();
324    let pcfg = std::sync::Arc::new(pipeline::Config {
325        data_dir: cfg.data_dir,
326        node,
327        retention: cfg.retention,
328        queue: cfg.queue,
329        shards: pipeline::shard_count(
330            cfg.shards,
331            std::thread::available_parallelism().map_or(1, |n| n.get()),
332        ),
333        wal: wal.clone(),
334        ..Default::default()
335    });
336    // One channel and block sequence per signal per shard, so a slow flush on
337    // one cannot stall another.
338    let (logs, o_logs, h_logs) = pipeline::spawn::<mira_core::logs::LogsBuilder>(&pcfg);
339    let (traces, o_traces, h_traces) = pipeline::spawn::<mira_core::traces::TracesBuilder>(&pcfg);
340    let (metrics, o_metrics, h_metrics) =
341        pipeline::spawn::<mira_core::metrics::MetricsBuilder>(&pcfg);
342    let flushers = [h_logs, h_traces, h_metrics];
343    // In `pipeline::SIGNALS` order, which is what `Api::open` indexes with.
344    let open_blocks = [o_logs, o_traces, o_metrics];
345    if wal.is_some() {
346        replay(
347            &pcfg.data_dir,
348            node,
349            logs.clone(),
350            traces.clone(),
351            metrics.clone(),
352        )
353        .await?;
354    }
355    // After the replay, so the first sample is of a node that has finished
356    // recovering rather than of one part-way through it.
357    //
358    // The handle is kept rather than detached, because the sampler holds an
359    // `Ingest` clone and a flusher only stops when the last one drops. Detached,
360    // it is the one holder that never lets go: every stop would sit out the full
361    // `DRAIN_GRACE` waiting for a channel that cannot close, and turning
362    // self-telemetry on would silently make each restart fifteen seconds slower.
363    let sampler = cfg.self_telemetry.then(|| {
364        tracing::info!(
365            interval_s = cfg.telemetry_interval.as_secs(),
366            "storing this node's own telemetry in this node"
367        );
368        tokio::spawn(telemetry::run(
369            node_name,
370            pcfg.data_dir.clone(),
371            cfg.telemetry_interval,
372            metrics.clone(),
373        ))
374    });
375    let recv = receiver::Receivers {
376        logs,
377        traces,
378        metrics,
379        max_request_bytes: cfg.max_request_bytes,
380    };
381    pipeline::spawn_retention(pcfg);
382
383    // One `watch` rather than two oneshots because both servers need the same
384    // edge and neither owns it.
385    let (stop, stop_rx) = tokio::sync::watch::channel(());
386    let stopped = |mut rx: tokio::sync::watch::Receiver<()>| async move {
387        let _ = rx.changed().await;
388    };
389
390    let grpc = tokio::spawn(
391        tonic::transport::Server::builder()
392            .add_service(recv.logs_server())
393            .add_service(recv.traces_server())
394            .add_service(recv.metrics_server())
395            .serve_with_incoming_shutdown(grpc_socket, stopped(stop_rx.clone())),
396    );
397
398    // One listener for all of it: `/v1/*` is OTLP in, `/api/v1/*` is query out
399    // and `/api/v1/stats` is this node describing itself, `/mcp` is the agent
400    // surface, `/health` and `/readyz` are the probes, `/` and `/{file}` are the
401    // UI. The UI's wildcard is one segment deep, so it cannot swallow any of the
402    // others.
403    let api = api::Api {
404        data_dir: std::sync::Arc::clone(&data_dir),
405        open: open_blocks,
406        alerts: std::sync::Arc::new(alert::Engine::new(rules)),
407    };
408    // Always routed, even with no rules: `/api/v1/alerts` answering `[]` is how
409    // the UI, the TUI and an agent learn that alerting is off, and a 404 is
410    // indistinguishable from an old build.
411    alert::spawn(api.clone());
412    let serve = axum::serve(
413        http_socket,
414        receiver::http_router(recv)
415            // Only the query router is timed. Layering the merged router would
416            // put OTLP exports and UI asset fetches in the same average, which
417            // is a latency number that means nothing.
418            .merge(api::router(api.clone()).layer(axum::middleware::from_fn(timed)))
419            .merge(mcp::router(api.clone()))
420            .merge(alert::router(api))
421            .merge(ops_router(std::sync::Arc::clone(&data_dir)))
422            .merge(ui::router()),
423    )
424    .with_graceful_shutdown(stopped(stop_rx));
425    // `IntoFuture`, not `Future`, so it cannot be spawned directly.
426    let http = tokio::spawn(async move { serve.await });
427
428    // One line, still one line, because it is what gets grepped out of a pod log
429    // and pasted into an issue. What it carries is every resolved value whose
430    // being wrong is silent: the node id, because it is the only externally
431    // visible thing that distinguishes two replicas' blocks and a collision is
432    // diagnosed here; the data directory, because a config that resolved
433    // `${env:MIRA_DATA}` to nothing writes a week of telemetry into `./data`
434    // inside a container and loses it at the next restart; the retention,
435    // because it is the one value whose mistake is irreversible — too short and
436    // the sweep has already deleted what it was going to delete by the time
437    // anyone reads this line; the request cap, because too small is a 413 the
438    // sender reports as Mira being broken. The UI URL is spelled out because
439    // `http://0.0.0.0:4318/` is not a thing anyone guesses from `http=0.0.0.0:4318`.
440    //
441    // `retention` is printed in the syntax `--retention` accepts, so the line
442    // round-trips: whatever it says can be pasted back in.
443    tracing::info!(
444        grpc = %grpc_addr, http = %http_addr,
445        ui = %format!("http://{http_addr}/"),
446        node = %cfg.node, node_id = format!("{node:08x}"),
447        data_dir = %data_dir.display(),
448        retention = %format!("{}s", cfg.retention.as_secs()),
449        max_request_bytes = cfg.max_request_bytes,
450        "mira listening"
451    );
452
453    let (mut grpc, mut http) = (grpc, http);
454    let mut flushers = flushers;
455    let mut wedged = false;
456    tokio::select! {
457        r = &mut grpc => r??,
458        r = &mut http => r??,
459        // A flusher cannot see its channel close while the listeners still hold
460        // an `Ingest`, so one that returns before the stop signal has failed and
461        // said why on its way out. Carrying on is what this used to do: the other
462        // two signals keep working, that one answers every export with a 503
463        // forever, and every probe stays green. A crashloop is the honest shape
464        // of "this process cannot store logs" — the orchestrator reports it, and
465        // the restart is the recovery for the case that caused it, a data
466        // directory that was not there yet.
467        _ = first_stopped(&mut flushers) => wedged = true,
468        _ = stop_signal => tracing::info!("draining"),
469    }
470
471    // Before the drain, and awaited so the cancellation has actually landed:
472    // dropping the sampler's `Ingest` clone is what lets the metrics flusher see
473    // its channel close. A self-sample lost to the abort is the least important
474    // row this process will ever not write.
475    if let Some(s) = sampler {
476        s.abort();
477        let _ = s.await;
478    }
479    drain(stop, grpc, http, flushers, DRAIN_GRACE).await;
480    tracing::info!("stopped");
481    if wedged {
482        // The other two signals were still drained above; only then is this
483        // process allowed to be a failed one.
484        return Err("a flusher stopped, so one signal can no longer be stored; \
485                    exiting for the supervisor to restart (the cause is logged above)"
486            .into());
487    }
488    Ok(())
489}
490
491/// How long a stop is allowed to take before the process leaves without it.
492///
493/// Bounded below by `pipeline::Config::max_block_age`: step 1 of [`drain`] only
494/// makes progress once the block an in-flight export is waiting on seals, and
495/// with the listener closed nothing else will grow it. So this is a multiple of
496/// that constant, not a number chosen freely.
497const DRAIN_GRACE: std::time::Duration = std::time::Duration::from_secs(15);
498
499/// Stop accepting, then let everything already in flight land.
500///
501/// Three ordered steps, and the order is the whole point.
502///
503/// 1. Stop accepting, but let in-flight exports run to their acknowledgement.
504///    Skipping this is not a data-loss bug — the queued job is still sealed
505///    below — it is a *duplicate* bug: the exporter sees a reset, OTLP tells it
506///    to retry, and it re-sends data that did get stored. Every rolling restart
507///    would double-write whatever was in flight.
508/// 2. Await the servers, which drops the last `Ingest` clone per signal and so
509///    closes each flusher's channel.
510/// 3. A flusher answering a closed channel seals whatever is open, publishes it
511///    and acks the waiters (`pipeline::flusher`). Exiting before that lands is
512///    what turns step 1's ack into a reset after all.
513///
514/// ponytail: bounded by `grace` because a hung fsync must not outlive the
515/// orchestrator's grace period and become a SIGKILL with no explanation in the
516/// log. Whatever had not landed by then is lost, which is why the bound is a
517/// last resort and not a policy.
518async fn drain<G, H>(
519    stop: tokio::sync::watch::Sender<()>,
520    grpc: tokio::task::JoinHandle<G>,
521    http: tokio::task::JoinHandle<H>,
522    flushers: [pipeline::Flushers; 3],
523    grace: std::time::Duration,
524) {
525    let _ = stop.send(());
526    let landed = async move {
527        let _ = grpc.await;
528        let _ = http.await;
529        for h in flushers {
530            // Awaited unconditionally: `first_stopped` may already have run one
531            // of these to completion, and a drained set answers at once rather
532            // than panicking the way a twice-polled `JoinHandle` would.
533            let _ = h.await;
534        }
535    };
536    if tokio::time::timeout(grace, landed).await.is_err() {
537        tracing::warn!(?grace, "did not drain in time; exiting anyway");
538    }
539}
540
541/// Push everything the log holds that no block claims back through the
542/// flushers, before the listeners open.
543///
544/// Ordering is the reason this is not a background task. A replayed frame keeps
545/// the sequence it already has, and `Ingest::replay` puts that sequence back
546/// among the log's unpublished before it queues the frame, which is what stops
547/// a shard sealing beside it from computing a watermark over it — but that only
548/// covers frames already read off disk. A frame still sitting in a segment is
549/// in nobody's pending set, so a live export sealing first would claim a
550/// watermark past it and the next boot would not replay it. Draining the whole
551/// log before the first new export is framed is the blunt guarantee that this
552/// never happens. It is also the reason nobody is waiting: the client that sent
553/// this either got its answer before the crash or gave up long before this
554/// process started.
555///
556/// One `spawn_blocking` for the whole log, not one per frame. The channel is
557/// bounded at `ingest.queue` and the sends are blocking, so the flushers set the pace and
558/// a multi-gigabyte log is decoded at the rate it can be sealed rather than
559/// into memory all at once.
560async fn replay(
561    dir: &Path,
562    node: u32,
563    logs: pipeline::Ingest<mira_proto::collector::logs::v1::ExportLogsServiceRequest>,
564    traces: pipeline::Ingest<mira_proto::collector::trace::v1::ExportTraceServiceRequest>,
565    metrics: pipeline::Ingest<mira_proto::collector::metrics::v1::ExportMetricsServiceRequest>,
566) -> Result<(), Box<dyn std::error::Error>> {
567    use mira_core::wal::Signal;
568
569    let dir = dir.to_path_buf();
570    let started = std::time::Instant::now();
571    let done = tokio::task::spawn_blocking(move || {
572        let watermarks = mira_core::block::wal_watermarks(&dir)?;
573        let mut undecodable = 0u64;
574        let out = mira_core::wal::Wal::replay(&dir, node, watermarks, |signal, seq, body| {
575            let pushed = match signal {
576                Signal::Logs => logs.replay(body, seq),
577                Signal::Traces => traces.replay(body, seq),
578                Signal::Metrics => metrics.replay(body, seq),
579            };
580            match pushed {
581                Ok(()) | Err(pipeline::Rejected::Failed(_)) => {
582                    // A frame that passed its checksum and then would not decode
583                    // is one export, and it is already unrecoverable — stopping
584                    // the boot over it would lose every frame behind it too.
585                    undecodable += u64::from(pushed.is_err());
586                    Ok(())
587                }
588                // The flusher is gone, so nothing after this would land either.
589                Err(_) => Err(mira_core::Error::WalCorrupt {
590                    path: dir.clone(),
591                    why: "the flusher for this signal stopped during replay",
592                }),
593            }
594        })?;
595        Ok::<_, mira_core::Error>((out, undecodable))
596    })
597    .await??;
598
599    let (out, undecodable) = done;
600    if undecodable > 0 {
601        tracing::error!(
602            frames = undecodable,
603            "write-ahead log frames passed their checksum and would not decode as OTLP; \
604             those exports are gone"
605        );
606    }
607    if out.torn_segments > 0 {
608        // Expected after a hard kill, and only after one. Info, not warn: the
609        // torn frame is the export that was mid-write when the process died,
610        // which the sender never got an acknowledgement for and has retried.
611        tracing::info!(
612            segments = out.torn_segments,
613            "write-ahead log segments ended in a torn frame; that is what a crash looks like"
614        );
615    }
616    if out.replayed > 0 || out.skipped > 0 {
617        tracing::info!(
618            replayed = out.replayed,
619            skipped = out.skipped,
620            bytes = out.bytes,
621            elapsed_ms = started.elapsed().as_millis() as u64,
622            "recovered from the write-ahead log"
623        );
624    }
625    Ok(())
626}
627
628/// Resolve as soon as any one flusher has returned.
629async fn first_stopped(flushers: &mut [pipeline::Flushers; 3]) {
630    let [logs, traces, metrics] = flushers;
631    tokio::select! {
632        _ = logs => {}
633        _ = traces => {}
634        _ = metrics => {}
635    }
636}
637
638/// The three endpoints that answer for the process rather than for the data:
639/// liveness, readiness, and everything this node counts about itself.
640///
641/// Here rather than in `api.rs` because `/health` and `/readyz` must not touch
642/// the block directory — no query engine, nothing that can be slow, nothing that
643/// can fail for a reason that is not the process's fault. `/api/v1/stats` does
644/// open the directory, which is why it is a query-namespace URL and not a probe.
645///
646/// `/health` and `/readyz` used to be the same handler, on the argument that
647/// Mira has no warm-up and no cluster to join, so there is no state in which it
648/// is alive and not ready. A node whose disk is full is exactly that state: the
649/// process is fine, answers every request, and cannot store a byte. That is the
650/// state readiness exists for — it is what takes the node out of the Service's
651/// endpoints so the exporters retry somewhere that can — so the two are now
652/// different answers. Liveness stays a constant 200: a flusher that stops takes
653/// the process with it (`serve_with`), so answering at all *is* the liveness
654/// answer, and restarting a node whose volume is full fixes nothing.
655fn ops_router(data_dir: std::sync::Arc<PathBuf>) -> Router {
656    Router::new()
657        .route("/health", get(health))
658        .route("/readyz", get(readyz))
659        .route("/api/v1/stats", get(stats))
660        .with_state(data_dir)
661}
662
663/// Liveness, plus what the ingest path has refused so far.
664async fn health() -> Response {
665    // A flusher that stops takes the process with it (see `serve_with`), so
666    // answering at all is the liveness answer. The counters are here so that the
667    // probe and the log agree about how much has been refused, and because a
668    // shedding node is the case where an operator is looking at exactly this.
669    let mut j = mira_core::json::Json::new();
670    j.obj(|j| {
671        j.key("status");
672        j.str("ok");
673        for r in &pipeline::REJECTS {
674            j.key(r.signal);
675            j.obj(|j| {
676                j.key("shed");
677                j.u64(r.shed.load(Relaxed));
678                j.key("failed");
679                j.u64(r.failed.load(Relaxed));
680            });
681        }
682    });
683    json_ok(j.into_string())
684}
685
686/// Readiness: 200 while exports can be made durable, 503 once they cannot.
687async fn readyz() -> Response {
688    ready(pipeline::stalled())
689}
690
691/// Readiness is one question: can this instance accept an export and make it
692/// durable? Everything else about the process is `/health`'s.
693///
694/// Split from the handler so both answers are testable without a disk that has
695/// actually filled up — and so the only input is the one fact the decision turns
696/// on. The threshold itself is `pipeline::UNREADY_AFTER`, and its whole job is to
697/// make this a *sustained* condition: a probe that flips on one failed publish
698/// would deregister a node for every EIO and every restart of the volume
699/// underneath it, and an endpoint list that changes every ten seconds costs more
700/// exports than the node it was protecting.
701fn ready(stalled: Option<(&'static str, u64)>) -> Response {
702    let mut j = mira_core::json::Json::new();
703    j.obj(|j| match stalled {
704        None => {
705            j.key("status");
706            j.str("ok");
707        }
708        Some((signal, secs)) => {
709            j.key("status");
710            j.str("unavailable");
711            j.key("signal");
712            j.str(signal);
713            j.key("stalled_s");
714            j.u64(secs);
715            j.key("reason");
716            j.str(
717                "this node has not been able to store an export for this signal; \
718                 the usual cause is a full or unwritable volume",
719            );
720        }
721    });
722    let code = match stalled {
723        None => axum::http::StatusCode::OK,
724        Some(_) => axum::http::StatusCode::SERVICE_UNAVAILABLE,
725    };
726    (
727        code,
728        [(axum::http::header::CONTENT_TYPE, "application/json")],
729        j.into_string(),
730    )
731        .into_response()
732}
733
734/// Queries served and what they cost, since start.
735///
736/// Sum and max rather than a histogram: a p99 needs buckets, buckets need a
737/// registry, and a registry is the metrics subsystem this endpoint exists to not
738/// be. The mean says whether the engine is in the shape it was benchmarked in
739/// and the max says whether anything pathological has run at all, which is the
740/// pair an operator acts on; a real p99 is measured at the caller, where the
741/// queue in front of Mira is also counted.
742static QUERIES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
743static QUERY_NANOS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
744static QUERY_MAX_NANOS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
745
746/// When the process started, so the counters below have a denominator. Forced in
747/// `serve_with` rather than left to first touch, or "uptime" would mean "seconds
748/// since someone first asked".
749static START: std::sync::LazyLock<std::time::Instant> =
750    std::sync::LazyLock::new(std::time::Instant::now);
751
752/// Wrapped around the query router only, so OTLP writes and UI asset fetches do
753/// not land in the read latency an operator is reading.
754async fn timed(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
755    let t = std::time::Instant::now();
756    let res = next.run(req).await;
757    let ns = t.elapsed().as_nanos() as u64;
758    QUERIES.fetch_add(1, Relaxed);
759    QUERY_NANOS.fetch_add(ns, Relaxed);
760    QUERY_MAX_NANOS.fetch_max(ns, Relaxed);
761    res
762}
763
764/// Everything this node knows about itself, in one document.
765///
766/// The shape is the argument. Mira is a telemetry backend, so the wrong move is
767/// to grow a second one inside it: no exposition format, no registry, no
768/// histograms, no scrape endpoint on a second port and no crate to render any of
769/// that. What is served is the counters the ingest path already keeps, plus the
770/// two facts only the filesystem has, in the same JSON object every `/api/v1`
771/// read already answers with — so the UI's `fetch`, an agent and `curl | jq` all
772/// speak it without being told. Rates are left to the reader: `uptime_s` is the
773/// denominator, and two polls give the interval rate, which is the only rate
774/// that is true of *now* rather than of the whole run.
775async fn stats(
776    axum::extract::State(dir): axum::extract::State<std::sync::Arc<PathBuf>>,
777) -> Response {
778    // `scan` and `statfs` are filesystem work, and a cold readdir over a week of
779    // blocks stalls the OS thread it lands on exactly like a query does. So it
780    // goes where queries go.
781    let disk = tokio::task::spawn_blocking(move || {
782        (
783            mira_core::block::free_fraction(&dir).ok(),
784            pipeline::SIGNALS.map(|s| mira_core::block::scan(&dir, s).ok().map(|b| b.len() as u64)),
785        )
786    })
787    .await;
788    // `null`, not zero, when the filesystem would not answer: "no blocks" and "I
789    // could not look" are different operational facts and a zero conflates them.
790    let (free, on_disk) = disk.unwrap_or((None, [None; 3]));
791
792    let now = std::time::SystemTime::now()
793        .duration_since(std::time::UNIX_EPOCH)
794        .unwrap_or_default()
795        .as_secs();
796    // `then_some` and not `then`: one saturating subtraction is cheaper to do
797    // than to defer behind a closure, and that closure is a body only a signal
798    // with a block already open would ever enter.
799    let age = |since: u64| (since != 0).then_some(now.saturating_sub(since));
800    let queries = QUERIES.load(Relaxed);
801
802    let mut j = mira_core::json::Json::new();
803    j.obj(|j| {
804        j.key("uptime_s");
805        j.u64(START.elapsed().as_secs());
806        j.key("peak_rss_bytes");
807        j.u64(peak_rss());
808        j.key("free_fraction");
809        match free {
810            Some(f) => j.f64(f),
811            None => j.null(),
812        }
813        // Zero on any volume that implements write barriers. Non-zero says this
814        // one does not, and that the durability promise here is `fsync`'s
815        // rather than `F_FULLFSYNC`'s — see `mira_core::sync_all`.
816        j.key("degraded_syncs");
817        j.u64(mira_core::degraded_syncs());
818        j.key("queries");
819        j.obj(|j| {
820            j.key("count");
821            j.u64(queries);
822            j.key("mean_ms");
823            j.f64(QUERY_NANOS.load(Relaxed) as f64 / queries.max(1) as f64 / 1e6);
824            j.key("max_ms");
825            j.f64(QUERY_MAX_NANOS.load(Relaxed) as f64 / 1e6);
826        });
827        j.key("signals");
828        j.obj(|j| {
829            for (r, blocks) in pipeline::REJECTS.iter().zip(on_disk) {
830                j.key(r.signal);
831                j.obj(|j| {
832                    for (k, v) in [
833                        ("shed", r.shed.load(Relaxed)),
834                        ("failed", r.failed.load(Relaxed)),
835                        ("refused", r.refused.load(Relaxed)),
836                        ("blocks_published", r.published.load(Relaxed)),
837                        ("rows", r.rows.load(Relaxed)),
838                        ("bytes", r.bytes.load(Relaxed)),
839                    ] {
840                        j.key(k);
841                        j.u64(v);
842                    }
843                    // Absent-as-null again: nothing open, and never stalled, are
844                    // both "no age to report" rather than "an age of zero".
845                    for (k, v) in [
846                        ("blocks_on_disk", blocks),
847                        ("open_block_age_s", age(r.open_since.load(Relaxed))),
848                        ("stalled_s", age(r.stalled_since.load(Relaxed))),
849                    ] {
850                        j.key(k);
851                        match v {
852                            Some(v) => j.u64(v),
853                            None => j.null(),
854                        }
855                    }
856                });
857            }
858        });
859    });
860    json_ok(j.into_string())
861}
862
863/// High-water mark of this process's resident set, in bytes.
864///
865/// Resident footprint is one of the four axes performance is scored on (section 11),
866/// so a node that cannot report it can only be measured from outside with a
867/// sampler — which is how the 64-connection run in the last sweep came back
868/// with an implausible 26 MiB: `ps` simply missed the peak. `getrusage` cannot
869/// miss it, because the kernel keeps the maximum rather than the instant.
870///
871/// The peak and not the current value because the peak is the number that has
872/// to fit in the container's limit, and because it is one portable call:
873/// `ru_maxrss` is bytes on macOS and kibibytes on Linux, which is the whole of
874/// the platform difference and is why this is not inlined at the call site.
875fn peak_rss() -> u64 {
876    // SAFETY: `getrusage` writes the whole struct or returns -1; the zeroed
877    // value is a valid `rusage` either way.
878    let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
879    // The return code is not checked, because the zeroed struct is already the
880    // answer if it failed: `getrusage(RUSAGE_SELF)` documents only EFAULT and
881    // EINVAL, both of which are this call site being wrong rather than anything
882    // that can happen at runtime, and a `ru_maxrss` the kernel never touched
883    // reports 0 — which is what a "cannot measure" branch would have returned.
884    //
885    // SAFETY: `ru` is a live, correctly-typed `rusage` the kernel may write in
886    // full; there is no other precondition on `RUSAGE_SELF`.
887    unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) };
888    // `#[cfg]` and not `cfg!`, which compiles both arms and runs one: the arm
889    // for the platform this is not built for can never execute, so under `cfg!`
890    // it is a line no test can ever reach.
891    #[cfg(target_os = "macos")]
892    const UNIT: u64 = 1;
893    #[cfg(not(target_os = "macos"))]
894    const UNIT: u64 = 1024;
895    ru.ru_maxrss.max(0) as u64 * UNIT
896}
897
898fn json_ok(body: String) -> Response {
899    (
900        [(axum::http::header::CONTENT_TYPE, "application/json")],
901        body,
902    )
903        .into_response()
904}
905
906/// Resolve on the first stop signal.
907///
908/// SIGTERM matters as much as SIGINT here: it is what every container
909/// orchestrator sends, so without this arm a rolling restart kills the process
910/// mid-block and the exporters waiting on that block see a reset.
911///
912/// Two `#[cfg]` bodies and not one with a `cfg!` in it: `cfg!` compiles both,
913/// so the arm for the platform this is not built for is a line no test on this
914/// platform can ever reach.
915#[cfg(unix)]
916async fn shutdown() {
917    // `expect`, rather than falling back to ^C alone. `signal` fails only for a
918    // signal that cannot be caught and on a runtime with no signal driver —
919    // both of them this call site being wrong — and a silent fall-back to ^C is
920    // a node in a container that nothing but SIGKILL can stop, which is exactly
921    // the failure this function exists to prevent, reported nowhere.
922    let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
923        .expect("a SIGTERM handler on the serving runtime");
924    tokio::select! {
925        _ = tokio::signal::ctrl_c() => {}
926        _ = term.recv() => {}
927    }
928}
929
930/// ^C only: there is no SIGTERM to wait for.
931#[cfg(not(unix))]
932async fn shutdown() {
933    let _ = tokio::signal::ctrl_c().await;
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    fn argv(s: &str) -> Vec<String> {
941        s.split_whitespace().map(str::to_owned).collect()
942    }
943
944    fn tmp(name: &str) -> PathBuf {
945        let d = std::env::temp_dir().join(format!("mira-{name}-{}", std::process::id()));
946        let _ = std::fs::remove_dir_all(&d);
947        std::fs::create_dir_all(&d).unwrap();
948        d
949    }
950
951    /// Both stop edges the tests below drive `serve_with` with, as one type.
952    ///
953    /// `serve_with` is generic over this future, so `ready(())` in one test and
954    /// `pending()` in the next compile two whole servers, each running only the
955    /// half its own callers reach — including under a coverage run, which counts
956    /// each copy separately and so reports lines as unrun that another copy of
957    /// the same source ran.
958    async fn stop_edge(immediately: bool) {
959        if !immediately {
960            std::future::pending::<()>().await;
961        }
962    }
963
964    /// Flag > file > default, and every flag lands in the field it names.
965    ///
966    /// This parser is hand-rolled, and the failure it can produce is the quiet
967    /// kind: a flag written into the wrong field starts a server that looks
968    /// exactly right until someone reads a block from the wrong directory.
969    #[test]
970    fn flags_override_the_file_which_overrides_the_defaults() {
971        let dir = tmp("load");
972        let file = dir.join("mira.yaml");
973        std::fs::write(
974            &file,
975            r#"{ "node": "from-file",
976                 "listen": { "grpc": "127.0.0.1:1", "http": "127.0.0.1:2" },
977                 "storage": { "dir": "/from/file", "retention": "3h" },
978                 "ingest": { "max_request_bytes": "1MiB" } }"#,
979        )
980        .unwrap();
981        let f = file.display();
982
983        let c = load_from(argv(&format!("--config {f}"))).unwrap();
984        assert_eq!(c.node, "from-file");
985        assert_eq!(c.data_dir, PathBuf::from("/from/file"));
986        assert_eq!(c.retention, std::time::Duration::from_secs(3 * 3600));
987        assert_eq!(c.max_request_bytes, 1 << 20);
988        assert_eq!(c.http.port(), 2);
989
990        // The same file, every value overridden. `--config` is seen twice — once
991        // to find the file and once by the loop, which must consume its value
992        // rather than read it as a flag.
993        let c = load_from(argv(&format!(
994            "--config {f} --node cli --grpc 127.0.0.1:3 --http 127.0.0.1:4 \
995             --data-dir /from/cli --retention 30s --max-request-bytes 2MiB"
996        )))
997        .unwrap();
998        assert_eq!(c.node, "cli");
999        assert_eq!(c.grpc.port(), 3);
1000        assert_eq!(c.http.port(), 4);
1001        assert_eq!(c.data_dir, PathBuf::from("/from/cli"));
1002        assert_eq!(c.retention, std::time::Duration::from_secs(30));
1003        assert_eq!(c.max_request_bytes, 2 << 20);
1004
1005        // The three that take no value and the two that were added with them.
1006        // `--self-telemetry` is here rather than above because a boolean flag
1007        // that silently swallowed the next argument would still pass every
1008        // assertion in that block.
1009        let c = load_from(argv(
1010            "--queue 4096 --self-telemetry --telemetry-interval 1m --wal",
1011        ))
1012        .unwrap();
1013        assert_eq!(c.queue, 4096);
1014        assert!(c.self_telemetry);
1015        assert!(c.wal);
1016        assert_eq!(c.telemetry_interval, std::time::Duration::from_secs(60));
1017
1018        // No arguments at all is the shipped configuration.
1019        let d = load_from(vec![]).unwrap();
1020        assert_eq!(d.node, Config::default().node);
1021        assert!(!d.self_telemetry, "self-telemetry is opt-in");
1022
1023        for (args, want) in [
1024            ("--nope", "unknown flag --nope"),
1025            // Deleted along with the fan-out that never existed. It is an
1026            // unknown flag now, which is the whole point of deleting it.
1027            ("--peers a:1", "unknown flag --peers"),
1028            ("--node", "--node needs a value"),
1029            ("--config", "--config needs a value"),
1030            ("--grpc nope", "--grpc:"),
1031            ("--http nope", "--http:"),
1032            ("--retention nope", "not a duration"),
1033            ("--max-request-bytes nope", "not a size"),
1034            ("--queue nope", "not a whole number"),
1035            ("--queue 0", "at least 1"),
1036            // `--shards 0` is *not* here: zero is the documented auto value.
1037            // A negative is rejected rather than wrapped — `usize::from_str`
1038            // has no sign to lose, so `-1` cannot arrive as 18 quintillion
1039            // shards that `clamp` then silently turns into 16.
1040            ("--shards nope", "not a whole number"),
1041            ("--shards -1", "not a whole number"),
1042            ("--telemetry-interval nope", "not a duration"),
1043            ("--config /no/such/file.yaml", "/no/such/file.yaml"),
1044        ] {
1045            let e = load_from(argv(args)).unwrap_err();
1046            assert!(e.contains(want), "{args:?} said {e:?}");
1047        }
1048        let _ = std::fs::remove_dir_all(&dir);
1049    }
1050
1051    /// `mira mira` reads the directory the matching `mira serve` would write to,
1052    /// which is the whole reason it takes `--config` at all.
1053    #[test]
1054    fn the_tui_reads_the_directory_its_config_writes_to() {
1055        let dir = tmp("tui-src");
1056        let file = dir.join("mira.yaml");
1057        std::fs::write(&file, r#"{ "storage": { "dir": "/from/file" } }"#).unwrap();
1058        let f = file.display();
1059
1060        // Rendered rather than matched, so a wrong *variant* is a diff on the
1061        // left of the assertion instead of a panic in a `let ... else`: which
1062        // of the three answers came back is exactly what is under test.
1063        let src = |a: &str| match tui_source(&argv(a)) {
1064            Ok(tui::Source::Local(p)) => format!("local {}", p.display()),
1065            Ok(tui::Source::Remote(a)) => format!("remote {a}"),
1066            // First line only: a flag error carries the whole usage text after
1067            // a blank line, which is `USAGE`'s contract to assert, not this
1068            // one's.
1069            Err(e) => format!("error {}", e.lines().next().unwrap_or_default()),
1070        };
1071        let default_dir = Config::default().data_dir;
1072        for (args, want) in [
1073            (format!("--config {f}"), "local /from/file".to_owned()),
1074            (
1075                format!("--config {f} --data-dir /from/cli"),
1076                "local /from/cli".to_owned(),
1077            ),
1078            (String::new(), format!("local {}", default_dir.display())),
1079            // `--addr` wins outright: a remote source has no directory to read.
1080            ("--addr host:9999".into(), "remote host:9999".to_owned()),
1081            ("--nope".into(), "error unknown flag --nope".to_owned()),
1082            (
1083                "--data-dir".into(),
1084                "error --data-dir needs a value".to_owned(),
1085            ),
1086            ("--config".into(), "error --config needs a value".to_owned()),
1087        ] {
1088            assert_eq!(src(&args), want, "{args:?}");
1089        }
1090        let _ = std::fs::remove_dir_all(&dir);
1091    }
1092
1093    /// Start everything, then stop it.
1094    ///
1095    /// The assertion is that this returns at all. Shutdown is three ordered
1096    /// steps — stop accepting, await the servers so the last `Ingest` clone
1097    /// drops, then let each flusher seal what it holds — and if any link in that
1098    /// chain is wrong the drain never completes and the 15s timeout fires. It
1099    /// also proves the two listeners and the retention worker start from a
1100    /// `Config` alone, which is the one thing `e2e.rs` cannot say: it builds the
1101    /// router itself — and that the one line it logs on the way up carries what
1102    /// was resolved rather than what was asked for.
1103    #[tokio::test]
1104    async fn the_server_starts_from_a_config_and_drains_when_stopped() {
1105        let dir = tmp("serve");
1106        let cfg = Config {
1107            data_dir: dir.join("data"),
1108            grpc: "127.0.0.1:0".parse().unwrap(),
1109            http: "127.0.0.1:0".parse().unwrap(),
1110            // On, with an interval no test run reaches: this asserts that the
1111            // sampler is started and announced, not what it samples — that is
1112            // `telemetry`'s own test, and a 15-second default here would make
1113            // this one's output depend on how slow the machine is. It also makes
1114            // the elapsed-time assertion below a regression test for the sampler
1115            // holding the metrics flusher open: detached, this stop took exactly
1116            // `DRAIN_GRACE` every time.
1117            self_telemetry: true,
1118            telemetry_interval: std::time::Duration::from_secs(3600),
1119            ..Config::default()
1120        };
1121
1122        // A file, because `File` is a `MakeWriter` and writes to one land without
1123        // a flush — a buffer would need a type of its own here.
1124        let log = dir.join("start.log");
1125        // Thread-local, so it holds for this test alone — and `#[tokio::test]`
1126        // is a current-thread runtime, so it holds for everything the server
1127        // does on it too.
1128        let _logging = tracing::subscriber::set_default(
1129            tracing_subscriber::fmt()
1130                .with_writer(std::fs::File::create(&log).unwrap())
1131                .without_time()
1132                // Or every field is wrapped in the escapes that make it readable
1133                // on a terminal, which this is not.
1134                .with_ansi(false)
1135                .finish(),
1136        );
1137
1138        let t = std::time::Instant::now();
1139        serve_with(cfg.clone(), stop_edge(true)).await.unwrap();
1140        assert!(
1141            t.elapsed() < std::time::Duration::from_secs(15),
1142            "timed out"
1143        );
1144        // Created, not required to exist: an operator points `--data-dir` at a
1145        // path and expects the first start to make it.
1146        assert!(cfg.data_dir.is_dir());
1147
1148        // `--http 127.0.0.1:0` is a real thing to ask for, and this line is the
1149        // only place the port that was actually chosen is ever written down. It
1150        // used to be logged from the requested address, which reads as correct
1151        // and sends whoever pastes it at a closed port.
1152        let logged = std::fs::read_to_string(&log).unwrap();
1153        assert!(logged.contains("mira listening"), "{logged}");
1154        // No bound port is spelled `0`, in any of the three fields that carry one.
1155        assert!(!logged.contains("127.0.0.1:0"), "{logged}");
1156        assert!(logged.contains("ui=http://127.0.0.1:"), "{logged}");
1157        assert!(
1158            logged.contains("storing this node's own telemetry"),
1159            "{logged}"
1160        );
1161        // In the syntax `--retention` accepts, so the line round-trips.
1162        assert!(
1163            logged.contains(&format!("retention={}s", cfg.retention.as_secs())),
1164            "{logged}"
1165        );
1166
1167        // A port already taken is reported, not survived, and the message names
1168        // the address — on both listeners. The gRPC half is the one that used to
1169        // print `transport error` *after* logging that it was listening on it.
1170        let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1171        let addr = held.local_addr().unwrap();
1172        for taken in [
1173            Config {
1174                http: addr,
1175                ..cfg.clone()
1176            },
1177            Config {
1178                grpc: addr,
1179                ..cfg.clone()
1180            },
1181        ] {
1182            let e = serve_with(taken, stop_edge(false))
1183                .await
1184                .unwrap_err()
1185                .to_string();
1186            assert!(e.contains(&addr.to_string()), "{e}");
1187            assert!(e.to_lowercase().contains("address"), "{e}");
1188        }
1189
1190        // A data directory that cannot be made says which path and why, rather
1191        // than `File exists (os error 17)`.
1192        let file = dir.join("a-file");
1193        std::fs::write(&file, b"").unwrap();
1194        let e = serve_with(
1195            Config {
1196                data_dir: file.clone(),
1197                ..cfg
1198            },
1199            stop_edge(false),
1200        )
1201        .await
1202        .unwrap_err()
1203        .to_string();
1204        assert!(e.contains(&file.display().to_string()), "{e}");
1205        let _ = std::fs::remove_dir_all(&dir);
1206    }
1207
1208    /// The two things `serve_with` does before anything is bound or mapped, and
1209    /// the order they are in.
1210    ///
1211    /// A rules file is read first *because* nothing has been created yet: a
1212    /// deployment that believes it is being paged and is not is the worst
1213    /// possible half-start, so it has to be a message and an exit rather than a
1214    /// node that is up with alerting quietly off. The log directory is opened
1215    /// next, and a failure there has to name the path — `File exists` on its
1216    /// own sends whoever reads it looking for a bug in Mira.
1217    #[tokio::test]
1218    async fn a_node_refuses_to_start_on_a_rules_file_or_a_log_it_cannot_open() {
1219        let dir = tmp("boot-guards");
1220        let rules = dir.join("alerts.kyaml");
1221        let cfg = |alerts: Option<PathBuf>, data_dir: PathBuf| Config {
1222            data_dir,
1223            grpc: "127.0.0.1:0".parse().unwrap(),
1224            http: "127.0.0.1:0".parse().unwrap(),
1225            alerts,
1226            ..Config::default()
1227        };
1228
1229        // A rules file that parses is loaded and the node comes up with it.
1230        std::fs::write(
1231            &rules,
1232            r#"{ "rules": [ { "name": "any-log", "over": "1m", "when": "count >= 1",
1233                              "query": { "signal": "logs" } } ] }"#,
1234        )
1235        .unwrap();
1236        serve_with(
1237            cfg(Some(rules.clone()), dir.join("ok")),
1238            std::future::ready(()),
1239        )
1240        .await
1241        .expect("a node with rules starts");
1242
1243        // One that does not is a refusal naming the file, before the data
1244        // directory it was given has been created.
1245        std::fs::write(&rules, "{ rules: nope }").unwrap();
1246        let never_made = dir.join("not-made");
1247        let e = serve_with(
1248            cfg(Some(rules.clone()), never_made.clone()),
1249            std::future::pending(),
1250        )
1251        .await
1252        .unwrap_err()
1253        .to_string();
1254        assert!(e.contains("alerts.kyaml"), "{e}");
1255        assert!(!never_made.exists(), "the boot got past the rules file");
1256
1257        // `.wal` occupied by a regular file: `create_dir_all` cannot make the
1258        // log directory, and an unopenable log is a start that would have
1259        // acknowledged exports it could not recover.
1260        let wal_blocked = dir.join("wal-blocked");
1261        std::fs::create_dir_all(&wal_blocked).unwrap();
1262        std::fs::write(wal_blocked.join(".wal"), b"not a directory").unwrap();
1263        let cfg = Config {
1264            wal: true,
1265            ..cfg(None, wal_blocked.clone())
1266        };
1267        let e = serve_with(cfg, std::future::pending())
1268            .await
1269            .unwrap_err()
1270            .to_string();
1271        assert!(e.contains(".wal"), "{e}");
1272        let _ = std::fs::remove_dir_all(&dir);
1273    }
1274
1275    /// A flusher that cannot start takes the process down with it.
1276    ///
1277    /// The alternative is the failure nobody notices: two signals keep working,
1278    /// the third answers every export with a 503 until someone restarts it, and
1279    /// the process stays up and green throughout. Here `logs` is a regular file,
1280    /// so the logs flusher cannot scan its directory and returns immediately.
1281    #[tokio::test]
1282    async fn a_flusher_that_cannot_start_stops_the_server() {
1283        let dir = tmp("wedged");
1284        std::fs::write(dir.join("logs"), b"not a directory").unwrap();
1285        let cfg = Config {
1286            data_dir: dir.clone(),
1287            grpc: "127.0.0.1:0".parse().unwrap(),
1288            http: "127.0.0.1:0".parse().unwrap(),
1289            // Off, or the replay reaches the same broken directory first and
1290            // this stops being a test about the flusher. That boot order is
1291            // itself correct — a log that cannot be read is a worse thing to
1292            // start over than a directory that cannot be scanned — but it is
1293            // not what is being asserted here.
1294            wal: false,
1295            ..Config::default()
1296        };
1297        // `pending`, so the only thing that can end this is the flusher.
1298        let e = serve_with(cfg, stop_edge(false))
1299            .await
1300            .unwrap_err()
1301            .to_string();
1302        assert!(e.contains("flusher"), "{e}");
1303        let _ = std::fs::remove_dir_all(&dir);
1304    }
1305
1306    /// [`drain`]'s last resort. The happy path is
1307    /// `the_server_starts_from_a_config_and_drains_when_stopped`; this is the
1308    /// branch that one can never take, because there the flushers do land.
1309    /// A real `grace` rather than a paused clock: `tokio`'s `test-util` is a
1310    /// feature the workspace does not carry, and a millisecond costs less than
1311    /// carrying it.
1312    #[tokio::test]
1313    async fn a_drain_that_never_lands_leaves_anyway() {
1314        let (stop, rx) = tokio::sync::watch::channel(());
1315        let wedged = || tokio::spawn(std::future::pending::<()>());
1316        drain(
1317            stop,
1318            wedged(),
1319            wedged(),
1320            std::array::from_fn(|_| pipeline::Flushers::wedged()),
1321            std::time::Duration::from_millis(1),
1322        )
1323        .await;
1324        // It returned, which is the assertion — an unbounded `drain` would still
1325        // be awaiting the first handle. The listeners were told to stop before
1326        // the wait began, so a caller that gave up still stopped accepting.
1327        assert!(
1328            rx.has_changed().is_err(),
1329            "drain owns the sender to the end"
1330        );
1331    }
1332
1333    async fn text(r: Response) -> (axum::http::StatusCode, String) {
1334        let (parts, body) = r.into_parts();
1335        let body = axum::body::to_bytes(body, 1 << 20).await.unwrap();
1336        (parts.status, String::from_utf8(body.to_vec()).unwrap())
1337    }
1338
1339    /// The probe answers, and it answers with the numbers an operator wants
1340    /// while ingest is unhappy — the same ones the warn! lines count.
1341    #[tokio::test]
1342    async fn health_reports_every_signals_rejections() {
1343        let (status, body) = text(health().await).await;
1344        assert_eq!(status, axum::http::StatusCode::OK);
1345        assert!(body.starts_with(r#"{"status":"ok""#), "{body}");
1346        for signal in pipeline::SIGNALS {
1347            assert!(body.contains(&format!(r#""{signal}":{{"shed":"#)), "{body}");
1348        }
1349    }
1350
1351    /// Readiness is no longer liveness under a second name.
1352    ///
1353    /// The state that broke the old argument is a node that answers every
1354    /// request and cannot store a byte: it stayed 200 and stayed in the
1355    /// Service's endpoints while NACKing 100% of exports. A 503 is what moves
1356    /// that traffic to a replica that can take it, and the body says which
1357    /// signal and for how long so the reason is in the probe's own log.
1358    #[tokio::test]
1359    async fn readiness_fails_only_once_a_signal_has_been_unable_to_store() {
1360        let (status, body) = text(ready(None)).await;
1361        assert_eq!(status, axum::http::StatusCode::OK);
1362        assert_eq!(body, r#"{"status":"ok"}"#);
1363
1364        let (status, body) = text(ready(Some(("logs", 300)))).await;
1365        assert_eq!(status, axum::http::StatusCode::SERVICE_UNAVAILABLE);
1366        assert!(body.contains(r#""signal":"logs""#), "{body}");
1367        assert!(body.contains(r#""stalled_s":300"#), "{body}");
1368
1369        // The live wiring, which is healthy in a test binary: this is the only
1370        // thing that proves `/readyz` is not still `/health`'s handler.
1371        let (status, _) = text(readyz().await).await;
1372        assert_eq!(status, axum::http::StatusCode::OK);
1373    }
1374
1375    /// The self-telemetry an agent or an operator reads instead of six numbers
1376    /// and a log stream. Every field is asserted by name because the shape is
1377    /// the API: this is what a dashboard and an MCP client bind to.
1378    #[tokio::test]
1379    async fn stats_reports_what_this_node_is_doing_with_its_disk() {
1380        let dir = tmp("stats");
1381        let (status, body) =
1382            text(stats(axum::extract::State(std::sync::Arc::new(dir.clone()))).await).await;
1383        assert_eq!(status, axum::http::StatusCode::OK);
1384        for key in [
1385            r#""uptime_s":"#,
1386            r#""free_fraction":"#,
1387            r#""queries":{"count":"#,
1388            r#""mean_ms":"#,
1389            r#""max_ms":"#,
1390        ] {
1391            assert!(body.contains(key), "{key} missing from {body}");
1392        }
1393        for signal in pipeline::SIGNALS {
1394            assert!(body.contains(&format!(r#""{signal}":{{"shed":"#)), "{body}");
1395        }
1396        for key in [
1397            "refused",
1398            "blocks_published",
1399            "rows",
1400            "bytes",
1401            "blocks_on_disk",
1402            "open_block_age_s",
1403            "stalled_s",
1404        ] {
1405            assert!(body.contains(&format!(r#""{key}":"#)), "{key}: {body}");
1406        }
1407        // An empty directory is zero blocks, not an unreadable one, and nothing
1408        // is open in a process with no flusher: `null` says so without a zero
1409        // that would read as a real measurement.
1410        assert!(body.contains(r#""blocks_on_disk":0"#), "{body}");
1411        assert!(body.contains(r#""open_block_age_s":null"#), "{body}");
1412        // A fraction, not a byte count and not a percentage — read as the
1413        // number it is rather than as the text it happened to render to, or a
1414        // volume with everything free (`"free_fraction":1`, which a fresh tmpfs
1415        // or a scratch CI disk really does report) fails a test about the
1416        // shape of the document.
1417        let free = api::parse(&body).expect("the document is KYAML")["free_fraction"]
1418            .as_f64()
1419            .expect("a readable volume reports a fraction");
1420        assert!(
1421            free > 0.0 && free <= 1.0,
1422            "free_fraction is a fraction of the volume: {free}"
1423        );
1424
1425        // A path the filesystem will not answer for — a detached volume, a
1426        // `subPath` that vanished. "I could not look" is not "the disk is
1427        // empty": a zero here reads as a volume with no room left and takes a
1428        // healthy node out of rotation, so the answer is `null` and the
1429        // endpoint still returns 200 rather than failing the whole document.
1430        let gone = std::sync::Arc::new(dir.join("no-such-volume"));
1431        let (status, body) = text(stats(axum::extract::State(gone)).await).await;
1432        assert_eq!(status, axum::http::StatusCode::OK);
1433        assert!(body.contains(r#""free_fraction":null"#), "{body}");
1434        let _ = std::fs::remove_dir_all(&dir);
1435    }
1436
1437    /// The peak resident set is one of the four axes performance is scored on,
1438    /// so it has to be a real measurement in the units the field name claims.
1439    ///
1440    /// The unit is the whole hazard: `ru_maxrss` is bytes on macOS and
1441    /// kibibytes on Linux, and getting it backwards is not a visible failure —
1442    /// it is a number 1024× out that a dashboard renders without complaint. A
1443    /// test process is comfortably inside these bounds on either platform;
1444    /// either mistake leaves it outside one of them.
1445    #[test]
1446    fn the_peak_resident_set_is_reported_in_bytes() {
1447        let rss = peak_rss();
1448        assert!(rss > 1 << 20, "{rss} bytes is below a running process");
1449        assert!(rss < 100 << 30, "{rss} bytes is a unit mistake, not an RSS");
1450    }
1451
1452    /// `mira mira --data-dir` maps blocks with no server in front of it, so it
1453    /// needs the guards the server runs or it answers the one question it exists
1454    /// for with a lie: `block::scan` reads ENOENT as an empty directory, and a
1455    /// detached volume comes out as `0/0 blocks` — the same screen a healthy
1456    /// empty store draws.
1457    #[test]
1458    fn the_tui_refuses_a_data_directory_the_server_would_have_refused() {
1459        let dir = tmp("tui-guard");
1460        let local = |p: &Path| tui::Source::Local(p.to_path_buf());
1461        // A real directory passes both guards, empty or not: an empty block
1462        // directory is a normal thing to point this at.
1463        check_source(&local(&dir), true).unwrap();
1464
1465        for bad in [dir.join("nope"), {
1466            let f = dir.join("a-file");
1467            std::fs::write(&f, b"").unwrap();
1468            f
1469        }] {
1470            let e = check_source(&local(&bad), true).unwrap_err().to_string();
1471            assert!(e.contains(&bad.display().to_string()), "{e}");
1472            assert!(e.contains("not a directory"), "{e}");
1473            // Off a terminal the same path is accepted, because `tui::run`'s
1474            // own refusal comes first and is the more useful answer: this
1475            // diagnosis is about a screen that was never going to be drawn.
1476            check_source(&local(&bad), false).unwrap();
1477            // And a remote source is never this path at all, however unusable
1478            // the same string would be as a directory: those blocks are mapped
1479            // by the server on the other end, which ran these guards itself.
1480            let remote = tui::Source::Remote(bad.display().to_string());
1481            check_source(&remote, true).unwrap();
1482        }
1483        let _ = std::fs::remove_dir_all(&dir);
1484    }
1485
1486    /// Boot recovery end to end: every frame no block claims goes back through
1487    /// its own signal's flusher, before anything is served.
1488    ///
1489    /// The three signals share one log, so a replay that read the frame header
1490    /// wrongly would hand a span to the log encoder. And a frame that passed its
1491    /// checksum and then will not decode has to be survivable, because stopping
1492    /// on it would abandon every frame behind it — one dead export instead of
1493    /// the whole log. It stays unclaimed until a later block of that signal
1494    /// covers it, which is the next export, so it does not pin the log either.
1495    #[tokio::test]
1496    async fn a_boot_replays_every_frame_no_block_claims() {
1497        use mira_core::wal::Signal;
1498        use mira_proto::collector::metrics::v1::ExportMetricsServiceRequest;
1499        use mira_proto::collector::trace::v1::ExportTraceServiceRequest;
1500        use mira_proto::metrics::v1::metric::Data;
1501        use mira_proto::metrics::v1::number_data_point::Value as NumValue;
1502        use mira_proto::metrics::v1::{
1503            Gauge, Metric, NumberDataPoint, ResourceMetrics, ScopeMetrics,
1504        };
1505        use mira_proto::trace::v1::{ResourceSpans, ScopeSpans, Span};
1506        use prost::Message as _;
1507
1508        let dir = tmp("replay");
1509        let node = mira_core::block::node_id("replaynode");
1510        let spans = ExportTraceServiceRequest {
1511            resource_spans: vec![ResourceSpans {
1512                scope_spans: vec![ScopeSpans {
1513                    spans: vec![Span {
1514                        trace_id: vec![0x11; 16].into(),
1515                        span_id: vec![0x22; 8].into(),
1516                        name: "GET /".into(),
1517                        start_time_unix_nano: 3_000,
1518                        end_time_unix_nano: 3_500,
1519                        ..Default::default()
1520                    }],
1521                    ..Default::default()
1522                }],
1523                ..Default::default()
1524            }],
1525        };
1526        let points = ExportMetricsServiceRequest {
1527            resource_metrics: vec![ResourceMetrics {
1528                scope_metrics: vec![ScopeMetrics {
1529                    metrics: vec![Metric {
1530                        name: "process.cpu".into(),
1531                        data: Some(Data::Gauge(Gauge {
1532                            data_points: vec![NumberDataPoint {
1533                                time_unix_nano: 4_000,
1534                                value: Some(NumValue::AsDouble(0.5)),
1535                                ..Default::default()
1536                            }],
1537                        })),
1538                        ..Default::default()
1539                    }],
1540                    ..Default::default()
1541                }],
1542                ..Default::default()
1543            }],
1544        };
1545
1546        // A crash: four frames appended, nothing sealed. The last is not an OTLP
1547        // export — field 1 tagged as a varint, where the schema has a message.
1548        {
1549            let wal = mira_core::wal::Wal::open(&dir, node).unwrap();
1550            let logs = e2e::logs_export("checkout", 2_000, 4).encode_to_vec();
1551            wal.append(Signal::Logs, &logs).unwrap();
1552            wal.append(Signal::Traces, &spans.encode_to_vec()).unwrap();
1553            wal.append(Signal::Metrics, &points.encode_to_vec())
1554                .unwrap();
1555            wal.append(Signal::Logs, b"\x08").unwrap();
1556        }
1557
1558        let wal = std::sync::Arc::new(mira_core::wal::Wal::open(&dir, node).unwrap());
1559        let pcfg = std::sync::Arc::new(pipeline::Config {
1560            data_dir: dir.clone(),
1561            node,
1562            wal: Some(wal),
1563            max_block_age: std::time::Duration::from_millis(50),
1564            ..Default::default()
1565        });
1566        let (logs, _ol, h_logs) = pipeline::spawn::<mira_core::logs::LogsBuilder>(&pcfg);
1567        let (traces, _ot, h_traces) = pipeline::spawn::<mira_core::traces::TracesBuilder>(&pcfg);
1568        let (metrics, _om, h_metrics) =
1569            pipeline::spawn::<mira_core::metrics::MetricsBuilder>(&pcfg);
1570
1571        replay(&dir, node, logs.clone(), traces.clone(), metrics.clone())
1572            .await
1573            .unwrap();
1574        drop((logs, traces, metrics));
1575        for h in [h_logs, h_traces, h_metrics] {
1576            h.await.unwrap();
1577        }
1578
1579        for signal in pipeline::SIGNALS {
1580            let published = mira_core::block::scan(&dir, signal).unwrap();
1581            assert_eq!(published.len(), 1, "{signal} did not store its frame");
1582        }
1583        // A block claims the first sequence of its signal that nothing covers,
1584        // not one past the frame it happens to hold — under shards the two are
1585        // different numbers, and only the first is safe to skip on the next
1586        // boot. Here nothing of any signal is left outstanding: frames 0, 1 and
1587        // 2 are in blocks, and frame 3 was retired when it failed to decode. So
1588        // all three claim the whole log, and the next boot replays nothing.
1589        //
1590        // Sequence 3 being dropped rather than pinned is the point of that
1591        // retirement: a frame that will never decode must not hold a watermark,
1592        // or every frame published behind it is replayed on every boot forever.
1593        assert_eq!(mira_core::block::wal_watermarks(&dir).unwrap(), [4, 4, 4]);
1594        let _ = std::fs::remove_dir_all(&dir);
1595    }
1596
1597    /// What a boot after a hard kill says out loud.
1598    ///
1599    /// Recovery is silent work on a path nobody watches, so the report is the
1600    /// only way an operator learns that this start replayed anything — and the
1601    /// torn tail is the line that distinguishes "we crashed" from "a byte on
1602    /// this volume went bad", which are different pages. Both are field
1603    /// expressions inside `tracing` macros, which means they are only ever
1604    /// *evaluated* under a subscriber: without one here, a typo in them ships.
1605    #[tokio::test]
1606    async fn a_boot_after_a_hard_kill_reports_the_torn_tail_and_what_it_recovered() {
1607        use mira_core::wal::Signal;
1608        use prost::Message as _;
1609
1610        let dir = tmp("torn-replay");
1611        let node = mira_core::block::node_id("tornnode");
1612        {
1613            let wal = mira_core::wal::Wal::open(&dir, node).unwrap();
1614            for i in 0..2 {
1615                let body = e2e::logs_export("checkout", 2_000 + i, 1).encode_to_vec();
1616                wal.append(Signal::Logs, &body).unwrap();
1617            }
1618            wal.sync().unwrap();
1619        }
1620        // Four bytes off the tail: the last frame loses its checksum, which is
1621        // exactly what a process killed mid-write leaves behind.
1622        let seg = dir
1623            .join(".wal")
1624            .join(format!("{node:08x}-{:020}.wal", 0u64));
1625        let len = std::fs::metadata(&seg).unwrap().len();
1626        std::fs::OpenOptions::new()
1627            .write(true)
1628            .open(&seg)
1629            .unwrap()
1630            .set_len(len - 4)
1631            .unwrap();
1632
1633        let pcfg = std::sync::Arc::new(pipeline::Config {
1634            data_dir: dir.clone(),
1635            node,
1636            max_block_age: std::time::Duration::from_millis(50),
1637            ..Default::default()
1638        });
1639        let (logs, _ol, h_logs) = pipeline::spawn::<mira_core::logs::LogsBuilder>(&pcfg);
1640        let (traces, _ot, h_traces) = pipeline::spawn::<mira_core::traces::TracesBuilder>(&pcfg);
1641        let (metrics, _om, h_metrics) =
1642            pipeline::spawn::<mira_core::metrics::MetricsBuilder>(&pcfg);
1643
1644        let (guard, log) = e2e::capture();
1645        replay(&dir, node, logs.clone(), traces.clone(), metrics.clone())
1646            .await
1647            .expect("a torn tail is a recovery, not a refusal");
1648        drop(guard);
1649
1650        let text = log.text();
1651        assert!(text.contains("torn frame"), "{text}");
1652        assert!(text.contains("segments=1"), "{text}");
1653        assert!(
1654            text.contains("recovered from the write-ahead log"),
1655            "{text}"
1656        );
1657        // The whole frame before the tear, and only it.
1658        assert!(text.contains("replayed=1"), "{text}");
1659        assert!(text.contains("elapsed_ms="), "{text}");
1660
1661        drop((logs, traces, metrics));
1662        for h in [h_logs, h_traces, h_metrics] {
1663            h.await.unwrap();
1664        }
1665        assert_eq!(mira_core::block::scan(&dir, "logs").unwrap().len(), 1);
1666        let _ = std::fs::remove_dir_all(&dir);
1667    }
1668
1669    /// A replay that cannot deliver stops the boot.
1670    ///
1671    /// The alternative is the quietest possible data loss: the flusher for one
1672    /// signal is gone, every frame for it is dropped on the floor, the node
1673    /// finishes starting and answers `/readyz` with a 200 — and the log those
1674    /// frames were in is truncated by the next block that gets published. So
1675    /// the first undeliverable frame ends the boot with an error naming the
1676    /// directory, and the supervisor restarts into a working process.
1677    #[tokio::test]
1678    async fn a_replay_with_nowhere_to_put_a_frame_refuses_to_finish_the_boot() {
1679        use mira_core::wal::Signal;
1680        use prost::Message as _;
1681
1682        let dir = tmp("replay-closed");
1683        let node = mira_core::block::node_id("closednode");
1684        {
1685            let wal = mira_core::wal::Wal::open(&dir, node).unwrap();
1686            wal.append(
1687                Signal::Logs,
1688                &e2e::logs_export("checkout", 2_000, 1).encode_to_vec(),
1689            )
1690            .unwrap();
1691            wal.sync().unwrap();
1692        }
1693
1694        let pcfg = std::sync::Arc::new(pipeline::Config {
1695            data_dir: dir.clone(),
1696            node,
1697            ..Default::default()
1698        });
1699        let (logs, _ol, mut h_logs) = pipeline::spawn::<mira_core::logs::LogsBuilder>(&pcfg);
1700        let (traces, _ot, _ht) = pipeline::spawn::<mira_core::traces::TracesBuilder>(&pcfg);
1701        let (metrics, _om, _hm) = pipeline::spawn::<mira_core::metrics::MetricsBuilder>(&pcfg);
1702        // The one failure mode a replay cannot route around: the receiving end
1703        // of this signal's channel is gone, so no retry and no other signal
1704        // makes the frame land.
1705        h_logs.abort();
1706        let _ = h_logs.await;
1707
1708        let e = replay(&dir, node, logs, traces, metrics)
1709            .await
1710            .expect_err("a frame with nowhere to go must stop the boot");
1711        let e = e.to_string();
1712        assert!(e.contains("flusher"), "{e}");
1713        assert!(e.contains(&dir.display().to_string()), "{e}");
1714        let _ = std::fs::remove_dir_all(&dir);
1715    }
1716}