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 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}