Readiness Wait
The wait() method on every socket and acceptor suspends until the
underlying file descriptor becomes ready in a chosen direction, without
transferring any bytes. Use it to integrate with C libraries that own
the I/O on a nonblocking file descriptor. Such libraries need only
notification that data is available, or that the descriptor is writable.
|
Code snippets assume:
|
Overview
Three directions are exposed via the wait_type enum. Keep at most
one wait() outstanding per socket or acceptor, whatever its direction.
On IOCP every socket and acceptor has one wait slot shared by all three
directions, as does an io_uring socket.
The awaitable yields an error_code with no bytes_transferred. On
success the socket is ready; the wait consumes no data from it. A
hangup or a pending error counts as ready for a read or write wait, and
the read or write that follows reports it.
auto [ec] = co_await sock.wait(corosio::wait_type::read);
if (!ec)
{
// sock is readable: a subsequent read_some will return data
// without blocking.
}
Wrapping a Nonblocking C API
The original motivation is libraries such as libssh and libpq that
manage their own buffers and do their own I/O on an O_NONBLOCK
socket. They need two things from the surrounding event loop. First,
"tell me when the fd is ready" without stealing bytes from the stream.
Second, "never touch my descriptor".
wait() provides the first. A read or write wait never reads, writes,
or consumes the socket’s pending error. Therefore the library’s next
PQconsumeInput (or equivalent) sees everything the kernel has
delivered. Adoption provides the second, with one rule to follow. assign() takes ownership and
closes the descriptor, so adopt a dup() of the library’s fd rather
than the fd itself. Readiness lives on the
open file description,
which both descriptors share. The duplicate therefore reports exactly the
library’s readiness, and closing it can never close the library’s
connection. Neither assign() nor wait()
alters the descriptor’s flags, so the library’s non-blocking
configuration is untouched.
// Adopt a duplicate: assigned means owned, and corosio closing
// the duplicate can never close the library's descriptor.
// Readiness travels through the shared open file description.
corosio::tcp_socket sock(ioc);
if (auto ec = sock.assign(::dup(foreign_socket(conn))))
co_return ec;
// Read side: wake, then let the library take the bytes itself.
while (foreign_wants_read(conn))
{
auto [ec] = co_await sock.wait(corosio::wait_type::read);
if (ec)
co_return ec;
if (foreign_consume(conn) != 0)
co_return std::make_error_code(std::errc::io_error);
}
// Write side: retry exactly when the socket can make progress.
while (foreign_flush(conn) == 1)
{
auto [ec] = co_await sock.wait(corosio::wait_type::write);
if (ec)
co_return ec;
}
Never call read_some() or write_some() on the adopted socket.
The library owns the byte stream. Corosio supplies readiness only.
On Windows, dup() does not duplicate a SOCKET. Either adopt the
library’s socket directly and release() it before the library needs
exclusive ownership again. Or create a true duplicate with
WSADuplicateSocketW and adopt that.
This applies to sockets. A library that owns a non-socket descriptor —
a pipe, a tty, inotify, eventfd — needs the same readiness
notification.
posix_stream_descriptor provides it with the same read and write
wait(). Error waits and devices without poll support differ, as
Where Errors Surface
describes.
Acceptors
tcp_acceptor and local_stream_acceptor expose the same wait().
For wait_type::read, completion signals that a connection is pending
on the acceptor. A subsequent accept() succeeds without
blocking:
auto [wec] = co_await acceptor.wait(corosio::wait_type::read);
if (wec)
co_return;
corosio::tcp_socket peer(ioc);
auto [aec] = co_await acceptor.accept(peer);
This is useful when application-level conditions must be checked
before consuming the next connection (rate limiting, backpressure
signaling) without holding an accept() call open.
A connection already queued when the wait begins completes it immediately — including on an adopted acceptor whose backlog predates the adoption.
Cancellation
wait() honors the stop token of its co_await environment and the
socket.cancel() / acceptor.cancel() non-virtuals, completing with
capy::cond::canceled:
auto waiter = [&]() -> capy::task<> {
auto [ec] = co_await sock.wait(corosio::wait_type::read);
// ec == capy::cond::canceled if sock.cancel() was invoked
};
timeout composes with wait() the
same way it does with the other socket operations, since wait()
yields an io_result like any other awaitable:
auto [ec] =
co_await corosio::timeout(sock.wait(corosio::wait_type::read), 200ms);
if (ec == capy::cond::timeout)
std::cout << "No readiness within 200ms\n";
wait_type::write Semantics
wait(wait_type::write) completes when the socket can accept a
non-blocking write. On a socket that is not backpressured this is
immediate. Once the send buffer is full, the wait parks until the peer
drains enough of it for a write to make progress again.
That is the signal an external flush loop needs. Code that owns its own
buffers and retries "when the socket is writable" would busy-spin if the
wait completed unconditionally. That would happen precisely when the
socket is congested.
Code that hands its buffers to write_some() does not need
wait(wait_type::write) at all — write_some() already parks on the
same condition.
Acceptors are the exception: writability has no meaning for an
acceptor, so wait(wait_type::write) on one fails
with errc::operation_not_supported on every backend.
Backend Notes
On Linux (epoll) and BSD/macOS (kqueue) a wait registers interest in the fd’s read, write, or error event without performing any I/O syscall. On the select backend the same registration semantics apply through the select-loop’s fd sets, whose write set includes fds with a parked write wait.
On Linux (io_uring) a wait submits a one-shot IORING_OP_POLL_ADD and
performs no I/O. An acceptor’s read wait is the exception. Multishot
accept drains the kernel backlog as connections arrive. The wait
therefore completes once multishot accept has queued a connection.
On Windows (IOCP), stream-socket wait(wait_type::read) uses a zero-byte
WSARecv: the kernel signals completion when data is available
without consuming bytes. All other waits — datagram-read, acceptor-read,
write-wait, error-wait — route through an auxiliary WSAPoll-based
reactor. That reactor runs on a dedicated thread and bridges into the
IOCP via PostQueuedCompletionStatus. The public API is
uniform across platforms.