Skip to content

Native descriptor and handle support: posix_descriptor, win_stream_handle, win_random_access_handle, win_object_handle #338

Description

@sgerbino

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.
  • 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 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

  1. Reactor error-propagation refactor + posix_descriptor on the four POSIX backends — unblocks the motivating use cases.
  2. win_stream_handle + win_random_access_handle — Windows file-service unification + the two-step gate.
  3. win_object_handle — threadpool-wait machinery.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Ready

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions