polyc_host/lib.rs
1//! Generic dedicated-thread bridge between the tokio control plane and a
2//! Commonware-runtime-backed durable store.
3//!
4//! Built slice by slice against the invariants every future host migration
5//! (`SpendLedgerHost`, `BurnHost`, `PersonaHost`,
6//! `EventLogHost`) depends on:
7//!
8//! - **INV-H1**: dropping a [`HostHandle`] never deadlocks — the command
9//! sender is dropped before the dedicated thread is joined.
10//! - **INV-H2**: an eager [`Body::open`] failure surfaces as
11//! [`spawn_host`]'s `Err`, before any command can be sent — never as a
12//! silently-empty host.
13//! - **INV-H3**: a lazy [`Body::open`] (returns `Ok(())` immediately)
14//! followed by a command that fails inside [`Body::handle`] never crashes
15//! the command loop.
16//! - **INV-H4**: a command already queued on the channel when
17//! `shutdown.cancel()` fires is still processed by the post-cancellation
18//! drain, not lost.
19//! - **INV-H5**: [`Body::on_drain_complete`] fires strictly after the drain
20//! has run every command it queued.
21//! - **INV-H6**: a caller-supplied `(Sender, Receiver)` pair
22//! ([`spawn_host_with_channel`]) drives the exact same ready
23//! handshake/loop/drain/`on_drain_complete`/`Drop` machinery as an
24//! internally-created one ([`spawn_host`]) — the only difference is who
25//! constructs the channel. `EventLogHost`'s 4 shards need this: every
26//! shard's `Sender` must exist and be handed to every OTHER shard's `Body`
27//! before any shard's thread starts (a chicken-and-egg an internally-built
28//! channel can't resolve), so the caller builds all its channels up front
29//! and hands each shard its own `(Sender, Receiver)` pair.
30
31use std::path::PathBuf;
32use std::thread::JoinHandle;
33
34use tokio::sync::{mpsc, oneshot};
35use tokio_util::sync::CancellationToken;
36
37/// Configuration for one dedicated host thread.
38#[derive(Debug, Clone)]
39pub struct HostOptions {
40 /// Name given to the dedicated OS thread (surfaces in a panic backtrace
41 /// or `top`/`ps` listing, so it should name the store it hosts).
42 pub thread_name: &'static str,
43 /// Directory the Commonware runtime roots its storage at.
44 pub storage_dir: PathBuf,
45 /// Bounded backlog of in-flight commands on the channel from the tokio
46 /// side to the dedicated thread.
47 pub command_backlog: usize,
48}
49
50/// Per-host behavior plugged into the generic dedicated-thread bridge.
51#[async_trait::async_trait]
52pub trait Body: Send + 'static {
53 /// The command type carried from the tokio side to the dedicated thread.
54 type Cmd: Send + 'static;
55
56 /// Called once, on the dedicated thread, inside the Commonware runtime,
57 /// before the command loop starts (see INV-H2 and INV-H3).
58 ///
59 /// # Errors
60 ///
61 /// Returns a human-readable reason the store could not be opened. Only
62 /// meaningful for a host that opens eagerly; a lazy host returns
63 /// `Ok(())` immediately and never returns `Err` here.
64 async fn open(&mut self, ctx: &commonware_runtime::tokio::Context) -> Result<(), String>;
65
66 /// Dispatch one command against this host's state.
67 async fn handle(&mut self, ctx: &commonware_runtime::tokio::Context, cmd: Self::Cmd);
68
69 /// Called once, after the post-cancellation drain has run every command
70 /// that was already queued when shutdown fired (INV-H5). Defaults to a
71 /// no-op; `EventLogHost`'s per-shard "sync every open log" epilogue is
72 /// exactly what this hook exists for.
73 async fn on_drain_complete(&mut self, ctx: &commonware_runtime::tokio::Context) {
74 let _ = ctx;
75 }
76}
77
78/// Error starting a dedicated host thread.
79#[derive(Debug, thiserror::Error)]
80pub enum SpawnError {
81 /// The dedicated thread panicked before it could report either outcome
82 /// of [`Body::open`].
83 #[error("dedicated host thread failed before it could report readiness")]
84 RuntimeStart,
85 /// [`Body::open`] returned an error: the host's store could not be
86 /// opened or recovered (INV-H2).
87 #[error("host body failed to open: {0}")]
88 Open(String),
89}
90
91/// The dedicated host has shut down; a [`call`] could not be served.
92#[derive(Debug, thiserror::Error)]
93#[error("host is shut down")]
94pub struct Closed;
95
96/// Send one command carrying a fresh `oneshot` ack, and await the reply.
97///
98/// This is the plumbing every host's own caller-facing method builds on:
99/// `build` wraps the ack half into that host's own `Cmd` variant, `call`
100/// sends it and awaits the reply, and a channel that is closed on either
101/// leg — the command couldn't be enqueued, or the ack was dropped without a
102/// reply — collapses to one [`Closed`] error. What a caller does with that
103/// error (fail open, fail closed, propagate it) stays entirely up to the
104/// caller; this helper only ever reports whether the round trip happened.
105///
106/// # Errors
107///
108/// Returns [`Closed`] if the dedicated thread has shut down: either the
109/// command could not be enqueued, or the ack was dropped without a reply.
110pub async fn call<Cmd, T>(
111 tx: &mpsc::Sender<Cmd>,
112 build: impl FnOnce(oneshot::Sender<T>) -> Cmd,
113) -> Result<T, Closed> {
114 let (ack, ack_rx) = oneshot::channel();
115 tx.send(build(ack)).await.map_err(|_| Closed)?;
116 ack_rx.await.map_err(|_| Closed)
117}
118
119/// Handle to a durable host running on its dedicated Commonware-runtime
120/// thread.
121#[derive(Debug)]
122pub struct HostHandle<Cmd> {
123 /// Outbound command channel; `Option` so `Drop` can close it before
124 /// joining (INV-H1).
125 tx: Option<mpsc::Sender<Cmd>>,
126 /// The dedicated OS thread, joined on drop.
127 thread: Option<JoinHandle<()>>,
128}
129
130impl<Cmd> HostHandle<Cmd> {
131 /// The outbound command channel, if the host has not already begun
132 /// shutting down. Feed this to [`call`] to build a caller-facing method
133 /// on top.
134 #[must_use]
135 pub const fn sender(&self) -> Option<&mpsc::Sender<Cmd>> {
136 self.tx.as_ref()
137 }
138}
139
140impl<Cmd> Drop for HostHandle<Cmd> {
141 fn drop(&mut self) {
142 // INV-H1: drop the sender first. This closes the command channel,
143 // which ends the dedicated thread's command loop (`rx.recv()`
144 // resolves to `None`) and lets `Runner::start` return — only THEN
145 // join. Joining first would deadlock: the dedicated thread would
146 // still be blocked waiting on this very channel to close, and it
147 // never will while the sender we are about to block on joining is
148 // still alive.
149 drop(self.tx.take());
150 if let Some(thread) = self.thread.take() {
151 let _ = thread.join();
152 }
153 }
154}
155
156/// Spawn a dedicated Commonware-runtime thread hosting `body`, rooted at
157/// `opts.storage_dir`, and returns once the thread has reported readiness.
158///
159/// # Errors
160///
161/// Returns [`SpawnError::Open`] if [`Body::open`] reports a failure (an
162/// eager host's store could not be opened or recovered — INV-H2), or
163/// [`SpawnError::RuntimeStart`] if the dedicated thread panics before it can
164/// report either outcome.
165///
166/// # Panics
167///
168/// Panics if the OS refuses to spawn the dedicated thread (resource
169/// exhaustion).
170pub fn spawn_host<B: Body>(
171 opts: HostOptions,
172 shutdown: CancellationToken,
173 body: B,
174) -> Result<HostHandle<B::Cmd>, SpawnError> {
175 let (tx, rx) = mpsc::channel::<B::Cmd>(opts.command_backlog);
176 spawn_host_with_channel(opts, shutdown, body, tx, rx)
177}
178
179/// Spawn a dedicated Commonware-runtime thread hosting `body`, using a
180/// caller-supplied `(Sender, Receiver)` pair instead of one
181/// [`spawn_host`] creates internally (INV-H6).
182///
183/// This is the entry point a multi-shard host needs: every shard's `Sender`
184/// must exist, and be cloned into every OTHER shard's `Body`, before any
185/// shard's dedicated thread starts — a chicken-and-egg [`spawn_host`]'s
186/// internally-created channel cannot resolve on its own. The caller builds
187/// every channel up front (e.g. one `mpsc::channel` per shard), clones each
188/// `Sender` into whichever peer `Body`s need it, then calls this once per
189/// shard with that shard's own pair — `tx` is consumed into the returned
190/// [`HostHandle`], exactly as it would be if [`spawn_host`] had built it.
191///
192/// `opts.command_backlog` is ignored here (the channel already exists);
193/// everything else — the ready handshake, the command loop, the post-cancel
194/// drain, [`Body::on_drain_complete`], and the returned [`HostHandle`]'s
195/// drop-tx-then-join `Drop` (INV-H1) — is identical to [`spawn_host`].
196///
197/// # Errors
198///
199/// Returns [`SpawnError::Open`] if [`Body::open`] reports a failure (INV-H2),
200/// or [`SpawnError::RuntimeStart`] if the dedicated thread panics before it
201/// can report either outcome.
202///
203/// # Panics
204///
205/// Panics if the OS refuses to spawn the dedicated thread (resource
206/// exhaustion).
207pub fn spawn_host_with_channel<B: Body>(
208 opts: HostOptions,
209 shutdown: CancellationToken,
210 body: B,
211 tx: mpsc::Sender<B::Cmd>,
212 rx: mpsc::Receiver<B::Cmd>,
213) -> Result<HostHandle<B::Cmd>, SpawnError> {
214 let HostOptions {
215 thread_name,
216 storage_dir,
217 command_backlog: _,
218 } = opts;
219 let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
220
221 let thread = std::thread::Builder::new()
222 .name(thread_name.to_owned())
223 .spawn(move || run_dedicated(storage_dir, rx, &ready_tx, &shutdown, body))
224 .expect("spawn dedicated host thread");
225
226 match ready_rx.recv() {
227 Ok(Ok(())) => Ok(HostHandle {
228 tx: Some(tx),
229 thread: Some(thread),
230 }),
231 Ok(Err(reason)) => {
232 let _ = thread.join();
233 Err(SpawnError::Open(reason))
234 }
235 Err(_) => {
236 // The thread panicked before reporting either outcome.
237 let _ = thread.join();
238 Err(SpawnError::RuntimeStart)
239 }
240 }
241}
242
243/// Body of the dedicated thread: owns the Commonware tokio runtime, opens
244/// `body`'s store per [`Body::open`]'s contract, then serves commands until
245/// either the channel closes or `shutdown` cancels (checked first, biased —
246/// so a `shutdown` racing a still-buffered command always takes the
247/// shutdown branch, leaving that command for the drain below, not lost).
248/// Once the loop ends, drains whatever was already queued (INV-H4) and
249/// reports completion via [`Body::on_drain_complete`].
250fn run_dedicated<B: Body>(
251 storage_dir: PathBuf,
252 rx: mpsc::Receiver<B::Cmd>,
253 ready_tx: &std::sync::mpsc::Sender<Result<(), String>>,
254 shutdown: &CancellationToken,
255 mut body: B,
256) {
257 use commonware_runtime::Runner as _;
258
259 let cfg = commonware_runtime::tokio::Config::default().with_storage_directory(storage_dir);
260 let runner = commonware_runtime::tokio::Runner::new(cfg);
261
262 runner.start(|context| async move {
263 if let Err(reason) = body.open(&context).await {
264 tracing::warn!(error = %reason, "dedicated host thread's body failed to open");
265 let _ = ready_tx.send(Err(reason));
266 return;
267 }
268 let _ = ready_tx.send(Ok(()));
269
270 let mut rx = rx;
271 loop {
272 // `commonware_macros::select!` is always biased (a verbatim
273 // rename of `tokio::select! { biased; ... }`), so `shutdown` is
274 // always checked first: a shutdown racing an already-buffered
275 // command always takes this branch, leaving that command for
276 // the drain below rather than losing the race unpredictably.
277 let cmd = commonware_macros::select! {
278 () = shutdown.cancelled() => break,
279 maybe = rx.recv() => match maybe {
280 Some(cmd) => cmd,
281 None => break,
282 },
283 };
284 body.handle(&context, cmd).await;
285 }
286
287 // INV-H4: drain whatever was already queued when the loop above
288 // ended, so a command that lost the race against cancellation (or
289 // was buffered behind the channel closing) is not silently dropped.
290 let mut drained = 0usize;
291 while let Ok(cmd) = rx.try_recv() {
292 body.handle(&context, cmd).await;
293 drained += 1;
294 }
295 if drained > 0 {
296 tracing::debug!(drained, "ran queued commands after shutdown");
297 }
298 // INV-H5: only after every drained command has actually run.
299 body.on_drain_complete(&context).await;
300 });
301}
302
303#[cfg(test)]
304mod tests {
305 #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
306 use super::*;
307 use tokio_util::sync::CancellationToken;
308
309 /// A scratch storage directory unique to one test. The Commonware
310 /// runtime creates the directory itself, so tests never do — only clean
311 /// up any stale directory a prior run of the same test may have left.
312 fn scratch_dir(label: &str) -> std::path::PathBuf {
313 let dir = std::env::temp_dir().join(format!(
314 "polyc-host-{label}-{}-{}",
315 std::process::id(),
316 uuid::Uuid::new_v4()
317 ));
318 let _ = std::fs::remove_dir_all(&dir);
319 dir
320 }
321
322 /// [`HostOptions`] for one test, with a fresh scratch directory.
323 fn opts(label: &str, command_backlog: usize) -> HostOptions {
324 // Leak the name into a `'static str`: fine in a test, which runs
325 // once and the process exits shortly after.
326 let thread_name: &'static str =
327 Box::leak(format!("polyc-host-test-{label}").into_boxed_str());
328 HostOptions {
329 thread_name,
330 storage_dir: scratch_dir(label),
331 command_backlog,
332 }
333 }
334
335 /// A [`Body`] whose `open` always fails.
336 struct EagerFailBody;
337
338 #[async_trait::async_trait]
339 impl Body for EagerFailBody {
340 type Cmd = ();
341
342 async fn open(&mut self, _ctx: &commonware_runtime::tokio::Context) -> Result<(), String> {
343 Err("the store is unopenable".to_owned())
344 }
345
346 async fn handle(&mut self, _ctx: &commonware_runtime::tokio::Context, (): Self::Cmd) {}
347 }
348
349 /// INV-H2: an eager `Body::open` failure surfaces as `spawn_host`'s
350 /// `Err`, before any command can be sent — never as a silently-empty
351 /// host.
352 #[test]
353 fn an_eager_open_failure_surfaces_through_spawn_host() {
354 let opts = HostOptions {
355 thread_name: "polyc-host-test-eager-fail",
356 storage_dir: scratch_dir("eager-fail"),
357 command_backlog: 8,
358 };
359 let err = spawn_host(opts, CancellationToken::new(), EagerFailBody)
360 .expect_err("an eager Body::open failure must fail spawn_host");
361 match err {
362 SpawnError::Open(reason) => assert_eq!(reason, "the store is unopenable"),
363 SpawnError::RuntimeStart => panic!("expected Open, got RuntimeStart"),
364 }
365 }
366
367 /// Command variants a lazily-opening test body understands.
368 enum LazyCmd {
369 /// Acks `id` back on `ack`.
370 Ping {
371 id: u32,
372 ack: tokio::sync::oneshot::Sender<u32>,
373 },
374 /// Always acks a simulated internal failure — never panics.
375 Fail {
376 ack: tokio::sync::oneshot::Sender<Result<u32, String>>,
377 },
378 }
379
380 /// A [`Body`] that opens lazily (returns `Ok(())` immediately), answers
381 /// every `Ping`, and answers `Fail` with a simulated internal error
382 /// rather than panicking.
383 struct LazyBody;
384
385 #[async_trait::async_trait]
386 impl Body for LazyBody {
387 type Cmd = LazyCmd;
388
389 async fn open(&mut self, _ctx: &commonware_runtime::tokio::Context) -> Result<(), String> {
390 Ok(())
391 }
392
393 async fn handle(&mut self, _ctx: &commonware_runtime::tokio::Context, cmd: Self::Cmd) {
394 match cmd {
395 LazyCmd::Ping { id, ack } => {
396 let _ = ack.send(id);
397 }
398 LazyCmd::Fail { ack } => {
399 let _ = ack.send(Err("simulated per-command failure".to_owned()));
400 }
401 }
402 }
403 }
404
405 /// INV-H1: dropping a [`HostHandle`] never deadlocks — the command
406 /// sender is dropped before the dedicated thread is joined. Proven by
407 /// actually completing a round trip through the loop first, then
408 /// dropping the handle: a join-before-drop bug would hang this test
409 /// forever instead of returning.
410 #[tokio::test]
411 async fn dropping_the_handle_never_deadlocks() {
412 let handle = spawn_host(opts("drop-order", 8), CancellationToken::new(), LazyBody)
413 .expect("lazy open always succeeds");
414
415 let (ack, ack_rx) = tokio::sync::oneshot::channel();
416 handle
417 .sender()
418 .expect("handle is fresh")
419 .send(LazyCmd::Ping { id: 42, ack })
420 .await
421 .expect("channel is open");
422 assert_eq!(ack_rx.await.expect("the loop answers"), 42);
423
424 // Dropping the ONLY sender (the handle's own) must close the
425 // channel and let the dedicated thread's loop end — if `Drop`
426 // joined before dropping it, this would hang forever.
427 drop(handle);
428 }
429
430 /// INV-H3: a lazy `Body::open` followed by a command that fails inside
431 /// `Body::handle` never crashes the command loop — a later command must
432 /// still be served.
433 #[tokio::test]
434 async fn a_command_that_fails_inside_handle_does_not_crash_the_loop() {
435 let handle = spawn_host(
436 opts("handle-failure", 8),
437 CancellationToken::new(),
438 LazyBody,
439 )
440 .expect("lazy open always succeeds");
441
442 let (fail_ack, fail_ack_rx) = tokio::sync::oneshot::channel();
443 handle
444 .sender()
445 .expect("handle is fresh")
446 .send(LazyCmd::Fail { ack: fail_ack })
447 .await
448 .expect("channel is open");
449 assert_eq!(
450 fail_ack_rx.await.expect("the loop still answers"),
451 Err("simulated per-command failure".to_owned())
452 );
453
454 // The loop must still be alive and answering commands after the
455 // internal failure above.
456 let (ack, ack_rx) = tokio::sync::oneshot::channel();
457 handle
458 .sender()
459 .expect("handle is fresh")
460 .send(LazyCmd::Ping { id: 7, ack })
461 .await
462 .expect("channel is open");
463 assert_eq!(ack_rx.await.expect("the loop answers"), 7);
464
465 drop(handle);
466 }
467
468 /// INV-H4: a command already queued on the channel when
469 /// `shutdown.cancel()` fires is still processed by the post-cancellation
470 /// drain, not lost. Proven with an extra live sender clone that is never
471 /// dropped during the test — the ONLY way the dedicated thread's loop
472 /// can end is by observing `shutdown`, never by the channel closing —
473 /// so this also proves the loop actually reacts to `shutdown` at all.
474 #[tokio::test]
475 async fn a_command_queued_before_shutdown_cancel_fires_is_still_processed() {
476 let shutdown = CancellationToken::new();
477 let handle = spawn_host(opts("post-cancel-drain", 8), shutdown.clone(), LazyBody)
478 .expect("lazy open always succeeds");
479 let extra_tx = handle.sender().expect("handle is fresh").clone();
480
481 let (ack, ack_rx) = tokio::sync::oneshot::channel();
482 extra_tx
483 .send(LazyCmd::Ping { id: 9, ack })
484 .await
485 .expect("channel is open");
486
487 shutdown.cancel();
488
489 // The queued Ping, sent before cancellation, must still run.
490 assert_eq!(ack_rx.await.expect("a queued command must still run"), 9);
491
492 // The dedicated thread must exit on `shutdown` alone: `extra_tx` is
493 // a live sender clone that is never dropped, so the channel itself
494 // never closes. If the loop only ever ended when the channel
495 // closed, this would hang forever.
496 drop(handle);
497 drop(extra_tx);
498 }
499
500 /// A [`Body`] that lets the test hold the loop mid-command via `gate`,
501 /// so it can force a deterministic race: queue commands, cancel
502 /// shutdown while the loop is still busy, then release and observe the
503 /// order everything ran in.
504 struct GatedBody {
505 gate: std::sync::Arc<tokio::sync::Notify>,
506 order: std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>,
507 }
508
509 enum GatedCmd {
510 /// Blocks on `gate` before acking — holds the loop mid-command.
511 Slow {
512 ack: tokio::sync::oneshot::Sender<()>,
513 },
514 /// Acks `id` immediately.
515 Ping {
516 id: u32,
517 ack: tokio::sync::oneshot::Sender<u32>,
518 },
519 }
520
521 #[async_trait::async_trait]
522 impl Body for GatedBody {
523 type Cmd = GatedCmd;
524
525 async fn open(&mut self, _ctx: &commonware_runtime::tokio::Context) -> Result<(), String> {
526 Ok(())
527 }
528
529 async fn handle(&mut self, _ctx: &commonware_runtime::tokio::Context, cmd: Self::Cmd) {
530 match cmd {
531 GatedCmd::Slow { ack } => {
532 self.gate.notified().await;
533 self.order.lock().unwrap().push("slow");
534 let _ = ack.send(());
535 }
536 GatedCmd::Ping { id, ack } => {
537 self.order.lock().unwrap().push("ping");
538 let _ = ack.send(id);
539 }
540 }
541 }
542
543 async fn on_drain_complete(&mut self, _ctx: &commonware_runtime::tokio::Context) {
544 self.order.lock().unwrap().push("drained");
545 }
546 }
547
548 /// INV-H5: `Body::on_drain_complete` fires strictly after the drain has
549 /// run every command it queued.
550 #[tokio::test]
551 async fn on_drain_complete_fires_after_the_drain_has_run_every_command() {
552 let gate = std::sync::Arc::new(tokio::sync::Notify::new());
553 let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
554 let shutdown = CancellationToken::new();
555
556 let body = GatedBody {
557 gate: gate.clone(),
558 order: order.clone(),
559 };
560 let handle = spawn_host(opts("drain-complete", 8), shutdown.clone(), body)
561 .expect("lazy open always succeeds");
562
563 // Start a Slow command: the loop is now stuck inside `handle()`,
564 // awaiting `gate`, so it cannot poll `rx.recv()` again until
565 // released.
566 let (slow_ack, slow_ack_rx) = tokio::sync::oneshot::channel();
567 handle
568 .sender()
569 .expect("handle is fresh")
570 .send(GatedCmd::Slow { ack: slow_ack })
571 .await
572 .expect("channel is open");
573
574 // Queue two Pings behind it — they sit in the mpsc buffer.
575 let (ack_a, ack_a_rx) = tokio::sync::oneshot::channel();
576 let (ack_b, ack_b_rx) = tokio::sync::oneshot::channel();
577 handle
578 .sender()
579 .expect("handle is fresh")
580 .send(GatedCmd::Ping { id: 1, ack: ack_a })
581 .await
582 .expect("channel is open");
583 handle
584 .sender()
585 .expect("handle is fresh")
586 .send(GatedCmd::Ping { id: 2, ack: ack_b })
587 .await
588 .expect("channel is open");
589
590 // Cancel while the loop is still stuck on Slow: the next iteration
591 // sees `shutdown` already resolved (checked first, biased) and
592 // breaks, WITHOUT ever taking a Ping off the select arm — leaving
593 // both for the drain.
594 shutdown.cancel();
595
596 // Release Slow. It completes; the loop's next iteration then breaks
597 // on `shutdown`; the drain runs both queued Pings.
598 gate.notify_one();
599 slow_ack_rx.await.expect("slow command completes");
600 ack_a_rx.await.expect("drained");
601 ack_b_rx.await.expect("drained");
602
603 // Drop the handle: joins the dedicated thread, which only returns
604 // once the drain loop AND `on_drain_complete` have both run.
605 drop(handle);
606
607 let seen = order.lock().unwrap().clone();
608 assert_eq!(
609 seen,
610 vec!["slow", "ping", "ping", "drained"],
611 "on_drain_complete must fire strictly after every drained command"
612 );
613 }
614
615 /// `call` is the plumbing every host's own caller-facing method builds
616 /// on: it must complete a normal round trip, and collapse a closed
617 /// channel (the host has shut down) to `Closed` rather than hanging or
618 /// panicking.
619 #[tokio::test]
620 async fn call_round_trips_and_reports_closed_once_the_host_is_gone() {
621 let shutdown = CancellationToken::new();
622 let handle = spawn_host(opts("call-helper", 8), shutdown.clone(), LazyBody)
623 .expect("lazy open always succeeds");
624 // A clone kept alive on purpose: it lets us still try a `call`
625 // after the host is gone without racing the channel's own closure.
626 let tx = handle.sender().expect("handle is fresh").clone();
627
628 let pong = call(&tx, |ack| LazyCmd::Ping { id: 5, ack })
629 .await
630 .expect("the channel is open");
631 assert_eq!(pong, 5);
632
633 // Shut the host down via cancellation, not by dropping every
634 // sender (this clone stays alive throughout). `drop(handle)` joins
635 // the dedicated thread, which only returns once its loop has
636 // actually ended — so by the time `drop` returns, `rx` is
637 // guaranteed gone; no race with the send below.
638 shutdown.cancel();
639 drop(handle);
640
641 let closed = call(&tx, |ack| LazyCmd::Ping { id: 6, ack }).await;
642 assert!(matches!(closed, Err(Closed)));
643 }
644
645 /// INV-H6: a caller-supplied `(Sender, Receiver)` pair drives the exact
646 /// same machinery an internally-created one does — the multi-shard
647 /// shape `EventLogHost` needs, where the caller must hold its own extra
648 /// `Sender` clone (handed to peer shards) before the dedicated thread
649 /// ever starts. Proven by building the channel here, keeping a peer
650 /// clone alive the whole time, and running the exact same round-trip +
651 /// drop-tx-then-join checks the internally-created-channel tests above
652 /// run against `spawn_host`.
653 #[tokio::test]
654 async fn a_caller_supplied_channel_works_identically_to_an_internal_one() {
655 let (tx, rx) = tokio::sync::mpsc::channel::<LazyCmd>(8);
656 // Stands in for a peer shard's clone of this shard's sender — held
657 // alive independently of the `HostHandle` below, exactly like
658 // `EventLogHost`'s `peer_senders`.
659 let peer_clone = tx.clone();
660
661 let handle = spawn_host_with_channel(
662 opts("with-channel", 8),
663 CancellationToken::new(),
664 LazyBody,
665 tx,
666 rx,
667 )
668 .expect("lazy open always succeeds");
669
670 let (ack, ack_rx) = tokio::sync::oneshot::channel();
671 peer_clone
672 .send(LazyCmd::Ping { id: 11, ack })
673 .await
674 .expect("channel is open");
675 assert_eq!(ack_rx.await.expect("the loop answers"), 11);
676
677 // Drop the peer clone FIRST: while it's alive the channel has two
678 // live senders (it and the handle's own), so the dedicated thread's
679 // `rx.recv()` cannot resolve to `None` yet — dropping the handle
680 // while `peer_clone` is still around would make `HostHandle::drop`'s
681 // `thread.join()` block forever, exactly the multi-sender hazard
682 // `EventLogHost` solves with `host_shutdown` cancellation instead of
683 // relying on channel closure. Once `peer_clone` is gone, the
684 // handle's own sender is the last one, so dropping it (INV-H1) closes
685 // the channel and the thread exits cleanly.
686 drop(peer_clone);
687 drop(handle);
688 }
689}