flybrain/services/flysim/crates/flybus/examples/demo.rs
dev f2dc5d434a flybus: assert the latest-mode guarantee, not the machine's timing
session_over_one_router asserted that a latest subscriber must drop
snapshots. bus-v1 section 7 says a latest subscription replaces an
undelivered value; it lets a consumer miss values, it does not oblige it
to. Under contention the publisher was slow enough that the renderer kept
up, saw all twenty snapshots and the test failed on conforming behaviour:
16 of 40 runs beside four busy loops, and 9 of 20 whole-crate runs.

The renderer is now held until the publisher's completion is observed
rather than until a timer expires, so the coalescing is forced instead of
raced for: the subscription keeps the one delivery in flight and one
replaceable queued value, and the renderer receives snapshots 1 and 20 of
20. The assertions are the guarantees that hold -- what arrives is in
publication order, the last value is the latest published, the stalled
spectator never refuses a publication or drops out of the fan-out, and
every snapshot the renderer missed is counted as a replacement to the
publisher at admission and to the renderer on delivery, so nothing is
lost silently.

Three more tests in the crate asserted the same kind of race:

- latest_replay_is_ordered_ahead_of_a_racing_publish demanded the
  non-coalesced outcome of a race section 7 allows either way ("bounded
  mode preserves that order, while latest mode may coalesce it"). It now
  puts one racing publication to a bounded and a latest subscription at
  once: bounded must deliver the replay and then the publication, and the
  latest branch is chosen by that publication's own replaced count.

- collection_waits_for_every_retained_owner, and the two disconnect
  cleanup tests beside it, read the store directory for the unlink that
  follows the registry update outside the router lock. They use
  settle_files, like every other unlink check in the suite.

- The demo example printed a root count taken before the producer's own
  release had reached the router, so the line the guide quotes was a
  race. It waits for the release, the same way it already waits for
  collection; the printed output is unchanged.

Nothing under flybus/src is touched: no routing defect was found. The
conformance rows for the credit/queue split, the replaced count, the
replay ordering and the latest spectator that cannot refuse a publication
now cite what each rewritten test actually varies.
2026-09-22 17:05:25 +00:00

157 lines
5.5 KiB
Rust

//! The guide's first deliverable: a counter RPC, a pub/sub observer and a frame artifact held
//! past its message object's lifetime, in one program (bus-v1 section 11, implementation
//! guide section 1). No game, browser or second transport is involved.
//!
//! ```text
//! cargo run -p flybus --example demo
//! ```
//!
//! `tests/example_demo.rs` runs [`run`] and asserts every line it returns.
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use flybus::{
Client, ClientConfig, Policy, Retained, Router, RouterConfig, ServiceConfig,
SubscriptionConfig,
};
use serde_json::{Map, Value, json};
const W: usize = 160;
const H: usize = 144;
fn obj(v: Value) -> Map<String, Value> {
v.as_object().cloned().unwrap_or_default()
}
/// The three parts, in one program, over one router. Returns the lines the example prints.
pub async fn run() -> Result<Vec<String>, Box<dyn std::error::Error>> {
static RUNS: AtomicU64 = AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"flybus-demo-{}-{}",
std::process::id(),
RUNS.fetch_add(1, Ordering::Relaxed)
));
let mut config = RouterConfig::new(&root);
config.policy = Policy::open();
let router = Router::new(config)?;
let connect = |id: &str| {
Client::connect(
router.connect_in_memory_as(id),
ClientConfig::new(id, &root),
)
};
let mut lines = Vec::new();
// 1. A counter service. An exclusive endpoint, pinned by its caller to the registration
// it discovered, reached through the router like every other operation.
let counter = connect("counter").await?;
let mut svc = counter
.register("example.counter", ServiceConfig::default())
.await?;
let incarnation = svc.incarnation().to_owned();
let service = tokio::spawn(async move {
let mut total = 0i64;
while let Some(req) = svc.next().await {
total += req.payload()["amount"].as_i64().unwrap_or(0);
let _ = req.reply(obj(json!({ "total": total })), &[]).await;
}
});
let app = connect("app").await?;
for _ in 0..3 {
let res = app
.call_and_wait(
"example.counter",
Some(&incarnation),
"Counter.Increment",
obj(json!({"amount": 1})),
&[],
)
.await?;
lines.push(format!("counter total = {}", res.outcome()["total"]));
}
// 2. A pub/sub observer. A latest-value subscription, so a slow observer coalesces
// instead of holding the producer up.
app.declare_topic("world.demo.frame", Retained::None).await?;
let observer = connect("observer").await?;
let mut frames = observer
.subscribe("world.demo.frame", SubscriptionConfig::latest())
.await?;
// 3. A frame artifact. The bytes live in the store; the message carries a reference and
// the dimensions.
let mut writer = app
.artifacts()
.allocate((W * H * 4) as u64, "image/x-rgba")
.await?;
writer.write_all(&vec![0x7f; W * H * 4])?;
let frame = writer.seal().await?;
let receipt = app
.publish(
"world.demo.frame",
obj(json!({"width": W, "height": H})),
&[("frame", &frame)],
)
.await?;
lines.push(format!(
"published sequence {} to {} subscriber(s)",
receipt.topic_sequence, receipt.subscribers
));
// The producer lets go of its own hold; the delivery keeps the bytes alive. The release
// travels the control lane like any other operation, so the count below waits for it
// instead of reading a number that may still include it.
drop(frame);
let released = Instant::now() + Duration::from_secs(10);
while router.stats().artifact_roots > 1 {
if Instant::now() > released {
return Err("the producer's own hold was never released".into());
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
let message = frames.next().await.ok_or("the subscription closed")?;
let image = message.artifact("frame")?;
drop(message); // the extracted handle still owns the delivery
let bytes = image.read_all().await?;
lines.push(format!(
"read {} bytes after the message was dropped",
bytes.len()
));
let held = router.stats();
lines.push(format!(
"while the frame is held: {} artifact(s), {} root(s)",
held.sealed_artifacts, held.artifact_roots
));
drop(image); // the last handle: the delivery is consumed and the frame collected
// Consumption reaches the router on the client's control lane, so collection is not
// instantaneous.
let deadline = Instant::now() + Duration::from_secs(10);
while router.stats().artifacts > 0 {
if Instant::now() > deadline {
return Err("the frame was never collected".into());
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
let collected = router.stats();
lines.push(format!(
"after the last handle: {} artifact(s), {} root(s)",
collected.artifacts, collected.artifact_roots
));
service.abort();
router.shutdown();
drop((app, observer, counter));
let _ = std::fs::remove_dir_all(&root);
Ok(lines)
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
for line in run().await? {
println!("{line}");
}
Ok(())
}