velo_ext/transport.rs
1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Active-message transport extension trait.
5//!
6//! This is the primary contract that out-of-tree transport authors implement.
7//! Concrete impls (TCP, HTTP, NATS, gRPC, ZMQ) ship in the `velo` runtime
8//! crate; external implementors `impl Transport for MyTransport` against this
9//! crate without depending on the runtime.
10
11use bytes::Bytes;
12use futures::future::BoxFuture;
13
14use crate::admission::SendOutcome;
15use crate::id::{InstanceId, PeerInfo, TransportKey, WorkerAddress};
16use crate::observability::TransportObservability;
17
18use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
19use std::{sync::Arc, time::Duration};
20use tokio::sync::Notify;
21use tokio_util::sync::CancellationToken;
22
23/// Errors returned by individual [`Transport`] implementations.
24#[derive(thiserror::Error, Debug)]
25pub enum TransportError {
26 /// The peer's [`WorkerAddress`] does not contain an entry for this transport.
27 #[error("No endpoint found for transport")]
28 NoEndpoint,
29
30 /// The endpoint string could not be parsed (malformed URL, invalid address).
31 #[error("Invalid endpoint format")]
32 InvalidEndpoint,
33
34 /// The target peer was never registered with this transport.
35 #[error("Peer not registered: {0}")]
36 PeerNotRegistered(InstanceId),
37
38 /// The transport has not been started yet (no runtime handle).
39 #[error("Transport not started")]
40 NotStarted,
41
42 /// No responders available for the peer (e.g. NATS request with no subscriber).
43 #[error("No responders for peer")]
44 NoResponders,
45}
46
47/// Error type specific to health check operations
48#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
49pub enum HealthCheckError {
50 /// The peer was never registered with this transport.
51 #[error("Peer not registered with transport")]
52 PeerNotRegistered,
53
54 /// The transport has not been started yet.
55 #[error("Transport not started")]
56 TransportNotStarted,
57
58 /// The peer is registered but no connection has ever been established.
59 #[error("Connection never established to peer")]
60 NeverConnected,
61
62 /// An existing connection is unhealthy or the peer is unreachable.
63 #[error("Connection failed or peer unreachable")]
64 ConnectionFailed,
65
66 /// The health check exceeded the specified timeout.
67 #[error("Health check timed out")]
68 Timeout,
69}
70
71/// Shared shutdown coordinator for graceful multi-phase shutdown.
72///
73/// **Phases**:
74/// 1. **Gate** — `begin_drain()` flips the draining flag; transports reject new inbound requests.
75/// 2. **Drain** — `wait_for_drain()` blocks until all in-flight guards are dropped.
76/// 3. **Teardown** — `teardown_token().cancel()` kills listeners and writer tasks.
77///
78/// Hot-path cost: one `SeqCst` `fetch_add` plus one `SeqCst` load per inbound
79/// [`MessageType::Message`], both inside [`TransportAdapter::admit_message`].
80/// Every other frame type is ungated and touches none of these atomics.
81/// `is_draining()` is a `Relaxed` load, but it is a reporting hook, not
82/// something the frame path calls — see below.
83///
84/// # Admission is not `is_draining()`
85///
86/// Deciding whether to accept an inbound [`MessageType::Message`] must go
87/// through [`TransportAdapter::admit_message`], never through a bare
88/// `is_draining()` check. `is_draining()` is a best-effort observer: a
89/// check-then-enqueue sequence built on it is a plain interleaving race —
90/// [`begin_drain`](Self::begin_drain), [`wait_for_drain`](Self::wait_for_drain)
91/// and teardown can all complete inside the producer's check-to-enqueue gap,
92/// and no memory ordering can close that. `admit_message` acquires the
93/// in-flight guard *first* and then re-reads the flag with `SeqCst`, which
94/// turns the pair into a store-buffer litmus that at least one side must win.
95#[derive(Clone)]
96pub struct ShutdownState {
97 inner: Arc<ShutdownStateInner>,
98}
99
100struct ShutdownStateInner {
101 draining: AtomicBool,
102 in_flight: AtomicUsize,
103 drain_complete: Notify,
104 teardown_token: CancellationToken,
105}
106
107impl ShutdownState {
108 /// Create a new shutdown state. Not draining, zero in-flight.
109 pub fn new() -> Self {
110 Self {
111 inner: Arc::new(ShutdownStateInner {
112 draining: AtomicBool::new(false),
113 in_flight: AtomicUsize::new(0),
114 drain_complete: Notify::new(),
115 teardown_token: CancellationToken::new(),
116 }),
117 }
118 }
119
120 /// Returns `true` if drain has been initiated (Phase 1).
121 ///
122 /// **Best-effort observer, not an admission decision.** Uses `Relaxed`
123 /// ordering: cheap enough for a per-frame hot-path peek, and sound for
124 /// *reporting* because the flag is monotonic (false → true, never reset),
125 /// so a `true` is always authoritative. A `false` is not — it may be
126 /// stale, and acting on it to enqueue inbound work reopens the
127 /// check-then-act race described on [`ShutdownState`]. Admit inbound
128 /// [`MessageType::Message`] frames with
129 /// [`TransportAdapter::admit_message`] instead.
130 #[inline]
131 pub fn is_draining(&self) -> bool {
132 self.inner.draining.load(Ordering::Relaxed)
133 }
134
135 /// Admission-strength read of the draining flag.
136 ///
137 /// `SeqCst` so that it participates in the single total order that also
138 /// contains [`begin_drain`](Self::begin_drain)'s store, the `fetch_add` in
139 /// [`acquire`](Self::acquire), and [`wait_for_drain`](Self::wait_for_drain)'s
140 /// load. Private on purpose: the only correct use is *after* acquiring a
141 /// guard, which is what [`TransportAdapter::admit_message`] does.
142 #[inline]
143 fn is_draining_for_admission(&self) -> bool {
144 self.inner.draining.load(Ordering::SeqCst)
145 }
146
147 /// Begin Phase 1: flip the draining flag. Idempotent.
148 ///
149 /// `SeqCst`: this store and [`wait_for_drain`](Self::wait_for_drain)'s
150 /// load are one half of the admission litmus (the other half being
151 /// [`acquire`](Self::acquire)'s `fetch_add` and the flag re-read in
152 /// [`TransportAdapter::admit_message`]). Under acquire/release neither
153 /// side's load would be ordered against the other side's store and both
154 /// could read stale — shutdown seeing zero in-flight while a producer
155 /// sees "not draining" — which is exactly the message that would slip
156 /// past the gate *and* past the drain wait.
157 pub fn begin_drain(&self) {
158 self.inner.draining.store(true, Ordering::SeqCst);
159 }
160
161 /// Acquire an in-flight guard. The guard increments the counter on creation
162 /// and decrements it on drop. Use this to track requests that are being processed.
163 ///
164 /// Guards are still acquirable after `begin_drain()` — this is intentional
165 /// so that already-accepted work can be tracked.
166 ///
167 /// `SeqCst` on the increment: see [`begin_drain`](Self::begin_drain).
168 pub fn acquire(&self) -> InFlightGuard {
169 self.inner.in_flight.fetch_add(1, Ordering::SeqCst);
170 InFlightGuard {
171 inner: self.inner.clone(),
172 }
173 }
174
175 /// Current number of in-flight requests. Primarily for testing/debugging.
176 pub fn in_flight_count(&self) -> usize {
177 self.inner.in_flight.load(Ordering::Acquire)
178 }
179
180 /// Wait until in-flight count reaches zero. Returns immediately if already zero.
181 ///
182 /// Registers interest *before* re-checking the counter: `notify_waiters()`
183 /// stores no permit, so a guard dropping between the check and the
184 /// registration would strand this waiter forever under
185 /// [`ShutdownPolicy::WaitForever`]. Creating the `Notified` future is
186 /// enough — tokio wakes futures that exist at `notify_waiters()` time even
187 /// if they have not been polled yet.
188 ///
189 /// The load is `SeqCst` so it is ordered against every producer's
190 /// `fetch_add` in [`acquire`](Self::acquire) — see
191 /// [`begin_drain`](Self::begin_drain). Note that a guard acquired and
192 /// released purely to *reject* a message (the `Draining` arm of
193 /// [`TransportAdapter::admit_message`]) makes the count transiently
194 /// non-zero; the register-before-check loop above turns that into one
195 /// extra iteration, not a missed wakeup.
196 pub async fn wait_for_drain(&self) {
197 loop {
198 let notified = self.inner.drain_complete.notified();
199 if self.inner.in_flight.load(Ordering::SeqCst) == 0 {
200 return;
201 }
202 notified.await;
203 }
204 }
205
206 /// Get the Phase 3 teardown token. Cancel this to kill listeners/writers.
207 pub fn teardown_token(&self) -> &CancellationToken {
208 &self.inner.teardown_token
209 }
210}
211
212impl Default for ShutdownState {
213 fn default() -> Self {
214 Self::new()
215 }
216}
217
218/// RAII guard that decrements the in-flight counter on drop.
219pub struct InFlightGuard {
220 inner: Arc<ShutdownStateInner>,
221}
222
223impl InFlightGuard {
224 /// Explicitly complete this guard (equivalent to dropping it).
225 pub fn complete(self) {
226 // Drop impl handles the decrement
227 }
228}
229
230impl Drop for InFlightGuard {
231 fn drop(&mut self) {
232 let prev = self.inner.in_flight.fetch_sub(1, Ordering::AcqRel);
233 // If we just decremented to 0, notify waiters
234 if prev == 1 {
235 self.inner.drain_complete.notify_waiters();
236 }
237 }
238}
239
240/// Policy for how long to wait during the drain phase.
241#[derive(Debug, Clone)]
242pub enum ShutdownPolicy {
243 /// Wait indefinitely for all in-flight requests to complete.
244 WaitForever,
245 /// Wait up to the given duration, then force teardown.
246 Timeout(Duration),
247}
248
249/// Abstraction over a single message transport (TCP, HTTP, NATS, gRPC, …).
250///
251/// Implementations handle peer registration, message sending, listener
252/// lifecycle, health checking, and graceful shutdown. The trait is object-safe
253/// so transports can be stored as `Arc<dyn Transport>`.
254///
255/// Out-of-tree implementors should `impl Transport for MyTransport`. The
256/// default [`set_observability`](Transport::set_observability) hook is a no-op
257/// — implementors only need to override it if they want to integrate with
258/// the runtime's metrics handle (which they recover via
259/// [`ObservabilityHook::downcast`]).
260pub trait Transport: Send + Sync {
261 /// Unique key identifying this transport (e.g. `"tcp"`, `"grpc"`).
262 fn key(&self) -> TransportKey;
263 /// The [`WorkerAddress`] fragment advertised by this transport.
264 fn address(&self) -> WorkerAddress;
265 /// Register a remote peer, extracting its endpoint from [`PeerInfo`].
266 fn register(&self, peer_info: PeerInfo) -> Result<(), TransportError>;
267
268 /// Send an active message to the remote instance.
269 ///
270 /// The frame is taken unconditionally: implementations must not hand it
271 /// back, and the caller has no way to retract it. What the return value
272 /// reports is *when* the frame reached the per-target send channel.
273 ///
274 /// - [`SendOutcome::Admitted`] — it is on the channel already. This is also
275 /// what a hard pre-wire failure returns, once `on_error` has been called
276 /// for it (peer unregistered, transport not started, oversized frame):
277 /// there is nothing left for the caller to wait on either way.
278 /// - [`SendOutcome::Pending`] — the channel was saturated, so the frame is
279 /// queued in the target's [`AdmissionGate`](crate::admission::AdmissionGate)
280 /// behind its predecessors. The returned
281 /// [`SendAdmission`](crate::admission::SendAdmission) resolves `Ok(())` when the frame is
282 /// enqueued and `Err` when it never will be (the connection epoch died,
283 /// the channel closed). Delivery does **not** depend on the caller
284 /// polling it — dropping it is a legitimate fire-and-forget pattern.
285 ///
286 /// Implementations must route every send through one gate per target and
287 /// keep no `try_send` path around it: an admission that can be overtaken by
288 /// a later fast-path send is the reordering hazard the gate exists to
289 /// remove (see the [`admission`](crate::admission) module docs).
290 ///
291 /// Failures *after* admission — the write itself — continue to flow
292 /// through `on_error`.
293 fn send_message(
294 &self,
295 instance_id: InstanceId,
296 header: Bytes,
297 payload: Bytes,
298 message_type: MessageType,
299 on_error: Arc<dyn TransportErrorHandler>,
300 ) -> SendOutcome;
301
302 /// Largest single message this transport will carry to `target`, in bytes.
303 ///
304 /// The number bounds `header.len() + payload.len()` for one
305 /// [`send_message`](Transport::send_message) — the *combined* frame
306 /// content, not the payload alone. A caller that prepends its own envelope
307 /// to the payload subtracts that envelope from this number; there is no
308 /// second allowance hiding behind it.
309 ///
310 /// `None` means this transport does not know its capacity: nothing was
311 /// negotiated, no limit is configured, or it has not started yet. It is
312 /// **not** a claim of unlimited capacity — a caller that reads `None` must
313 /// fall back to a conservative budget of its own choosing.
314 ///
315 /// The answer is per-target because it can be genuinely per-connection: a
316 /// NATS client learns `max_payload` from the server it happens to be
317 /// connected to, so two peers reached through two clients can differ.
318 /// Transports with a single static limit ignore the argument.
319 ///
320 /// Nothing about this method changes what `send_message` does with an
321 /// oversized frame — that still fails pre-wire through the send's
322 /// `on_error` handler. It exists so callers can size sends to avoid
323 /// meeting that failure at all.
324 fn max_message_size(&self, _target: InstanceId) -> Option<usize> {
325 None
326 }
327
328 /// Start the transport (bind listener, spawn tasks) for the given instance.
329 fn start(
330 &self,
331 instance_id: InstanceId,
332 channels: TransportAdapter,
333 rt: tokio::runtime::Handle,
334 ) -> BoxFuture<'_, anyhow::Result<()>>;
335
336 /// Tear down the transport, cancelling all tasks and closing connections.
337 ///
338 /// This is phase 3 of the runtime's graceful shutdown and the runtime calls
339 /// it only after [`ShutdownState::begin_drain`] and the drain wait. Several
340 /// in-tree transports (TCP, UDS, gRPC, UCX) also cancel the *shared*
341 /// [`ShutdownState::teardown_token`] here, which is instance-wide: it stops
342 /// every transport's listeners **and** the runtime's inbound message
343 /// consumer, which then abandons whatever is still queued. Calling
344 /// `shutdown()` directly on one such transport of a live instance therefore
345 /// kills inbound dispatch for all of them, with no drain and no
346 /// [`MessageType::ShuttingDown`] correlation for the senders. Reach for
347 /// the runtime's graceful shutdown instead unless that is precisely what
348 /// you want.
349 fn shutdown(&self);
350
351 /// Install a transport-scoped observability handle.
352 ///
353 /// The runtime calls this once per transport during startup with a handle
354 /// pre-bound to this transport's `key`. Implementations typically store it
355 /// in an [`OnceLock`](std::sync::OnceLock) and call its methods on the
356 /// hot path; transports that do not emit metrics can leave the default
357 /// no-op.
358 fn set_observability(&self, _observability: std::sync::Arc<dyn TransportObservability>) {}
359
360 /// Notification hook for Phase 1 (Gate) of graceful shutdown.
361 ///
362 /// The runtime calls this *after* flipping the shared [`ShutdownState`]'s
363 /// drain flag — the flag the runtime handed this transport inside the
364 /// [`TransportAdapter`] at [`start`](Transport::start). Flipping that flag
365 /// is the runtime's job, not this method's: per-frame gating is
366 /// implemented by routing every inbound [`MessageType::Message`] through
367 /// [`TransportAdapter::admit_message`], which returns
368 /// [`AdmitOutcome::Draining`] with the frame handed back so this transport
369 /// can answer it with a [`MessageType::ShuttingDown`] correlation reply.
370 /// Do not gate on [`ShutdownState::is_draining`] directly — that is a
371 /// best-effort observer and a check-then-enqueue built on it can let a
372 /// message slip past both the gate and the drain wait.
373 ///
374 /// Override only for drain work the shared flag cannot express — e.g.,
375 /// unsubscribing from broker subjects so new requests stop arriving at
376 /// all, or pausing an accept loop. The default no-op is correct for
377 /// transports whose listeners gate per-frame off the shared flag.
378 ///
379 /// Must be idempotent. Do not flip the shared `ShutdownState` here: it is
380 /// instance-wide, shared by every transport of the instance, so flipping
381 /// it from one transport would silently drain them all.
382 fn begin_drain(&self) {}
383
384 /// Check if a registered peer is reachable and healthy.
385 ///
386 /// Returns `Ok(())` if the peer responds within the timeout. Different
387 /// transports implement this differently:
388 /// - NATS: request/reply to health subject
389 /// - TCP: check existing connection or attempt new connection
390 /// - HTTP: HEAD request to health endpoint
391 fn check_health(
392 &self,
393 instance_id: InstanceId,
394 timeout: Duration,
395 ) -> std::pin::Pin<
396 Box<dyn std::future::Future<Output = Result<(), HealthCheckError>> + Send + '_>,
397 >;
398}
399
400/// Callback trait invoked when a transport fails to deliver a message.
401///
402/// The original `header` and `payload` are returned so higher layers can
403/// retry or log the failure.
404pub trait TransportErrorHandler: Send + Sync {
405 /// Called when message delivery fails. Receives the original data and error description.
406 fn on_error(&self, header: Bytes, payload: Bytes, error: String);
407}
408
409/// Message type discriminator for routing frames to appropriate streams
410#[repr(u8)]
411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
412pub enum MessageType {
413 #[allow(missing_docs)]
414 Message = 0,
415 #[allow(missing_docs)]
416 Response = 1,
417 #[allow(missing_docs)]
418 Ack = 2,
419 #[allow(missing_docs)]
420 Event = 3,
421 /// Sent back to a peer when we are draining and cannot accept new messages.
422 /// The original request header is echoed back for correlation.
423 ShuttingDown = 4,
424}
425
426impl MessageType {
427 /// Try to convert a u8 to a MessageType
428 pub fn from_u8(value: u8) -> Option<Self> {
429 match value {
430 0 => Some(MessageType::Message),
431 1 => Some(MessageType::Response),
432 2 => Some(MessageType::Ack),
433 3 => Some(MessageType::Event),
434 4 => Some(MessageType::ShuttingDown),
435 _ => None,
436 }
437 }
438
439 /// Convert MessageType to u8
440 pub fn as_u8(self) -> u8 {
441 self as u8
442 }
443}
444
445/// An inbound [`MessageType::Message`] frame together with the in-flight
446/// guard that makes it visible to [`ShutdownState::wait_for_drain`].
447///
448/// The guard is **mandatory**, which is the whole point of the type: a message
449/// cannot sit on the inbound queue without being counted work. Producers never
450/// build one directly — [`TransportAdapter::admit_message`] acquires the guard
451/// and constructs it, and the message channel's sender is private so there is
452/// no other way in. Whatever happens to the message afterwards — dispatched,
453/// dropped on a decode error, handed back undelivered when the receiver has
454/// gone, discarded by a consumer that is abandoning its backlog at teardown —
455/// the guard rides along and its `Drop` releases the count. "Queued implies
456/// counted" is therefore an invariant of the type system, not a convention
457/// producers have to honour.
458///
459/// The one thing that does *not* release a guard is walking away from the
460/// channel: flume frees buffered items only once the last endpoint is gone, so
461/// a consumer that stops receiving while transports still hold sender clones
462/// must drain and drop what it is abandoning, not merely drop its receiver.
463///
464/// `#[non_exhaustive]`: construct with [`InboundMessage::new`], match with a
465/// trailing `..`.
466#[non_exhaustive]
467pub struct InboundMessage {
468 /// The frame's header bytes.
469 pub header: Bytes,
470 /// The frame's payload bytes.
471 pub payload: Bytes,
472 /// Keeps the instance's in-flight count non-zero for as long as this
473 /// message exists, queued or in a handler.
474 pub guard: InFlightGuard,
475}
476
477impl InboundMessage {
478 /// Bind a frame to an already-acquired in-flight guard.
479 ///
480 /// Public for consumer-side fabrication — a test that wants a realistic
481 /// item to feed a receiver, or a harness that stands in for the runtime.
482 /// It is not a way onto the inbound queue: the channel's sender is
483 /// private, so producers still go through
484 /// [`TransportAdapter::admit_message`].
485 pub fn new(header: Bytes, payload: Bytes, guard: InFlightGuard) -> Self {
486 Self {
487 header,
488 payload,
489 guard,
490 }
491 }
492}
493
494/// What [`TransportAdapter::admit_message`] did with an inbound frame.
495///
496/// The rejecting variants hand the frame back, because the reply a transport
497/// owes its peer is transport-specific: a TCP listener writes a
498/// [`MessageType::ShuttingDown`] frame onto the same socket, NATS publishes to
499/// the reply inbox, gRPC pushes onto the RPC's server-to-client stream, and a
500/// reader with no reply path at all just drops it and records a rejection.
501///
502/// Deliberately *not* `#[non_exhaustive]`: each variant hands back a frame that
503/// demands a different mandatory action, so a transport that grew a `_ => {}`
504/// arm would silently swallow frames. A future outcome should be a compile
505/// error at every producer — worth the coordinated bump it would cost.
506#[derive(Debug)]
507#[must_use = "the rejecting variants hand back a frame the caller still owes its peer a reply for"]
508pub enum AdmitOutcome {
509 /// The frame is queued and counted; nothing left for the caller to do.
510 Admitted,
511 /// This instance is draining and will not accept new requests. Reply
512 /// [`MessageType::ShuttingDown`] with `header` echoed verbatim so the
513 /// sender fails fast instead of waiting out its own timeout.
514 Draining {
515 /// The rejected request's header.
516 header: Bytes,
517 /// The rejected request's payload.
518 payload: Bytes,
519 },
520 /// The inbound queue's receiver is gone — the runtime has torn down.
521 /// Route the frame to the transport's error handler.
522 Disconnected {
523 /// The undelivered frame's header.
524 header: Bytes,
525 /// The undelivered frame's payload.
526 payload: Bytes,
527 },
528}
529
530/// Sender-side handle given to transports for routing inbound frames.
531///
532/// Each transport receives a clone of this adapter during [`Transport::start`]
533/// and uses it to forward decoded `(header, payload)` pairs to the appropriate
534/// stream based on [`MessageType`]. Inbound [`MessageType::Message`] frames are
535/// the exception: they go through [`admit_message`](Self::admit_message), which
536/// owns both the drain gate and the enqueue.
537#[derive(Clone)]
538pub struct TransportAdapter {
539 /// Channel for inbound [`MessageType::Message`] frames.
540 ///
541 /// Private: every item must carry an [`InFlightGuard`], so the only way to
542 /// enqueue is [`admit_message`](Self::admit_message).
543 message_stream: flume::Sender<InboundMessage>,
544 /// Channel for inbound [`MessageType::Response`] frames.
545 pub response_stream: flume::Sender<(Bytes, Bytes)>,
546 /// Channel for inbound [`MessageType::Ack`] and [`MessageType::Event`] frames.
547 pub event_stream: flume::Sender<(Bytes, Bytes)>,
548 /// Channel for inbound [`MessageType::ShuttingDown`] frames — drain
549 /// rejections from a peer.
550 ///
551 /// Each carries the rejected *request's* header, echoed back verbatim so
552 /// the sender can correlate it, and an empty payload. The header is in
553 /// the request format, not the response format — which is why these
554 /// frames have their own lane instead of sharing `response_stream`.
555 pub shutdown_stream: flume::Sender<(Bytes, Bytes)>,
556 /// Shared shutdown coordinator for drain-aware routing.
557 pub shutdown_state: ShutdownState,
558}
559
560impl TransportAdapter {
561 /// Offer an inbound [`MessageType::Message`] frame to the runtime.
562 ///
563 /// This is the *only* way to enqueue inbound request work, and it is the
564 /// drain gate: transports must not pre-filter on
565 /// [`ShutdownState::is_draining`].
566 ///
567 /// # Why acquire before checking
568 ///
569 /// The order below is load-bearing: acquire the guard, *then* read the
570 /// draining flag, and only then send.
571 ///
572 /// Checking first and enqueueing second is a check-then-act race that no
573 /// memory ordering can fix — the whole of
574 /// [`begin_drain`](ShutdownState::begin_drain) →
575 /// [`wait_for_drain`](ShutdownState::wait_for_drain) → teardown can run
576 /// inside the gap between a producer's check and its send, and the message
577 /// then lands on a queue nobody will ever drain.
578 ///
579 /// Acquiring first makes the two sides a store-buffer litmus instead. This
580 /// side does `fetch_add(in_flight)` then `load(draining)`; the shutdown
581 /// side does `store(draining)` then `load(in_flight)`. All four accesses
582 /// are `SeqCst`, so they share one total order and at least one side must
583 /// observe the other: either the shutdown wait sees the increment and
584 /// parks until this message is done, or this call sees the flag and
585 /// rejects. Both-stale — admitted *and* invisible to the drain — cannot
586 /// happen.
587 ///
588 /// A rejected message therefore blips the in-flight count up and back
589 /// down. That is harmless: `wait_for_drain` registers its wakeup before
590 /// re-checking, so the blip costs it one extra loop iteration.
591 ///
592 /// Synchronous by design — the inbound channel is unbounded, so the send
593 /// never blocks, and callers on non-async threads (a ZMQ listener thread,
594 /// a UCX active-message callback) can use it unchanged.
595 pub fn admit_message(&self, header: Bytes, payload: Bytes) -> AdmitOutcome {
596 let guard = self.shutdown_state.acquire();
597
598 if self.shutdown_state.is_draining_for_admission() {
599 drop(guard);
600 return AdmitOutcome::Draining { header, payload };
601 }
602
603 match self
604 .message_stream
605 .send(InboundMessage::new(header, payload, guard))
606 {
607 Ok(()) => AdmitOutcome::Admitted,
608 Err(flume::SendError(InboundMessage {
609 header,
610 payload,
611 guard,
612 ..
613 })) => {
614 // Explicit: the guard must be released here, or an
615 // undeliverable frame would keep `wait_for_drain` parked
616 // forever under `ShutdownPolicy::WaitForever`.
617 drop(guard);
618 AdmitOutcome::Disconnected { header, payload }
619 }
620 }
621 }
622}
623
624/// Receiver-side handle for consuming inbound frames from all transports.
625///
626/// Returned by [`make_channels`] alongside the corresponding [`TransportAdapter`].
627/// Higher layers pull [`InboundMessage`]s off the message lane and
628/// `(header, payload)` pairs off the other three.
629pub struct DataStreams {
630 /// Receiver for inbound message frames.
631 ///
632 /// Every item is an [`InboundMessage`] carrying its own [`InFlightGuard`],
633 /// so a plain receive is already drain-tracked and the consumer must *not*
634 /// acquire a guard of its own. (This is why there is no
635 /// `recv_message_tracked`: it acquired after the dequeue, which left the
636 /// queued-but-unconsumed window invisible to the drain.)
637 ///
638 /// A consumer that stops receiving while messages are still queued must
639 /// drain and drop them, not just drop this receiver: flume keeps the
640 /// buffer alive until the *last* endpoint goes, and transports hold sender
641 /// clones for the instance's lifetime, so guards abandoned in the buffer
642 /// pin [`ShutdownState::wait_for_drain`] above zero for good.
643 pub message_stream: flume::Receiver<InboundMessage>,
644 /// Receiver for inbound response frames.
645 pub response_stream: flume::Receiver<(Bytes, Bytes)>,
646 /// Receiver for inbound ack and event frames.
647 pub event_stream: flume::Receiver<(Bytes, Bytes)>,
648 /// Receiver for inbound shutting-down frames (drain rejections): the
649 /// rejected request's header, echoed verbatim, with an empty payload.
650 pub shutdown_stream: flume::Receiver<(Bytes, Bytes)>,
651 /// Shared shutdown coordinator.
652 pub shutdown_state: ShutdownState,
653}
654
655type DataStreamTuple = (
656 flume::Receiver<InboundMessage>,
657 flume::Receiver<(Bytes, Bytes)>,
658 flume::Receiver<(Bytes, Bytes)>,
659 flume::Receiver<(Bytes, Bytes)>,
660);
661
662impl DataStreams {
663 /// Destructure into the four raw receivers
664 /// `(message, response, event, shutdown)`.
665 pub fn into_parts(self) -> DataStreamTuple {
666 (
667 self.message_stream,
668 self.response_stream,
669 self.event_stream,
670 self.shutdown_stream,
671 )
672 }
673}
674
675/// Create a matched pair of [`TransportAdapter`] (sender) and [`DataStreams`] (receiver).
676///
677/// Both sides share the same [`ShutdownState`] so drain coordination is automatic.
678pub fn make_channels() -> (TransportAdapter, DataStreams) {
679 let shutdown_state = ShutdownState::new();
680 let (message_tx, message_rx) = flume::unbounded();
681 let (response_tx, response_rx) = flume::unbounded();
682 let (event_tx, event_rx) = flume::unbounded();
683 let (shutdown_tx, shutdown_rx) = flume::unbounded();
684 (
685 TransportAdapter {
686 message_stream: message_tx,
687 response_stream: response_tx,
688 event_stream: event_tx,
689 shutdown_stream: shutdown_tx,
690 shutdown_state: shutdown_state.clone(),
691 },
692 DataStreams {
693 message_stream: message_rx,
694 response_stream: response_rx,
695 event_stream: event_rx,
696 shutdown_stream: shutdown_rx,
697 shutdown_state,
698 },
699 )
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705 use tokio::time::{sleep, timeout};
706
707 #[test]
708 fn test_shutdown_state_initial() {
709 let state = ShutdownState::new();
710 assert!(!state.is_draining());
711 assert_eq!(state.in_flight_count(), 0);
712 }
713
714 #[test]
715 fn test_begin_drain_flips_flag() {
716 let state = ShutdownState::new();
717 state.begin_drain();
718 assert!(state.is_draining());
719 }
720
721 #[test]
722 fn test_acquire_increments_inflight() {
723 let state = ShutdownState::new();
724 let _g1 = state.acquire();
725 assert_eq!(state.in_flight_count(), 1);
726 }
727
728 #[test]
729 fn test_guard_drop_decrements_inflight() {
730 let state = ShutdownState::new();
731 let g = state.acquire();
732 assert_eq!(state.in_flight_count(), 1);
733 drop(g);
734 assert_eq!(state.in_flight_count(), 0);
735 }
736
737 #[tokio::test]
738 async fn test_wait_for_drain_blocks_then_completes() {
739 let state = ShutdownState::new();
740 let guard = state.acquire();
741
742 let state_clone = state.clone();
743 let handle = tokio::spawn(async move {
744 state_clone.wait_for_drain().await;
745 });
746
747 sleep(Duration::from_millis(50)).await;
748 assert!(!handle.is_finished());
749
750 drop(guard);
751 timeout(Duration::from_millis(100), handle)
752 .await
753 .expect("should complete after guard drop")
754 .unwrap();
755 }
756
757 #[test]
758 fn test_message_type_roundtrip() {
759 for v in 0..=4 {
760 let mt = MessageType::from_u8(v).unwrap();
761 assert_eq!(mt.as_u8(), v);
762 }
763 assert_eq!(MessageType::from_u8(5), None);
764 }
765
766 #[test]
767 fn test_make_channels_includes_shutdown_state() {
768 let (adapter, streams) = make_channels();
769 assert!(!adapter.shutdown_state.is_draining());
770 adapter.shutdown_state.begin_drain();
771 assert!(streams.shutdown_state.is_draining());
772 }
773
774 // ---------------------------------------------------------------------
775 // Admission / drain-visibility invariants.
776 //
777 // The property under test is "queued implies counted": a frame that
778 // `admit_message` put on the inbound queue keeps `wait_for_drain` parked
779 // until somebody takes ownership of it and drops it. Before the guard rode
780 // inside the queued item, the consumer acquired it only *after* the
781 // dequeue, so a queued-but-unconsumed frame was invisible to the drain and
782 // `wait_for_drain` returned while it was still sitting there.
783 // ---------------------------------------------------------------------
784
785 /// A frame sitting on the inbound queue is counted work: `wait_for_drain`
786 /// must park until it is received *and dropped*, not until it is enqueued.
787 #[tokio::test]
788 async fn queued_message_holds_drain() {
789 let (adapter, streams) = make_channels();
790
791 let outcome = adapter.admit_message(
792 Bytes::from_static(b"queued-header"),
793 Bytes::from_static(b"queued-payload"),
794 );
795 assert!(matches!(outcome, AdmitOutcome::Admitted));
796 assert_eq!(
797 adapter.shutdown_state.in_flight_count(),
798 1,
799 "a queued message must be counted work the moment it is admitted"
800 );
801
802 adapter.shutdown_state.begin_drain();
803
804 let waiter_state = adapter.shutdown_state.clone();
805 let waiter = tokio::spawn(async move { waiter_state.wait_for_drain().await });
806
807 sleep(Duration::from_millis(50)).await;
808 assert!(
809 !waiter.is_finished(),
810 "wait_for_drain completed while a message was still queued and undispatched"
811 );
812
813 let queued = timeout(
814 Duration::from_millis(500),
815 streams.message_stream.recv_async(),
816 )
817 .await
818 .expect("the admitted message must still be on the queue")
819 .expect("recv");
820 assert_eq!(&queued.header[..], b"queued-header");
821 assert_eq!(&queued.payload[..], b"queued-payload");
822
823 // Dropping the message drops its guard — this is what a consumer that
824 // finished dispatching does.
825 drop(queued);
826
827 timeout(Duration::from_millis(500), waiter)
828 .await
829 .expect("wait_for_drain must complete once the queued message is released")
830 .expect("waiter task panicked");
831 assert_eq!(streams.shutdown_state.in_flight_count(), 0);
832 }
833
834 /// Admission during drain rejects and hands the frame back, and the guard
835 /// it acquired to close the check-then-act race is released again.
836 #[tokio::test]
837 async fn admit_message_rejects_during_drain() {
838 let (adapter, streams) = make_channels();
839 adapter.shutdown_state.begin_drain();
840
841 match adapter.admit_message(
842 Bytes::from_static(b"reject-header"),
843 Bytes::from_static(b"reject-payload"),
844 ) {
845 AdmitOutcome::Draining { header, payload } => {
846 assert_eq!(&header[..], b"reject-header");
847 assert_eq!(&payload[..], b"reject-payload");
848 }
849 AdmitOutcome::Admitted => panic!("a draining instance must not admit a Message"),
850 AdmitOutcome::Disconnected { .. } => panic!("the receiver is still alive"),
851 }
852
853 assert!(
854 streams.message_stream.is_empty(),
855 "a rejected message must not reach the queue"
856 );
857 assert_eq!(
858 adapter.shutdown_state.in_flight_count(),
859 0,
860 "the acquire-then-check probe guard must not outlive the rejection"
861 );
862
863 // The blip must not have left a waiter stranded either.
864 timeout(
865 Duration::from_millis(500),
866 adapter.shutdown_state.wait_for_drain(),
867 )
868 .await
869 .expect("wait_for_drain must complete after a rejected admission");
870 }
871
872 /// Discarding the inbound channel with work still on it releases every
873 /// guard that work was holding — nobody has to compensate by hand.
874 ///
875 /// This is the RAII property the mandatory guard buys. A design where
876 /// producers bump a fungible counter and the consumer "adopts" it on
877 /// dequeue leaks the entire backlog here, and every later
878 /// `wait_for_drain` hangs on a count that can no longer reach zero.
879 ///
880 /// Note *when* the release happens: flume keeps the queue alive until the
881 /// last endpoint goes, so dropping only the receiver is not enough — the
882 /// guards go with the buffer, once both ends are gone. Held deliberately
883 /// as one assertion at the end rather than after each drop, so this test
884 /// does not turn flume's current ordering into a requirement.
885 #[test]
886 fn dropped_channel_releases_queued_guards() {
887 let (adapter, streams) = make_channels();
888 let state = adapter.shutdown_state.clone();
889
890 for i in 0..8u8 {
891 let outcome = adapter.admit_message(
892 Bytes::from(vec![b'h', i]),
893 Bytes::from_static(b"queued-payload"),
894 );
895 assert!(matches!(outcome, AdmitOutcome::Admitted));
896 }
897 assert_eq!(state.in_flight_count(), 8);
898
899 // Teardown: the consumer's receiver goes, then the transports holding
900 // the adapter clones go.
901 drop(streams);
902 drop(adapter);
903
904 assert_eq!(
905 state.in_flight_count(),
906 0,
907 "discarding the inbound queue must release every guard it was holding"
908 );
909 }
910
911 /// An undeliverable frame comes back to the caller so it can route it to
912 /// the transport's error handler — and its guard is released, or the
913 /// frame nobody can deliver would keep `wait_for_drain` parked forever.
914 #[tokio::test]
915 async fn admit_message_disconnected_returns_frames() {
916 let (adapter, streams) = make_channels();
917 drop(streams);
918
919 match adapter.admit_message(
920 Bytes::from_static(b"orphan-header"),
921 Bytes::from_static(b"orphan-payload"),
922 ) {
923 AdmitOutcome::Disconnected { header, payload } => {
924 assert_eq!(&header[..], b"orphan-header");
925 assert_eq!(&payload[..], b"orphan-payload");
926 }
927 AdmitOutcome::Admitted => panic!("there is no receiver left to admit to"),
928 AdmitOutcome::Draining { .. } => panic!("the instance is not draining"),
929 }
930
931 assert_eq!(
932 adapter.shutdown_state.in_flight_count(),
933 0,
934 "an undeliverable frame must not strand the drain"
935 );
936 timeout(
937 Duration::from_millis(500),
938 adapter.shutdown_state.wait_for_drain(),
939 )
940 .await
941 .expect("wait_for_drain must complete after an undeliverable admission");
942 }
943
944 /// `wait_for_drain` must not lose the wakeup when the last guard drops
945 /// while it is between reading the counter and parking.
946 ///
947 /// `notify_waiters()` stores no permit, so a `Notified` future created
948 /// *after* the drop never hears it. The fix creates the future before
949 /// reading the counter; tokio records the `notify_waiters` generation at
950 /// construction, so a future that merely exists when the drop happens
951 /// completes on its first poll.
952 ///
953 /// The window is a handful of instructions inside a single task, so this
954 /// scans it rather than hitting one interleaving. Both sides start from
955 /// the same reference point — the waiter arming `armed` — and each then
956 /// burns a busy-wait: the waiter a fixed `WAITER_LEAD`, the dropper a
957 /// `spins` that sweeps `0..SPIN_SWEEP` across iterations. The fixed lead
958 /// pays for the dropper's cache-coherence latency in seeing `armed`, which
959 /// otherwise makes it land systematically *after* the window; the sweep
960 /// then walks the drop across it. A lost wakeup is permanent, so the
961 /// per-iteration bound is short and the first hit fails the test.
962 ///
963 /// Probabilistic by nature, but the constants are measured, not guessed:
964 /// against the pre-fix `while load { notified().await }` the first hit
965 /// landed between iteration 819 and 6703 across runs on two machines, so
966 /// `ITERATIONS` leaves roughly 5x headroom over the worst observed.
967 ///
968 /// The per-iteration bound is a *detector*, not a deadline: a runner that
969 /// fails to schedule the freshly spawned dropper thread inside it looks
970 /// identical to a lost wakeup from here. So a timeout joins the dropper —
971 /// making the guard drop a fact rather than an assumption — and re-awaits
972 /// under a generous grace window. A genuinely lost wakeup is permanent
973 /// (nothing else ever calls `notify_waiters`), so the grace window costs
974 /// no detection power and turns a scheduler stall back into a pass.
975 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
976 async fn wait_for_drain_survives_guard_dropped_at_the_check() {
977 const ITERATIONS: usize = 32768;
978 const SPIN_SWEEP: usize = 1024;
979 const WAITER_LEAD: usize = 600;
980 /// Second chance after the detector window, for a stalled runner.
981 const GRACE: Duration = Duration::from_secs(2);
982
983 // `black_box` inside each busy-wait keeps LLVM from folding the sum
984 // into a closed form and deleting the delay the scan depends on.
985 fn burn(rounds: usize) {
986 let mut sink = 0usize;
987 for k in 0..rounds {
988 sink = std::hint::black_box(sink.wrapping_add(k));
989 }
990 }
991
992 for iteration in 0..ITERATIONS {
993 let state = ShutdownState::new();
994 let guard = state.acquire();
995
996 let armed = Arc::new(AtomicBool::new(false));
997 let spins = iteration % SPIN_SWEEP;
998
999 let dropper_armed = armed.clone();
1000 let dropper = std::thread::spawn(move || {
1001 while !dropper_armed.load(Ordering::Acquire) {
1002 std::hint::spin_loop();
1003 }
1004 burn(spins);
1005 drop(guard);
1006 });
1007
1008 let waiter_state = state.clone();
1009 let mut waiter = tokio::spawn(async move {
1010 armed.store(true, Ordering::Release);
1011 burn(WAITER_LEAD);
1012 waiter_state.wait_for_drain().await;
1013 });
1014
1015 let finished = timeout(Duration::from_millis(200), &mut waiter).await;
1016 dropper.join().expect("dropper thread panicked");
1017 let joined = match finished {
1018 Ok(joined) => joined,
1019 // The drop has definitely happened now (the thread is joined),
1020 // so anything still parked is either a lost wakeup or a stall
1021 // that outlived the detector window.
1022 Err(_) => timeout(GRACE, &mut waiter).await.unwrap_or_else(|_| {
1023 panic!(
1024 "wait_for_drain lost the drain wakeup (iteration {iteration}, spins {spins})"
1025 )
1026 }),
1027 };
1028 joined.expect("waiter task panicked");
1029 }
1030 }
1031}