You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Corosio has no way to hand an already-open native resource to the event loop unless it is one of the shipped socket or file types. The motivating request is a user working with character devices and inotify who wants to give an fd directly to the io_context. The same machinery is a prerequisite for #93 (console I/O builds on it on POSIX) and #267 (process support explicitly needs pidfd waiting on POSIX and object-handle waiting on Windows).
Proposed types
posix_descriptor : io_stream — all four POSIX backends. Adopts any pollable fd (character device, inotify, eventfd, timerfd, pidfd, pipe, tty, unwrapped socket kinds like netlink). read_some / write_some / wait(read|write|error) plus the assign / release / close / is_open / native_handle / cancel verb set already carried by local_stream_socket and stream_file.
win_stream_handle : io_stream — IOCP. Overlapped handles with implicit position (named pipes opened overlapped, COM ports, mailslots). Proactor-only: no wait(), deliberately — IOCP has no readiness primitive for arbitrary handles.
win_random_access_handle : io_object — IOCP. read_some_at / write_some_at for overlapped handles with caller-controlled offsets (volumes, physical drives); concurrent positional ops supported, matching the shipped random-access file service.
Design principle: portability lives at the stream layer
A single portable descriptor type was considered and rejected. The platform gaps are the type's core semantics, not edge cases: IOCP cannot do readiness on arbitrary handles (so wait() would be operation_not_supported across a whole platform), console and anonymous CreatePipe handles cannot do overlapped I/O at all, and waitable kernel objects have no fd. A portable-looking type would invite code that compiles everywhere and works on one platform. Thread-emulation of the gaps was also rejected (uncancellable blocked reads, pinned threads, incompatible with the lockless tier).
Instead the escape hatches are platform-named, and portability comes from the interfaces they already implement: every type is an io_object / io_read_stream / io_write_stream / io_stream, so corosio::read/write, capy::Stream-constrained algorithms, and TLS layering work identically on all of them. Portable use cases graduate later to portable types (pipe, serial, process) built on these services internally, with the same verb spellings — a type-name change for users, not a re-architecture.
Capability walls (documented, not papered over)
No wait() on arbitrary Windows handles (interface asymmetry; the main reason the types carry platform names).
Console handles and anonymous CreatePipe handles rejected at assign() (cannot do overlapped I/O). Named pipes created with FILE_FLAG_OVERLAPPED are an exact substitute and the future portable pipe type uses them internally.
Regular files / block devices / directories rejected by policy on the stream-shaped types — stream_file::assign() / random_access_file::assign() already cover adoption there. The check is a reject-list on fstat file-type bits, deliberately: the flagship fd kinds (eventfd, timerfd, inotify, pidfd) are anonymous inodes whose type bits are all zero, so an accept-list would silently reject them. win_random_access_handle is the deliberate exception (volumes report FILE_TYPE_DISK like regular files; it accepts anything passing the overlapped gate).
Mutex waits on win_object_handle excluded (a satisfied wait would acquire the mutex on a thread the resuming coroutine doesn't own; the threadpool wait API enforces this at arm time).
Capability rejections compare equal to std::errc::operation_not_supported. Policy checks surface at assign() uniformly; kernel refusals surface at assign() only where a registration syscall exists (epoll, kqueue) — io_uring and select can defer to first I/O, and the docs say so.
Key contract points
assign() validates before it mutates or closes anything — strong guarantee on failure: object keeps its previous state and pending ops, caller keeps the rejected fd, flags untouched. Self-assign rejected. This supersedes the shipped adopt path's close-old-first order, changing failed-assign semantics for the local_* types too (tests updated to pin it).
O_NONBLOCK applied lazily at first read_some/write_some, never at assign(), and never restored.wait()-only usage never modifies the fd — adopting STDIN_FILENO to await readiness must not flip the parent shell's terminal to nonblocking, and foreign-library integration ("poll my fd, don't touch it") stays possible. Restoration is unsound because the flag lives on the shared open file description; the docs state the mode change is permanent and tell users to dup() first when another party owns the fd.
win_object_handle: completion wins over cancellation. The kernel consumes the signal (semaphore unit, auto-reset event) at wait satisfaction, before any callback runs — so teardown drains callbacks, never cancels them, and an already-satisfied wait always reports signaled even when it raced cancel()/release()/destruction. One pending wait per object; a second wait() errors.
Real wait() on the reactor backends: probe, and never trust the sticky readiness flags. They under-report (an op that consumes an edge leaves no trace → naive wait parks forever on a ready fd) and over-report (registration latches write_ready on an empty pipe and speculative writes never clear it → wait(write) would lie on a full pipe). do_wait probes with zero-timeout poll(); a set sticky flag is only a re-probe trigger, never truth. Socket hot paths untouched. (The shipped socket wait(read) has the consumed-edge hazard today; a probe-based fix is in progress separately.)
Implementation notes
Prerequisite refactor (stage 1):register_descriptor currently throws from inside noexcept paths, so a kernel registration refusal terminates the process — and the hazard already covers do_assign_fd and do_open_socket via init_and_register. The refactor makes it return an error on all three reactor schedulers, threaded through all three callers (do_listen is the model).
Reactor backends: generic adopt core split out of the AF_UNIX do_assign_fd, readv/writev op types alongside the recvmsg/sendmsg ones, non-socket error paths (no SO_ERROR on a pipe — re-run the syscall and let the kernel report EPIPE/EIO), select snapshot fix so a parked wait_write_op sets the write set.
io_uring: READV/WRITEV SQEs at offset -1; lazy O_NONBLOCK here too, for cancellability — a blocking read punted to an io-wq worker cannot be cancelled; with nonblocking, EAGAIN CQE → arm poll_add and resubmit, cancellable on every kernel version.
IOCP: unify the two existing Windows file services into one overlapped-handle core carrying the per-op in-flight model, with four façades (stream_file, random_access_file, plus the two new handle types). assign() gates in two steps: console pre-check, then NtQueryInformationFile(FileModeInformation) synchronous-mode rejection — CreateIoCompletionPort failing for synchronous handles is undocumented and not relied on.
win_object_handle uses CreateThreadpoolWait: one cached PTP_WAIT per impl re-armed per wait, generation-counted claim word, completion posted through the port with both scheduler counters participating, and a drain-only rundown (SetThreadpoolWait(nullptr) then WaitForThreadpoolWaitCallbacks with fCancelPendingCallbacks=FALSE) shared by cancel/close/release/destructor/shutdown — cancelling a queued callback would destroy the only witness to a consumed signal.
Staging
Reactor error-propagation refactor + posix_descriptor on the four POSIX backends — unblocks the motivating use cases.
win_stream_handle + win_random_access_handle — Windows file-service unification + the two-step gate.
win_object_handle — threadpool-wait machinery.
Portable pipe type as the first consumer of the layering contract (feeds process support #267).
Each stage independently shippable and testable. Non-throwing overload policy follows #261 (open); the sketches show the throwing form only.
Corosio has no way to hand an already-open native resource to the event loop unless it is one of the shipped socket or file types. The motivating request is a user working with character devices and inotify who wants to give an fd directly to the
io_context. The same machinery is a prerequisite for #93 (console I/O builds on it on POSIX) and #267 (process support explicitly needs pidfd waiting on POSIX and object-handle waiting on Windows).Proposed types
posix_descriptor : io_stream— all four POSIX backends. Adopts any pollable fd (character device, inotify, eventfd, timerfd, pidfd, pipe, tty, unwrapped socket kinds like netlink).read_some/write_some/wait(read|write|error)plus theassign/release/close/is_open/native_handle/cancelverb set already carried bylocal_stream_socketandstream_file.win_stream_handle : io_stream— IOCP. Overlapped handles with implicit position (named pipes opened overlapped, COM ports, mailslots). Proactor-only: nowait(), deliberately — IOCP has no readiness primitive for arbitrary handles.win_random_access_handle : io_object— IOCP.read_some_at/write_some_atfor overlapped handles with caller-controlled offsets (volumes, physical drives); concurrent positional ops supported, matching the shipped random-access file service.win_object_handle : io_object— IOCP.wait()for waitable kernel objects (process/thread handles, events, semaphores, waitable timers).Design principle: portability lives at the stream layer
A single portable descriptor type was considered and rejected. The platform gaps are the type's core semantics, not edge cases: IOCP cannot do readiness on arbitrary handles (so
wait()would beoperation_not_supportedacross a whole platform), console and anonymousCreatePipehandles cannot do overlapped I/O at all, and waitable kernel objects have no fd. A portable-looking type would invite code that compiles everywhere and works on one platform. Thread-emulation of the gaps was also rejected (uncancellable blocked reads, pinned threads, incompatible with the lockless tier).Instead the escape hatches are platform-named, and portability comes from the interfaces they already implement: every type is an
io_object/io_read_stream/io_write_stream/io_stream, socorosio::read/write,capy::Stream-constrained algorithms, and TLS layering work identically on all of them. Portable use cases graduate later to portable types (pipe, serial, process) built on these services internally, with the same verb spellings — a type-name change for users, not a re-architecture.Capability walls (documented, not papered over)
wait()on arbitrary Windows handles (interface asymmetry; the main reason the types carry platform names).CreatePipehandles rejected atassign()(cannot do overlapped I/O). Named pipes created withFILE_FLAG_OVERLAPPEDare an exact substitute and the future portable pipe type uses them internally.stream_file::assign()/random_access_file::assign()already cover adoption there. The check is a reject-list onfstatfile-type bits, deliberately: the flagship fd kinds (eventfd, timerfd, inotify, pidfd) are anonymous inodes whose type bits are all zero, so an accept-list would silently reject them.win_random_access_handleis the deliberate exception (volumes reportFILE_TYPE_DISKlike regular files; it accepts anything passing the overlapped gate).win_object_handleexcluded (a satisfied wait would acquire the mutex on a thread the resuming coroutine doesn't own; the threadpool wait API enforces this at arm time).std::errc::operation_not_supported. Policy checks surface atassign()uniformly; kernel refusals surface atassign()only where a registration syscall exists (epoll, kqueue) — io_uring and select can defer to first I/O, and the docs say so.Key contract points
assign()validates before it mutates or closes anything — strong guarantee on failure: object keeps its previous state and pending ops, caller keeps the rejected fd, flags untouched. Self-assign rejected. This supersedes the shipped adopt path's close-old-first order, changing failed-assign semantics for thelocal_*types too (tests updated to pin it).O_NONBLOCKapplied lazily at firstread_some/write_some, never atassign(), and never restored.wait()-only usage never modifies the fd — adoptingSTDIN_FILENOto await readiness must not flip the parent shell's terminal to nonblocking, and foreign-library integration ("poll my fd, don't touch it") stays possible. Restoration is unsound because the flag lives on the shared open file description; the docs state the mode change is permanent and tell users todup()first when another party owns the fd.win_object_handle: completion wins over cancellation. The kernel consumes the signal (semaphore unit, auto-reset event) at wait satisfaction, before any callback runs — so teardown drains callbacks, never cancels them, and an already-satisfied wait always reports signaled even when it racedcancel()/release()/destruction. One pending wait per object; a secondwait()errors.wait()on the reactor backends: probe, and never trust the sticky readiness flags. They under-report (an op that consumes an edge leaves no trace → naive wait parks forever on a ready fd) and over-report (registration latcheswrite_readyon an empty pipe and speculative writes never clear it → wait(write) would lie on a full pipe).do_waitprobes with zero-timeoutpoll(); a set sticky flag is only a re-probe trigger, never truth. Socket hot paths untouched. (The shipped socketwait(read)has the consumed-edge hazard today; a probe-based fix is in progress separately.)Implementation notes
register_descriptorcurrently throws from insidenoexceptpaths, so a kernel registration refusal terminates the process — and the hazard already coversdo_assign_fdanddo_open_socketviainit_and_register. The refactor makes it return an error on all three reactor schedulers, threaded through all three callers (do_listenis the model).do_assign_fd,readv/writevop types alongside therecvmsg/sendmsgones, non-socket error paths (noSO_ERRORon a pipe — re-run the syscall and let the kernel reportEPIPE/EIO), select snapshot fix so a parkedwait_write_opsets the write set.READV/WRITEVSQEs at offset -1; lazyO_NONBLOCKhere too, for cancellability — a blocking read punted to an io-wq worker cannot be cancelled; with nonblocking,EAGAINCQE → armpoll_addand resubmit, cancellable on every kernel version.stream_file,random_access_file, plus the two new handle types).assign()gates in two steps: console pre-check, thenNtQueryInformationFile(FileModeInformation)synchronous-mode rejection —CreateIoCompletionPortfailing for synchronous handles is undocumented and not relied on.win_object_handleusesCreateThreadpoolWait: one cachedPTP_WAITper impl re-armed per wait, generation-counted claim word, completion posted through the port with both scheduler counters participating, and a drain-only rundown (SetThreadpoolWait(nullptr)thenWaitForThreadpoolWaitCallbackswithfCancelPendingCallbacks=FALSE) shared by cancel/close/release/destructor/shutdown — cancelling a queued callback would destroy the only witness to a consumed signal.Staging
posix_descriptoron the four POSIX backends — unblocks the motivating use cases.win_stream_handle+win_random_access_handle— Windows file-service unification + the two-step gate.win_object_handle— threadpool-wait machinery.Each stage independently shippable and testable. Non-throwing overload policy follows #261 (open); the sketches show the throwing form only.