Skip to main content

Module blocking_io

Module blocking_io 

Source
Expand description

Blocking socket ⇄ AsyncRead/AsyncWrite adapters: the byte source for every reactor-free driver in the workspace.

  socket --read--> reader pump thread --Vec<u8> chunks--> ChannelReader
                                                             |
                                      whatever drives the protocol future
                                                             |
  socket <--write-- writer pump thread <--framed bytes-- ChannelWriter

§Why this lives in epics-base-rs and not in a protocol crate

It was written once, inside epics-pva-rs’s blocking server driver, and the obvious next move was to promote it within that crate so the PVA client could reach it too. Measured, that destination is wrong: epics-ca-rs does not depend on epics-pva-rs and must not — the only crate that depends on both is epics-bridge-rs, which sits above them (doc/calink-rtems-design.md §3.3). A primitive promoted inside epics-pva-rs is one the CA client structurally cannot call, so the next CA increment writes a third copy — exactly the outcome “one seam, two callers” exists to prevent.

So it lands here, beside the rest of its family: runtime::task::spawn, block_on_sync/park_on, StackSizeClass, spawn_dedicated_thread, enter_ioc_thread. Every protocol crate can reach it, and none of them owns it.

§The seam is the byte source, not the frame pipeline

Nothing here parses anything. Both pumps move Vec<u8> and neither knows whether the bytes are PVA frames, CA messages, or noise; the protocol future on the other side of the adapters is untouched and uncompiled-differently. That is what makes a driver built on this primitive arguable from the hosted driver’s own tests: same parser, same select!, same handlers, different implementors of two dyn traits.

§Two facts that must not be re-derived

  • No fd dup. The read and write roles come from one descriptor shared through an Arc, via impl Read for &TcpStream / impl Write for &TcpStream — never try_clone. try_clone is fcntl(F_DUPFD_CLOEXEC), and on RTEMS 6 that cannot work for a socket: RTEMS’s fcntl has no F_DUPFD_CLOEXEC case at all (cpukit/libcsupport/src/fcntl.c:146-220 falls to default: errno = EINVAL), and even plain F_DUPFD fails because duplicate_iop calls the file’s open_h while rtems-libbsd installs rtems_bsd_sysgen_nodeops on every socket. Measured on the target: dup, F_DUPFD and F_DUPFD_CLOEXEC all fail on a socket while F_DUPFD on /dev/console succeeds. A caller that reaches for try_clone compiles and fails at runtime on target only.
  • A blocking write needs a deadline, not a per-syscall timeout. SO_SNDTIMEO bounds each write syscall, so a peer that accepts one byte per tick never trips it and holds the pump thread indefinitely — and a target that does not implement the option at all (VxWorks 7) has no bound whatever. write_frame_deadline therefore waits for writability against its own deadline and sets no socket option.

§Lifecycle: a pump you cannot spawn without holding the thing that ends it

spawn_reader_pump and spawn_writer_pump each return an adapter and a guard, and there is no way to obtain the former without the latter. The guards’ Drop is what retires the threads, which is what makes every exit path — clean return, ?, and a panic unwinding through the caller — covered without any cleanup written on an error branch.

The two guards end their threads differently because the threads park differently:

guardhow its thread is parkedhow the guard returns it
ReaderPumpGuardinside a blocking read behind an effectively-infinite SO_RCVTIMEOshutdown(Shutdown::Both) on the shared socket, then join
WriterPumpGuardinside recv() on the frame channeldrop the only strong sender, then join

A caller that needs a specific teardown order — writer down first so frames emitted on the way out reach the wire, then reader — gets it by dropping the guards in that order, or by declaring them in the reverse of it.

The reader guard’s row is a POSIX contract: shutdown waking a thread parked in a blocking read is what unix (RTEMS included) provides and Windows does not (measured, PR #56 CI 2026-07-24 — the parked read outlived a 120 s bound). That is why exec_backend, the only configuration that runs these pumps in production, refuses Windows at compile time (lib.rs), and why the tests asserting this contract are #[cfg(unix)].

§Where the async goes

block_on_sync is the single bridge, in both pumps. On a bare thread it parks; on a multi-thread runtime worker it hands the worker off first. It is not blocking_send, which panics inside a runtime context and would make this module unusable from a hosted worker.

§Before the pumps: the dial

A reactor-free driver cannot await a connect either, so the blocking connect needs a thread just as the two pumps do — and for the same reason, at the same band. DialPool owns those threads. It is here rather than in a protocol crate for the reason the pumps are: epics-ca-rs and epics-pva-rs both dial and neither may depend on the other.

Structs§

ChannelReader
AsyncRead over a channel of byte chunks — the blocking stand-in for a socket read half.
ChannelWriter
AsyncWrite over a channel of frames — the blocking stand-in for a socket write half.
DialPool
A bounded, permanent set of threads that own this role’s blocking TCP dials.
GuardedReader
A ChannelReader that owns its pump guard.
GuardedWriter
A ChannelWriter that owns its pump guard. See GuardedReader.
PumpConfig
Everything a caller needs to set up on a socket before its pumps start.
ReaderPumpGuard
The spawned reader pump, woken and joined on every exit path.
WriterPumpGuard
The spawned writer pump and the only strong frame sender, retired together on every exit path.

Constants§

DEFAULT_READ_CHUNK
One blocking read, sized to match the frame readers that consume it so the byte arrival pattern is the hosted one.
MAX_DIAL_WORKERS
How many dial threads one DialPool may ever create.

Functions§

circuit_roster
The role roster a circuit’s drive_socket_blocking pool must be built with: [reader, writer], both on Small stacks. The two bands are the caller’s to choose (see drive_socket_blocking’s docs).
drive_socket_blocking
Drive one already-connected socket with two blocking pumps, returning the two owning adapters.
is_socket_timeout
A blocking socket op hit its SO_RCVTIMEO/SO_SNDTIMEO.
pending_bytes
Bytes pending in the socket receive queue via FIONREAD.
spawn_reader_pump
Drive sock’s read half on a pooled worker, yielding the AsyncRead half of the seam and the guard that retires the job.
spawn_writer_pump
Drive sock’s write half on a pooled worker, yielding the AsyncWrite half of the seam and the guard that retires the job. Infallible for the same reason as spawn_reader_pump.
write_frame_deadline
Write one whole frame under a deadline, not merely a per-syscall timeout.