use std::collections::HashMap;
use std::io::{BufRead, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use crate::{Error, Result};
use zenkey::qos::QosProfile;
use zenoh::Session;
use zenoh::sample::SampleKind;
use crate::bus::monitor::{EventStream, FleetEvent, SampleView, StreamItem};
use crate::model::registry::SliceSet;
use crate::report::{ReplayReport, SampleRow, ZrecHeader};
use crate::tape::ingest::{IngestRow, parse_row};
pub const ZREC_VERSION: u32 = 1;
pub fn rfc3339_now() -> String {
rfc3339_from_unix(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
)
}
fn rfc3339_from_unix(secs: u64) -> String {
let (days, rem) = (secs / 86_400, secs % 86_400);
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
pub struct ZrecWriter<W: Write> {
out: W,
epoch: Instant,
samples: u64,
dropped: u64,
}
impl<W: Write> ZrecWriter<W> {
pub fn new(out: W, header: &ZrecHeader) -> Result<Self> {
ZrecWriter::new_at(out, header, Instant::now())
}
pub fn new_at(mut out: W, header: &ZrecHeader, epoch: Instant) -> Result<Self> {
serde_json::to_writer(&mut out, header).map_err(|e| Error::Io {
path: std::path::PathBuf::new(),
source: e.into(),
})?;
out.write_all(b"\n").map_err(|e| Error::Io {
path: std::path::PathBuf::new(),
source: e,
})?;
Ok(ZrecWriter {
out,
epoch,
samples: 0,
dropped: 0,
})
}
pub fn write_sample(&mut self, view: &SampleView) -> Result<()> {
let t_us = u64::try_from(
view.received
.saturating_duration_since(self.epoch)
.as_micros(),
)
.unwrap_or(u64::MAX);
let mut row = SampleRow {
key: view.key.clone(),
t: Some(t_us),
..SampleRow::default()
}
.with_wire(view);
if view.kind != SampleKind::Delete {
row = row.with_payload_bytes(&view.payload.to_bytes());
}
if let Some(a) = &view.attachment {
row.attachment_b64 = Some(crate::tape::ingest::b64(&a.to_bytes()));
}
self.out
.write_all(row.to_line().as_bytes())
.map_err(|e| Error::Io {
path: std::path::PathBuf::new(),
source: e,
})?;
self.out.write_all(b"\n").map_err(|e| Error::Io {
path: std::path::PathBuf::new(),
source: e,
})?;
self.samples += 1;
Ok(())
}
pub fn write_dropped(&mut self, n: u64) -> Result<()> {
serde_json::to_writer(&mut self.out, &serde_json::json!({ "dropped": n })).map_err(
|e| Error::Io {
path: std::path::PathBuf::new(),
source: e.into(),
},
)?;
self.out.write_all(b"\n").map_err(|e| Error::Io {
path: std::path::PathBuf::new(),
source: e,
})?;
self.dropped += n;
Ok(())
}
pub fn counts(&self) -> (u64, u64) {
(self.samples, self.dropped)
}
pub fn finish(mut self) -> Result<W> {
self.out.flush().map_err(|e| Error::Io {
path: std::path::PathBuf::new(),
source: e,
})?;
Ok(self.out)
}
}
const SINK_QUEUE: usize = 4096;
enum ZrecLine {
Sample(Arc<SampleView>),
Dropped(u64),
}
#[derive(Debug, Default)]
struct SinkState {
samples: AtomicU64,
dropped: AtomicU64,
failure: std::sync::Mutex<Option<String>>,
}
pub struct ZrecSink {
tx: tokio::sync::mpsc::Sender<ZrecLine>,
state: Arc<SinkState>,
writer: tokio::task::JoinHandle<Result<(u64, u64)>>,
}
impl ZrecSink {
pub async fn spawn<W: Write + Send + 'static>(out: W, header: &ZrecHeader) -> Result<ZrecSink> {
ZrecSink::spawn_at(out, header, Instant::now()).await
}
pub async fn spawn_at<W: Write + Send + 'static>(
out: W,
header: &ZrecHeader,
epoch: Instant,
) -> Result<ZrecSink> {
let (tx, mut rx) = tokio::sync::mpsc::channel(SINK_QUEUE);
let (ready, opened) = tokio::sync::oneshot::channel();
let state = Arc::new(SinkState::default());
let header = header.clone();
let task_state = Arc::clone(&state);
let writer = tokio::task::spawn_blocking(move || {
let mut writer = match ZrecWriter::new_at(out, &header, epoch) {
Ok(w) => {
let _ = ready.send(None);
w
}
Err(e) => {
let _ = ready.send(Some(crate::one_line(&e)));
return Err(e);
}
};
while let Some(line) = rx.blocking_recv() {
let wrote = match line {
ZrecLine::Sample(view) => writer.write_sample(&view),
ZrecLine::Dropped(n) => writer.write_dropped(n),
};
if let Err(e) = wrote {
*task_state.failure.lock().expect("sink failure lock") =
Some(crate::one_line(&e));
return Err(e);
}
}
let counts = writer.counts();
writer.finish().map(|_| counts)
});
match opened.await {
Ok(None) => Ok(ZrecSink { tx, state, writer }),
Ok(Some(reason)) => Err(Error::Io {
path: std::path::PathBuf::new(),
source: std::io::Error::other(reason),
}),
Err(_) => Err(Error::Internal(
"the .zrec writer stopped before it opened".into(),
)),
}
}
pub async fn write_sample(&self, view: Arc<SampleView>) -> Result<()> {
self.send(ZrecLine::Sample(view)).await?;
self.state.samples.fetch_add(1, Ordering::Relaxed);
Ok(())
}
pub async fn write_dropped(&self, n: u64) -> Result<()> {
self.send(ZrecLine::Dropped(n)).await?;
self.state.dropped.fetch_add(n, Ordering::Relaxed);
Ok(())
}
async fn send(&self, line: ZrecLine) -> Result<()> {
if self.tx.send(line).await.is_ok() {
return Ok(());
}
let failure = self
.state
.failure
.lock()
.expect("sink failure lock")
.clone();
Err(Error::Internal(
failure.unwrap_or_else(|| "the .zrec writer stopped".to_string()),
))
}
pub fn counts(&self) -> (u64, u64) {
(
self.state.samples.load(Ordering::Relaxed),
self.state.dropped.load(Ordering::Relaxed),
)
}
pub async fn finish(self) -> Result<(u64, u64)> {
let ZrecSink { tx, state, writer } = self;
drop(tx);
drop(state);
writer
.await
.map_err(|e| Error::Internal(format!("the .zrec writer panicked: {e}")))?
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RecordBounds {
pub max_samples: Option<u64>,
pub max_duration: Option<Duration>,
}
pub async fn record(
events: &mut EventStream,
sink: &ZrecSink,
bounds: RecordBounds,
mut on_progress: impl FnMut(u64, u64),
) -> Result<()> {
let deadline = bounds.max_duration.map(|d| Instant::now() + d);
loop {
let (samples, _) = sink.counts();
if bounds.max_samples.is_some_and(|max| samples >= max) {
return Ok(());
}
let item = match deadline {
Some(d) => {
let left = d.saturating_duration_since(Instant::now());
if left.is_zero() {
return Ok(());
}
match tokio::time::timeout(left, events.recv()).await {
Ok(item) => item,
Err(_) => return Ok(()),
}
}
None => events.recv().await,
};
match item {
Some(StreamItem::Event(FleetEvent::Sample(view))) => {
sink.write_sample(view).await?;
}
Some(StreamItem::Dropped(n)) => {
sink.write_dropped(n).await?;
}
Some(_) => continue,
None => return Ok(()),
}
let (samples, dropped) = sink.counts();
on_progress(samples, dropped);
}
}
#[derive(Debug, Clone)]
pub enum ZrecItem {
Sample {
row: IngestRow,
t_us: Option<u64>,
timestamp: Option<String>,
},
Dropped(u64),
}
pub struct ZrecReader<R: BufRead> {
header: ZrecHeader,
lines: std::io::Lines<R>,
line: u64,
}
impl<R: BufRead> ZrecReader<R> {
pub fn new(source: R) -> Result<Self> {
let mut lines = source.lines();
let first = lines
.next()
.ok_or_else(|| Error::malformed(".zrec", "empty file — no header line"))?
.map_err(|e| Error::Io {
path: std::path::PathBuf::new(),
source: e,
})?;
let header: ZrecHeader = serde_json::from_str(&first)
.map_err(|e| Error::malformed_with(".zrec line 1", "is not a header", e))?;
if header.zrec != ZREC_VERSION {
return Err(Error::malformed(
".zrec",
format!(
"unsupported version {} (this reader speaks {ZREC_VERSION})",
header.zrec
),
));
}
Ok(ZrecReader {
header,
lines,
line: 1,
})
}
pub fn header(&self) -> &ZrecHeader {
&self.header
}
#[allow(clippy::should_implement_trait)] pub fn next(&mut self) -> Option<std::result::Result<ZrecItem, String>> {
loop {
let line = match self.lines.next()? {
Ok(l) => l,
Err(e) => {
self.line += 1;
return Some(Err(format!("line {}: read: {e}", self.line)));
}
};
self.line += 1;
if line.trim().is_empty() {
continue;
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line)
&& v.get("key").is_none()
&& let Some(n) = v.get("dropped").and_then(serde_json::Value::as_u64)
{
return Some(Ok(ZrecItem::Dropped(n)));
}
return Some(match parse_row(&line) {
Ok(row) => {
let v: serde_json::Value = serde_json::from_str(&line).unwrap_or_default();
Ok(ZrecItem::Sample {
row,
t_us: v.get("t").and_then(serde_json::Value::as_u64),
timestamp: v
.get("timestamp")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
})
}
Err(e) => Err(format!("line {}: {e}", self.line)),
});
}
}
}
pub struct ZrecSource {
header: ZrecHeader,
rx: tokio::sync::mpsc::Receiver<std::result::Result<ZrecItem, String>>,
}
impl ZrecSource {
pub async fn spawn<R: BufRead + Send + 'static>(source: R) -> Result<ZrecSource> {
let (tx, rx) = tokio::sync::mpsc::channel(SINK_QUEUE);
let (ready, opened) = tokio::sync::oneshot::channel();
tokio::task::spawn_blocking(move || {
let mut reader = match ZrecReader::new(source) {
Ok(r) => r,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
if ready.send(Ok(reader.header().clone())).is_err() {
return;
}
while let Some(item) = reader.next() {
if tx.blocking_send(item).is_err() {
return;
}
}
});
match opened.await {
Ok(header) => Ok(ZrecSource {
header: header?,
rx,
}),
Err(_) => Err(Error::Internal(
"the .zrec reader stopped before it opened".into(),
)),
}
}
pub fn header(&self) -> &ZrecHeader {
&self.header
}
pub async fn next(&mut self) -> Option<std::result::Result<ZrecItem, String>> {
self.rx.recv().await
}
}
pub enum ReplayTarget<'a> {
DryRun,
Bus {
session: &'a Session,
slices: Option<&'a SliceSet>,
},
}
pub struct ReplaySpec<'a> {
pub target: ReplayTarget<'a>,
pub speed: f64,
pub i_know: bool,
pub default_qos: QosProfile,
}
#[derive(Debug, Clone)]
pub enum ReplayEvent<'a> {
WouldPut {
key: &'a str,
bytes: usize,
encoding: Option<&'a str>,
},
WouldRetire { key: &'a str },
Malformed { reason: String },
Refused { key: String, reason: String },
CaptureDropped(u64),
}
#[derive(Default)]
struct Publications(HashMap<String, crate::bus::write::Publication>);
impl std::ops::Deref for Publications {
type Target = HashMap<String, crate::bus::write::Publication>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for Publications {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Publications {
async fn close(mut self) -> Result<()> {
crate::bus::teardown::drain_undeclare(self.0.drain().collect(), |p| {
crate::bus::write::Publication::undeclare(p)
})
.await
}
}
impl Drop for Publications {
fn drop(&mut self) {
if self.0.is_empty() {
return;
}
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
return;
};
let declared: Vec<(String, crate::bus::write::Publication)> = self.0.drain().collect();
runtime.spawn(async move {
for (key, publication) in declared {
if let Err(e) = publication.undeclare().await {
tracing::warn!(key = %key, "undeclare after a cancelled replay: {e}");
}
}
});
}
}
pub async fn replay(
reader: &mut ZrecSource,
spec: ReplaySpec<'_>,
mut on_event: impl FnMut(ReplayEvent<'_>),
) -> Result<ReplayReport> {
let ReplaySpec {
target,
speed,
i_know,
default_qos,
} = spec;
if !(speed.is_finite() && speed > 0.0) {
return Err(Error::unaskable(
"--speed",
format!("must be a positive number (got {speed})"),
));
}
let base = reader.header().base.clone();
let mut report = ReplayReport {
header: reader.header().clone(),
dry_run: matches!(target, ReplayTarget::DryRun),
speed,
published: 0,
tombstones: 0,
malformed: 0,
refused: 0,
capture_dropped: 0,
first_errors: Vec::new(),
};
let record_err = |report: &mut ReplayReport, reason: String, refused: bool| {
if refused {
report.refused += 1;
} else {
report.malformed += 1;
}
if report.first_errors.len() < 3 {
report.first_errors.push(reason);
}
};
let mut publications = Publications::default();
let mut prev_t: Option<u64> = None;
let mut fatal: Option<Error> = None;
while let Some(item) = reader.next().await {
let (row, t_us) = match item {
Ok(ZrecItem::Sample { row, t_us, .. }) => (row, t_us),
Ok(ZrecItem::Dropped(n)) => {
report.capture_dropped += n;
on_event(ReplayEvent::CaptureDropped(n));
continue;
}
Err(reason) => {
on_event(ReplayEvent::Malformed {
reason: reason.clone(),
});
record_err(&mut report, reason, false);
continue;
}
};
let slices = match &target {
ReplayTarget::Bus { slices, .. } => *slices,
ReplayTarget::DryRun => None,
};
if row.delete
&& let Err(e) = crate::bus::write::check_retire(&base, &row.key, slices, i_know)
{
let reason = e.to_string();
on_event(ReplayEvent::Refused {
key: row.key.clone(),
reason: reason.clone(),
});
record_err(&mut report, format!("{}: {reason}", row.key), true);
continue;
}
match &target {
ReplayTarget::DryRun => {
if row.delete {
on_event(ReplayEvent::WouldRetire { key: &row.key });
report.tombstones += 1;
} else {
on_event(ReplayEvent::WouldPut {
key: &row.key,
bytes: row.payload.len(),
encoding: row.encoding.as_deref(),
});
report.published += 1;
}
}
ReplayTarget::Bus { session, .. } => {
if let (Some(prev), Some(t)) = (prev_t, t_us)
&& t > prev
{
let delay = Duration::from_micros(t - prev).div_f64(speed);
tokio::time::sleep(delay).await;
}
if t_us.is_some() {
prev_t = t_us;
}
let publication = match publications.entry(row.key.clone()) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
let qos = match &row.qos {
None => default_qos,
Some(name) => match zenkey::qos::QosProfile::from_name(name) {
Some(qos) => qos,
None => {
let reason = format!("unknown QoS profile {name:?}");
on_event(ReplayEvent::Malformed {
reason: reason.clone(),
});
record_err(&mut report, reason, false);
continue;
}
},
};
let publication = match crate::bus::write::declare_publication(
session,
&row.key,
qos,
row.encoding.as_deref(),
)
.await
{
Ok(p) => p,
Err(e) => {
fatal = Some(e);
break;
}
};
e.insert(publication)
}
};
let delete = row.delete;
let sent = if delete {
publication.retire().await
} else {
publication.send(row.payload, row.attachment).await
};
match (sent, delete) {
(Ok(()), true) => report.tombstones += 1,
(Ok(()), false) => report.published += 1,
(Err(e), _) => {
fatal = Some(e);
break;
}
}
}
}
}
let closed = publications.close().await;
if let Some(e) = fatal {
return Err(e);
}
closed?;
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_wall_clock_formats_correctly() {
assert_eq!(rfc3339_from_unix(0), "1970-01-01T00:00:00Z");
assert_eq!(rfc3339_from_unix(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(rfc3339_from_unix(1_786_492_800), "2026-08-12T00:00:00Z");
assert!(!rfc3339_now().is_empty());
}
fn header() -> ZrecHeader {
ZrecHeader {
zrec: ZREC_VERSION,
selectors: vec!["v1/**".into()],
base: String::new(),
captured_at: "2026-08-12T00:00:00Z".into(),
}
}
async fn source_of(body: &str) -> ZrecSource {
ZrecSource::spawn(std::io::Cursor::new(body.as_bytes().to_vec()))
.await
.expect("a .zrec header")
}
#[test]
fn the_header_is_a_contract() {
let mut sink = Vec::new();
let writer = ZrecWriter::new(&mut sink, &header()).unwrap();
let _ = writer.finish().unwrap();
let reader = ZrecReader::new(sink.as_slice()).unwrap();
assert_eq!(reader.header(), &header());
let future = r#"{"zrec":99,"selectors":[],"base":"","captured_at":"x"}"#;
let err = ZrecReader::new(future.as_bytes())
.err()
.unwrap()
.to_string();
assert!(err.contains("version 99"), "{err}");
let not_zrec = r#"{"key":"v1/x","value":1}"#;
let err = ZrecReader::new(not_zrec.as_bytes())
.err()
.unwrap()
.to_string();
assert!(err.contains("header"), "{err}");
}
#[test]
fn an_injected_epoch_preserves_a_window_written_after_the_fact() {
let epoch = Instant::now();
let view = |t_ms: u64| crate::bus::monitor::SampleView {
key: "v1/h-0123456789ab/state/p/a".into(),
payload: zenoh::bytes::ZBytes::from(vec![1u8]),
encoding: String::new(),
kind: SampleKind::Put,
timestamp: None,
stamped_by: None,
attachment: None,
priority: zenoh::qos::Priority::DEFAULT,
congestion_control: zenoh::qos::CongestionControl::DEFAULT,
reliability: zenoh::qos::Reliability::DEFAULT,
express: false,
source: None,
received: epoch + Duration::from_millis(t_ms),
};
let mut sink = Vec::new();
let mut w = ZrecWriter::new_at(&mut sink, &header(), epoch).unwrap();
w.write_sample(&view(0)).unwrap();
w.write_sample(&view(1500)).unwrap();
let _ = w.finish().unwrap();
let mut reader = ZrecReader::new(sink.as_slice()).unwrap();
let t_of = |item| match item {
Some(Ok(ZrecItem::Sample { t_us, .. })) => t_us,
other => panic!("expected a sample, got {other:?}"),
};
assert_eq!(t_of(reader.next()), Some(0));
assert_eq!(
t_of(reader.next()),
Some(1_500_000),
"the offset the ring preserved, not a saturated zero"
);
}
#[test]
fn drops_are_interleaved_facts() {
let body = format!(
"{}\n{}\n{}\n{}\n",
serde_json::to_string(&header()).unwrap(),
r#"{"key":"v1/h/state/p/a","t":0,"bytes":"AQ=="}"#,
r#"{"dropped":7}"#,
r#"{"key":"v1/h/state/p/a","t":1000,"bytes":"Ag=="}"#,
);
let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
assert!(matches!(reader.next(), Some(Ok(ZrecItem::Sample { .. }))));
assert!(matches!(reader.next(), Some(Ok(ZrecItem::Dropped(7)))));
assert!(matches!(
reader.next(),
Some(Ok(ZrecItem::Sample {
t_us: Some(1000),
..
}))
));
assert!(reader.next().is_none());
}
#[test]
fn malformed_lines_are_named_not_skipped() {
let body = format!(
"{}\nnot json\n{}\n",
serde_json::to_string(&header()).unwrap(),
r#"{"key":"v1/h/state/p/a","t":0,"bytes":"AQ=="}"#,
);
let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
let err = match reader.next() {
Some(Err(e)) => e,
other => panic!("expected a named error, got {other:?}"),
};
assert!(err.starts_with("line 2:"), "{err}");
assert!(matches!(reader.next(), Some(Ok(ZrecItem::Sample { .. }))));
}
#[tokio::test]
async fn a_dry_run_lists_and_publishes_nothing() {
let body = format!(
"{}\n{}\n{}\n{}\n",
serde_json::to_string(&header()).unwrap(),
r#"{"key":"v1/h-0123456789ab/state/p/health","t":0,"bytes":"eyJvayI6dHJ1ZX0=","encoding":"application/json"}"#,
r#"{"dropped":3}"#,
r#"{"key":"v1/h-0123456789ab/state/p/health","t":500000,"delete":true}"#,
);
let mut reader = source_of(&body).await;
let mut would = Vec::new();
let report = replay(
&mut reader,
ReplaySpec {
target: ReplayTarget::DryRun,
speed: 1.0,
i_know: false,
default_qos: QosProfile::Refreshed,
},
|ev| {
would.push(format!("{ev:?}"));
},
)
.await
.unwrap();
assert!(report.dry_run);
assert_eq!(report.published, 1);
assert_eq!(report.tombstones, 1); assert_eq!(report.capture_dropped, 3);
assert_eq!(report.malformed, 0);
assert_eq!(would.len(), 3, "{would:?}");
}
#[tokio::test]
async fn replayed_tombstones_pass_the_retire_gate() {
let body = format!(
"{}\n{}\n",
serde_json::to_string(&header()).unwrap(),
r#"{"key":"v1/h-0123456789ab/telemetry/p/temp","t":0,"delete":true}"#,
);
let mut reader = source_of(&body).await;
let report = replay(
&mut reader,
ReplaySpec {
target: ReplayTarget::DryRun,
speed: 1.0,
i_know: false,
default_qos: QosProfile::Refreshed,
},
|_| {},
)
.await
.unwrap();
assert_eq!(report.refused, 1);
assert_eq!(report.tombstones, 0);
assert!(
report.first_errors[0].contains("telemetry"),
"{:?}",
report.first_errors
);
}
#[tokio::test]
async fn speed_must_be_positive() {
let body = serde_json::to_string(&header()).unwrap() + "\n";
for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
let mut reader = source_of(&body).await;
let err = replay(
&mut reader,
ReplaySpec {
target: ReplayTarget::DryRun,
speed: bad,
i_know: false,
default_qos: QosProfile::Refreshed,
},
|_| {},
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("speed"), "{err}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_row_the_bus_refuses_tears_down_and_still_reports_itself() {
let session = crate::bus::session::open(&[], &[], false)
.await
.expect("a standalone peer");
let good = SampleRow {
key: "v1/h-aaaaaaaaaaaa/state/demo/health".into(),
..SampleRow::default()
}
.with_payload_bytes(b"{}");
let bad = SampleRow {
key: "v1//nowhere".into(),
..SampleRow::default()
}
.with_payload_bytes(b"{}");
let body = format!(
"{}\n{}\n{}\n",
serde_json::to_string(&header()).unwrap(),
good.to_line(),
bad.to_line(),
);
let mut reader = source_of(&body).await;
let err = replay(
&mut reader,
ReplaySpec {
target: ReplayTarget::Bus {
session: &session,
slices: None,
},
speed: 1000.0,
i_know: false,
default_qos: QosProfile::Transition,
},
|_| {},
)
.await
.expect_err("the bus refused the second row")
.to_string();
assert!(err.contains("nowhere"), "{err}");
session.close().await.expect("close the session");
}
}