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