Skip to main content

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}