Skip to main content

tsoracle_server/
server.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 core::time::Duration;
25use std::future::Future;
26use std::net::SocketAddr;
27use std::sync::Arc;
28use tokio::sync::watch;
29use tonic::service::Routes;
30use tonic::transport::Server as TonicServer;
31use tsoracle_consensus::ConsensusDriver;
32#[cfg(any(test, feature = "test-fakes"))]
33use tsoracle_core::{CoreError, WindowGrant};
34use tsoracle_core::{
35    DEFAULT_LEASE_TTL_CEILING_MS, DEFAULT_LEASE_TTL_FLOOR_MS, Epoch, PeerEndpoint,
36};
37use tsoracle_proto::v1::tso_service_server::TsoServiceServer;
38
39use crate::bt::Bt;
40use crate::clock::{Clock, SystemClock};
41use crate::service::TsoServiceImpl;
42use crate::serving_core::ServingCore;
43
44#[derive(Debug, thiserror::Error)]
45pub enum BuildError {
46    #[error("consensus_driver is required")]
47    MissingConsensusDriver,
48    /// `max_seq_count` was set to 0. Every positive `count` would then be
49    /// rejected as `SeqCountTooLarge` (the `count >= 1` floor leaves no valid
50    /// value), silently disabling `GetSeq`. Rejected at build time so the
51    /// misconfiguration surfaces immediately rather than at first request.
52    #[error("max_seq_count must be >= 1 (0 would reject every GetSeq request)")]
53    ZeroMaxSeqCount,
54    /// `max_seq_batch_keys` was set to 0. Every `GetSeqBatch` request would
55    /// then be rejected as exceeding the batch-key cap (the minimum is 1
56    /// entry), silently disabling `GetSeqBatch`. Rejected at build time so the
57    /// misconfiguration surfaces immediately rather than at first request.
58    #[error("max_seq_batch_keys must be >= 1 (0 would reject every GetSeqBatch request)")]
59    ZeroMaxSeqBatchKeys,
60    #[error("lease_ttl_floor must be non-zero and no greater than lease_ttl_ceiling")]
61    LeaseTtlBoundsInvalid,
62}
63
64#[derive(Debug, thiserror::Error)]
65pub enum ServerError {
66    #[error("transport: {0}")]
67    Transport(#[from] tonic::transport::Error),
68    #[error("consensus: {0}")]
69    Consensus(#[from] tsoracle_consensus::ConsensusError),
70    #[error("core: {0}")]
71    Core(#[from] tsoracle_core::CoreError),
72    /// The leader-watch task panicked. Distinct from a clean error return so
73    /// operators can tell "driver returned Err" (recoverable design) from
74    /// "task panicked" (programming bug).
75    #[error("leader-watch task panicked: {payload}{bt}")]
76    WatchPanic { payload: String, bt: Bt },
77    /// The consensus driver's `leadership_events()` stream ended cleanly while
78    /// the leader-watch task was running. The stream is contracted to live for
79    /// the life of the server, so its end is anomalous (driver shutdown, lost
80    /// session, etc.) — distinct from a `Consensus` error returned mid-fence.
81    /// The watch task publishes `ServingState::NotServing` before returning
82    /// this variant so embedders who never observe the [`WatchGuard`] still get
83    /// the documented fail-safe behavior.
84    #[error("consensus driver leadership stream closed")]
85    WatchStreamClosed,
86    /// The embedded protobuf descriptor set failed to decode while building the
87    /// gRPC reflection service. `tsoracle-proto`'s `build.rs` emits these bytes
88    /// from checked-in `.proto` sources, so a failure here signals build-artifact
89    /// drift (a corrupt or stale descriptor) rather than a runtime condition —
90    /// surfaced as a diagnosable startup error instead of a process panic.
91    #[cfg(feature = "reflection")]
92    #[error("failed to build gRPC reflection service from embedded descriptor set: {0}")]
93    ReflectionInit(#[source] tonic_reflection::server::Error),
94}
95
96#[derive(Clone, Debug)]
97pub enum ServingState {
98    NotServing {
99        leader_endpoint: Option<PeerEndpoint>,
100        leader_epoch: Option<Epoch>,
101    },
102    Serving,
103}
104
105/// Default bound on how long a graceful shutdown waits for the leader-watch
106/// task to stop cooperatively before forcibly aborting it. The abort is a
107/// last-resort safety net for a consensus driver whose `load_high_water` /
108/// `persist_high_water` is wedged (the trait places no latency bound; see
109/// [`ConsensusDriver`]). Chosen to sit comfortably under a typical Kubernetes
110/// `terminationGracePeriodSeconds` (30s) so the abort, the tonic drain, and
111/// process exit all complete before the kubelet escalates to SIGKILL.
112const DEFAULT_SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
113
114pub struct ServerBuilder {
115    consensus: Option<Arc<dyn ConsensusDriver>>,
116    clock: Option<Arc<dyn Clock>>,
117    window_ahead: Duration,
118    failover_advance: Duration,
119    shutdown_grace: Duration,
120    heartbeat_interval: Duration,
121    max_seq_count: u32,
122    max_seq_batch_keys: u32,
123    lease_ttl_floor: Duration,
124    lease_ttl_ceiling: Duration,
125    #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
126    tls_config: Option<tonic::transport::ServerTlsConfig>,
127}
128
129impl Default for ServerBuilder {
130    fn default() -> Self {
131        ServerBuilder {
132            consensus: None,
133            clock: None,
134            window_ahead: Duration::from_secs(3),
135            failover_advance: Duration::from_secs(1),
136            shutdown_grace: DEFAULT_SHUTDOWN_GRACE,
137            heartbeat_interval: Duration::from_secs(10),
138            max_seq_count: tsoracle_core::DEFAULT_MAX_SEQ_COUNT,
139            max_seq_batch_keys: tsoracle_core::DEFAULT_MAX_SEQ_BATCH_KEYS,
140            lease_ttl_floor: Duration::from_millis(DEFAULT_LEASE_TTL_FLOOR_MS),
141            lease_ttl_ceiling: Duration::from_millis(DEFAULT_LEASE_TTL_CEILING_MS),
142            #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
143            tls_config: None,
144        }
145    }
146}
147
148impl ServerBuilder {
149    pub fn consensus_driver(mut self, driver: Arc<dyn ConsensusDriver>) -> Self {
150        self.consensus = Some(driver);
151        self
152    }
153    pub fn clock(mut self, clock: Arc<dyn Clock>) -> Self {
154        self.clock = Some(clock);
155        self
156    }
157    pub fn window_ahead(mut self, window_ahead: Duration) -> Self {
158        self.window_ahead = window_ahead;
159        self
160    }
161    pub fn failover_advance(mut self, failover_advance: Duration) -> Self {
162        self.failover_advance = failover_advance;
163        self
164    }
165
166    /// Server-enforced lower bound on requested lease TTLs.
167    pub fn lease_ttl_floor(mut self, lease_ttl_floor: Duration) -> Self {
168        self.lease_ttl_floor = lease_ttl_floor;
169        self
170    }
171
172    /// Server-enforced upper bound on requested lease TTLs.
173    pub fn lease_ttl_ceiling(mut self, lease_ttl_ceiling: Duration) -> Self {
174        self.lease_ttl_ceiling = lease_ttl_ceiling;
175        self
176    }
177
178    /// Bound on how long a graceful shutdown waits for the leader-watch task to
179    /// stop cooperatively before forcibly aborting it.
180    ///
181    /// On shutdown the server drops the watch task's cancel signal and waits for
182    /// it to publish `NotServing` and return. That wait is normally
183    /// near-instant, but the task observes cancellation only at its `select!`
184    /// boundaries — never inside a fence attempt. A consensus driver whose
185    /// `load_high_water` / `persist_high_water` never returns (the trait places
186    /// no latency bound) would otherwise park the task mid-fence and block
187    /// process exit indefinitely, leading to a SIGKILL on a Kubernetes drain.
188    /// Once `shutdown_grace` elapses the server aborts the task so exit always
189    /// makes progress. Set this comfortably below your deployment's
190    /// `terminationGracePeriodSeconds`. Defaults to 10s. A value of zero aborts
191    /// immediately without waiting for a cooperative stop.
192    pub fn shutdown_grace(mut self, shutdown_grace: Duration) -> Self {
193        self.shutdown_grace = shutdown_grace;
194        self
195    }
196
197    /// Interval between heartbeat log lines emitted at `target = "tsoracle::heartbeat"`.
198    /// Defaults to 10 seconds. Pass `Duration::ZERO` to disable the heartbeat task entirely.
199    ///
200    /// The heartbeat surfaces serving role, current epoch, requests served,
201    /// timestamps issued, and key error counters every interval — proof-of-life
202    /// for production deployments that may not have a metrics exporter installed.
203    ///
204    /// Requires `feature = "tracing"` to emit output; with `tracing` off the
205    /// setter is accepted but no task is spawned (no subscriber to log to).
206    pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
207        self.heartbeat_interval = interval;
208        self
209    }
210
211    /// Configure TLS termination for this server. Applied inside
212    /// [`Server::serve`], [`Server::serve_with_shutdown`], and
213    /// [`Server::serve_with_listener`]. Not applied to [`Server::into_router`] —
214    /// embedders mounting tsoracle alongside their own services control TLS
215    /// on their own tonic builder.
216    #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
217    pub fn tls_config(mut self, cfg: tonic::transport::ServerTlsConfig) -> Self {
218        self.tls_config = Some(cfg);
219        self
220    }
221
222    /// Per-call ceiling on `GetSeq`'s `count` — the largest contiguous block a
223    /// single dense-sequence request may reserve. Defaults to
224    /// [`tsoracle_core::DEFAULT_MAX_SEQ_COUNT`] (`65_536`).
225    ///
226    /// This is a soft anti-abuse guardrail, not a representational limit: a
227    /// dense block is permanently consumed (the gapless counter only moves
228    /// forward), so the cap bounds how much one call can irrevocably reserve.
229    /// Raise it for batch-allocation workloads or lower it to tighten abuse
230    /// control. The server is the sole authority — clients no longer pre-check
231    /// `count` against a fixed constant, so changing this needs no client
232    /// rebuild; an over-cap request is rejected with `INVALID_ARGUMENT`. The
233    /// `count >= 1` floor is always enforced regardless of this value.
234    ///
235    /// A value of `0` is rejected by [`Self::build`] with
236    /// [`BuildError::ZeroMaxSeqCount`]: it would leave no valid `count` (the
237    /// floor is 1), silently disabling `GetSeq`, so the misconfiguration is
238    /// surfaced at build time rather than at first request.
239    pub fn max_seq_count(mut self, max_seq_count: u32) -> Self {
240        self.max_seq_count = max_seq_count;
241        self
242    }
243
244    /// Cap on the number of `(key, count)` entries accepted in one
245    /// `GetSeqBatch` request. Defaults to
246    /// [`tsoracle_core::DEFAULT_MAX_SEQ_BATCH_KEYS`] (`128`).
247    ///
248    /// This is a soft anti-abuse guardrail bounding fan-out and the size of
249    /// one atomic consensus entry. A value of `0` is rejected by
250    /// [`Self::build`] with [`BuildError::ZeroMaxSeqBatchKeys`]: it would
251    /// make every `GetSeqBatch` call fail, silently disabling the RPC, so the
252    /// misconfiguration is surfaced at build time. Deployment-specific tuning;
253    /// clients need no rebuild after changing this.
254    pub fn max_seq_batch_keys(mut self, max_seq_batch_keys: u32) -> Self {
255        self.max_seq_batch_keys = max_seq_batch_keys;
256        self
257    }
258
259    pub fn build(self) -> Result<Server, BuildError> {
260        let consensus = self.consensus.ok_or(BuildError::MissingConsensusDriver)?;
261        if self.max_seq_count == 0 {
262            return Err(BuildError::ZeroMaxSeqCount);
263        }
264        if self.max_seq_batch_keys == 0 {
265            return Err(BuildError::ZeroMaxSeqBatchKeys);
266        }
267        if self.lease_ttl_floor.is_zero() || self.lease_ttl_floor > self.lease_ttl_ceiling {
268            return Err(BuildError::LeaseTtlBoundsInvalid);
269        }
270        let clock = self.clock.unwrap_or_else(|| Arc::new(SystemClock));
271        Ok(Server {
272            consensus,
273            clock,
274            window_ahead: self.window_ahead,
275            failover_advance: self.failover_advance,
276            shutdown_grace: self.shutdown_grace,
277            heartbeat_interval: self.heartbeat_interval,
278            lease_ttl_floor: self.lease_ttl_floor,
279            lease_ttl_ceiling: self.lease_ttl_ceiling,
280            core: Arc::new(ServingCore::new(
281                self.window_ahead,
282                self.max_seq_count,
283                self.max_seq_batch_keys,
284            )),
285            reporter: Arc::new(crate::reporter::Reporter::new()),
286            #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
287            tls_config: self.tls_config,
288        })
289    }
290}
291
292pub struct Server {
293    pub(crate) consensus: Arc<dyn ConsensusDriver>,
294    pub(crate) clock: Arc<dyn Clock>,
295    pub(crate) window_ahead: Duration,
296    pub(crate) failover_advance: Duration,
297    pub(crate) lease_ttl_floor: Duration,
298    pub(crate) lease_ttl_ceiling: Duration,
299    /// Bound on the graceful-shutdown wait for the leader-watch task before a
300    /// forced abort. See [`ServerBuilder::shutdown_grace`].
301    pub(crate) shutdown_grace: Duration,
302    /// Interval between periodic heartbeat log lines. See [`ServerBuilder::heartbeat_interval`].
303    ///
304    /// The only reader is the `cfg(feature = "tracing")` spawn block in
305    /// `into_router_parts`; without `tracing` there is no subscriber to log to
306    /// and the spawn arm is compiled out, so the field is genuinely unread.
307    #[cfg_attr(not(feature = "tracing"), allow(dead_code))]
308    pub(crate) heartbeat_interval: Duration,
309    /// Owns the allocator, serving-state channel, and both extension locks, with
310    /// the lock-ordering and step-down invariants private behind its methods.
311    ///
312    /// Held behind an `Arc` so the leader-watch task, the gRPC service, and the
313    /// [`WatchGuard`] / [`serve_inner`] shutdown paths can all reach the same
314    /// core. The guard and the serve loop use their clone to close the serving
315    /// gate *synchronously* at shutdown, leaving the watch task's later
316    /// `step_down` a harmless idempotent repeat.
317    pub(crate) core: Arc<ServingCore>,
318    pub(crate) reporter: Arc<crate::reporter::Reporter>,
319    #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
320    pub(crate) tls_config: Option<tonic::transport::ServerTlsConfig>,
321}
322
323/// Raw parts produced by [`Server::into_router_parts`]: the gRPC `Routes`, the
324/// leader-watch task's cooperative-cancel sender (dropping it stops the task),
325/// the task's join handle, and the optional heartbeat task's cancel sender /
326/// join handle. [`Server::into_router`] wraps these into a [`WatchGuard`]; the
327/// `serve_*` methods consume them directly via [`serve_inner`].
328///
329/// The heartbeat fields are `None` when the heartbeat is disabled — either by
330/// `ServerBuilder::heartbeat_interval(Duration::ZERO)` or by building without
331/// the `tracing` feature (there is no subscriber to log to).
332pub(crate) struct RouterParts {
333    pub routes: Routes,
334    pub cancel_tx: tokio::sync::oneshot::Sender<()>,
335    pub watch_handle: tokio::task::JoinHandle<Result<(), ServerError>>,
336    pub heartbeat_cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
337    pub heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
338}
339
340impl Server {
341    pub fn builder() -> ServerBuilder {
342        ServerBuilder::default()
343    }
344
345    /// Subscribe to serving-state transitions.
346    ///
347    /// Returns a fresh `watch::Receiver` observing the same `ServingState`
348    /// the server publishes as leadership comes and goes. Embedders use this
349    /// to gate their own startup on `ServingState::Serving` (see the
350    /// `embedded_router` and piggyback examples). Because `into_router`
351    /// consumes the `Server`, capture the receiver before mounting.
352    ///
353    /// This method is the stable observation API: the receiver is minted from
354    /// the server's `watch::Sender`, so the receiver's type can evolve (e.g. a
355    /// future newtype around `ServingState`) without breaking embedders that
356    /// go through it.
357    pub fn subscribe(&self) -> watch::Receiver<ServingState> {
358        self.core.subscribe()
359    }
360}
361
362impl Server {
363    /// Return the configured `TsoServiceServer<TsoServiceImpl>` as a tonic
364    /// `Routes` value plus a [`WatchGuard`] for the spawned leader-watch task,
365    /// so callers can mount tsoracle's service alongside their own services
366    /// on a shared tonic listener instead of binding a dedicated port.
367    ///
368    /// The returned [`WatchGuard`] owns the leader-watch task. **Keep it alive
369    /// for as long as the mounted `Routes` should serve**: the watch task holds
370    /// an `Arc<Server>` (and the consensus driver) and maintains serving state
371    /// across leadership transitions. Dropping the guard — or calling
372    /// [`WatchGuard::shutdown`] — cooperatively stops the task at the embedder's
373    /// own shutdown. Without the guard the task would keep `Arc<Server>` alive
374    /// until the leadership stream happened to close.
375    ///
376    /// Every termination of the task — cooperative cancellation, driver error,
377    /// panic, or clean EOF on the leadership stream (surfaced as
378    /// `ServerError::WatchStreamClosed`) — publishes
379    /// `ServingState::NotServing { leader_endpoint: None }` before returning, so
380    /// all subsequent RPCs fail fast with `FAILED_PRECONDITION`. Embedders who
381    /// drop the guard without awaiting still get fail-safe behavior.
382    ///
383    /// The `Server::serve()` method is a thin wrapper over this — it calls
384    /// `into_router`, builds a tonic `Server`, and binds a listener.
385    ///
386    /// Returns `Err(ServerError::ReflectionInit)` (only reachable under the
387    /// `reflection` feature) if the embedded descriptor set fails to decode.
388    /// That decode happens before the leader-watch task is spawned, so a failure
389    /// leaves nothing running to clean up.
390    pub fn into_router(self) -> Result<(Routes, WatchGuard), ServerError> {
391        // Read the `Copy` grace before `into_router_parts` consumes `self`, so
392        // the returned guard can bound its own shutdown wait identically to the
393        // `serve_*` paths.
394        let shutdown_grace = self.shutdown_grace;
395        // Clone the shared core and reporter before `into_router_parts` consumes
396        // `self`, so the guard can close the serving gate synchronously on drop /
397        // shutdown rather than relying on the watch task's later publish, and can
398        // record the shutdown_watch_aborted counter if the grace-bounded reap fires.
399        let core = self.core.clone();
400        let reporter = self.reporter.clone();
401        let parts = self.into_router_parts()?;
402        Ok((
403            parts.routes,
404            WatchGuard {
405                cancel_tx: Some(parts.cancel_tx),
406                handle: Some(parts.watch_handle),
407                shutdown_grace,
408                core,
409                reporter,
410                heartbeat_cancel_tx: parts.heartbeat_cancel_tx,
411                heartbeat_handle: parts.heartbeat_handle,
412            },
413        ))
414    }
415
416    /// Spawn the leader-watch task and assemble the gRPC `Routes`, returning
417    /// the raw parts: the routes, the task's cooperative-cancel sender, and its
418    /// `JoinHandle`. [`Self::into_router`] wraps these into a [`WatchGuard`] for
419    /// embedders; the `serve_*` methods drive the parts directly via
420    /// [`serve_inner`], so neither path needs to unwrap the guard's `Option`
421    /// fields.
422    fn into_router_parts(self) -> Result<RouterParts, ServerError> {
423        // Build the reflection service first: a descriptor-decode failure must
424        // surface before we spawn the leader-watch task below, so an error path
425        // never leaks a running task.
426        #[cfg(feature = "reflection")]
427        let reflection = build_reflection_service(tsoracle_proto::FILE_DESCRIPTOR_SET)?;
428
429        let server = Arc::new(self);
430
431        // Cooperative cancellation channel. The `WatchGuard` holds the sender;
432        // the task's `cancel` future resolves on either an explicit send or a
433        // sender drop, so dropping the guard is sufficient to stop the task.
434        let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
435
436        let watch_server = server.clone();
437        let watch_handle = tokio::spawn(async move {
438            use futures::FutureExt;
439            // Resolves when the WatchGuard signals cancellation or is dropped.
440            let cancel = async move {
441                let _ = cancel_rx.await;
442            };
443            // catch_unwind so a panic in run_leader_watch still routes through
444            // the poisoning path. Without this, embedders who mount into_router
445            // directly and never observe the guard would see
446            // ServingState::Serving remain published while the watch task is
447            // dead — the inverse of the fail-safe guarantee documented above.
448            // The panic is re-raised after poisoning so serve / serve_with_*
449            // continue to translate it into ServerError::WatchPanic via
450            // join_to_server_result.
451            let outcome = std::panic::AssertUnwindSafe(crate::fence::run_leader_watch(
452                watch_server.clone(),
453                cancel,
454            ))
455            .catch_unwind()
456            .await;
457            match outcome {
458                Ok(result) => {
459                    if let Err(ref _e) = result {
460                        // Poison BEFORE returning so embedders who do not observe
461                        // the guard still get fail-safe behavior.
462                        watch_server.core.step_down(None, None);
463                        #[cfg(feature = "tracing")]
464                        tracing::error!(error = %_e, "leader-watch terminated; serving disabled");
465                    }
466                    result
467                }
468                Err(panic_payload) => {
469                    // Mirror the Err branch: poison BEFORE re-raising so
470                    // guard-dropping embedders still observe NotServing.
471                    watch_server.core.step_down(None, None);
472                    #[cfg(feature = "tracing")]
473                    tracing::error!("leader-watch panicked; serving disabled");
474                    std::panic::resume_unwind(panic_payload);
475                }
476            }
477        });
478
479        // Spawn the heartbeat task, if enabled. Gated on `feature = "tracing"`
480        // because the heartbeat module is only compiled with `tracing`
481        // (no subscriber to emit to without it) — and on a non-zero interval,
482        // since `Duration::ZERO` is the documented opt-out.
483        //
484        // The task body is wrapped in `AssertUnwindSafe(...).catch_unwind()`
485        // mirroring the leader-watch spawn above: on panic we bump the
486        // `heartbeat_task_panicked` counter and log at error level, then let
487        // the task end (no restart — the heartbeat is observability, not
488        // correctness, so a panicked task must not be allowed to thrash).
489        let (heartbeat_cancel_tx, heartbeat_handle) = {
490            #[cfg(feature = "tracing")]
491            {
492                if server.heartbeat_interval.is_zero() {
493                    (None, None)
494                } else {
495                    use futures::FutureExt;
496                    let (htx, hrx) = tokio::sync::oneshot::channel::<()>();
497                    let hb_reporter = server.reporter.clone();
498                    let hb_core = server.core.clone();
499                    let hb_interval = server.heartbeat_interval;
500                    let handle = tokio::spawn(async move {
501                        let outcome =
502                            std::panic::AssertUnwindSafe(crate::heartbeat::run_heartbeat(
503                                hb_interval,
504                                hb_core,
505                                hb_reporter.clone(),
506                                hrx,
507                            ))
508                            .catch_unwind()
509                            .await;
510                        if outcome.is_err() {
511                            hb_reporter.heartbeat_task_panicked.increment(1);
512                            tracing::error!(
513                                target: "tsoracle::heartbeat",
514                                "heartbeat task panicked; liveness logs disabled until restart"
515                            );
516                        }
517                    });
518                    (Some(htx), Some(handle))
519                }
520            }
521            #[cfg(not(feature = "tracing"))]
522            {
523                (None, None)
524            }
525        };
526
527        let service = TsoServiceImpl { server };
528        #[allow(unused_mut)]
529        let mut routes = Routes::new(TsoServiceServer::new(service));
530        #[cfg(feature = "reflection")]
531        {
532            routes = routes.add_service(reflection);
533        }
534        Ok(RouterParts {
535            routes,
536            cancel_tx,
537            watch_handle,
538            heartbeat_cancel_tx,
539            heartbeat_handle,
540        })
541    }
542
543    pub async fn serve(self, addr: SocketAddr) -> Result<(), ServerError> {
544        self.serve_with_shutdown(addr, futures::future::pending())
545            .await
546    }
547
548    /// Run the gRPC server until either the caller's `shutdown` fires or the
549    /// leader-watch task terminates.
550    ///
551    /// Three outcomes:
552    /// 1. `shutdown` fires first → tonic drains in-flights and returns Ok.
553    ///    The watch task is then stopped cooperatively, bounded by
554    ///    `shutdown_grace` and forcibly aborted if it overruns (e.g. parked in a
555    ///    wedged consensus-driver call); any error it had been about to return
556    ///    is forfeited (the process is shutting down anyway).
557    /// 2. Watch returns `Ok(Err(e))` → poisoned state is already published;
558    ///    `cancel_tx` triggers tonic's graceful shutdown; in-flight `GetTs`
559    ///    calls whose `try_grant` already succeeded complete with the
560    ///    timestamps they were allocated; new calls fail fast. Returns `Err(e)`
561    ///    — the watch error wins even if the drain itself also errors (see
562    ///    `combine_watch_and_drain`); a drain error is surfaced only when the
563    ///    watch ended cleanly.
564    /// 3. Watch task panics → returns `Err(ServerError::WatchPanic{..})`
565    ///    with the panic payload stringified. Same drain semantics as (2).
566    pub async fn serve_with_shutdown(
567        self,
568        addr: SocketAddr,
569        shutdown: impl Future<Output = ()> + Send + 'static,
570    ) -> Result<(), ServerError> {
571        #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
572        let tls_config = self.tls_config.clone();
573
574        // Read the `Copy` grace and clone the shared core and reporter before
575        // `into_router_parts` consumes `self`.
576        let shutdown_grace = self.shutdown_grace;
577        let core = self.core.clone();
578        let reporter = self.reporter.clone();
579        let parts = self.into_router_parts()?;
580        let (combined_shutdown, cancel_tx) = combined_shutdown_with_cancel(shutdown);
581
582        let mut tonic = TonicServer::builder();
583        #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
584        if let Some(cfg) = tls_config {
585            tonic = tonic.tls_config(cfg).map_err(ServerError::Transport)?;
586        }
587        let serve = tonic
588            .add_routes(parts.routes)
589            .serve_with_shutdown(addr, combined_shutdown);
590
591        serve_inner(
592            parts.cancel_tx,
593            parts.watch_handle,
594            parts.heartbeat_cancel_tx,
595            parts.heartbeat_handle,
596            serve,
597            cancel_tx,
598            shutdown_grace,
599            core,
600            reporter,
601        )
602        .await
603    }
604
605    /// Run the gRPC server on a caller-provided `TcpListener` until either
606    /// the caller-provided `shutdown` fires or the leader-watch task terminates.
607    ///
608    /// Use this instead of [`Self::serve_with_shutdown`] when you need to
609    /// observe the OS-picked port (`127.0.0.1:0`) before clients connect, or
610    /// when you want to wrap the listener in an outer adapter before passing it
611    /// in. The listening socket is owned by the caller and passed here; tsoracle
612    /// starts accepting on it immediately.
613    ///
614    /// Three outcomes:
615    /// 1. `shutdown` fires first → tonic drains in-flights and returns `Ok`.
616    ///    The watch handle is aborted; any error it had been about to return
617    ///    is forfeited (the process is shutting down anyway).
618    /// 2. Watch returns `Ok(Err(e))` → poisoned state is already published;
619    ///    the caller-provided shutdown is cancelled internally so tonic begins
620    ///    graceful shutdown; in-flight `GetTs` calls whose `try_grant` already
621    ///    succeeded complete with the timestamps they were allocated; new calls
622    ///    fail fast. Returns `Err(e)` — the watch error wins even if the drain
623    ///    itself also errors (see `combine_watch_and_drain`); a drain error is
624    ///    surfaced only when the watch ended cleanly.
625    /// 3. Watch task panics → returns `Err(ServerError::WatchPanic{..})`
626    ///    with the panic payload stringified. Same drain semantics as (2).
627    pub async fn serve_with_listener(
628        self,
629        listener: tokio::net::TcpListener,
630        shutdown: impl Future<Output = ()> + Send + 'static,
631    ) -> Result<(), ServerError> {
632        #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
633        let tls_config = self.tls_config.clone();
634
635        // Read the `Copy` grace and clone the shared core and reporter before
636        // `into_router_parts` consumes `self`.
637        let shutdown_grace = self.shutdown_grace;
638        let core = self.core.clone();
639        let reporter = self.reporter.clone();
640        let parts = self.into_router_parts()?;
641        let (combined_shutdown, cancel_tx) = combined_shutdown_with_cancel(shutdown);
642
643        let incoming = tonic::transport::server::TcpIncoming::from(listener);
644
645        let mut tonic = TonicServer::builder();
646        #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
647        if let Some(cfg) = tls_config {
648            tonic = tonic.tls_config(cfg).map_err(ServerError::Transport)?;
649        }
650        let serve = tonic
651            .add_routes(parts.routes)
652            .serve_with_incoming_shutdown(incoming, combined_shutdown);
653
654        serve_inner(
655            parts.cancel_tx,
656            parts.watch_handle,
657            parts.heartbeat_cancel_tx,
658            parts.heartbeat_handle,
659            serve,
660            cancel_tx,
661            shutdown_grace,
662            core,
663            reporter,
664        )
665        .await
666    }
667}
668
669/// RAII handle to the leader-watch task spawned by [`Server::into_router`].
670///
671/// The watch task holds an `Arc<Server>` (and thus the consensus driver) and
672/// maintains serving state across leadership transitions. This guard ties the
673/// task's lifetime to the guard's: dropping it cooperatively cancels the task,
674/// and the task publishes [`ServingState::NotServing`] before it stops, so any
675/// `Routes` an embedder still has mounted fails subsequent RPCs fast.
676///
677/// Cancellation is cooperative — the task stops at its next await boundary and
678/// never mid-fence, so it is never torn down while holding internal locks, in
679/// contrast to a raw [`tokio::task::JoinHandle::abort`].
680pub struct WatchGuard {
681    // `Option` so `Drop` and the consuming `shutdown` / `abort` methods can
682    // each take a field without a partial-move conflict against the `Drop`
683    // impl. Dropping the sender (rather than sending) is itself the cancel
684    // signal: the task's `cancel` future resolves on sender-drop too.
685    cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
686    handle: Option<tokio::task::JoinHandle<Result<(), ServerError>>>,
687    /// Bound on the cooperative-stop wait in [`Self::shutdown`] before a forced
688    /// abort. Inherited from [`ServerBuilder::shutdown_grace`].
689    shutdown_grace: Duration,
690    /// Shared serving core, cloned from the `Server`. Lets `Drop` (and the
691    /// consuming `shutdown` / `abort`, which trigger `Drop` on return) close the
692    /// serving gate synchronously at the drop site, instead of waiting for the
693    /// watch task to observe cancellation and publish `NotServing` on its own
694    /// timeline — a window during which the fast gate would still admit RPCs.
695    core: Arc<ServingCore>,
696    /// Metrics reporter, cloned from the `Server`. Used to record the
697    /// `shutdown_watch_aborted` counter if the grace-bounded reap fires.
698    reporter: Arc<crate::reporter::Reporter>,
699    /// Cooperative-cancel sender for the heartbeat task. `None` when the
700    /// heartbeat is disabled (interval == 0 or built without `tracing`).
701    /// Dropping the sender resolves the task's cancel future.
702    heartbeat_cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
703    /// Join handle for the heartbeat task. `None` when the heartbeat is
704    /// disabled. Output is `()` because the task never returns an error —
705    /// panics are caught inside the task body and recorded via the
706    /// `heartbeat_task_panicked` counter.
707    heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
708}
709
710impl WatchGuard {
711    /// Signal the leader-watch task to stop, wait for it to drain, and report
712    /// its outcome.
713    ///
714    /// A cooperatively cancelled task returns `Ok(())` — the stop was
715    /// requested, so it is not an error. If the task had already terminated on
716    /// its own (driver error, stream EOF, or panic) the original outcome is
717    /// surfaced verbatim: `Err(e)` or [`ServerError::WatchPanic`]. Either way
718    /// serving state is `NotServing` once this returns.
719    ///
720    /// The cooperative wait is bounded by the configured
721    /// [`ServerBuilder::shutdown_grace`]: if the task is parked in a
722    /// consensus-driver call that never returns it is aborted once the grace
723    /// elapses (still reported as `Ok(())`), so an embedder's shutdown can never
724    /// wedge behind a hung driver.
725    pub async fn shutdown(mut self) -> Result<(), ServerError> {
726        // Dropping the senders fires each task's cancel future. The heartbeat
727        // task is reaped first because it is bounded by `tokio::time::sleep`
728        // (cooperative stop is fast and never wedges on a driver call), so its
729        // reap returns quickly and leaves the grace budget for the watch task
730        // — which may be parked in a wedged consensus-driver call.
731        self.heartbeat_cancel_tx.take();
732        self.cancel_tx.take();
733        if let Some(mut hb_handle) = self.heartbeat_handle.take() {
734            match tokio::time::timeout(self.shutdown_grace, &mut hb_handle).await {
735                Ok(Ok(())) => {}
736                // Task panicked — already counted + logged via catch_unwind in
737                // the task body. Nothing more to do here.
738                Ok(Err(_join_err)) => {}
739                // Grace overrun — sleep + select! should always observe a
740                // dropped cancel sender, so this is a backstop. Abort and
741                // reap; no separate metric (the heartbeat is observability
742                // only — its lateness is not a serving correctness signal).
743                Err(_elapsed) => {
744                    hb_handle.abort();
745                    let _ = (&mut hb_handle).await;
746                }
747            }
748        }
749        match self.handle.take() {
750            Some(mut handle) => join_to_server_result(
751                await_watch_within_grace(&mut handle, self.shutdown_grace, &self.reporter).await,
752            ),
753            None => Ok(()),
754        }
755    }
756
757    /// Hard-abort the leader-watch task without waiting for a cooperative stop.
758    ///
759    /// Prefer [`Self::shutdown`] or simply dropping the guard; both let the
760    /// task stop at a safe point. This is an escape hatch for callers that
761    /// cannot await and accept that the task may be torn down mid-fence.
762    pub fn abort(mut self) {
763        if let Some(handle) = self.handle.take() {
764            handle.abort();
765        }
766        // Hard-abort the heartbeat task too — leaving it running after the
767        // watch is torn down would publish heartbeats describing a stale
768        // (typically `NotServing`) view until the Arc<Reporter> is dropped.
769        if let Some(hb_handle) = self.heartbeat_handle.take() {
770            hb_handle.abort();
771        }
772    }
773
774    /// Whether the leader-watch task has finished — terminated for any reason
775    /// (cooperative cancel, driver error, stream EOF, or panic).
776    ///
777    /// A read-only liveness probe that neither consumes the guard nor disturbs
778    /// its cancel-on-drop behavior, so an embedder can poll task health while
779    /// keeping the guard alive.
780    pub fn is_finished(&self) -> bool {
781        self.handle
782            .as_ref()
783            .is_none_or(|handle| handle.is_finished())
784    }
785}
786
787impl Drop for WatchGuard {
788    fn drop(&mut self) {
789        // Close the serving gate synchronously, here at the drop site. Dropping
790        // the cancel sender below only *requests* the watch task to stop; the
791        // task publishes `NotServing` later, on its own timeline. Between this
792        // drop and the task's next poll the fast gate would still read `Serving`
793        // (and the allocator would still grant), so an RPC could be admitted by a
794        // server that has already been told to stop serving. `step_down` clears
795        // the allocator and publishes `NotServing` now, before any await; the
796        // watch task's later `step_down(None, None)` on cooperative cancel (see
797        // `fence::run_leader_watch`) republishes the identical state, so the
798        // double-close is harmless and idempotent.
799        self.core.step_down(None, None);
800        // Dropping the sender (if `shutdown` / `abort` did not already take it)
801        // resolves the task's cancel future; the task then publishes
802        // `NotServing` and returns. The `JoinHandle` is dropped here too,
803        // detaching the task to finish its cooperative shutdown on its own.
804        self.cancel_tx.take();
805        // Same treatment for the heartbeat task. `Drop` is sync so we cannot
806        // await the cooperative stop; instead we drop the cancel sender (the
807        // task will observe it at its next select! boundary) and hard-abort
808        // the handle so it cannot outlive the guard and publish heartbeats
809        // describing a stale view if the runtime keeps the Arc alive.
810        self.heartbeat_cancel_tx.take();
811        if let Some(hb_handle) = self.heartbeat_handle.take() {
812            hb_handle.abort();
813        }
814    }
815}
816
817/// Merge the caller's `shutdown` future with an internal cancellation signal.
818///
819/// Both [`Server::serve_with_shutdown`] and [`Server::serve_with_listener`]
820/// need tonic to stop when EITHER the caller's `shutdown` fires OR the
821/// leader-watch task terminates (signalled by firing the returned
822/// `oneshot::Sender`). This builds that merged shutdown future and hands back
823/// the sender so [`serve_inner`] can trip it from the watch arm.
824fn combined_shutdown_with_cancel(
825    shutdown: impl Future<Output = ()> + Send + 'static,
826) -> (
827    impl Future<Output = ()> + Send + 'static,
828    tokio::sync::oneshot::Sender<()>,
829) {
830    let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
831    let combined_shutdown = async move {
832        tokio::select! {
833            _ = shutdown => {}
834            _ = cancel_rx => {}
835        }
836    };
837    (combined_shutdown, cancel_tx)
838}
839
840/// Wait for the leader-watch task to stop cooperatively, but no longer than
841/// `grace`, then forcibly abort it if it is still running.
842///
843/// The watch task observes its cancel signal only at the `select!` boundaries
844/// in [`crate::fence::run_leader_watch`], never inside a fence attempt. A
845/// consensus driver whose `load_high_water` / `persist_high_water` never
846/// returns (the trait places no latency bound; see
847/// [`tsoracle_consensus::ConsensusDriver`]) therefore parks the task upstream
848/// of any cancel-observing await, so dropping the cancel sender cannot stop it.
849/// Left unbounded, the shutdown wait would block process exit until the kubelet
850/// escalates to SIGKILL on a drain. Bounding the wait by `grace` and aborting
851/// on expiry guarantees forward progress: `tokio` tears a suspended task (and
852/// the wedged driver future it holds) down at the abort, dropping its
853/// drain-barrier guard.
854///
855/// Returns the task's join result. A clean cooperative stop forwards its real
856/// outcome verbatim; an aborted task surfaces as a cancelled `JoinError`, which
857/// [`join_to_server_result`] maps to `Ok(())` — the stop was requested, so a
858/// forced abort during shutdown is not an error. A `grace` of zero aborts
859/// immediately (the `timeout` future is already elapsed on first poll).
860async fn await_watch_within_grace(
861    watch_handle: &mut tokio::task::JoinHandle<Result<(), ServerError>>,
862    grace: Duration,
863    reporter: &Arc<crate::reporter::Reporter>,
864) -> Result<Result<(), ServerError>, tokio::task::JoinError> {
865    match tokio::time::timeout(grace, &mut *watch_handle).await {
866        Ok(join_result) => join_result,
867        Err(_elapsed) => {
868            reporter.shutdown_watch_aborted.increment(1);
869            #[cfg(feature = "tracing")]
870            tracing::warn!(
871                grace_ms = grace.as_millis() as u64,
872                "leader-watch task did not stop within the shutdown grace; aborting it (a consensus-driver call likely exceeded its latency bound)"
873            );
874            watch_handle.abort();
875            // Reap the aborted task so its Drop (releasing any held drain-barrier
876            // guard) runs before we report shutdown complete. Bounded: an aborted
877            // task resolves at its next poll.
878            (&mut *watch_handle).await
879        }
880    }
881}
882
883/// Drive the gRPC `serve_future` against the leader-watch task, shared by
884/// [`Server::serve_with_shutdown`] and [`Server::serve_with_listener`].
885///
886/// The two public methods differ only in how `serve_future` is assembled
887/// (address-bound via `serve_with_shutdown` vs listener-bound via
888/// `serve_with_incoming_shutdown`); everything downstream — the biased select,
889/// the cooperative-cancel path, and the drain/translate logic — is identical
890/// and lives here so a future change need only be made once.
891///
892/// `tonic_cancel_tx` is the cancellation half paired with the `serve_future`'s
893/// shutdown signal (see [`combined_shutdown_with_cancel`]); firing it begins
894/// tonic's graceful drain when the watch task terminates first. `watch_cancel_tx`
895/// is the leader-watch task's own cooperative-cancel sender (the same one a
896/// [`WatchGuard`] holds for embedders); dropping it stops the task. Taking the
897/// raw parts rather than a `WatchGuard` keeps this path free of the guard's
898/// `Option` fields — neither the watch handle nor the cancel sender is optional
899/// here. `shutdown_grace` bounds the user-shutdown arm's wait for the watch task
900/// (see [`await_watch_within_grace`]). `core` is the shared serving core (the
901/// same one the watch task and the gRPC service hold): the user-shutdown arm
902/// closes the gate on it synchronously so no RPC is admitted in the window
903/// before the watch task observes cancellation and publishes `NotServing`.
904// Private serve helper. The wide signature is the cost of being the single
905// merge point for the two public `serve_*` paths and the leader-watch +
906// heartbeat task pair: bundling these into a struct just to placate clippy
907// would obscure the lifecycle (every parameter is consumed exactly once and
908// has no shared identity worth naming). Keep the arguments visible.
909#[allow(clippy::too_many_arguments)]
910async fn serve_inner<S>(
911    watch_cancel_tx: tokio::sync::oneshot::Sender<()>,
912    mut watch_handle: tokio::task::JoinHandle<Result<(), ServerError>>,
913    heartbeat_cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
914    heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
915    serve_future: S,
916    tonic_cancel_tx: tokio::sync::oneshot::Sender<()>,
917    shutdown_grace: Duration,
918    core: Arc<ServingCore>,
919    reporter: Arc<crate::reporter::Reporter>,
920) -> Result<(), ServerError>
921where
922    S: Future<Output = Result<(), tonic::transport::Error>>,
923{
924    tokio::pin!(serve_future);
925
926    let outcome = tokio::select! {
927        // Bias toward the watch arm: if both are ready in the same poll
928        // (rare but possible — graceful shutdown completed in the same
929        // tick the watch returned), we want to surface the watch error
930        // rather than report a clean shutdown.
931        biased;
932
933        watch_result = &mut watch_handle => {
934            // Watch terminated. State is already poisoned (see watch
935            // task body in into_router). Trigger tonic drain, wait for
936            // it to finish, then report the watch's outcome — preferring
937            // it over any drain error, which surfaces only if the watch
938            // itself ended cleanly.
939            let _ = tonic_cancel_tx.send(());
940            let drain_result = serve_future.await;
941            combine_watch_and_drain(watch_result, drain_result)
942        }
943        serve_result = &mut serve_future => {
944            // User shutdown fired (or our cancel — but watch arm has
945            // `biased` priority, so reaching here means user shutdown).
946            // Prefer a cooperative stop: dropping the cancel sender resolves
947            // the task's cancel future so it stops at its next `select!`
948            // boundary, having published `NotServing` and never torn down
949            // mid-fence while holding `extension_gate.write()`. But a
950            // cooperative stop is only observed at those boundaries, never
951            // inside a fence attempt — a consensus-driver call that never
952            // returns (the trait places no latency bound) would park the task
953            // upstream of any cancel point and block process exit until a
954            // kubelet SIGKILL. `await_watch_within_grace` therefore bounds the
955            // wait by `shutdown_grace` and aborts the task if it overruns. The
956            // task's own outcome (a clean `Ok(())` on cooperative stop, a
957            // cancelled `JoinError` on abort) is discarded; the user-requested
958            // shutdown result wins.
959            //
960            // Close the serving gate synchronously first: dropping the sender
961            // only *requests* the stop, and a task aborted on grace expiry (or
962            // simply not yet rescheduled) may never reach its own `step_down`. So
963            // a `GetTs` arriving during the drain would still be admitted unless
964            // we close the gate here. `step_down` is idempotent with the watch
965            // task's own cooperative-cancel publish.
966            core.step_down(None, None);
967            drop(watch_cancel_tx);
968            let _ = await_watch_within_grace(&mut watch_handle, shutdown_grace, &reporter).await;
969            serve_result?;
970            Ok(())
971        }
972    };
973
974    // Stop the heartbeat task on every exit path. Done after the watch reap so
975    // the watch-arm `combine_watch_and_drain` already saw the watch outcome,
976    // and the user-shutdown arm has finished its grace-bounded wait. Dropping
977    // the cancel sender breaks the task's `tokio::select! { biased; cancel,
978    // sleep }` loop on the next poll; if the task is wedged for any reason we
979    // hard-abort on grace overrun. The task's outcome is observability only
980    // and cannot influence serving correctness, so its join result is dropped.
981    drop(heartbeat_cancel_tx);
982    if let Some(mut hb_handle) = heartbeat_handle {
983        match tokio::time::timeout(shutdown_grace, &mut hb_handle).await {
984            Ok(Ok(())) => {}
985            Ok(Err(_join_err)) => {} // panic — already counted via catch_unwind
986            Err(_elapsed) => {
987                hb_handle.abort();
988                let _ = (&mut hb_handle).await;
989            }
990        }
991    }
992
993    outcome
994}
995
996/// Convert a `JoinHandle` result into a `ServerError`-typed result.
997///
998/// - `Ok(Ok(()))` — cooperative cancellation: `run_leader_watch` observed its
999///   cancel signal (the `WatchGuard` was dropped, `WatchGuard::shutdown` was
1000///   called, or `serve_inner` cancelled it on user shutdown), published
1001///   `NotServing`, and returned cleanly. Forwarded verbatim as `Ok(())`.
1002/// - `Ok(Err(e))` — task returned an error (including `WatchStreamClosed`
1003///   from a clean EOF). Forward verbatim.
1004/// - `Err(JoinError)` — task was aborted or panicked. An abort
1005///   (`WatchGuard::abort` or `JoinHandle::abort`) maps to Ok (we asked for it);
1006///   a panic maps to `WatchPanic` with payload.
1007fn join_to_server_result(
1008    join_result: Result<Result<(), ServerError>, tokio::task::JoinError>,
1009) -> Result<(), ServerError> {
1010    match join_result {
1011        Ok(inner) => inner,
1012        Err(join_err) if join_err.is_panic() => {
1013            let payload = panic_payload_to_string(join_err.into_panic());
1014            Err(ServerError::WatchPanic {
1015                payload,
1016                bt: Bt::capture(),
1017            })
1018        }
1019        Err(_cancelled) => Ok(()),
1020    }
1021}
1022
1023/// Combine the leader-watch outcome with the tonic graceful-drain outcome
1024/// after the watch arm fired.
1025///
1026/// When the watch task terminates first we trigger the drain and then must
1027/// report a single result. The watch error is the root cause — poisoned
1028/// serving state was already published before the task returned — so it wins
1029/// when both fail. A drain error (port stolen, resource exhaustion) is only
1030/// surfaced when the watch outcome is otherwise `Ok`; previously it was
1031/// discarded via `let _ = serve.await`, hiding a failed drain behind a clean
1032/// shutdown report.
1033///
1034/// Generic over the drain error so the precedence logic is unit-testable
1035/// without fabricating a `tonic::transport::Error` (which has no public
1036/// constructor): production passes `Result<(), tonic::transport::Error>`,
1037/// tests pass `Result<(), ServerError>` via the reflexive `From` impl.
1038fn combine_watch_and_drain<E>(
1039    watch_result: Result<Result<(), ServerError>, tokio::task::JoinError>,
1040    drain_result: Result<(), E>,
1041) -> Result<(), ServerError>
1042where
1043    ServerError: From<E>,
1044{
1045    match join_to_server_result(watch_result) {
1046        Err(watch_err) => Err(watch_err),
1047        Ok(()) => drain_result.map_err(ServerError::from),
1048    }
1049}
1050
1051/// Build the gRPC reflection service from an encoded protobuf descriptor set.
1052///
1053/// Factored out of [`Server::into_router`] so the decode-failure path is unit
1054/// testable: production passes [`tsoracle_proto::FILE_DESCRIPTOR_SET`], while
1055/// tests can feed deliberately corrupt bytes to exercise the error mapping.
1056/// A decode failure becomes [`ServerError::ReflectionInit`] rather than a panic.
1057#[cfg(feature = "reflection")]
1058fn build_reflection_service(
1059    descriptor_set: &[u8],
1060) -> Result<
1061    tonic_reflection::server::v1::ServerReflectionServer<
1062        impl tonic_reflection::server::v1::ServerReflection,
1063    >,
1064    ServerError,
1065> {
1066    tonic_reflection::server::Builder::configure()
1067        .register_encoded_file_descriptor_set(descriptor_set)
1068        .build_v1()
1069        .map_err(ServerError::ReflectionInit)
1070}
1071
1072fn panic_payload_to_string(panic: Box<dyn std::any::Any + Send>) -> String {
1073    if let Some(text) = panic.downcast_ref::<&'static str>() {
1074        (*text).to_string()
1075    } else if let Some(text) = panic.downcast_ref::<String>() {
1076        text.clone()
1077    } else {
1078        "watch task panicked with non-string payload".to_string()
1079    }
1080}
1081
1082#[cfg(any(test, feature = "test-fakes"))]
1083impl Server {
1084    /// Test-only entry point for the leader-watch loop. Exposed to integration
1085    /// tests via the `test-fakes` feature; not part of the stable public API.
1086    #[doc(hidden)]
1087    pub async fn run_leader_watch_for_tests(self: Arc<Self>) -> Result<(), ServerError> {
1088        // A never-resolving cancel future: these tests drive termination via
1089        // leadership events or `JoinHandle::abort`, not cooperative cancel.
1090        crate::fence::run_leader_watch(self, futures::future::pending()).await
1091    }
1092
1093    /// Test-only allocator probe. Issues a window grant against the current
1094    /// in-memory state without going through the gRPC service. Used by
1095    /// regression tests that need to observe the behavioral fence (no
1096    /// timestamp at or below the prior leader's high-water) directly.
1097    #[doc(hidden)]
1098    pub fn try_grant_for_tests(&self, count: u32) -> Result<WindowGrant, CoreError> {
1099        self.core.try_grant(self.clock.now_ms(), count)
1100    }
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105    use super::*;
1106
1107    #[test]
1108    fn panic_payload_to_string_recovers_static_str() {
1109        // `panic!("literal")` produces a `&'static str` payload; we want the
1110        // verbatim text so operators see what the watch task said.
1111        let payload: Box<dyn std::any::Any + Send> = Box::new("watch boom");
1112        assert_eq!(panic_payload_to_string(payload), "watch boom");
1113    }
1114
1115    #[test]
1116    fn panic_payload_to_string_recovers_owned_string() {
1117        // `panic!("{var}")` produces a `String` payload (formatted at panic
1118        // time); the helper must downcast that branch too.
1119        let payload: Box<dyn std::any::Any + Send> = Box::new(String::from("formatted"));
1120        assert_eq!(panic_payload_to_string(payload), "formatted");
1121    }
1122
1123    #[test]
1124    fn panic_payload_to_string_falls_back_for_other_types() {
1125        // Custom payloads (panic!(MyType { .. })) hit the catch-all branch.
1126        struct Custom;
1127        let payload: Box<dyn std::any::Any + Send> = Box::new(Custom);
1128        assert_eq!(
1129            panic_payload_to_string(payload),
1130            "watch task panicked with non-string payload",
1131        );
1132    }
1133
1134    #[test]
1135    fn serving_transitions_publish_through_core() {
1136        // The Server delegates serving-state transitions to its ServingCore; a
1137        // step_down on a freshly built Server lands as NotServing with the hint.
1138        // (The #346 send_replace-with-zero-receivers regression is pinned by the
1139        // ServingCore unit tests, which can observe `receiver_count` directly.)
1140        let server = Server::builder()
1141            .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1142            .build()
1143            .expect("build must succeed");
1144
1145        let hint = PeerEndpoint::try_from("new-leader:9000").unwrap();
1146        server.core.step_down(Some(hint.clone()), Some(Epoch(7)));
1147
1148        match server.core.serving_state() {
1149            ServingState::NotServing {
1150                leader_endpoint,
1151                leader_epoch,
1152            } => {
1153                assert_eq!(leader_endpoint, Some(hint));
1154                assert_eq!(leader_epoch, Some(Epoch(7)));
1155            }
1156            ServingState::Serving => panic!("expected NotServing after step_down"),
1157        }
1158    }
1159
1160    #[cfg(any(feature = "tls-rustls", feature = "tls-native"))]
1161    #[test]
1162    fn builder_stores_tls_config() {
1163        // The serve_* paths read `tls_config` from `Server` (not the builder)
1164        // after `into_router` consumes self — so the field must survive the
1165        // builder → Server hand-off, not just the builder method.
1166        use crate::test_fakes::InMemoryDriver;
1167
1168        let driver = Arc::new(InMemoryDriver::new());
1169        let cfg = tonic::transport::ServerTlsConfig::new();
1170        let server = Server::builder()
1171            .consensus_driver(driver)
1172            .tls_config(cfg)
1173            .build()
1174            .expect("build with tls_config must succeed");
1175        assert!(server.tls_config.is_some());
1176    }
1177
1178    #[test]
1179    fn build_rejects_zero_max_seq_count() {
1180        // max_seq_count(0) makes every positive `count` fail as
1181        // SeqCountTooLarge{max:0} (the count>=1 floor leaves no valid value) —
1182        // GetSeq is silently dead. build() must fail-fast rather than ship a
1183        // server where GetSeq can never succeed.
1184        // `Server` is not `Debug`, so match rather than `expect_err`.
1185        match Server::builder()
1186            .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1187            .max_seq_count(0)
1188            .build()
1189        {
1190            Err(BuildError::ZeroMaxSeqCount) => {}
1191            Err(other) => panic!("expected ZeroMaxSeqCount, got {other:?}"),
1192            Ok(_) => panic!("max_seq_count(0) must be rejected at build, but build succeeded"),
1193        }
1194    }
1195
1196    #[test]
1197    fn build_accepts_max_seq_count_of_one() {
1198        // 1 is the smallest usable cap: a single-ordinal block is valid, so the
1199        // floor and the cap coincide and GetSeq still works. Must build.
1200        Server::builder()
1201            .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1202            .max_seq_count(1)
1203            .build()
1204            .expect("max_seq_count(1) must build");
1205    }
1206
1207    #[test]
1208    fn build_accepts_max_seq_batch_keys_of_one() {
1209        // 1 is the smallest usable batch cap: a single-entry batch is valid, so
1210        // the cap must build (the guard rejects only 0).
1211        Server::builder()
1212            .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1213            .max_seq_batch_keys(1)
1214            .build()
1215            .expect("max_seq_batch_keys(1) must build");
1216    }
1217
1218    #[test]
1219    fn build_rejects_inverted_lease_ttl_bounds() {
1220        match Server::builder()
1221            .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1222            .lease_ttl_floor(Duration::from_secs(10))
1223            .lease_ttl_ceiling(Duration::from_secs(5))
1224            .build()
1225        {
1226            Err(BuildError::LeaseTtlBoundsInvalid) => {}
1227            Err(other) => panic!("expected LeaseTtlBoundsInvalid, got {other:?}"),
1228            Ok(_) => panic!("inverted lease TTL bounds must be rejected"),
1229        }
1230    }
1231
1232    #[test]
1233    fn build_accepts_default_lease_ttl_bounds() {
1234        Server::builder()
1235            .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1236            .build()
1237            .expect("default lease TTL bounds must build");
1238    }
1239
1240    #[test]
1241    fn zero_max_seq_batch_keys_is_rejected() {
1242        // max_seq_batch_keys(0) makes every GetSeqBatch call fail as
1243        // "batch has N entries; the maximum is 0" — the RPC is silently dead.
1244        // build() must fail-fast rather than ship a server where GetSeqBatch
1245        // can never succeed.
1246        match Server::builder()
1247            .consensus_driver(Arc::new(crate::test_fakes::InMemoryDriver::new()))
1248            .max_seq_batch_keys(0)
1249            .build()
1250        {
1251            Err(BuildError::ZeroMaxSeqBatchKeys) => {}
1252            Err(other) => panic!("expected ZeroMaxSeqBatchKeys, got {other:?}"),
1253            Ok(_) => panic!("max_seq_batch_keys(0) must be rejected at build, but build succeeded"),
1254        }
1255    }
1256
1257    #[tokio::test]
1258    async fn join_to_server_result_passes_through_clean_outcome() {
1259        // Ok(Ok(())) — task finished cleanly; forward verbatim.
1260        let handle = tokio::spawn(async { Ok::<(), ServerError>(()) });
1261        let join = handle.await;
1262        assert!(matches!(join_to_server_result(join), Ok(())));
1263    }
1264
1265    #[tokio::test]
1266    async fn join_to_server_result_forwards_inner_error() {
1267        // Ok(Err(e)) — task returned an error; forward it.
1268        let handle = tokio::spawn(async {
1269            Err::<(), ServerError>(ServerError::WatchPanic {
1270                payload: "synthetic".into(),
1271                bt: Bt::capture(),
1272            })
1273        });
1274        let join = handle.await;
1275        match join_to_server_result(join) {
1276            Err(ServerError::WatchPanic { payload, .. }) => assert_eq!(payload, "synthetic"),
1277            other => panic!("expected forwarded WatchPanic, got {other:?}"),
1278        }
1279    }
1280
1281    #[tokio::test]
1282    async fn join_to_server_result_translates_panic_to_watch_panic() {
1283        // Err(JoinError::is_panic) — task panicked; surface as WatchPanic with
1284        // the payload stringified by `panic_payload_to_string`.
1285        let handle = tokio::spawn(async {
1286            panic!("intentional");
1287            #[allow(unreachable_code)]
1288            Ok::<(), ServerError>(())
1289        });
1290        let join = handle.await;
1291        match join_to_server_result(join) {
1292            Err(ServerError::WatchPanic { payload, .. }) => {
1293                assert!(payload.contains("intentional"))
1294            }
1295            other => panic!("expected WatchPanic, got {other:?}"),
1296        }
1297    }
1298
1299    #[tokio::test]
1300    async fn join_to_server_result_treats_cancellation_as_clean_exit() {
1301        // Err(JoinError::is_cancelled) — caller aborted the task; we asked
1302        // for that, so map to Ok.
1303        let handle: tokio::task::JoinHandle<Result<(), ServerError>> =
1304            tokio::spawn(async { futures::future::pending().await });
1305        handle.abort();
1306        let join = handle.await;
1307        assert!(matches!(join_to_server_result(join), Ok(())));
1308    }
1309
1310    #[tokio::test]
1311    async fn combine_watch_and_drain_surfaces_drain_error_when_watch_ok() {
1312        // Watch ended cleanly but the graceful drain failed (port stolen,
1313        // resource exhaustion). The drain error must not be swallowed.
1314        let watch = tokio::spawn(async { Ok::<(), ServerError>(()) }).await;
1315        let drain: Result<(), ServerError> = Err(ServerError::WatchStreamClosed);
1316        assert!(matches!(
1317            combine_watch_and_drain(watch, drain),
1318            Err(ServerError::WatchStreamClosed)
1319        ));
1320    }
1321
1322    #[tokio::test]
1323    async fn combine_watch_and_drain_returns_ok_when_both_succeed() {
1324        // Watch clean, drain clean — the only fully-Ok outcome.
1325        let watch = tokio::spawn(async { Ok::<(), ServerError>(()) }).await;
1326        let drain: Result<(), ServerError> = Ok(());
1327        assert!(matches!(combine_watch_and_drain(watch, drain), Ok(())));
1328    }
1329
1330    #[tokio::test]
1331    async fn combine_watch_and_drain_prefers_watch_error_over_drain_error() {
1332        // Both failed. The watch error is the root cause (poisoned state was
1333        // already published), so it wins; the drain error is dropped.
1334        let watch = tokio::spawn(async {
1335            Err::<(), ServerError>(ServerError::WatchPanic {
1336                payload: "watch".into(),
1337                bt: Bt::capture(),
1338            })
1339        })
1340        .await;
1341        let drain: Result<(), ServerError> = Err(ServerError::WatchStreamClosed);
1342        match combine_watch_and_drain(watch, drain) {
1343            Err(ServerError::WatchPanic { payload, .. }) => assert_eq!(payload, "watch"),
1344            other => panic!("expected watch error to win, got {other:?}"),
1345        }
1346    }
1347
1348    #[tokio::test]
1349    async fn combine_watch_and_drain_returns_watch_error_when_drain_ok() {
1350        // Watch failed, drain succeeded — forward the watch error verbatim.
1351        let watch = tokio::spawn(async {
1352            Err::<(), ServerError>(ServerError::WatchPanic {
1353                payload: "watch".into(),
1354                bt: Bt::capture(),
1355            })
1356        })
1357        .await;
1358        let drain: Result<(), ServerError> = Ok(());
1359        match combine_watch_and_drain(watch, drain) {
1360            Err(ServerError::WatchPanic { payload, .. }) => assert_eq!(payload, "watch"),
1361            other => panic!("expected forwarded watch error, got {other:?}"),
1362        }
1363    }
1364
1365    #[tokio::test]
1366    async fn dropping_watch_guard_closes_serving_gate_synchronously() {
1367        // Regression: dropping the guard must close the serving gate at the
1368        // drop site, not on the watch task's later timeline. Build a guard whose
1369        // watch handle never touches the core (so the ONLY thing that can flip
1370        // the state to NotServing is `Drop`), publish `Serving`, then drop the
1371        // guard and read the state with NO await in between. On the current-thread
1372        // test runtime no other task can run between the synchronous `drop` and
1373        // the synchronous `serving_state` read, so observing `NotServing` proves
1374        // `Drop` closed the gate synchronously rather than the watch task.
1375        let core = Arc::new(ServingCore::new(
1376            Duration::from_secs(3),
1377            tsoracle_core::DEFAULT_MAX_SEQ_COUNT,
1378            tsoracle_core::DEFAULT_MAX_SEQ_BATCH_KEYS,
1379        ));
1380        core.publish_serving();
1381
1382        let handle: tokio::task::JoinHandle<Result<(), ServerError>> =
1383            tokio::spawn(async { Ok(()) });
1384        let (cancel_tx, _cancel_rx) = tokio::sync::oneshot::channel::<()>();
1385        let guard = WatchGuard {
1386            cancel_tx: Some(cancel_tx),
1387            handle: Some(handle),
1388            shutdown_grace: Duration::from_secs(10),
1389            core: core.clone(),
1390            reporter: Arc::new(crate::reporter::Reporter::new()),
1391            heartbeat_cancel_tx: None,
1392            heartbeat_handle: None,
1393        };
1394
1395        drop(guard);
1396
1397        assert!(
1398            matches!(core.serving_state(), ServingState::NotServing { .. }),
1399            "dropping the WatchGuard must close the serving gate synchronously",
1400        );
1401    }
1402
1403    #[tokio::test]
1404    async fn serve_inner_closes_serving_gate_on_user_shutdown() {
1405        // Regression for the serve path: when the caller's shutdown fires,
1406        // `serve_inner` drops the watch cancel sender and waits out the grace,
1407        // forcibly aborting the watch task if it overruns. A task parked upstream
1408        // of any cancel-observing await (modelled here by a never-resolving
1409        // future) is aborted before it can publish `NotServing`, so the gate
1410        // would stay open unless `serve_inner` closes it itself. Seed `Serving`,
1411        // run the user-shutdown arm with a zero grace (immediate abort), and
1412        // assert the gate is closed on return.
1413        let core = Arc::new(ServingCore::new(
1414            Duration::from_secs(3),
1415            tsoracle_core::DEFAULT_MAX_SEQ_COUNT,
1416            tsoracle_core::DEFAULT_MAX_SEQ_BATCH_KEYS,
1417        ));
1418        core.publish_serving();
1419
1420        let watch_handle: tokio::task::JoinHandle<Result<(), ServerError>> =
1421            tokio::spawn(async { futures::future::pending().await });
1422        let (watch_cancel_tx, _watch_cancel_rx) = tokio::sync::oneshot::channel::<()>();
1423        let (tonic_cancel_tx, _tonic_cancel_rx) = tokio::sync::oneshot::channel::<()>();
1424
1425        // A serve future that is immediately ready models the user's shutdown
1426        // having fired; with the biased select preferring the (pending) watch arm,
1427        // control reaches the user-shutdown arm.
1428        let serve_future = async { Ok::<(), tonic::transport::Error>(()) };
1429
1430        let result = serve_inner(
1431            watch_cancel_tx,
1432            watch_handle,
1433            None, // heartbeat_cancel_tx — heartbeat disabled in this regression test
1434            None, // heartbeat_handle
1435            serve_future,
1436            tonic_cancel_tx,
1437            Duration::from_millis(0),
1438            core.clone(),
1439            Arc::new(crate::reporter::Reporter::new()),
1440        )
1441        .await;
1442
1443        assert!(
1444            result.is_ok(),
1445            "user shutdown must return Ok, got {result:?}"
1446        );
1447        assert!(
1448            matches!(core.serving_state(), ServingState::NotServing { .. }),
1449            "serve_inner's user-shutdown arm must close the serving gate synchronously",
1450        );
1451    }
1452
1453    #[cfg(feature = "reflection")]
1454    #[test]
1455    fn build_reflection_service_accepts_embedded_descriptor_set() {
1456        // The descriptor set emitted by `tsoracle-proto`'s build.rs must decode
1457        // cleanly — this is the production happy path that previously sat behind
1458        // an `expect`.
1459        assert!(build_reflection_service(tsoracle_proto::FILE_DESCRIPTOR_SET).is_ok());
1460    }
1461
1462    #[cfg(feature = "reflection")]
1463    #[test]
1464    fn build_reflection_service_maps_corrupt_descriptor_to_typed_error() {
1465        // A descriptor set that fails to decode (build artifact drift) must
1466        // surface as a typed `ServerError::ReflectionInit`, not a panic. The
1467        // bytes below are not a valid encoded `FileDescriptorSet`.
1468        let corrupt = b"\xff\xff\xff\xff not a descriptor set";
1469        // The Ok variant wraps a reflection service that is not `Debug`, so map
1470        // to a unit result before asserting on the error variant.
1471        match build_reflection_service(corrupt).map(|_| ()) {
1472            Err(ServerError::ReflectionInit(_)) => {}
1473            other => panic!("expected ReflectionInit error, got {other:?}"),
1474        }
1475    }
1476}