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:

#include <boost/corosio/tcp_socket.hpp>
#include <boost/corosio/timeout.hpp>
#include <boost/corosio/wait_type.hpp>
#include <boost/capy/cond.hpp>
#include <boost/capy/task.hpp>
#include <iostream>

namespace corosio = boost::corosio;
namespace capy    = boost::capy;
using namespace std::chrono_literals;

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.