Skip to main content

epics_libcom_rs/runtime/
blocking_io.rs

1//! Blocking socket ⇄ `AsyncRead`/`AsyncWrite` adapters: the byte source for
2//! every reactor-free driver in the workspace.
3//!
4//! ```text
5//!   socket --read--> reader pump thread --Vec<u8> chunks--> ChannelReader
6//!                                                              |
7//!                                       whatever drives the protocol future
8//!                                                              |
9//!   socket <--write-- writer pump thread <--framed bytes-- ChannelWriter
10//! ```
11//!
12//! # Why this lives in `epics-base-rs` and not in a protocol crate
13//!
14//! It was written once, inside `epics-pva-rs`'s blocking **server** driver, and
15//! the obvious next move was to promote it within that crate so the PVA client
16//! could reach it too. Measured, that destination is wrong:
17//! `epics-ca-rs` does not depend on `epics-pva-rs` and must not — the only
18//! crate that depends on both is `epics-bridge-rs`, which sits *above* them
19//! (`doc/calink-rtems-design.md` §3.3). A primitive promoted inside
20//! `epics-pva-rs` is one the CA client structurally cannot call, so the next CA
21//! increment writes a third copy — exactly the outcome "one seam, two callers"
22//! exists to prevent.
23//!
24//! So it lands here, beside the rest of its family: `runtime::task::spawn`,
25//! `block_on_sync`/`park_on`, `StackSizeClass`, `spawn_dedicated_thread`,
26//! `enter_ioc_thread`. Every protocol crate can reach it, and none of them owns
27//! it.
28//!
29//! # The seam is the byte source, not the frame pipeline
30//!
31//! Nothing here parses anything. Both pumps move `Vec<u8>` and neither knows
32//! whether the bytes are PVA frames, CA messages, or noise; the protocol future
33//! on the other side of the adapters is untouched and uncompiled-differently.
34//! That is what makes a driver built on this primitive arguable from the hosted
35//! driver's own tests: same parser, same `select!`, same handlers, different
36//! implementors of two `dyn` traits.
37//!
38//! # Two facts that must not be re-derived
39//!
40//! * **No fd dup.** The read and write roles come from **one** descriptor
41//!   shared through an `Arc`, via `impl Read for &TcpStream` /
42//!   `impl Write for &TcpStream` — never `try_clone`. `try_clone` is
43//!   `fcntl(F_DUPFD_CLOEXEC)`, and on RTEMS 6 that cannot work for a socket:
44//!   RTEMS's `fcntl` has no `F_DUPFD_CLOEXEC` case at all
45//!   (`cpukit/libcsupport/src/fcntl.c:146-220` falls to
46//!   `default: errno = EINVAL`), and even plain `F_DUPFD` fails because
47//!   `duplicate_iop` calls the file's `open_h` while rtems-libbsd installs
48//!   `rtems_bsd_sysgen_nodeops` on every socket. Measured on the target: `dup`,
49//!   `F_DUPFD` and `F_DUPFD_CLOEXEC` all fail on a socket while `F_DUPFD` on
50//!   `/dev/console` succeeds. A caller that reaches for `try_clone` compiles
51//!   and fails at runtime on target only.
52//! * **A blocking write needs a deadline, not a per-syscall timeout.**
53//!   `SO_SNDTIMEO` bounds each `write` syscall, so a peer that accepts one byte
54//!   per tick never trips it and holds the pump thread indefinitely — and a
55//!   target that does not implement the option at all (VxWorks 7) has no bound
56//!   whatever. [`write_frame_deadline`] therefore waits for writability against
57//!   its own deadline and sets no socket option.
58//!
59//! # Lifecycle: a pump you cannot spawn without holding the thing that ends it
60//!
61//! [`spawn_reader_pump`] and [`spawn_writer_pump`] each return an adapter *and*
62//! a guard, and there is no way to obtain the former without the latter. The
63//! guards' `Drop` is what retires the threads, which is what makes every exit
64//! path — clean return, `?`, and a panic unwinding through the caller — covered
65//! without any cleanup written on an error branch.
66//!
67//! The two guards end their threads differently because the threads park
68//! differently:
69//!
70//! | guard | how its thread is parked | how the guard returns it |
71//! |---|---|---|
72//! | [`ReaderPumpGuard`] | inside a blocking `read` behind an effectively-infinite `SO_RCVTIMEO` | `shutdown(Shutdown::Both)` on the shared socket, then `join` |
73//! | [`WriterPumpGuard`] | inside `recv()` on the frame channel | drop the only strong sender, then `join` |
74//!
75//! A caller that needs a specific teardown *order* — writer down first so
76//! frames emitted on the way out reach the wire, then reader — gets it by
77//! dropping the guards in that order, or by declaring them in the reverse of
78//! it.
79//!
80//! The reader guard's row is a POSIX contract: `shutdown` waking a thread
81//! parked in a blocking `read` is what unix (RTEMS included) provides and
82//! Windows does not (measured, PR #56 CI 2026-07-24 — the parked read
83//! outlived a 120 s bound). That is why `exec_backend`, the only
84//! configuration that runs these pumps in production, refuses Windows at
85//! compile time (`lib.rs`), and why the tests asserting this contract are
86//! `#[cfg(unix)]`.
87//!
88//! # Where the async goes
89//!
90//! [`block_on_sync`] is the single bridge, in both pumps. On a bare thread it
91//! parks; on a multi-thread runtime worker it hands the worker off first. It is
92//! **not** `blocking_send`, which panics inside a runtime context and would
93//! make this module unusable from a hosted worker.
94//!
95//! # Before the pumps: the dial
96//!
97//! A reactor-free driver cannot `await` a connect either, so the blocking
98//! `connect` needs a thread just as the two pumps do — and for the same reason,
99//! at the same band. [`DialPool`] owns those threads. It is here rather than in
100//! a protocol crate for the reason the pumps are: `epics-ca-rs` and
101//! `epics-pva-rs` both dial and neither may depend on the other.
102
103// RTEMS-EXEC-MODEL-ALLOW(1): `one_descriptor_serves_both_pumps` asserts both
104// pump directions concurrently from the async side, which needs the
105// multi-thread tokio flavor; it runs and passes in the feature-ON suite (the
106// pumps themselves are std threads, the tokio runtime only hosts the
107// assertions).
108
109use std::collections::VecDeque;
110use std::io::{self, Read, Write};
111use std::net::{Shutdown, SocketAddr, TcpStream};
112use std::pin::Pin;
113use std::sync::{Arc, Condvar, Mutex, MutexGuard};
114use std::task::{Context, Poll, Waker};
115use std::time::{Duration, Instant};
116
117use tokio::io::ReadBuf;
118use tokio::sync::{mpsc, oneshot};
119use tracing::{debug, warn};
120
121use crate::runtime::task::{StackSizeClass, ThreadPriority, block_on_sync, spawn_dedicated_thread};
122use crate::runtime::worker_pool::{Job, SetLease, Worker, WorkerPool, WorkerRole};
123
124/// One blocking read, sized to match the frame readers that consume it so the
125/// byte arrival pattern is the hosted one.
126pub const DEFAULT_READ_CHUNK: usize = 4096;
127
128/// The `FIONREAD` ioctl request — bytes pending in the socket receive queue.
129/// C `rsrv`'s batch-up gate: hold accumulated replies while this is `> 0`,
130/// flush at `0` (`camsgtask.c:55`, `cast_server.c:272`), and libca's flow
131/// control input on the client side (`tcpiiu.cpp:544`).
132///
133/// The `libc` crate exposes `FIONREAD` for hosted Unix but omits it for
134/// `armv7-rtems-eabihf`, so the RTEMS value is supplied here. RTEMS newlib
135/// defines it in `sys/rtems/include/sys/filio.h` as `_IOR('f', 127, int)`;
136/// `sys/ioccom.h` in the same tree encodes
137/// `_IOR(g,n,t) = IOC_OUT | (sizeof(t) << 16) | (g << 8) | n` with
138/// `IOC_OUT = 0x40000000`. For a 4-byte `int` that is
139/// `0x40000000 | (4 << 16) | ('f' << 8) | 127 = 0x4004_667F` — the same value
140/// the `libc` crate hardcodes for the whole BSD family (`unix/bsd/mod.rs`),
141/// which C `rsrv` runs on RTEMS in production. Pending on-target runtime
142/// verification at the QEMU/BSP phase; a wrong value only makes the `ioctl`
143/// error, and every caller then flushes (C's own `status < 0` branch),
144/// degrading to per-datagram / per-iteration flushing — never a hang or a
145/// crash. (Candidate for an upstream `libc` newlib/rtems binding so this
146/// local definition can later be dropped.)
147#[cfg(all(unix, not(target_os = "rtems")))]
148const FIONREAD_REQUEST: libc::c_ulong = libc::FIONREAD as libc::c_ulong;
149#[cfg(target_os = "rtems")]
150const FIONREAD_REQUEST: libc::c_ulong = 0x4004_667F;
151
152/// Bytes pending in the socket receive queue via `FIONREAD`.
153///
154/// **One owner for the whole workspace.** Two callers need this exact
155/// question answered and they are on opposite sides of the protocol: C
156/// `rsrv`'s batch-up gate holds accumulated replies while this is `> 0` and
157/// flushes at `0` (`camsgtask.c:52-67`, `cast_server.c:268-281`), and libca's
158/// `tcpiiu::bytesArePendingInOS()` is the sole input to client flow control
159/// (`tcpiiu.cpp:544-567`). They were two implementations — the server's here,
160/// the client's a bare `libc::FIONREAD` that does not exist on
161/// `armv7-rtems-eabihf` at all — which is one implementation too many for a
162/// constant whose RTEMS value had to be derived from newlib headers by hand.
163///
164/// On any `ioctl` error this returns `Err`, and every caller treats that as
165/// "flush now" / "nothing pending" — matching C's `status < 0` branch — so an
166/// absent or wrong FIONREAD never coalesces (byte-correct, just unbatched),
167/// never latches flow control on, and never hangs.
168#[cfg(unix)]
169pub fn pending_bytes<F: std::os::fd::AsRawFd>(sock: &F) -> io::Result<usize> {
170    let mut n: libc::c_int = 0;
171    // SAFETY: `as_raw_fd()` is a valid open socket fd; FIONREAD writes one
172    // `c_int` count through the out-pointer, whose type and size match.
173    let rc = unsafe {
174        libc::ioctl(
175            sock.as_raw_fd(),
176            FIONREAD_REQUEST as _,
177            &mut n as *mut libc::c_int,
178        )
179    };
180    if rc != 0 {
181        return Err(io::Error::last_os_error());
182    }
183    Ok(n.max(0) as usize)
184}
185
186#[cfg(not(unix))]
187pub fn pending_bytes<F>(_sock: &F) -> io::Result<usize> {
188    // No FIONREAD off Unix (RTEMS and the host CI are both Unix-family). Report
189    // "unavailable" so callers flush every iteration — never coalesce — which
190    // is byte-correct, just unbatched.
191    Err(io::Error::new(
192        io::ErrorKind::Unsupported,
193        "FIONREAD unavailable on this platform",
194    ))
195}
196
197/// A blocking socket op hit its `SO_RCVTIMEO`/`SO_SNDTIMEO`.
198///
199/// Unix reports the expiry as `WouldBlock`, some platforms as `TimedOut`.
200pub fn is_socket_timeout(kind: io::ErrorKind) -> bool {
201    matches!(kind, io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut)
202}
203
204/// Announce a pump thread that did not end normally.
205///
206/// The guards below make a loss *survivable* — the connection is torn down
207/// however a pump ends. They do not make it *visible*, and a process that has
208/// lost a thread but reads exactly like a healthy one is what closes here. The
209/// loss that reaches this function is a pump that panicked; a pooled worker is
210/// never *created* per connection, so the old "could not be created" loss is
211/// gone with the per-connection spawn.
212///
213/// Through `errlog` and not `tracing` alone: `errlog_sev_printf` reaches the
214/// console whatever the log configuration — including an RTEMS console whose
215/// subscriber is the in-tree one — and printing it is what a C IOC does.
216fn pump_thread_lost(role: &str, label: &str, what: &str) {
217    crate::runtime::log::errlog_sev_printf(
218        crate::runtime::log::ErrlogSevEnum::Major,
219        &format!(
220            "{label}: the {role} thread {what}; this connection is being torn \
221             down. Other connections are unaffected."
222        ),
223    );
224    warn!(label, role, what, "blocking socket pump: a thread was lost");
225}
226
227// ---------------------------------------------------------------------------
228// Dial side
229// ---------------------------------------------------------------------------
230
231/// How many dial threads one [`DialPool`] may ever create.
232///
233/// The bound is on *creations for the life of the process*, not on threads
234/// alive at an instant, because that is the resource that was being consumed:
235/// every `std::thread` leaks 128 B on RTEMS permanently (its TLS key is freed
236/// before the key's destructor runs), so a dial that spawns per attempt leaks
237/// per attempt. A pool whose workers never retire creates at most this many,
238/// ever — the leak becomes a one-off 4 × 128 B, whatever the redial cadence.
239///
240/// Four, not one: a worker is occupied for as long as its `connect` blocks, and
241/// a SYN-blackholed peer holds one for the whole OS connect ladder long after
242/// the awaiting side gave up at its own bound. The ladder that bounds a worker
243/// is the *target's*, not the host's: on `armv7-rtems-eabihf` libbsd ends an
244/// unanswered handshake at `TCPTV_KEEP_INIT` (`75 * hz`), measured at 75 s,
245/// while a Linux host runs `tcp_syn_retries` out to ~130 s. This pool exists
246/// for the RTEMS target, so 75 s is the figure its sizing is reasoned against.
247/// One worker would let a single unreachable peer head-of-line-block
248/// every other dial in the process; four keeps distinct in-flight dials
249/// independent in normal operation. The cost is four `Small` stacks
250/// (4 × 256 KiB on `armv7-rtems-eabihf`), and only if four dials were ever
251/// concurrently in flight — a client that only ever dials one server at a time
252/// creates exactly one worker and reuses it forever.
253///
254/// Past the bound, dials queue. That is not a failure mode that needs its own
255/// handling: a queued request is still under the caller's own timeout, so it
256/// fails at that deadline exactly as an in-flight one would, and a worker that
257/// later reaches a request whose caller has gone opens no socket at all.
258pub const MAX_DIAL_WORKERS: usize = 4;
259
260/// One dial handed to a worker: where to connect, and where the result goes.
261struct DialRequest {
262    target: SocketAddr,
263    reply: oneshot::Sender<io::Result<TcpStream>>,
264}
265
266/// Everything the pool mutates, under one lock.
267///
268/// The three counts answer one question — *is a worker owed?* — and are kept in
269/// the shape that makes the answer exact: `workers - busy` is available, and a
270/// request is covered iff the available ones outnumber the queue.
271struct DialQueue {
272    /// Requests no worker has taken yet.
273    pending: VecDeque<DialRequest>,
274    /// Workers holding a request. Counting the *busy* ones rather than the
275    /// parked ones is load-bearing: a worker between its `connect` and its park
276    /// is neither, and counting parked workers would make it read as
277    /// unavailable — so a caller woken by that very worker's reply would create
278    /// a second one it does not need. The busy count is released *before* the
279    /// reply is sent, so a woken caller always sees its worker as available.
280    busy: usize,
281    /// Workers created. Only ever decremented when a spawn *fails*: a worker
282    /// that exists never exits, which is the whole point.
283    workers: usize,
284}
285
286/// A bounded, permanent set of threads that own this role's blocking TCP
287/// dials.
288///
289/// # Why the dial needs a thread at all
290///
291/// The connect is a blocking syscall and every caller is a task. On the exec
292/// backend a task runs on a cooperative callback-band worker shared with every
293/// other future on its band, so connecting inline parks the band for the whole
294/// attempt — measured exactly there (gdb all-thread dump, host-linux
295/// `realtime-pva-ioc`): one unanswering name server starved every future on Medium
296/// for ~40 s per attempt. So the connect goes to a thread and the caller parks
297/// on a oneshot instead.
298///
299/// # Why the threads are permanent
300///
301/// The obvious shape — one transient thread per dial — is unbounded in thread
302/// *creations*, and creations are what cost on RTEMS (see [`MAX_DIAL_WORKERS`]).
303/// A search engine whose name server is down redials roughly every 10 s for as
304/// long as the IOC runs, so "transient, one per attempt" is a leak with no
305/// ceiling. Making the workers permanent and reusing them removes the family
306/// rather than capping it: after the first dial of each concurrency level there
307/// is nothing left to create.
308///
309/// # What a worker owes the socket it opens
310///
311/// A worker is the **single finalizer** for every socket it opens. If the
312/// caller gave up (timed out, or its future was dropped) the oneshot send fails
313/// and the returned `TcpStream` is dropped right there, closing the fresh
314/// socket. A worker that reaches a request whose caller is already gone skips
315/// the connect entirely, so a backlog built up behind a blackholed peer costs
316/// no sockets at all.
317///
318/// # Where the timeout is *not*
319///
320/// The worker issues a plain blocking [`TcpStream::connect`] — the CA client's
321/// proven on-target dial, C parity with `tcpiiu.cpp`'s blocking `::connect()`,
322/// and a thread that owns its blocking needs no poll machinery. The
323/// application-level bound belongs to the awaiting side, which holds the
324/// [`oneshot::Receiver`] this returns and is free to wrap it in
325/// `runtime::task::timeout`. Do not add a bound here: the two are deliberately
326/// split, and collapsing them puts the application deadline back inside a
327/// syscall that cannot honour it.
328pub struct DialPool {
329    /// OS thread-name stem; workers are `"{name_prefix} {index}"`. Keep it
330    /// short — RTEMS truncates thread names at 16 bytes.
331    name_prefix: &'static str,
332    /// The band every worker enters. Dials belong to the band of the pumps
333    /// they precede, so this is per-role and is why the pool is not global.
334    priority: ThreadPriority,
335    queue: Mutex<DialQueue>,
336    work: Condvar,
337}
338
339impl DialPool {
340    /// Declare a role's dial pool. `const` so it can be a `static`: a pool is
341    /// per-role and lives as long as the process, so a caller needs no `Arc`
342    /// and no lazy initialiser.
343    pub const fn new(name_prefix: &'static str, priority: ThreadPriority) -> Self {
344        Self {
345            name_prefix,
346            priority,
347            queue: Mutex::new(DialQueue {
348                pending: VecDeque::new(),
349                busy: 0,
350                workers: 0,
351            }),
352            work: Condvar::new(),
353        }
354    }
355
356    /// Threads this pool has created — never more than [`MAX_DIAL_WORKERS`].
357    ///
358    /// The bound made observable: this is the number the per-attempt shape grew
359    /// without limit.
360    pub fn worker_count(&self) -> usize {
361        self.lock().workers
362    }
363
364    /// Requests waiting for a worker, and workers currently inside a dial.
365    ///
366    /// The other half of the bound: `worker_count()` alone cannot distinguish
367    /// "four workers, nothing queued" from "four workers, every one pinned and
368    /// a fifth dial waiting" — which is the state
369    /// [`MAX_DIAL_WORKERS`] exists to produce and the
370    /// only state in which the queueing it documents is observable.
371    pub fn queue_depth(&self) -> (usize, usize) {
372        let q = self.lock();
373        (q.pending.len(), q.busy)
374    }
375
376    /// Submit a dial. The returned receiver resolves with whatever the worker's
377    /// `connect` returned.
378    ///
379    /// The error is a thread-creation failure, and only that: it is returned
380    /// *before* the request is queued, so a caller that sees it knows no dial is
381    /// pending on its behalf.
382    pub fn dial(
383        &'static self,
384        target: SocketAddr,
385    ) -> io::Result<oneshot::Receiver<io::Result<TcpStream>>> {
386        let (reply, rx) = oneshot::channel();
387        let req = DialRequest { target, reply };
388
389        let mut q = self.lock();
390        // Each queued request already claims one available worker, so this
391        // request is covered only if the available ones outnumber the queue.
392        if q.pending.len() + q.busy < q.workers || q.workers >= MAX_DIAL_WORKERS {
393            q.pending.push_back(req);
394            drop(q);
395            self.work.notify_one();
396            return Ok(rx);
397        }
398
399        // Create the worker *before* queueing, so a spawn failure leaves the
400        // pool exactly as it found it and the caller keeps its error.
401        let index = q.workers;
402        q.workers += 1;
403        drop(q);
404        if let Err(e) = spawn_dedicated_thread(
405            format!("{} {index}", self.name_prefix),
406            self.priority,
407            StackSizeClass::Small,
408            move || self.worker_loop(),
409        ) {
410            self.lock().workers -= 1;
411            return Err(e);
412        }
413        self.lock().pending.push_back(req);
414        self.work.notify_one();
415        Ok(rx)
416    }
417
418    /// A worker's whole life: take a request, connect, hand the socket back.
419    ///
420    /// Never returns. See the type docs for why that is the fix rather than an
421    /// oversight.
422    fn worker_loop(&self) -> ! {
423        loop {
424            let req = {
425                let mut q = self.lock();
426                loop {
427                    if let Some(req) = q.pending.pop_front() {
428                        q.busy += 1;
429                        break req;
430                    }
431                    // No lost wakeup to worry about: every worker re-reads
432                    // `pending` under this lock before parking, so a request
433                    // queued while this one was still running is seen here.
434                    q = self.work.wait(q).unwrap_or_else(|e| e.into_inner());
435                }
436            };
437            // The caller gave up while this request sat in the queue. Opening a
438            // socket nobody can receive would only make this worker its
439            // finalizer for no reason.
440            let dialed = (!req.reply.is_closed()).then(|| TcpStream::connect(req.target));
441            // Release the slot *before* replying: the caller this reply wakes
442            // may dial again immediately, and it must see this worker as
443            // available rather than create a second one.
444            self.lock().busy -= 1;
445            if let Some(dialed) = dialed {
446                // Single finalizer: a failed send drops the `TcpStream` here,
447                // which closes the socket this worker opened.
448                let _ = req.reply.send(dialed);
449            }
450        }
451    }
452
453    fn lock(&self) -> MutexGuard<'_, DialQueue> {
454        self.queue.lock().unwrap_or_else(|e| e.into_inner())
455    }
456}
457
458// ---------------------------------------------------------------------------
459// Reader side
460// ---------------------------------------------------------------------------
461
462/// `AsyncRead` over a channel of byte chunks — the blocking stand-in for a
463/// socket read half.
464///
465/// **Cancel-safety** is the whole point of the `cur`/`pos` pair and is why this
466/// type exists at all rather than a channel being read inline. A frame reader
467/// used directly as a `select!` arm survives losing that race because its
468/// accumulated bytes live *outside* it. This adapter has the same property: a
469/// chunk leaves the channel only when `poll_recv` returns `Ready`, and a
470/// partially-copied chunk stays in `cur`/`pos` across as many dropped
471/// `poll_read` futures as the caller likes. A lost race consumes nothing.
472pub struct ChannelReader {
473    rx: mpsc::Receiver<Vec<u8>>,
474    /// The chunk currently being handed out, and how much of it has gone.
475    cur: Vec<u8>,
476    pos: usize,
477}
478
479impl ChannelReader {
480    /// Build an adapter over an existing chunk channel.
481    ///
482    /// Public because a caller may want the adapter without a socket behind it
483    /// — a test double, or a byte source that is not a `TcpStream`. The paired
484    /// [`spawn_reader_pump`] is what a socket-backed caller wants.
485    pub fn new(rx: mpsc::Receiver<Vec<u8>>) -> Self {
486        Self {
487            rx,
488            cur: Vec::new(),
489            pos: 0,
490        }
491    }
492}
493
494impl tokio::io::AsyncRead for ChannelReader {
495    fn poll_read(
496        mut self: Pin<&mut Self>,
497        cx: &mut Context<'_>,
498        buf: &mut ReadBuf<'_>,
499    ) -> Poll<io::Result<()>> {
500        // No room offered: report "nothing filled" without taking anything out
501        // of the channel. Consuming here would be the one way this adapter
502        // could lose bytes.
503        if buf.remaining() == 0 {
504            return Poll::Ready(Ok(()));
505        }
506        let me = &mut *self;
507        loop {
508            if me.pos < me.cur.len() {
509                let n = (me.cur.len() - me.pos).min(buf.remaining());
510                buf.put_slice(&me.cur[me.pos..me.pos + n]);
511                me.pos += n;
512                if me.pos == me.cur.len() {
513                    me.cur.clear();
514                    me.pos = 0;
515                }
516                return Poll::Ready(Ok(()));
517            }
518            match me.rx.poll_recv(cx) {
519                Poll::Ready(Some(chunk)) => {
520                    // An empty chunk is not an EOF marker; skip it rather than
521                    // letting it read as one.
522                    if chunk.is_empty() {
523                        continue;
524                    }
525                    me.cur = chunk;
526                    me.pos = 0;
527                }
528                // Every sender gone = the reader thread ended (EOF, read error,
529                // or RCVTIMEO). Zero bytes filled is what a frame reader turns
530                // into its own peer-closed error — the existing hosted EOF
531                // path, unchanged.
532                Poll::Ready(None) => return Poll::Ready(Ok(())),
533                Poll::Pending => return Poll::Pending,
534            }
535        }
536    }
537}
538
539/// Read loop. Ends on EOF, read error, or a read that outlives `read_timeout`;
540/// dropping `tx` on the way out is the EOF signal to the adapter.
541///
542/// The wait is [`wait_readable`], not the socket's own `SO_RCVTIMEO`, because
543/// the descriptor is non-blocking — see [`own_blocking_mode`] for why it has to
544/// be. `read_timeout` is the same value the option carried, applied per read
545/// exactly as the option applied it, so a connection ends on a silent peer at
546/// the same point it did before.
547fn reader_pump(
548    sock: Arc<TcpStream>,
549    tx: mpsc::Sender<Vec<u8>>,
550    chunk_size: usize,
551    label: String,
552    read_timeout: Option<Duration>,
553) {
554    // `impl Read for &TcpStream`: one shared descriptor, no `try_clone`.
555    let mut sock = &*sock;
556    let mut chunk = vec![0u8; chunk_size];
557    loop {
558        match wait_readable(sock, read_timeout.map(|t| Instant::now() + t)) {
559            Ok(true) => {}
560            Ok(false) => {
561                debug!(label, "blocking reader: receive timeout, ending connection");
562                break;
563            }
564            Err(e) => {
565                debug!(label, error = %e, "blocking reader: wait failed");
566                break;
567            }
568        }
569        let n = match sock.read(&mut chunk) {
570            Ok(0) => break,
571            Ok(n) => n,
572            Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
573            // Readiness that yielded nothing: back to the wait, which re-arms
574            // the same bound, so this cannot spin.
575            Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue,
576            Err(e) if is_socket_timeout(e.kind()) => {
577                debug!(label, "blocking reader: receive timeout, ending connection");
578                break;
579            }
580            Err(e) => {
581                debug!(label, error = %e, "blocking reader: read failed");
582                break;
583            }
584        };
585        // The house sync-over-async primitive: parks this thread (no runtime
586        // entered) or hands the worker off (hosted). NOT `blocking_send`.
587        if !matches!(block_on_sync(tx.send(chunk[..n].to_vec())), Ok(Ok(()))) {
588            break;
589        }
590    }
591}
592
593/// The spawned reader pump, woken and joined on **every** exit path.
594///
595/// # Invariant
596///
597/// MUST: once the reader pump has been spawned, it is woken and joined before
598/// its owner returns — clean return, `?`, or a panic unwinding out of the
599/// caller.
600///
601/// # The defect this closes
602///
603/// A writer-spawn failure used to `?` out with the reader already running,
604/// leaving it parked in `read` behind an `SO_RCVTIMEO` that a PVA `op_timeout`
605/// makes effectively infinite (~64,000 s by default), holding its socket and
606/// its descriptor for the life of the IOC. The connection slot was returned
607/// correctly, which is exactly what made the leak invisible: the connection
608/// count looked healthy while descriptors drained away.
609///
610/// Owning the handle in a guard, rather than calling cleanup on the error
611/// branch, is what makes the leak unexpressible: there is no way to have
612/// spawned the reader without also holding the value that joins it. The same
613/// applies to the panic path, which no error-branch cleanup could have covered.
614pub struct ReaderPumpGuard {
615    /// The same descriptor the pump reads from. Owning an `Arc` rather than
616    /// borrowing is load-bearing: waking a pump that has already ended must be
617    /// a no-op on a still-open fd, never a `shutdown` of an fd number the OS
618    /// has since handed to someone else.
619    sock: Arc<TcpStream>,
620    label: String,
621    /// The pooled job running `reader_pump`. Joining it returns the worker to
622    /// its pool; the worker itself is not retired, only the job.
623    job: Option<Job>,
624}
625
626impl Drop for ReaderPumpGuard {
627    fn drop(&mut self) {
628        if let Some(job) = self.job.take() {
629            // The pump's `read` is parked behind an effectively-infinite
630            // timeout, so the socket has to be shut to return it. `ENOTCONN`
631            // when the peer has already gone: there was nothing to wake, which
632            // is not a failure of anything.
633            let _ = self.sock.shutdown(Shutdown::Both);
634            // The join result is the only place a panicked pump is ever
635            // reported: `reader_pump` returns `()`, so an `Err` here means it
636            // unwound, and the connection's own error will be a bland
637            // channel-closed rather than the cause. Discarding it left the two
638            // unlinkable.
639            if job.join().is_err() {
640                pump_thread_lost("reader", &self.label, "panicked");
641            }
642        }
643    }
644}
645
646/// Drive `sock`'s read half on a pooled `worker`, yielding the `AsyncRead`
647/// half of the seam and the guard that retires the job.
648///
649/// Infallible: the thread already exists — it is the leased `worker` — so there
650/// is no creation to fail. Admission failure now lives in
651/// [`WorkerPool::acquire`], which is where it belongs.
652///
653/// `queue_depth` is the chunk channel's depth. **One** is the faithful choice
654/// for a demand-driven frame reader — one read per poll, each frame dispatched
655/// fully before the next read — because it reproduces that with at most one
656/// chunk of read-ahead, which the kernel receive buffer already provides. A
657/// larger depth lets a fast peer queue chunks while a slow consumer blocks: a
658/// behaviour change, not an optimisation.
659pub fn spawn_reader_pump(
660    worker: Worker,
661    sock: Arc<TcpStream>,
662    label: &str,
663    chunk_size: usize,
664    queue_depth: usize,
665) -> (ChannelReader, ReaderPumpGuard) {
666    // What the caller configured, read back rather than passed in, so this
667    // signature is unchanged: the socket already carries the read bound, and
668    // `SO_RCVTIMEO` stops being the mechanism that applies it without ceasing
669    // to be where the value lives. `None` — never set, or a target whose
670    // getter declines — polls with no deadline, which is what a socket with no
671    // `SO_RCVTIMEO` did before.
672    let read_timeout = sock_read_timeout(&sock);
673    spawn_reader_pump_with_timeout(worker, sock, label, chunk_size, queue_depth, read_timeout)
674}
675
676fn sock_read_timeout(sock: &TcpStream) -> Option<Duration> {
677    sock.read_timeout().ok().flatten()
678}
679
680fn spawn_reader_pump_with_timeout(
681    worker: Worker,
682    sock: Arc<TcpStream>,
683    label: &str,
684    chunk_size: usize,
685    queue_depth: usize,
686    read_timeout: Option<Duration>,
687) -> (ChannelReader, ReaderPumpGuard) {
688    let (tx, rx) = mpsc::channel::<Vec<u8>>(queue_depth);
689    let pump_sock = sock.clone();
690    let pump_label = label.to_string();
691    let job = worker.run(move || reader_pump(pump_sock, tx, chunk_size, pump_label, read_timeout));
692    (
693        ChannelReader::new(rx),
694        ReaderPumpGuard {
695            sock,
696            label: label.to_string(),
697            job: Some(job),
698        },
699    )
700}
701
702// ---------------------------------------------------------------------------
703// Writer side
704// ---------------------------------------------------------------------------
705
706/// Wake slot for a `poll_write` that found the frame channel full. The writer
707/// pump wakes it after each frame it takes, which is the moment room appears.
708#[derive(Default)]
709struct WriteRoom {
710    waker: Mutex<Option<Waker>>,
711}
712
713impl WriteRoom {
714    fn park(&self, cx: &Context<'_>) {
715        *self.waker.lock().expect("write-room waker poisoned") = Some(cx.waker().clone());
716    }
717
718    fn wake(&self) {
719        let waker = self.waker.lock().expect("write-room waker poisoned").take();
720        if let Some(w) = waker {
721            w.wake();
722        }
723    }
724}
725
726/// `AsyncWrite` over a channel of frames — the blocking stand-in for a socket
727/// write half.
728///
729/// Holds a [`mpsc::WeakSender`], never a strong one, and that is load-bearing
730/// rather than tidiness. This adapter is typically owned by a task that is
731/// *aborted*, not joined, when the connection ends, so the moment its last
732/// strong sender drops is not a moment the owner controls. With only a weak
733/// handle here, [`WriterPumpGuard`]'s sender is the sole thing keeping the
734/// channel open, and dropping it ends the pump deterministically instead of
735/// whenever the runtime gets round to reaping an aborted task.
736pub struct ChannelWriter {
737    tx: mpsc::WeakSender<Vec<u8>>,
738    room: Arc<WriteRoom>,
739}
740
741fn write_closed() -> io::Error {
742    io::Error::new(
743        io::ErrorKind::BrokenPipe,
744        "the writer pump thread has ended",
745    )
746}
747
748impl tokio::io::AsyncWrite for ChannelWriter {
749    fn poll_write(
750        self: Pin<&mut Self>,
751        cx: &mut Context<'_>,
752        buf: &[u8],
753    ) -> Poll<io::Result<usize>> {
754        if buf.is_empty() {
755            return Poll::Ready(Ok(0));
756        }
757        let Some(tx) = self.tx.upgrade() else {
758            return Poll::Ready(Err(write_closed()));
759        };
760        // Register interest BEFORE trying, so a take that happens between the
761        // try and the return cannot be missed: either `try_send` sees the room
762        // that take created, or the take's `wake()` finds this waker.
763        self.room.park(cx);
764        match tx.try_send(buf.to_vec()) {
765            Ok(()) => Poll::Ready(Ok(buf.len())),
766            Err(mpsc::error::TrySendError::Full(_)) => Poll::Pending,
767            Err(mpsc::error::TrySendError::Closed(_)) => Poll::Ready(Err(write_closed())),
768        }
769        // `tx` drops here. Nothing in this adapter holds a strong sender across
770        // a suspension, which is what makes the guard's drop decisive.
771    }
772
773    /// Frames are flushed by the writer pump as it takes them; there is no
774    /// buffer here to push.
775    fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
776        Poll::Ready(Ok(()))
777    }
778
779    fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
780        Poll::Ready(Ok(()))
781    }
782}
783
784/// `POLLOUT` and `MSG_DONTWAIT` for this target.
785///
786/// Deliberately not `libc::POLLOUT` / `libc::MSG_DONTWAIT`. On
787/// `armv7-rtems-eabihf` the `libc` crate glob-re-exports both
788/// `unix/newlib/arm` and `unix/newlib/rtems`, and those two modules define
789/// these names with **different values** — `POLLOUT` `0x10` against `0x0004`,
790/// `MSG_DONTWAIT` `4` against `0x80`, 16 names colliding that way in total. A
791/// glob-versus-glob collision is an ambiguity error at the use site, so naming
792/// them through `libc` does not compile there; and taking the `arm` values
793/// would be wrong regardless, because RTEMS's stack is libbsd and the FreeBSD
794/// values are the true ones (`sys/poll.h`, `sys/socket.h`). Stated here for the
795/// same reason [`FIONREAD_REQUEST`] above is stated: the constant is derived
796/// from the target's own headers rather than from whichever module the glob
797/// happened to win.
798///
799/// Every other Unix — the hosted hosts and `*-wrs-vxworks*`, where one module
800/// defines each name — takes `libc`'s.
801#[cfg(target_os = "rtems")]
802const POLLOUT_EVENT: libc::c_short = 0x0004;
803#[cfg(target_os = "rtems")]
804const POLLIN_EVENT: libc::c_short = 0x0001;
805#[cfg(target_os = "rtems")]
806const SEND_DONTWAIT: libc::c_int = 0x0080;
807#[cfg(all(unix, not(target_os = "rtems")))]
808const POLLOUT_EVENT: libc::c_short = libc::POLLOUT;
809#[cfg(all(unix, not(target_os = "rtems")))]
810const POLLIN_EVENT: libc::c_short = libc::POLLIN;
811#[cfg(all(unix, not(target_os = "rtems")))]
812const SEND_DONTWAIT: libc::c_int = libc::MSG_DONTWAIT;
813
814/// Put `sock` in non-blocking mode, so that no syscall this module issues on it
815/// can park regardless of which flags and options the target honours.
816///
817/// This module owns the descriptor's blocking mode; that ownership is what the
818/// bound is made of. Both directions are gated by a `poll` against the caller's
819/// own deadline ([`wait_readable`], [`wait_writable`]), so the mode is not a
820/// performance choice but the thing that makes "the syscall returns" true by
821/// construction instead of true wherever `MSG_DONTWAIT` or `SO_SNDTIMEO`
822/// happens to be implemented.
823///
824/// C reaches the same place the same way: `setNonBlock(fd, 1)` at connect under
825/// `USE_POLL` (`drvAsynIPPort.c:511`), with a poll on reads as well as writes.
826/// Which is also the evidence that the call is available on the embedded
827/// targets — it is `ioctl(FIONBIO)`, the one socket control C already relies on
828/// there, not one of the options VxWorks answers `ENOPROTOOPT` to.
829///
830/// Windows keeps blocking sockets and its `SO_SNDTIMEO`/`SO_RCVTIMEO`, which it
831/// does implement; the `not(unix)` arms of both waits are built on them.
832#[cfg(unix)]
833fn own_blocking_mode(sock: &TcpStream) -> io::Result<()> {
834    sock.set_nonblocking(true)
835}
836
837#[cfg(not(unix))]
838fn own_blocking_mode(_sock: &TcpStream) -> io::Result<()> {
839    Ok(())
840}
841
842/// Wait until `sock` has a byte to read or has hit EOF, or `deadline` passes.
843/// `Ok(true)` = readable, `Ok(false)` = the deadline passed with it still empty.
844/// `None` waits with no deadline, which is what an unconfigured socket did
845/// before.
846///
847/// The read-side twin of [`wait_writable`], and it exists for the same reason:
848/// with the descriptor non-blocking, a `read` cannot park, so the bound has to
849/// come from here. It replaces `SO_RCVTIMEO` as the *mechanism* while keeping
850/// it as the *value* — callers still say how long a read may take, and
851/// [`drive_socket_blocking`] still sets the option for the `not(unix)` arm.
852///
853/// `POLLHUP` also returns `Ok(true)`: the read that follows returns 0 and the
854/// pump ends on its existing EOF path, which is how a `shutdown` still wakes a
855/// waiting reader now that no `read` is parked for it to interrupt.
856#[cfg(unix)]
857fn wait_readable(sock: &TcpStream, deadline: Option<Instant>) -> io::Result<bool> {
858    use std::os::fd::AsRawFd;
859
860    loop {
861        let ms = match deadline {
862            Some(d) => {
863                let remaining = d.saturating_duration_since(Instant::now());
864                if remaining.is_zero() {
865                    return Ok(false);
866                }
867                remaining.as_millis().max(1).min(libc::c_int::MAX as u128) as libc::c_int
868            }
869            None => -1,
870        };
871        let mut fds = libc::pollfd {
872            fd: sock.as_raw_fd(),
873            events: POLLIN_EVENT,
874            revents: 0,
875        };
876        // SAFETY: one initialised `pollfd` whose `fd` is this borrowed socket's
877        // and stays open for the call; `poll` reads `fd`/`events` and writes
878        // only `revents`.
879        let rc = unsafe { libc::poll(&mut fds, 1, ms) };
880        if rc > 0 {
881            return Ok(true);
882        }
883        if rc == 0 {
884            return Ok(false);
885        }
886        let e = io::Error::last_os_error();
887        if e.kind() != io::ErrorKind::Interrupted {
888            return Err(e);
889        }
890        // `EINTR`: the remaining time is recomputed at the top, so a signal
891        // storm cannot extend the bound.
892    }
893}
894
895/// Non-Unix arm: Windows implements `SO_RCVTIMEO` and keeps a blocking socket,
896/// so the read that follows carries its own bound and this only arms it.
897#[cfg(not(unix))]
898fn wait_readable(sock: &TcpStream, deadline: Option<Instant>) -> io::Result<bool> {
899    let Some(d) = deadline else {
900        sock.set_read_timeout(None)?;
901        return Ok(true);
902    };
903    let remaining = d.saturating_duration_since(Instant::now());
904    if remaining.is_zero() {
905        return Ok(false);
906    }
907    sock.set_read_timeout(Some(remaining.max(Duration::from_millis(1))))?;
908    Ok(true)
909}
910
911/// Wait until `sock` will accept at least one byte, or `deadline` passes.
912/// `Ok(true)` = writable, `Ok(false)` = the deadline passed with it still full.
913///
914/// Half of what makes [`write_frame_deadline`]'s bound hold by construction:
915/// the wait belongs to this module, so no socket option is load-bearing and a
916/// target that implements none of them is bounded exactly as one that
917/// implements them all. `POLLERR`/`POLLHUP` also return `Ok(true)`, so the send
918/// that follows reports the real errno instead of this function inventing one.
919#[cfg(unix)]
920fn wait_writable(sock: &TcpStream, deadline: Instant) -> io::Result<bool> {
921    use std::os::fd::AsRawFd;
922
923    loop {
924        let remaining = deadline.saturating_duration_since(Instant::now());
925        if remaining.is_zero() {
926            return Ok(false);
927        }
928        // Rounded up to 1 ms so a sub-millisecond remainder waits instead of
929        // spinning, and clamped so a very long deadline still fits `poll`'s
930        // `c_int` milliseconds.
931        let ms = remaining.as_millis().max(1).min(libc::c_int::MAX as u128) as libc::c_int;
932        let mut fds = libc::pollfd {
933            fd: sock.as_raw_fd(),
934            events: POLLOUT_EVENT,
935            revents: 0,
936        };
937        // SAFETY: one initialised `pollfd` whose `fd` is this borrowed socket's
938        // and stays open for the call; `poll` reads `fd`/`events` and writes
939        // only `revents`.
940        let rc = unsafe { libc::poll(&mut fds, 1, ms) };
941        if rc > 0 {
942            return Ok(true);
943        }
944        if rc == 0 {
945            return Ok(false);
946        }
947        let e = io::Error::last_os_error();
948        if e.kind() != io::ErrorKind::Interrupted {
949            return Err(e);
950        }
951        // `EINTR`: the remaining time is recomputed at the top, so a signal
952        // storm cannot extend the bound.
953    }
954}
955
956/// Hand as much of `buf` to the socket as it will take **without parking**,
957/// however many bytes that is.
958///
959/// The other half of the bound, and the half that is easy to get wrong: a
960/// blocking `write` on a stream socket does not return a short count when the
961/// send buffer fills, it waits until the *whole* buffer is queued
962/// (`tcp_sendmsg` parks in `sk_stream_wait_memory`). So waiting for `POLLOUT`
963/// first is not enough on its own — the very next `write` re-enters the same
964/// unbounded wait one byte later. The socket is non-blocking for as long as it
965/// is live ([`own_blocking_mode`]), so the send takes what there is room for and
966/// returns; `MSG_DONTWAIT` rides on top as the per-call form of the same ask.
967///
968/// That mode is on the file description the reader pump shares (see the module
969/// docs on why it is shared and not `dup`ed), which is what [`wait_readable`]
970/// exists for: the reader polls before reading rather than parking in `read`,
971/// so sharing the description with a non-blocking writer costs it nothing.
972///
973/// A full buffer surfaces as `EAGAIN`/`WouldBlock`, which returns the caller to
974/// [`wait_writable`] and therefore to the deadline.
975///
976/// `SIGPIPE` needs no flag here: Rust's startup sets it to `SIG_IGN` on every
977/// Unix target, so a send to a closed peer returns `EPIPE`.
978///
979/// # The flag is the fast path, not the guarantee
980///
981/// It cannot be the guarantee, because a target may ignore it. XNU's `sosend`
982/// decides whether to sleep from `so_state & SS_NBIO` and its own internal
983/// `MSG_NBIO`, and `MSG_DONTWAIT` reaches it only as the sockbuf-lock wait
984/// hint, so on Darwin the send parked and the deadline was left riding on
985/// whatever `SO_SNDTIMEO` the caller had armed — measured, macOS CI
986/// 2026-07-27, `doc/darwin-send-dontwait-gap.md`. What makes the send return
987/// on every target is [`own_blocking_mode`]. The flag stays because where it
988/// *is* honoured it saves the loop a `poll` on the common path where the
989/// socket has room.
990#[cfg(unix)]
991fn write_some(sock: &TcpStream, buf: &[u8]) -> io::Result<usize> {
992    use std::os::fd::AsRawFd;
993
994    // SAFETY: `buf` is a valid initialised slice borrowed for the call, and
995    // `as_raw_fd()` is this borrowed socket's open descriptor. `send` reads
996    // `buf.len()` bytes from the pointer and writes nothing through it.
997    let n = unsafe {
998        libc::send(
999            sock.as_raw_fd(),
1000            buf.as_ptr().cast(),
1001            buf.len(),
1002            SEND_DONTWAIT,
1003        )
1004    };
1005    if n < 0 {
1006        return Err(io::Error::last_os_error());
1007    }
1008    Ok(n as usize)
1009}
1010
1011/// Non-Unix arm of the same two-part contract.
1012///
1013/// `poll` would mean `WSAPoll` and a Win32 dependency this crate does not
1014/// carry, and Windows *does* implement `SO_SNDTIMEO`. So arm it — from inside
1015/// this module, not from a caller — to the time the deadline has left: the send
1016/// that follows returns within `remaining`, and the loop ends the frame on the
1017/// next pass. The bound is still owned here, which is the property that
1018/// matters.
1019///
1020/// The blocking pumps are refused on Windows at compile time (`lib.rs`), so
1021/// this arm keeps the primitive's contract uniform where the module still
1022/// compiles rather than carrying production traffic.
1023#[cfg(not(unix))]
1024fn wait_writable(sock: &TcpStream, deadline: Instant) -> io::Result<bool> {
1025    let remaining = deadline.saturating_duration_since(Instant::now());
1026    if remaining.is_zero() {
1027        return Ok(false);
1028    }
1029    sock.set_write_timeout(Some(remaining.max(Duration::from_millis(1))))?;
1030    Ok(true)
1031}
1032
1033#[cfg(not(unix))]
1034fn write_some(sock: &TcpStream, buf: &[u8]) -> io::Result<usize> {
1035    let mut sock = sock;
1036    sock.write(buf)
1037}
1038
1039/// Write one whole frame under a **deadline**, not merely a per-syscall
1040/// timeout.
1041///
1042/// A hosted writer bounds `write_all(&frame)` as a unit. A per-syscall socket
1043/// timeout bounds each `write` instead, so a peer that accepts one byte per
1044/// tick never trips it and holds the pump thread indefinitely — the exact
1045/// stuck-peer hazard the hosted timeout exists to prevent, on a resource (an OS
1046/// thread) that is scarcer on RTEMS than a task is on the host.
1047///
1048/// # The deadline holds on every target, by construction
1049///
1050/// This function owns its bound end to end. It takes over the socket's blocking
1051/// mode (`own_blocking_mode`, crate-private) so that no syscall below it can
1052/// park; `wait_writable` does every wait, against `deadline`. Between them there
1053/// is no call in this loop that can outlast `send_timeout`, and nothing a caller
1054/// does or fails to do can disarm it.
1055///
1056/// Owning the mode is what makes that true rather than nearly true. `write_some`
1057/// passing `MSG_DONTWAIT` is not enough on its own: XNU consults `SS_NBIO` and
1058/// its internal `MSG_NBIO` and ignores the flag a caller sends, so on Darwin the
1059/// send parked and the deadline was carried by whatever `SO_SNDTIMEO` the caller
1060/// happened to have armed — measured, macOS CI 2026-07-27, where the case that
1061/// armed none outlived a 20 s wait while its armed sibling ended on time. The
1062/// flag stays, because where it is honoured it saves the loop a `poll`, but it
1063/// is no longer what the guarantee rests on.
1064///
1065/// It used to lean on the caller having set `SO_SNDTIMEO`, which was the only
1066/// thing that returned control to this loop. That made the bound conditional on
1067/// a socket option, and VxWorks 7 does not implement it — `setsockopt` returns
1068/// `ENOPROTOOPT`, so on that target the deadline was silently absent and a peer
1069/// that accepted the connection and then stopped reading parked the pump with
1070/// nothing entitled to reclaim it
1071/// (`doc/vxworks-circuit-wedge-on-target-measurement.md` §5). An invariant that
1072/// one target can switch off is not an invariant; the wait is the caller's own
1073/// now, and `SO_SNDTIMEO` is not set anywhere in this module.
1074///
1075/// A partial write on expiry needs no repair: the caller ends the pump and the
1076/// connection is torn down, so nothing is ever written to this socket again.
1077pub fn write_frame_deadline(
1078    sock: &TcpStream,
1079    frame: &[u8],
1080    send_timeout: Duration,
1081) -> io::Result<()> {
1082    // Here, not at the call sites, so that no caller can be the one that forgot
1083    // — including a caller that reached this socket without going through
1084    // `drive_socket_blocking`. Idempotent, so the writer pump paying for it once
1085    // per frame costs an `ioctl` next to the `poll` and `send` it already makes.
1086    own_blocking_mode(sock)?;
1087    // `impl Write for &TcpStream`: rebind so `write`/`flush` have a mutable
1088    // place to borrow, without needing `&mut TcpStream` from the caller.
1089    let mut sock = sock;
1090    let deadline = Instant::now() + send_timeout;
1091    let mut off = 0;
1092    while off < frame.len() {
1093        // The one gate, ahead of every syscall, so every way round the loop is
1094        // bounded — a stalled peer, a trickling one, and an `Interrupted`
1095        // storm alike.
1096        if !wait_writable(sock, deadline)? {
1097            return Err(io::Error::new(
1098                io::ErrorKind::TimedOut,
1099                "send deadline expired with the frame incomplete",
1100            ));
1101        }
1102        match write_some(sock, &frame[off..]) {
1103            Ok(0) => {
1104                return Err(io::Error::new(
1105                    io::ErrorKind::WriteZero,
1106                    "peer accepted no bytes",
1107                ));
1108            }
1109            Ok(n) => off += n,
1110            Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
1111            // `EAGAIN` from the non-blocking send, or the non-Unix arm's socket
1112            // timeout: no progress, back round to the gate above.
1113            Err(e) if is_socket_timeout(e.kind()) => {}
1114            Err(e) => return Err(e),
1115        }
1116    }
1117    sock.flush()
1118}
1119
1120/// Drain frames to the socket in order. Ends when the guard drops the last
1121/// strong sender, or on the first write error / send-deadline expiry.
1122///
1123/// Whichever of those ends it, it shuts the socket on the way out. A dead
1124/// writer means the connection is over, and the consumer must not wait up to a
1125/// heartbeat period to find that out — but the fix is the socket shutdown, not
1126/// an extra `select!` arm in the protocol loop: the reader pump's `read` then
1127/// returns 0 and the consumer unwinds down its existing EOF path, leaving the
1128/// protocol module and the hosted timing alone.
1129fn writer_pump(
1130    sock: Arc<TcpStream>,
1131    mut rx: mpsc::Receiver<Vec<u8>>,
1132    room: Arc<WriteRoom>,
1133    send_timeout: Duration,
1134    label: String,
1135) {
1136    // `Ok(None)` = the guard let go of its sender; `Err(_)` = this thread
1137    // cannot block here at all. Both end the pump.
1138    while let Ok(Some(frame)) = block_on_sync(rx.recv()) {
1139        // A slot just opened; let a parked `poll_write` retry.
1140        room.wake();
1141        if let Err(e) = write_frame_deadline(&sock, &frame, send_timeout) {
1142            debug!(label, error = %e, "blocking writer: send failed, ending connection");
1143            break;
1144        }
1145    }
1146    // Whatever parked the producer, it must not stay parked on a dead writer.
1147    room.wake();
1148    // Uniform, not special-cased on *why* the pump ended: the only thing that
1149    // ends it is the connection being over. On the error paths this is what
1150    // retires the connection at once; on the normal path the owner is already
1151    // tearing down and repeats the same shutdown a moment later, harmlessly —
1152    // every frame this thread was given has been written before it gets here.
1153    let _ = sock.shutdown(Shutdown::Both);
1154}
1155
1156/// The spawned writer pump and the only strong frame sender, retired together
1157/// on **every** exit path.
1158///
1159/// The sender lives here rather than beside the guard because the pump parks on
1160/// `rx.recv()` and leaves only when the last strong sender drops. A guard that
1161/// joined without dropping the sender would hang; keeping the two in one value
1162/// means the order cannot be got wrong, and does not depend on the declaration
1163/// order of two separate locals.
1164pub struct WriterPumpGuard {
1165    frames: Option<mpsc::Sender<Vec<u8>>>,
1166    label: String,
1167    /// The pooled job running `writer_pump`; joining it returns the worker.
1168    job: Option<Job>,
1169}
1170
1171impl Drop for WriterPumpGuard {
1172    fn drop(&mut self) {
1173        // Decisive because it is the only strong sender — [`ChannelWriter`]
1174        // holds a weak handle. The pump drains what is queued, sees `None`, and
1175        // exits; on its way out it shuts the socket.
1176        drop(self.frames.take());
1177        if let Some(job) = self.job.take() {
1178            // Same reading as [`ReaderPumpGuard`]'s: an `Err` is a panicked
1179            // pump, and a pump that unwound with frames still queued dropped
1180            // them.
1181            if job.join().is_err() {
1182                pump_thread_lost("writer", &self.label, "panicked");
1183            }
1184        }
1185    }
1186}
1187
1188/// Drive `sock`'s write half on a pooled `worker`, yielding the `AsyncWrite`
1189/// half of the seam and the guard that retires the job. Infallible for the same
1190/// reason as [`spawn_reader_pump`].
1191///
1192/// `queue_depth` follows the same reasoning as [`spawn_reader_pump`]'s: a
1193/// producer that emits one frame at a time and waits for it gets, at depth 1,
1194/// the same backpressure a blocking socket write would.
1195///
1196/// `send_timeout` bounds one whole frame and needs no cooperation from the
1197/// caller: [`write_frame_deadline`] owns the wait it is enforced by.
1198pub fn spawn_writer_pump(
1199    worker: Worker,
1200    sock: Arc<TcpStream>,
1201    label: &str,
1202    send_timeout: Duration,
1203    queue_depth: usize,
1204) -> (ChannelWriter, WriterPumpGuard) {
1205    let (tx, rx) = mpsc::channel::<Vec<u8>>(queue_depth);
1206    let room = Arc::new(WriteRoom::default());
1207    let adapter = ChannelWriter {
1208        tx: tx.downgrade(),
1209        room: room.clone(),
1210    };
1211    let pump_label = label.to_string();
1212    let job = worker.run(move || writer_pump(sock, rx, room, send_timeout, pump_label));
1213    (
1214        adapter,
1215        WriterPumpGuard {
1216            // The only strong sender moves into the guard, so it cannot be
1217            // dropped out of order with the join. `adapter` above already took
1218            // its weak handle.
1219            frames: Some(tx),
1220            label: label.to_string(),
1221            job: Some(job),
1222        },
1223    )
1224}
1225
1226// ---------------------------------------------------------------------------
1227// Owning adapters: the shape a caller with no teardown thread of its own wants
1228// ---------------------------------------------------------------------------
1229
1230/// A [`ChannelReader`] that owns its pump guard.
1231///
1232/// The server driver keeps its guards as locals because it *has* a thread that
1233/// outlives the protocol future and can drop them in a chosen order. A client
1234/// connection has no such thread: its reader and writer are tasks, and the
1235/// adapters are the only things the connection hands them. So for that shape
1236/// the guard rides *inside* the adapter, and the rule "you cannot hold the byte
1237/// source without holding the thing that retires its pump" holds there too.
1238pub struct GuardedReader {
1239    inner: ChannelReader,
1240    _guard: ReaderPumpGuard,
1241    /// The connection's worker-set lease, shared with [`GuardedWriter`]. The set
1242    /// returns to its pool only when both adapters — and both pump jobs they
1243    /// hold — are gone, which is exactly when the connection is over.
1244    _lease: Arc<SetLease>,
1245}
1246
1247impl tokio::io::AsyncRead for GuardedReader {
1248    fn poll_read(
1249        self: Pin<&mut Self>,
1250        cx: &mut Context<'_>,
1251        buf: &mut ReadBuf<'_>,
1252    ) -> Poll<io::Result<()>> {
1253        Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
1254    }
1255}
1256
1257/// A [`ChannelWriter`] that owns its pump guard. See [`GuardedReader`].
1258pub struct GuardedWriter {
1259    inner: ChannelWriter,
1260    _guard: WriterPumpGuard,
1261    /// The other strong reference to the connection's lease; see
1262    /// [`GuardedReader`].
1263    _lease: Arc<SetLease>,
1264}
1265
1266impl tokio::io::AsyncWrite for GuardedWriter {
1267    fn poll_write(
1268        self: Pin<&mut Self>,
1269        cx: &mut Context<'_>,
1270        buf: &[u8],
1271    ) -> Poll<io::Result<usize>> {
1272        Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
1273    }
1274
1275    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
1276        Pin::new(&mut self.get_mut().inner).poll_flush(cx)
1277    }
1278
1279    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
1280        Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
1281    }
1282}
1283
1284/// Everything a caller needs to set up on a socket before its pumps start.
1285#[derive(Clone, Debug)]
1286pub struct PumpConfig {
1287    /// `SO_RCVTIMEO`. NOT a shutdown mechanism — a protocol's idle timeout is
1288    /// typically hours, so what ends a parked reader is the guard's `shutdown`.
1289    pub read_timeout: Duration,
1290    /// The bound on writing one whole frame; see [`write_frame_deadline`].
1291    pub send_timeout: Duration,
1292    /// Bytes per blocking read. [`DEFAULT_READ_CHUNK`] unless the consumer's
1293    /// hosted reader uses a different one.
1294    pub chunk_size: usize,
1295    /// Depth of both the chunk and the frame channel.
1296    pub queue_depth: usize,
1297}
1298
1299impl Default for PumpConfig {
1300    fn default() -> Self {
1301        Self {
1302            read_timeout: Duration::from_secs(64_000),
1303            send_timeout: Duration::from_secs(30),
1304            chunk_size: DEFAULT_READ_CHUNK,
1305            queue_depth: 1,
1306        }
1307    }
1308}
1309
1310/// Drive one already-connected socket with two blocking pumps, returning the
1311/// two owning adapters.
1312///
1313/// This is the whole seam for a caller that has no teardown thread: hand it a
1314/// connected `TcpStream`, receive an `AsyncRead` and an `AsyncWrite` that the
1315/// protocol code cannot tell from a split socket, with both pump threads owned
1316/// by the values returned.
1317///
1318/// The socket's blocking mode is taken over here (`own_blocking_mode`,
1319/// crate-private), and fatally: it is what makes both pumps' bounds hold by
1320/// construction rather than wherever a flag or option is honoured, so a target
1321/// that refused it would be a target this seam cannot bound, and that is worth
1322/// a failed dial rather than a silent park. `SO_RCVTIMEO` is still set, also
1323/// fatally, but as the *value* the reader's wait uses and as the mechanism on
1324/// the `not(unix)` arm; on unix the wait is a `POLLIN` poll. There is no
1325/// send-side counterpart: [`write_frame_deadline`] owns its own bound and needs
1326/// no socket option, so there is nothing here for a target that implements
1327/// fewer of them to switch off.
1328///
1329/// # The pool, and the two bands it carries
1330///
1331/// The two pumps come from one leased worker set of `pool`, taken atomically so
1332/// a circuit at capacity can never hold one pump and block for the other. Their
1333/// bands are the pool roster's, not this function's: at least one caller's
1334/// upstream C derives two — libca gives a circuit's receive thread
1335/// `highestPriorityLevelBelow(initializing thread)` and its send thread
1336/// `lowestPriorityLevelAbove(...)` (`tcpiiu.cpp:677-682`), so the sender sits
1337/// *above* the receiver and can always drain a queue the receiver's work is
1338/// filling — and a caller whose upstream uses one band for both (pvxs, one
1339/// reactor thread) declares the same band twice in its roster. The `Err` is now
1340/// admission's: [`io::ErrorKind::WouldBlock`] when the circuit pool is full,
1341/// otherwise a socket-option or thread-creation failure.
1342pub fn drive_socket_blocking(
1343    pool: &WorkerPool<2>,
1344    stream: TcpStream,
1345    label: &str,
1346    config: &PumpConfig,
1347) -> io::Result<(GuardedReader, GuardedWriter)> {
1348    let _ = stream.set_nodelay(true);
1349    // The mode both pumps' bounds are built on, fatal: see this function's docs.
1350    own_blocking_mode(&stream)?;
1351    // SO_RCVTIMEO, fatal: every target that runs this accepts it.
1352    stream.set_read_timeout(Some(config.read_timeout))?;
1353    // No SO_SNDTIMEO, on purpose. It was set here once, and it was the send
1354    // deadline's only way of regaining control — which made the deadline
1355    // conditional on an option VxWorks 7 does not implement (`ENOPROTOOPT`,
1356    // errno 42, measured on target). Setting it fatally aborted every CA client
1357    // circuit the instant its dial succeeded; setting it best-effort left the
1358    // writer pump able to park with nothing to reclaim it. Neither is a bound.
1359    // `write_frame_deadline` now waits for writability against its own
1360    // deadline, so the guarantee is the same on a target that implements every
1361    // socket option and on one that implements none.
1362
1363    // Borrow the circuit's two pump workers as one set, or refuse. Roster order
1364    // is [reader, writer], the order `acquire` returns them.
1365    let (lease, [reader_worker, writer_worker]) = pool.acquire()?;
1366    let lease = Arc::new(lease);
1367
1368    // One socket, two roles: the SAME descriptor shared through an `Arc`. See
1369    // the module docs for why this is not `try_clone`.
1370    let stream = Arc::new(stream);
1371
1372    // The configured value directly, not read back off the socket: this is the
1373    // path that knows it, and it should not depend on the target implementing
1374    // the `SO_RCVTIMEO` *getter* as well as the setter.
1375    let (reader, reader_guard) = spawn_reader_pump_with_timeout(
1376        reader_worker,
1377        stream.clone(),
1378        label,
1379        config.chunk_size,
1380        config.queue_depth,
1381        Some(config.read_timeout),
1382    );
1383    let (writer, writer_guard) = spawn_writer_pump(
1384        writer_worker,
1385        stream,
1386        label,
1387        config.send_timeout,
1388        config.queue_depth,
1389    );
1390
1391    Ok((
1392        GuardedReader {
1393            inner: reader,
1394            _guard: reader_guard,
1395            _lease: lease.clone(),
1396        },
1397        GuardedWriter {
1398            inner: writer,
1399            _guard: writer_guard,
1400            _lease: lease,
1401        },
1402    ))
1403}
1404
1405/// The role roster a circuit's [`drive_socket_blocking`] pool must be built
1406/// with: `[reader, writer]`, both on `Small` stacks. The two bands are the
1407/// caller's to choose (see [`drive_socket_blocking`]'s docs).
1408pub fn circuit_roster(
1409    reader_priority: ThreadPriority,
1410    writer_priority: ThreadPriority,
1411) -> [WorkerRole; 2] {
1412    [
1413        WorkerRole {
1414            suffix: "reader",
1415            stack: StackSizeClass::Small,
1416            priority: reader_priority,
1417        },
1418        WorkerRole {
1419            suffix: "writer",
1420            stack: StackSizeClass::Small,
1421            priority: writer_priority,
1422        },
1423    ]
1424}
1425
1426#[cfg(test)]
1427mod tests {
1428    use super::*;
1429    use std::net::{TcpListener, TcpStream as StdTcpStream};
1430    use std::thread;
1431    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1432
1433    /// Production scope of this file: everything before the first column-0
1434    /// `#[cfg(test)]`.
1435    fn production_scope(src: &str) -> &str {
1436        match src.find("\n#[cfg(test)]") {
1437            Some(i) => &src[..i],
1438            None => src,
1439        }
1440    }
1441
1442    /// The production scope with every comment removed.
1443    ///
1444    /// Both guards below forbid *code* from naming something, and this module's
1445    /// docs name several of those things at length precisely because explaining
1446    /// why they are forbidden is the point. Matching raw source made the
1447    /// `try_clone` guard fail on its own rationale — five prose hits, zero code
1448    /// hits — which is a guard that punishes documentation. Stripping comments
1449    /// first is what makes the assertion mean what it says.
1450    fn code_only(src: &str) -> String {
1451        src.lines()
1452            .map(|line| match line.find("//") {
1453                Some(i) => &line[..i],
1454                None => line,
1455            })
1456            .collect::<Vec<_>>()
1457            .join("\n")
1458    }
1459
1460    /// The RTEMS constraint this module exists to satisfy: it must not reach
1461    /// for tokio's async net/timer/spawn machinery, none of which builds for
1462    /// `armv7-rtems-eabihf`, and it must not suspend a future directly — every
1463    /// await goes through `block_on_sync`. `tokio::sync` and `tokio::io`'s
1464    /// traits ARE allowed and are what the two adapters are built from.
1465    ///
1466    /// Same guard the two blocking drivers carry, moved here with the code it
1467    /// describes. Needles are `concat!`-split so this body does not match
1468    /// itself under `include_str!`.
1469    #[test]
1470    fn the_blocking_io_seam_has_no_async_runtime_symbols() {
1471        let prod = code_only(production_scope(include_str!("blocking_io.rs")));
1472        // Fail closed: if the seam is no longer in the slice, the slice is
1473        // wrong and every assertion below would pass vacuously.
1474        assert!(
1475            prod.contains("fn drive_socket_blocking"),
1476            "production slice no longer covers the seam"
1477        );
1478        let forbidden = [
1479            concat!("tokio", "::net"),
1480            concat!("tokio", "::time"),
1481            concat!("tokio", "::", "spawn"),
1482            concat!("block", "_in_place"),
1483            concat!(".", "await"),
1484        ];
1485        for token in forbidden {
1486            assert_eq!(
1487                prod.matches(token).count(),
1488                0,
1489                "the blocking I/O seam must not reference `{token}`: it has no async \
1490                 net/timer/spawn on RTEMS, and every await goes through `block_on_sync`"
1491            );
1492        }
1493    }
1494
1495    /// The no-fd-dup rule, as a source-text guard rather than a comment.
1496    ///
1497    /// `try_clone` compiles everywhere and fails `ENXIO` on RTEMS only, so a
1498    /// reviewer who has not read the module docs has no local signal that it is
1499    /// wrong. This gives them one.
1500    #[test]
1501    fn the_seam_never_duplicates_a_descriptor() {
1502        let prod = code_only(production_scope(include_str!("blocking_io.rs")));
1503        assert!(
1504            prod.contains("fn drive_socket_blocking"),
1505            "production slice no longer covers the seam"
1506        );
1507        for token in [concat!("try", "_clone"), concat!("F_DUP", "FD")] {
1508            assert_eq!(
1509                prod.matches(token).count(),
1510                0,
1511                "`{token}` is back in the blocking I/O seam: on RTEMS 6 every fd \
1512                 duplication of a socket fails ENXIO. The read and write roles come \
1513                 from one descriptor shared through an `Arc`."
1514            );
1515        }
1516    }
1517
1518    // ── adapter: cancel-safety ──────────────────────────────────────────
1519
1520    /// Losing a `select!` race must consume nothing. A frame reader is used
1521    /// directly as a `select!` arm, so if this adapter dropped bytes on a lost
1522    /// race the failure would be silent and intermittent — a truncated frame
1523    /// long after the fact.
1524    ///
1525    /// Both boundaries of "what was in flight when the race was lost":
1526    ///
1527    /// * **mid-chunk** — part of a chunk has been handed out and the rest is
1528    ///   parked in `cur`/`pos`;
1529    /// * **pending** — no chunk has arrived at all, so the poll registered a
1530    ///   waker and returned `Pending`.
1531    #[epics_macros_rs::epics_test]
1532    async fn channel_reader_loses_no_bytes_when_a_select_race_is_lost() {
1533        let (tx, rx) = mpsc::channel::<Vec<u8>>(1);
1534        let mut reader = ChannelReader::new(rx);
1535
1536        // Boundary 1: a partially-consumed chunk survives.
1537        tx.send(b"ABCDEFGH".to_vec()).await.expect("chunk queued");
1538        let mut small = [0u8; 3];
1539        let n = reader.read(&mut small).await.expect("first read");
1540        assert_eq!(&small[..n], b"ABC");
1541        for _ in 0..4 {
1542            let mut buf = [0u8; 8];
1543            tokio::select! {
1544                biased;
1545                // This arm always wins, so the read future below is created and
1546                // dropped without ever completing.
1547                _ = std::future::ready(()) => {}
1548                _ = reader.read(&mut buf) => unreachable!("the ready arm wins under `biased`"),
1549            }
1550        }
1551        let mut rest = [0u8; 8];
1552        let n = reader.read(&mut rest).await.expect("read after lost races");
1553        assert_eq!(
1554            &rest[..n],
1555            b"DEFGH",
1556            "a lost race must not eat the parked tail of the chunk"
1557        );
1558
1559        // Boundary 2: a poll that returned Pending consumed nothing either.
1560        for _ in 0..4 {
1561            let mut buf = [0u8; 8];
1562            tokio::select! {
1563                biased;
1564                _ = std::future::ready(()) => {}
1565                _ = reader.read(&mut buf) => unreachable!("the ready arm wins under `biased`"),
1566            }
1567        }
1568        tx.send(b"IJKL".to_vec())
1569            .await
1570            .expect("second chunk queued");
1571        let mut after = [0u8; 8];
1572        let n = reader
1573            .read(&mut after)
1574            .await
1575            .expect("read after pending races");
1576        assert_eq!(
1577            &after[..n],
1578            b"IJKL",
1579            "a chunk must not be taken out of the channel by a poll that returned Pending"
1580        );
1581
1582        // And EOF still reads as EOF once every sender is gone.
1583        drop(tx);
1584        let mut eof = [0u8; 8];
1585        assert_eq!(
1586            reader.read(&mut eof).await.expect("eof read"),
1587            0,
1588            "all senders dropped must surface as a zero-length read"
1589        );
1590    }
1591
1592    /// A zero-length `poll_read` buffer must not eat a chunk.
1593    #[epics_macros_rs::epics_test]
1594    async fn a_zero_length_read_consumes_nothing() {
1595        let (tx, rx) = mpsc::channel::<Vec<u8>>(1);
1596        let mut reader = ChannelReader::new(rx);
1597        tx.send(b"XY".to_vec()).await.expect("chunk queued");
1598        let mut none = [0u8; 0];
1599        assert_eq!(reader.read(&mut none).await.expect("empty read"), 0);
1600        let mut buf = [0u8; 8];
1601        let n = reader.read(&mut buf).await.expect("real read");
1602        assert_eq!(&buf[..n], b"XY", "the chunk survived a zero-length read");
1603    }
1604
1605    // ── adapter: the weak sender ────────────────────────────────────────
1606
1607    /// The adapter must not be what keeps the frame channel open, or a pump
1608    /// would outlive the guard that is supposed to end it.
1609    #[epics_macros_rs::epics_test]
1610    async fn channel_writer_does_not_keep_the_frame_channel_open() {
1611        let (tx, mut rx) = mpsc::channel::<Vec<u8>>(1);
1612        let room = Arc::new(WriteRoom::default());
1613        let mut writer = ChannelWriter {
1614            tx: tx.downgrade(),
1615            room,
1616        };
1617        writer.write_all(b"frame").await.expect("queued");
1618        assert_eq!(rx.recv().await.as_deref(), Some(&b"frame"[..]));
1619
1620        // The guard's sender goes; the adapter is still alive and holding only
1621        // a weak handle.
1622        drop(tx);
1623        assert!(
1624            rx.recv().await.is_none(),
1625            "a live ChannelWriter must not keep the channel open once the only \
1626             strong sender is gone"
1627        );
1628        assert!(
1629            writer.write_all(b"after").await.is_err(),
1630            "writing to a closed channel must be an error, not a silent drop"
1631        );
1632    }
1633
1634    // ── the deadline loop ───────────────────────────────────────────────
1635
1636    fn socket_pair() -> (StdTcpStream, StdTcpStream) {
1637        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
1638        let addr = listener.local_addr().expect("addr");
1639        let client = StdTcpStream::connect(addr).expect("connect");
1640        let (server, _) = listener.accept().expect("accept");
1641        (client, server)
1642    }
1643
1644    /// A peer that never reads must not hold the writer pump past the deadline.
1645    ///
1646    /// unix-only: this asserts the POSIX loopback send-backpressure contract —
1647    /// a bounded send buffer, so a never-reading peer parks the sender.
1648    /// Windows grows the loopback send backlog dynamically and accepted the
1649    /// whole 8 MiB frame in 12 ms (measured, PR #56 CI 2026-07-24), so there
1650    /// is no backpressure for the deadline to trip there. The drivers that
1651    /// need the deadline run on `exec_backend`, which refuses Windows at
1652    /// compile time (`lib.rs`).
1653    #[cfg(unix)]
1654    #[test]
1655    fn the_deadline_loop_ends_a_trickling_peer() {
1656        let (client, server) = socket_pair();
1657        let send_timeout = Duration::from_millis(200);
1658        client
1659            .set_write_timeout(Some(send_timeout / 4))
1660            .expect("sndtimeo");
1661        // Never read from `server`, so the socket buffers fill and stay full.
1662        let big = vec![0u8; 8 * 1024 * 1024];
1663        let started = Instant::now();
1664        let err = write_frame_deadline(&client, &big, send_timeout)
1665            .expect_err("a peer that never reads must trip the deadline");
1666        assert_eq!(err.kind(), io::ErrorKind::TimedOut);
1667        assert!(
1668            started.elapsed() < send_timeout * 20,
1669            "the deadline bounded the whole frame, not each syscall: {:?}",
1670            started.elapsed()
1671        );
1672        drop(server);
1673    }
1674
1675    /// The same bound, on a socket carrying **no `SO_SNDTIMEO` at all**.
1676    ///
1677    /// This is the VxWorks 7 boundary: `setsockopt(SO_SNDTIMEO)` is
1678    /// unimplemented there and returns `ENOPROTOOPT`, so no caller can arm the
1679    /// option however hard it tries
1680    /// (`doc/vxworks-circuit-wedge-on-target-measurement.md` §5). The two cases
1681    /// above cover "the option took"; this one covers "it did not", which is
1682    /// the only case where the deadline had nothing to regain control on.
1683    ///
1684    /// The write runs on its own thread and the assertion is on a bounded
1685    /// `recv`, because the failure being excluded is a park with no end: a
1686    /// direct call would hang the test rather than fail it.
1687    ///
1688    /// Runs on Darwin too, which is the point of it. `MSG_DONTWAIT` does not
1689    /// make an XNU send non-blocking, so while the flag was the only thing
1690    /// keeping the send out of a park this case was the one that failed there;
1691    /// it passes because [`own_blocking_mode`] no longer leaves the guarantee
1692    /// to the flag.
1693    #[cfg(unix)]
1694    #[test]
1695    fn the_deadline_holds_with_no_socket_send_timeout() {
1696        let (client, server) = socket_pair();
1697        // Deliberately no `set_write_timeout`. That is the whole boundary.
1698        let send_timeout = Duration::from_millis(200);
1699        // Never read from `server`, so the socket buffers fill and stay full.
1700        let big = vec![0u8; 8 * 1024 * 1024];
1701        let (tx, rx) = std::sync::mpsc::channel();
1702        let started = Instant::now();
1703        thread::spawn(move || {
1704            let outcome = write_frame_deadline(&client, &big, send_timeout).map_err(|e| e.kind());
1705            let _ = tx.send(outcome);
1706        });
1707        // Two orders of magnitude above the deadline, and still finite: what
1708        // this separates is "bounded" from "never".
1709        let outcome = rx
1710            .recv_timeout(send_timeout * 100)
1711            .expect("the frame's deadline must end the write without a socket timeout to lean on");
1712        assert_eq!(
1713            outcome.expect_err("a peer that never reads must trip the deadline"),
1714            io::ErrorKind::TimedOut
1715        );
1716        assert!(
1717            started.elapsed() < send_timeout * 20,
1718            "the deadline bounded the whole frame: {:?}",
1719            started.elapsed()
1720        );
1721        drop(server);
1722    }
1723
1724    /// And the ordinary case still delivers.
1725    #[test]
1726    fn the_deadline_loop_delivers_a_frame_to_a_reading_peer() {
1727        let (client, mut server) = socket_pair();
1728        let send_timeout = Duration::from_secs(5);
1729        client
1730            .set_write_timeout(Some(send_timeout / 4))
1731            .expect("sndtimeo");
1732        let reader = thread::spawn(move || {
1733            let mut got = vec![0u8; 5];
1734            server.read_exact(&mut got).expect("read");
1735            got
1736        });
1737        write_frame_deadline(&client, b"hello", send_timeout).expect("delivered");
1738        assert_eq!(reader.join().expect("reader"), b"hello");
1739    }
1740
1741    // ── guards ──────────────────────────────────────────────────────────
1742
1743    /// A process-lifetime circuit pool for the pump/guard tests, so a `#[test]`
1744    /// can borrow a worker exactly as a real circuit does. Ample capacity so no
1745    /// test refuses; the tests that care about refusal build their own pool.
1746    static TEST_POOL: std::sync::LazyLock<WorkerPool<2>> = std::sync::LazyLock::new(|| {
1747        WorkerPool::new(
1748            "test",
1749            circuit_roster(ThreadPriority::Low, ThreadPriority::Low),
1750            64,
1751        )
1752    });
1753
1754    /// Borrow one set: the lease and its reader/writer workers. The lease must
1755    /// be held until both pump jobs are joined, or the set would re-idle early.
1756    fn lease_pair() -> (SetLease, Worker, Worker) {
1757        let (lease, [reader, writer]) = TEST_POOL.acquire().expect("acquire a test set");
1758        (lease, reader, writer)
1759    }
1760
1761    /// The reader guard's whole purpose: a pump parked in `read` behind a
1762    /// timeout longer than the test could wait is returned by the guard's drop.
1763    ///
1764    /// unix-only: this asserts the POSIX teardown contract — a local
1765    /// `shutdown(Shutdown::Both)` returns a thread parked in a blocking
1766    /// `read`. Windows does not provide that wake (measured, PR #56 CI
1767    /// 2026-07-24: the parked read outlived the 120 s test bound on x86_64),
1768    /// which is why `exec_backend` refuses Windows at compile time
1769    /// (`lib.rs`) — nothing there can reach the pumps' teardown.
1770    #[cfg(unix)]
1771    #[test]
1772    fn the_reader_guard_returns_a_pump_parked_in_read() {
1773        let (client, server) = socket_pair();
1774        // An effectively-infinite receive timeout: only the shutdown can end
1775        // this pump.
1776        client
1777            .set_read_timeout(Some(Duration::from_secs(64_000)))
1778            .expect("rcvtimeo");
1779        let (lease, reader_worker, _writer_worker) = lease_pair();
1780        let (reader, guard) = spawn_reader_pump(reader_worker, Arc::new(client), "parked", 4096, 1);
1781        // The peer sends nothing, so the pump is parked in `read`.
1782        let started = Instant::now();
1783        drop(reader);
1784        drop(guard);
1785        assert!(
1786            started.elapsed() < Duration::from_secs(10),
1787            "the guard's shutdown must return a parked read, not wait out SO_RCVTIMEO"
1788        );
1789        drop(lease);
1790        drop(server);
1791    }
1792
1793    /// The writer guard must drop its sender *before* joining, or the join
1794    /// deadlocks against a pump parked on `recv()`.
1795    #[test]
1796    fn the_writer_guard_drops_its_sender_before_joining() {
1797        let (client, server) = socket_pair();
1798        let (lease, _reader_worker, writer_worker) = lease_pair();
1799        let (writer, guard) = spawn_writer_pump(
1800            writer_worker,
1801            Arc::new(client),
1802            "sender-order",
1803            Duration::from_secs(5),
1804            1,
1805        );
1806        let started = Instant::now();
1807        drop(writer);
1808        drop(guard);
1809        assert!(
1810            started.elapsed() < Duration::from_secs(10),
1811            "dropping the guard must end a pump parked on recv(), not hang"
1812        );
1813        drop(lease);
1814        drop(server);
1815    }
1816
1817    // ── loss announcements ──────────────────────────────────────────────
1818
1819    /// A subscriber that keeps what was emitted, so a test can assert an
1820    /// announcement actually happened.
1821    ///
1822    /// `errlog_sev_printf` routes through `tracing` on the
1823    /// `epics_base_rs::errlog` target *and* to the console fallback; the
1824    /// `tracing` half is the observable one from in-process, and installing
1825    /// this for the duration of a drop is how these tests read it.
1826    #[derive(Clone, Default)]
1827    struct CapturedLines(Arc<Mutex<Vec<String>>>);
1828
1829    impl tracing::Subscriber for CapturedLines {
1830        fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
1831            true
1832        }
1833        fn event(&self, event: &tracing::Event<'_>) {
1834            struct Fields<'a>(&'a mut String);
1835            impl tracing::field::Visit for Fields<'_> {
1836                fn record_debug(
1837                    &mut self,
1838                    field: &tracing::field::Field,
1839                    value: &dyn std::fmt::Debug,
1840                ) {
1841                    use std::fmt::Write;
1842                    let _ = write!(self.0, " {}={value:?}", field.name());
1843                }
1844            }
1845            let mut line = event.metadata().target().to_string();
1846            event.record(&mut Fields(&mut line));
1847            self.0.lock().expect("captured lines").push(line);
1848        }
1849        fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1850            tracing::span::Id::from_u64(1)
1851        }
1852        fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
1853        fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
1854        fn enter(&self, _: &tracing::span::Id) {}
1855        fn exit(&self, _: &tracing::span::Id) {}
1856    }
1857
1858    /// Everything emitted on the calling thread while `f` runs.
1859    fn lines_while(f: impl FnOnce()) -> Vec<String> {
1860        let captured = CapturedLines::default();
1861        tracing::subscriber::with_default(captured.clone(), f);
1862        let lines = captured.0.lock().expect("captured lines");
1863        lines.clone()
1864    }
1865
1866    /// The guards made a lost pump *survivable*; this is what makes it
1867    /// *visible*.
1868    ///
1869    /// `ReaderPumpGuard::drop` used to join and throw the result away, so a
1870    /// pump that unwound left nothing behind: the connection's own error is a
1871    /// bland channel-closed, and the two were unlinkable. Dropping must also not
1872    /// itself panic — a propagating drop would abort the process during another
1873    /// unwind.
1874    #[test]
1875    fn a_panicked_reader_pump_is_reported_and_not_discarded() {
1876        let (client, server) = socket_pair();
1877        let (lease, reader_worker, _writer_worker) = lease_pair();
1878        let lines = lines_while(|| {
1879            let _guard = ReaderPumpGuard {
1880                sock: Arc::new(client),
1881                label: "PVA connection 127.0.0.1:0".to_string(),
1882                job: Some(reader_worker.run(|| panic!("reader blew up"))),
1883            };
1884        });
1885        assert!(
1886            lines
1887                .iter()
1888                .any(|l| l.starts_with("epics_base_rs::errlog")
1889                    && l.contains("reader thread panicked")),
1890            "a panicked reader must reach errlog, which prints whatever the log \
1891             configuration is — including an RTEMS console. Captured: {lines:?}"
1892        );
1893        drop(lease);
1894        drop(server);
1895    }
1896
1897    #[test]
1898    fn a_panicked_writer_pump_is_reported_and_not_discarded() {
1899        let (frames, _rx) = mpsc::channel::<Vec<u8>>(1);
1900        let (lease, _reader_worker, writer_worker) = lease_pair();
1901        let lines = lines_while(|| {
1902            let _guard = WriterPumpGuard {
1903                frames: Some(frames),
1904                label: "PVA connection 127.0.0.1:0".to_string(),
1905                job: Some(writer_worker.run(|| panic!("writer blew up"))),
1906            };
1907        });
1908        assert!(
1909            lines
1910                .iter()
1911                .any(|l| l.starts_with("epics_base_rs::errlog")
1912                    && l.contains("writer thread panicked")),
1913            "a panicked writer dropped whatever frames were still queued; that \
1914             must not be silent. Captured: {lines:?}"
1915        );
1916        drop(lease);
1917    }
1918
1919    /// The other boundary: an ordinary teardown is not a loss. Every connection
1920    /// that ever closes runs these drops, so announcing there would bury the
1921    /// real losses on a serial console.
1922    #[test]
1923    fn a_pump_that_ends_cleanly_is_not_announced() {
1924        let (client, server) = socket_pair();
1925        let (lease, reader_worker, _writer_worker) = lease_pair();
1926        let lines = lines_while(|| {
1927            let _guard = ReaderPumpGuard {
1928                sock: Arc::new(client),
1929                label: "PVA connection 127.0.0.1:0".to_string(),
1930                job: Some(reader_worker.run(|| {})),
1931            };
1932        });
1933        assert!(
1934            !lines
1935                .iter()
1936                .any(|l| l.contains("was lost") || l.contains("panicked")),
1937            "an ordinary connection teardown must print nothing: {lines:?}"
1938        );
1939        drop(lease);
1940        drop(server);
1941    }
1942
1943    /// Structural closure as source: a pump lost to a panic must be announced.
1944    /// Both guards report through the one announcement function; the old
1945    /// creation-failure loss is gone with the per-connection spawn — a pooled
1946    /// worker is borrowed, never created per connection.
1947    #[test]
1948    fn every_pump_loss_goes_through_the_announcement() {
1949        let prod = production_scope(include_str!("blocking_io.rs"));
1950        assert_eq!(
1951            code_only(prod)
1952                .matches(concat!("let _ = jo", "b.join()"))
1953                .count(),
1954            0,
1955            "a discarded join result is a panicked pump nobody hears about"
1956        );
1957        for owner in [
1958            "impl Drop for ReaderPumpGuard",
1959            "impl Drop for WriterPumpGuard",
1960        ] {
1961            let at = prod
1962                .find(owner)
1963                .unwrap_or_else(|| panic!("`{owner}` is gone from this module"));
1964            let body = &prod[at..(at + 900).min(prod.len())];
1965            assert!(
1966                body.contains(concat!("pump_thread_", "lost(")),
1967                "`{owner}` can lose a pump thread without saying so"
1968            );
1969        }
1970    }
1971
1972    /// Both roles come from one descriptor, and both actually move bytes.
1973    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1974    async fn one_descriptor_serves_both_pumps() {
1975        let (client, mut server) = socket_pair();
1976        let (mut reader, mut writer) = drive_socket_blocking(
1977            &TEST_POOL,
1978            client,
1979            "127.0.0.1:0",
1980            &PumpConfig {
1981                read_timeout: Duration::from_secs(5),
1982                send_timeout: Duration::from_secs(5),
1983                ..PumpConfig::default()
1984            },
1985        )
1986        .expect("pumps started");
1987
1988        let peer = thread::spawn(move || {
1989            let mut got = vec![0u8; 4];
1990            server.read_exact(&mut got).expect("peer read");
1991            server.write_all(b"pong").expect("peer write");
1992            got
1993        });
1994
1995        writer.write_all(b"ping").await.expect("wrote");
1996        let mut got = [0u8; 4];
1997        reader.read_exact(&mut got).await.expect("read back");
1998        assert_eq!(&got, b"pong");
1999        assert_eq!(peer.join().expect("peer"), b"ping");
2000    }
2001
2002    /// The invariant [`DialPool`] exists for: a dial *borrows* a thread, it does
2003    /// not create one.
2004    ///
2005    /// Sequential dials — the shape a reconnect loop makes — must all be served
2006    /// by the same worker, so the count of threads created over the process's
2007    /// life is 1 rather than one per attempt. The per-attempt shape this
2008    /// replaced would report 8 here (and leak 8 × 128 B of RTEMS TLS key).
2009    ///
2010    /// The tight spot is the *first* dial after a reply: the caller is woken by
2011    /// the very worker that must serve it next, so a pool that counted parked
2012    /// workers would see none available and create a second. That is why the
2013    /// assertion is inside the loop and not only after it.
2014    #[epics_macros_rs::epics_test]
2015    async fn sequential_dials_reuse_one_worker() {
2016        static POOL: DialPool = DialPool::new("test-dial", ThreadPriority::Low);
2017        const DIALS: usize = 8;
2018
2019        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2020        let addr = listener.local_addr().expect("addr");
2021        // Hold every accepted side open: a peer that closed would let a dial
2022        // fail for a reason this test is not about.
2023        let acceptor = thread::spawn(move || {
2024            (0..DIALS)
2025                .map(|_| listener.accept().expect("accept").0)
2026                .collect::<Vec<_>>()
2027        });
2028
2029        for i in 0..DIALS {
2030            let dialed = POOL.dial(addr).expect("dial submitted");
2031            let stream = dialed
2032                .await
2033                .expect("the worker must reply")
2034                .expect("connect to a live listener");
2035            assert_eq!(
2036                POOL.worker_count(),
2037                1,
2038                "dial {i} created a new thread instead of reusing the idle \
2039                 worker: sequential dials must borrow one thread, not one each"
2040            );
2041            drop(stream);
2042        }
2043
2044        assert_eq!(
2045            POOL.worker_count(),
2046            1,
2047            "{DIALS} sequential dials must have created exactly one thread"
2048        );
2049        drop(acceptor.join().expect("acceptor"));
2050    }
2051}