Skip to main content

tsoracle_standalone/
transport.rs

1//
2//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6//  tsoracle — Distributed Timestamp Oracle
7//  https://www.tsoracle.rs
8//
9//  Copyright (c) 2026 Prisma Risk
10//
11//  Licensed under the Apache License, Version 2.0 (the "License");
12//  you may not use this file except in compliance with the License.
13//  You may obtain a copy of the License at
14//
15//      https://www.apache.org/licenses/LICENSE-2.0
16//
17//  Unless required by applicable law or agreed to in writing, software
18//  distributed under the License is distributed on an "AS IS" BASIS,
19//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20//  See the License for the specific language governing permissions and
21//  limitations under the License.
22//
23
24use std::future::Future;
25use std::pin::Pin;
26use std::sync::Arc;
27use std::sync::atomic::{AtomicBool, Ordering};
28
29use tokio::sync::{oneshot, watch};
30use tokio::task::JoinHandle;
31
32/// One-per-node fail-fast signal. Any supervised transport-server task that
33/// exits unexpectedly trips it; the bin's run loop selects on
34/// [`FatalSignal::tripped`] next to the OS shutdown signal and exits non-zero
35/// so an orchestrator restarts the node, instead of leaving a zombie whose
36/// peer/admin surface is dead while the consensus driver keeps running.
37#[derive(Clone)]
38pub struct FatalSignal {
39    component: watch::Sender<Option<&'static str>>,
40}
41
42impl FatalSignal {
43    /// A signal with no failure recorded.
44    pub fn new() -> Self {
45        Self {
46            component: watch::Sender::new(None),
47        }
48    }
49
50    /// Record that `component`'s server task died. The first trip wins —
51    /// later trips keep the original component so the surfaced error names
52    /// the root failure, not a teardown cascade.
53    pub fn trip(&self, component: &'static str) {
54        self.component.send_if_modified(|current| {
55            if current.is_none() {
56                *current = Some(component);
57                true
58            } else {
59                false
60            }
61        });
62    }
63
64    /// The component whose server died, if any. Non-blocking.
65    pub fn check(&self) -> Option<&'static str> {
66        *self.component.borrow()
67    }
68
69    /// Resolve with the failed component once the signal trips. Pending
70    /// forever while the node stays healthy.
71    pub async fn tripped(&self) -> &'static str {
72        let mut watcher = self.component.subscribe();
73        loop {
74            if let Some(component) = *watcher.borrow_and_update() {
75                return component;
76            }
77            // `&self` holds a sender, so the channel cannot close while we
78            // wait; if it somehow does, "never trips" is the right reading.
79            if watcher.changed().await.is_err() {
80                std::future::pending::<()>().await;
81            }
82        }
83    }
84}
85
86impl Default for FatalSignal {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92/// Owns a spawned peer-transport server and shuts it down cooperatively.
93/// The `file` driver has no peer transport, so it uses [`TransportHandle::noop`].
94pub struct TransportHandle {
95    cancel: Option<oneshot::Sender<()>>,
96    join: Option<JoinHandle<()>>,
97}
98
99impl TransportHandle {
100    /// A handle with nothing to shut down (file driver).
101    pub fn noop() -> Self {
102        Self {
103            cancel: None,
104            join: None,
105        }
106    }
107
108    /// Wrap a spawned server task plus the trigger that asks it to stop.
109    pub fn new(cancel: oneshot::Sender<()>, join: JoinHandle<()>) -> Self {
110        Self {
111            cancel: Some(cancel),
112            join: Some(join),
113        }
114    }
115
116    /// Spawn `serve` under supervision. The closure receives the shutdown
117    /// future to hand to `serve_with_incoming_shutdown`, and the returned
118    /// handle cancels it cooperatively exactly like [`TransportHandle::new`].
119    /// Every other way out of the server — an `Err`, a clean return nobody
120    /// asked for, or a panic — trips `fatal` with `component`, so the node
121    /// fails fast instead of running on as a zombie with a dead transport.
122    pub fn spawn_supervised<F, Fut, E>(
123        component: &'static str,
124        fatal: FatalSignal,
125        serve: F,
126    ) -> Self
127    where
128        F: FnOnce(Pin<Box<dyn Future<Output = ()> + Send>>) -> Fut,
129        Fut: Future<Output = Result<(), E>> + Send + 'static,
130        E: std::fmt::Debug + Send + 'static,
131    {
132        let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
133        let stop_requested = Arc::new(AtomicBool::new(false));
134        let stop_observed = stop_requested.clone();
135        let shutdown: Pin<Box<dyn Future<Output = ()> + Send>> = Box::pin(async move {
136            // Resolves on explicit cancel AND on handle drop (`RecvError`):
137            // both mean stop was requested, so a subsequent `Ok` is clean.
138            let _ = cancel_rx.await;
139            stop_observed.store(true, Ordering::Release);
140        });
141        // The server runs in its own task so a panic surfaces here as a
142        // `JoinError` instead of killing the supervisor with it.
143        let server = tokio::spawn(serve(shutdown));
144        let supervisor = tokio::spawn(async move {
145            match server.await {
146                Ok(Ok(())) if stop_requested.load(Ordering::Acquire) => {}
147                Ok(Ok(())) => {
148                    tracing::error!(component, "server exited without a shutdown request");
149                    fatal.trip(component);
150                }
151                Ok(Err(err)) => {
152                    tracing::error!(error = ?err, component, "server died");
153                    fatal.trip(component);
154                }
155                Err(join_error) => {
156                    tracing::error!(error = ?join_error, component, "server task panicked or was aborted");
157                    fatal.trip(component);
158                }
159            }
160        });
161        Self::new(cancel_tx, supervisor)
162    }
163
164    /// Signal the peer server to stop and wait for the task to finish.
165    /// Idempotent: calling twice is harmless.
166    pub async fn shutdown(&mut self) {
167        if let Some(cancel) = self.cancel.take() {
168            // Receiver dropped (task already gone) is fine.
169            let _ = cancel.send(());
170        }
171        if let Some(join) = self.join.take() {
172            if let Err(err) = join.await {
173                tracing::warn!(error = ?err, "peer transport task join error");
174            }
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    /// `shutdown` fires the cancel trigger, the task observes it and returns,
184    /// and the join completes. A second `shutdown` is a no-op.
185    #[tokio::test]
186    async fn shutdown_signals_the_task_and_joins() {
187        let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
188        let join = tokio::spawn(async move {
189            // Stand in for the peer server's `serve_with_incoming_shutdown`:
190            // park until the cancel trigger fires.
191            let _ = cancel_rx.await;
192        });
193        let mut handle = TransportHandle::new(cancel_tx, join);
194        handle.shutdown().await;
195        // Idempotent: cancel and join slots are already taken.
196        handle.shutdown().await;
197    }
198
199    /// The file driver's no-op handle has nothing to signal or join.
200    #[tokio::test]
201    async fn noop_shutdown_is_harmless() {
202        let mut handle = TransportHandle::noop();
203        handle.shutdown().await;
204    }
205
206    /// If the spawned task has already finished, sending the (now-ignored)
207    /// cancel and awaiting the completed join is still clean.
208    #[tokio::test]
209    async fn shutdown_after_task_already_returned() {
210        let (cancel_tx, _cancel_rx) = oneshot::channel::<()>();
211        let join = tokio::spawn(async {});
212        // Let the empty task finish before we shut down.
213        tokio::task::yield_now().await;
214        let mut handle = TransportHandle::new(cancel_tx, join);
215        handle.shutdown().await;
216    }
217
218    /// A fresh signal reports no failure.
219    #[test]
220    fn fatal_signal_starts_untripped() {
221        assert_eq!(FatalSignal::new().check(), None);
222    }
223
224    /// The first component to trip wins; later trips do not overwrite it.
225    #[tokio::test]
226    async fn fatal_trip_records_first_component_only() {
227        let fatal = FatalSignal::new();
228        fatal.trip("peer server");
229        fatal.trip("admin server");
230        assert_eq!(fatal.check(), Some("peer server"));
231        assert_eq!(fatal.tripped().await, "peer server");
232    }
233
234    /// A waiter parked on `tripped()` is woken by a later `trip()`.
235    #[tokio::test]
236    async fn fatal_tripped_wakes_a_pending_waiter() {
237        let fatal = FatalSignal::new();
238        let waiter = tokio::spawn({
239            let fatal = fatal.clone();
240            async move { fatal.tripped().await }
241        });
242        // Let the waiter park before tripping.
243        tokio::task::yield_now().await;
244        fatal.trip("peer server");
245        assert_eq!(waiter.await.unwrap(), "peer server");
246    }
247
248    /// A server future that returns `Err` trips the fatal signal with the
249    /// component label — the zombie-node condition from issue #616.
250    #[tokio::test]
251    async fn supervised_error_exit_trips_fatal() {
252        let fatal = FatalSignal::new();
253        let mut handle =
254            TransportHandle::spawn_supervised("test server", fatal.clone(), |_shutdown| async {
255                Err::<(), std::io::Error>(std::io::Error::other("listener torn down"))
256            });
257        assert_eq!(fatal.tripped().await, "test server");
258        handle.shutdown().await;
259    }
260
261    /// The graceful path — cancel requested, server drains and returns `Ok`
262    /// — must NOT trip the signal.
263    #[tokio::test]
264    async fn supervised_graceful_cancel_does_not_trip() {
265        let fatal = FatalSignal::new();
266        let mut handle = TransportHandle::spawn_supervised(
267            "test server",
268            fatal.clone(),
269            |shutdown| async move {
270                shutdown.await;
271                Ok::<(), std::io::Error>(())
272            },
273        );
274        // Joins the supervisor, so the post-exit bookkeeping has run.
275        handle.shutdown().await;
276        assert_eq!(fatal.check(), None);
277    }
278
279    /// A server that returns `Ok` before anyone asked it to stop is just as
280    /// dead as one that errored: trip.
281    #[tokio::test]
282    async fn supervised_premature_clean_exit_trips_fatal() {
283        let fatal = FatalSignal::new();
284        let mut handle =
285            TransportHandle::spawn_supervised("test server", fatal.clone(), |_shutdown| async {
286                Ok::<(), std::io::Error>(())
287            });
288        assert_eq!(fatal.tripped().await, "test server");
289        handle.shutdown().await;
290    }
291
292    /// A panic inside the server future must also trip the signal — a flat
293    /// task would die with the panic and never report.
294    #[tokio::test]
295    async fn supervised_panic_trips_fatal() {
296        let fatal = FatalSignal::new();
297        let mut handle =
298            TransportHandle::spawn_supervised("test server", fatal.clone(), |_shutdown| async {
299                panic!("server task blew up");
300                #[allow(unreachable_code)]
301                Ok::<(), std::io::Error>(())
302            });
303        assert_eq!(fatal.tripped().await, "test server");
304        handle.shutdown().await;
305    }
306
307    /// Dropping the handle without calling `shutdown()` is intentional
308    /// teardown (the cancel sender drop resolves the shutdown future), not a
309    /// server death: no trip.
310    #[tokio::test]
311    async fn supervised_drop_without_shutdown_does_not_trip() {
312        let fatal = FatalSignal::new();
313        let handle = TransportHandle::spawn_supervised(
314            "test server",
315            fatal.clone(),
316            |shutdown| async move {
317                shutdown.await;
318                Ok::<(), std::io::Error>(())
319            },
320        );
321        drop(handle);
322        for _ in 0..32 {
323            tokio::task::yield_now().await;
324            assert_eq!(fatal.check(), None);
325        }
326    }
327
328    /// End-to-end through real tonic: an incoming stream that errors kills
329    /// `serve_with_incoming_shutdown`, and supervision converts that into a
330    /// fatal trip (whether tonic surfaces it as `Err` or an early `Ok`).
331    #[cfg(feature = "openraft")]
332    #[tokio::test]
333    async fn tonic_incoming_error_propagates_to_fatal() {
334        use std::sync::Arc;
335
336        use crate::admin::service::AdminServiceImpl;
337        use crate::admin::{MembershipAdmin, MembershipView, UnsupportedAdmin};
338        use crate::admin_proto::membership_admin_server::MembershipAdminServer;
339
340        let admin: Arc<dyn MembershipAdmin> = Arc::new(UnsupportedAdmin::new(MembershipView {
341            members: Vec::new(),
342            leader: None,
343        }));
344        let service = MembershipAdminServer::new(AdminServiceImpl::new(admin));
345        let incoming = futures::stream::iter(vec![Err::<tokio::net::TcpStream, std::io::Error>(
346            std::io::Error::other("accept failed"),
347        )]);
348        let fatal = FatalSignal::new();
349        let mut handle =
350            TransportHandle::spawn_supervised("admin server", fatal.clone(), move |shutdown| {
351                tonic::transport::Server::builder()
352                    .add_service(service)
353                    .serve_with_incoming_shutdown(incoming, shutdown)
354            });
355        assert_eq!(fatal.tripped().await, "admin server");
356        handle.shutdown().await;
357    }
358}