Native Descriptors
posix_descriptor adopts a file descriptor you already have and
drives it from an io_context, giving it read_some(), write_some()
and wait(). It exists on POSIX platforms only.
|
Code snippets assume:
|
Overview
Corosio calls a descriptor pollable when a reactor can wait on it for
readiness. Anything pollable that corosio does not already wrap is in
scope. That includes character devices, inotify, eventfd,
timerfd, pidfd, pipes, ttys, and socket kinds that have no
dedicated corosio type.
The type never creates a descriptor. Open it with whichever platform
call suits it — eventfd(), inotify_init1(), open() on a device
node — and hand the result to assign(). corosio supplies the event
loop, not the constructor.
Why the Name Says POSIX
A single portable native_descriptor spanning POSIX and Windows was
considered and rejected. The platform gaps here are not edge cases
around a shared core; they are the type’s semantics. O_NONBLOCK on a
shared open file description, dup(), and a file-type reject list
expressed in st_mode bits are the entire contract below. An open
file description is the kernel-side object a descriptor refers to.
Every dup() of a descriptor shares the same one. None of them has a
Windows counterpart. A type that named both would have to either
document each rule twice or say nothing precise about either.
Portability lives one layer up instead. A posix_descriptor is an
io_object, io_read_stream, io_write_stream and
io_stream — the same bases tcp_socket has — and, like every
corosio stream, it satisfies capy::Stream. That concept is what the
generic algorithms are written against, so they run on a descriptor, a
socket and a tls_stream alike:
// Nothing below is descriptor-specific. `capy::read` is constrained on
// capy::Stream, and a posix_descriptor models it exactly as a
// tcp_socket or a tls_stream does, so the same algorithm drives all
// three.
capy::task<std::error_code>
fill(corosio::posix_descriptor& d, capy::mutable_buffer buf)
{
auto [ec, n] = co_await capy::read(d, buf);
co_return ec;
}
Only the handful of lines that produce the descriptor are platform-specific. TLS layers over it on the same terms.
Adopting a Descriptor
assign() takes ownership: close() and the destructor close the
descriptor. It returns a std::error_code and is [[nodiscard]</code>]; a
failed assign() leaves the descriptor with you, so close it yourself.
An eventfd as a cross-thread wakeup:
int fd = ::eventfd(0, EFD_CLOEXEC);
if (fd < 0)
co_return last_error();
corosio::posix_descriptor d(ioc);
if (auto ec = d.assign(fd))
{
// A failed assign() leaves the descriptor with the caller.
::close(fd);
co_return ec;
}
// An eventfd delivers its accumulated count as a single 8-byte
// host-order integer; the read parks until the count is nonzero
// and resets it to zero.
std::uint64_t count = 0;
auto [ec, n] =
co_await d.read_some(capy::mutable_buffer(&count, sizeof(count)));
|
Distinct |
An inotify watch. The descriptor is a stream of variable-length
records, so read_some() is the whole interface you need:
int fd = ::inotify_init1(IN_CLOEXEC);
if (fd < 0)
co_return last_error();
if (::inotify_add_watch(fd, path, IN_CREATE | IN_DELETE) < 0)
{
auto ec = last_error();
::close(fd);
co_return ec;
}
corosio::posix_descriptor d(ioc);
if (auto ec = d.assign(fd))
{
::close(fd);
co_return ec;
}
// inotify delivers whole events. The buffer must be aligned for
// inotify_event and large enough for at least one event plus its
// variable-length name.
alignas(struct inotify_event) char buf[4096];
auto [ec, n] = co_await d.read_some(capy::mutable_buffer(buf, sizeof(buf)));
if (ec)
co_return ec;
auto const* ev = reinterpret_cast<struct inotify_event const*>(buf);
release() hands the descriptor back, cancelling pending operations
and leaving the object not-open.
Ownership and the dup() Rule
Another party sometimes owns the descriptor — a C library that does
its own I/O on it, or a process-wide descriptor such as
STDIN_FILENO. When that happens, adopt a dup() of it rather than
the descriptor itself:
// Adopt a duplicate, never the library's own descriptor. Both refer
// to one open file description, so readiness is identical and the
// lazily applied O_NONBLOCK is visible to the library either way --
// but corosio's close() can only ever close the copy.
int copy = ::dup(foreign_fd(conn));
if (copy < 0)
co_return last_error();
corosio::posix_descriptor d(ioc);
if (auto ec = d.assign(copy))
{
::close(copy);
co_return ec;
}
Both descriptors refer to one open file description, so the duplicate reports exactly the original’s readiness. Corosio closing the duplicate can never close the original. This is the same rule Readiness Wait states for adopted sockets.
Descriptor Flags
|
The flag lives on the shared open file description, not on the descriptor, so every other holder of that description sees it. Restoring it on close would race whoever else is holding it. Permanent is the only safe choice. A |
That is what makes standard input safe to adopt for readiness alone.
Flipping O_NONBLOCK on it would change the terminal the parent shell
is still using.
// wait() transfers no bytes and sets no flag, so standard input --
// and the terminal the parent shell shares with it -- stays exactly
// as the process inherited it.
int fd = ::dup(STDIN_FILENO);
if (fd < 0)
co_return last_error();
corosio::posix_descriptor d(ioc);
if (auto ec = d.assign(fd))
{
::close(fd);
co_return ec;
}
auto [ec] = co_await d.wait(corosio::wait_type::read);
if (ec)
co_return ec;
// Readable, so a blocking ::read returns here. Readiness is not a
// general guarantee against parking -- a socket can report ready
// and still have nothing to hand over -- but it holds for a tty.
char line[256];
auto n = ::read(d.native_handle(), line, sizeof(line));
What Is Rejected
assign() rejects regular files, block devices, and directories with a
code comparing equal to errc::operation_not_supported. A reactor
cannot report readiness for them, and they already have a home:
stream_file and random_access_file
adopt exactly those kinds.
The test is a reject list, not an accept list. The reason: the
flagship descriptor kinds — eventfd, timerfd, inotify, pidfd —
are anonymous inodes whose st_mode type bits are all zero. An accept list
would reject the descriptors this type exists to carry.
A negative or closed descriptor fails with errc::bad_file_descriptor,
and re-assigning the descriptor the object already holds fails with
errc::invalid_argument.
Where Errors Surface
Validation runs before anything is mutated. A rejected descriptor leaves the object holding whatever it held before — pending operations included — and leaves you owning the descriptor.
A refusal from the kernel is the one exception, and where it appears depends on the backend:
- epoll, kqueue, select
-
These register the descriptor with the reactor during
assign(), so a refusal failsassign(). The old descriptor has already been closed by then, so the object is left closed. On select this is reachable in normal use:select()cannot monitor a descriptor at or aboveFD_SETSIZE, and such a descriptor is rejected withEMFILE. - io_uring
-
There is no adopt-time registration syscall, so
assign()succeeds and a refusal appears at the first operation instead.
Whichever way assign() fails, the descriptor you passed is still
yours to close.
Not every character device can be adopted. /dev/null, /dev/zero and
/dev/urandom are not pollable: epoll refuses them with EPERM and
kqueue with EINVAL, so assign() fails on those backends. select and
io_uring have nothing to refuse them with, so assign() succeeds and the
descriptor works — a read_some() on /dev/zero returns zeros. Character
devices backed by a real driver, a tty among them, are pollable and
adopt everywhere.
wait(wait_type::error) is the one verb that is not uniform. epoll and
io_uring report a pipe or FIFO hangup as an error condition and name a
code. kqueue and select do not. kqueue raises an error event only for
EV_ERROR or for EV_EOF with fflags != 0, and a hangup sets
neither. select’s exceptional set does not cover it. On those two backends
the wait never completes; end it with cancel() or a stop token. Prefer
wait(wait_type::read), which is uniform — the hangup surfaces there as
readiness, and the read that follows names the real failure.
SIGPIPE
|
Writing to a descriptor whose peer has closed raises The suppression sockets get has no general form. Install |
Asio’s posix::stream_descriptor behaves the same way, for the same
reason. Code ported from it needs no change here.