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