From 7e89e96ec97dbdfdbe6387daf6aa8a87afd031e2 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 17 Oct 2026 22:39:47 +1300 Subject: [PATCH 1/5] Add explicit socket descriptors and syscall-shaped operations. --- Cargo.lock | 1 + context/design.md | 4 +- context/gaps.md | 4 +- context/implementation.md | 4 +- crates/executor/Cargo.toml | 7 +- crates/executor/design.md | 81 +++- crates/executor/examples/interface_probe.rs | 37 +- .../examples/interface_probe/simulated.rs | 366 +++++++++++++----- .../examples/interface_probe/tests.rs | 79 +++- crates/executor/examples/portable_io.rs | 37 +- crates/executor/readme.md | 14 +- .../{file_handle.rs => file_descriptor.rs} | 12 +- crates/executor/src/lib.rs | 13 +- crates/executor/src/scheduler.rs | 3 + crates/executor/src/scheduler/file_close.rs | 8 +- .../src/scheduler/file_close/tests.rs | 4 +- .../executor/src/scheduler/selector/epoll.rs | 2 +- .../src/scheduler/selector/io_uring.rs | 101 +++-- .../src/scheduler/selector/io_uring/tests.rs | 51 ++- .../executor/src/scheduler/selector/iocp.rs | 2 +- .../executor/src/scheduler/selector/kqueue.rs | 2 +- .../src/scheduler/selector/readiness.rs | 161 ++++---- .../selector/readiness/socket_descriptor.rs | 44 +++ .../src/scheduler/selector/readiness/tests.rs | 79 ++-- crates/executor/src/scheduler/socket.rs | 67 +++- crates/executor/src/scheduler/socket_io.rs | 93 +++++ .../src/scheduler/socket_registration.rs | 4 +- .../src/scheduler/socketry/operations.rs | 124 ++++-- .../scheduler/socketry/operations/tests.rs | 36 +- crates/executor/src/scheduler/tokio.rs | 362 ++++++++--------- .../src/scheduler/tokio/socket_descriptor.rs | 110 ++++++ crates/executor/src/scheduler/tokio/tests.rs | 77 +--- crates/executor/src/socket_domain.rs | 20 + crates/executor/src/socket_protocol.rs | 11 + crates/executor/src/socket_type.rs | 11 + crates/executor/tests/contracts.rs | 92 ++++- crates/executor/tests/io.rs | 170 +++++--- examples/echo/client.rs | 21 +- examples/echo/server.rs | 24 +- readme.md | 6 +- releases.md | 4 +- src/lib.rs | 9 +- 42 files changed, 1554 insertions(+), 803 deletions(-) rename crates/executor/src/{file_handle.rs => file_descriptor.rs} (82%) create mode 100644 crates/executor/src/scheduler/selector/readiness/socket_descriptor.rs create mode 100644 crates/executor/src/scheduler/socket_io.rs create mode 100644 crates/executor/src/scheduler/tokio/socket_descriptor.rs create mode 100644 crates/executor/src/socket_domain.rs create mode 100644 crates/executor/src/socket_protocol.rs create mode 100644 crates/executor/src/socket_type.rs diff --git a/Cargo.lock b/Cargo.lock index df50079..8c9aa6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1008,6 +1008,7 @@ dependencies = [ "pin-project-lite", "polling", "signal-hook", + "socket2", "tempfile", "tokio", "windows-sys 0.61.2", diff --git a/context/design.md b/context/design.md index 143a459..7d1051f 100644 --- a/context/design.md +++ b/context/design.md @@ -135,9 +135,9 @@ The implementations live under `scheduler/selector/`, selected with Cargo featur The Linux `io-uring` feature selects a dedicated ring for socket/file reads and writes and `file_sync`. `file_truncate` uses the ring when its opcode is available, with a blocking-file fallback on older kernels. Optional `FileAllocate` adds Linux range reservation with `AllocationMode::Extend` or `KeepSize`, using a probed `Fallocate` request or the blocking syscall fallback. Other platforms currently omit that capability. Synchronization, allocation, and native truncation retain the file without a payload buffer and use the same original-completion retirement contract as transfers. Connect, accept, explicit readiness waits and sleep still use async-io. Initialization probes required opcodes and reliable completion overflow; it returns an error if unavailable rather than silently changing implementation. Shutdown cancels and drains original completions. Operation identifiers are never reused, and cancellation completions cannot release original buffers. Unexpected selector failure retains resources whose kernel lifetime cannot be established and panics their waiting operations. Normal cancellation does not retain completed resources. -The owned-buffer API supports `IoBuf`, `IoBufMut`, and `BufferSlice`, returning `(OperationResult, B)` with ownership on every normal result. Read buffers must already have an initialized length. Operations may be partial; dropping a waiting future can abandon I/O that consumes or transmits bytes. Socket and listener types are associated with the concrete implementation. Tokio registrations reject use through another runtime's adapter. +The owned-buffer API supports `IoBuf`, `IoBufMut`, and `BufferSlice`, returning `(OperationResult, B)` with ownership on every normal result. Read buffers must already have an initialized length. Operations may be partial; dropping a waiting future can abandon I/O that consumes or transmits bytes. Each implementation chooses one associated socket resource for both accepted and listening sockets. Tokio registrations reject use through another runtime's adapter. -This is an initial implementation: each ring operation uses a boxed typed request, a command, and a completion channel, and operation records and buffers are not pooled. General descriptors, UDP and native overlapped Windows files remain future work. Blocking file operations can outlive task cancellation; neither Socketry nor the Tokio adapter shuts down the process-wide or external blocking pool. +This is an initial implementation: each ring operation uses a boxed typed request, a command, and a completion channel, and operation records and buffers are not pooled. Raw descriptor import beyond std TCP, socket message/address APIs, and native overlapped Windows files remain future work. Blocking file operations can outlive task cancellation; neither Socketry nor the Tokio adapter shuts down the process-wide or external blocking pool. ## Timer port diff --git a/context/gaps.md b/context/gaps.md index 37e53c0..0511ff4 100644 --- a/context/gaps.md +++ b/context/gaps.md @@ -18,9 +18,9 @@ The executor does not expose a Barrier or require a group/owner argument. Librar ## 3. The I/O boundary is still narrow -`Socket` covers TCP listener creation, address inspection, connect, accept, reads, writes, and readiness. OS-resource import is a separate `SocketRegistration` capability. `File` opens associated resources and borrows them for seeking, truncation, synchronization, shared-cursor and positioned I/O. Explicit close/submission now consumes an exclusively owned handle, and optional `FileAllocate` supplies Linux allocation. Metadata hooks remain to implement. File/socket operations support cancellation and owned buffer views. `Clock` supplies monotonic time and cancellable absolute and relative waits. +`Socket` uses one associated resource type for unconnected, bound, listening, connected, and datagram sockets. It exposes `socket_open`, `socket_bind`, syscall-like `socket_listen`, `socket_connect`, `socket_accept`, address inspection, reads, writes, and readiness. The initial API supports IPv4/IPv6 and stream/datagram sockets with optional TCP/UDP protocol selection. OS-resource import is a separate `SocketRegistration` capability. Socket close/shutdown, options, message/address APIs, and ancillary data remain future work. `File` opens associated resources and borrows them for seeking, truncation, synchronization, shared-cursor and positioned I/O. Explicit close/submission now consumes an exclusively owned handle, and optional `FileAllocate` supplies Linux allocation. Metadata hooks remain to implement. File/socket operations support cancellation and owned buffer views. `Clock` supplies monotonic time and cancellable absolute and relative waits. -`Blocking` now adds bounded asynchronous admission for owned synchronous closures, implemented through dedicated native and Tokio pools. Existing file fallbacks still use backend pools. General socket creation/options, DNS, randomness, and processes remain outside the portable boundary. Deadline policies can now compose `Clock` and cancellation; higher-level timeout helpers remain separate. Ordinary synchronization uses futures and wakers above scheduler primitives. Expand capabilities as real consumers need them, keeping the application's implementation choice explicit. A broader boundary would also allow more operating-system behavior to be substituted in deterministic tests. +`Blocking` now adds bounded asynchronous admission for owned synchronous closures, implemented through dedicated native and Tokio pools. Existing file fallbacks still use backend pools. Socket options, message/address APIs, DNS, randomness, and processes remain outside the portable boundary. Deadline policies can now compose `Clock` and cancellation; higher-level timeout helpers remain separate. Ordinary synchronization uses futures and wakers above scheduler primitives. Expand capabilities as real consumers need them, keeping the application's implementation choice explicit. A broader boundary would also allow more operating-system behavior to be substituted in deterministic tests. ## 4. The backends establish behavior before performance diff --git a/context/implementation.md b/context/implementation.md index 73123bc..5461af6 100644 --- a/context/implementation.md +++ b/context/implementation.md @@ -83,7 +83,7 @@ This guide describes the current implementation and its boundaries. Read [the de ## Interface experiment -`crates/executor/examples/interface_probe.rs` runs generic file-copy and echo-server functions against native, Tokio, and an in-memory implementation. It uses the exported `File` and `Socket` traits for associated resources, creation, cancellation and address inspection. It uses scheduler implementations directly and retries partial writes through owned buffer views. The simulator exercises short transfers and ordinary resource destruction without native I/O dependencies. It supports cancellation but does not model timers, kernel completion tracking, or a simulated task scheduler; findings and next changes are recorded in `crates/executor/design.md`. +`crates/executor/examples/interface_probe.rs` runs generic file-copy and echo-server functions against native, Tokio, and an in-memory implementation. It uses the exported `File` and `Socket` traits for associated resources, creation, cancellation and address inspection. Socket setup follows open, bind, and listen/connect steps; no listener-specific resource type is required. It uses scheduler implementations directly and retries partial writes through owned buffer views. The simulator exercises short transfers and ordinary resource destruction without native I/O dependencies. It supports cancellation but does not model timers, kernel completion tracking, or a simulated task scheduler; findings and next changes are recorded in `crates/executor/design.md`. ## Verification @@ -95,7 +95,7 @@ Coverage measures source regions for native and Tokio schedulers on each support ## Explicit file closure -- Native and Tokio `File::File` is an owned, non-`Clone` `FileHandle`. Backend retention uses a private `Arc`; consuming it for close uses `Arc::try_unwrap` and panics on outstanding use. Do not cancel or wait for other operations during close. +- Native and Tokio `File::File` is an owned, non-`Clone` `FileDescriptor`. Backend retention uses a private `Arc`; consuming it for close uses `Arc::try_unwrap` and panics on outstanding use. Do not cancel or wait for other operations during close. - `file_close` awaits bounded admission and execution; `file_close_submit` awaits only admission. Neither accepts cancellation. Accepted close jobs survive waiter destruction and run on a dedicated cleanup pool independent of user blocking closures. Native shutdown outside future and blocking-pool workers and awaited Tokio shutdown drain them. Completion and capacity wakeups run in blocking-pool worker context, so reentrant scheduler destruction requests shutdown without waiting on its own cleanup queue. - All operation result notifications must follow release of backend file references, including cancellation and requests discarded before submission. io_uring still retains published requests until original terminal CQEs; cancellation CQEs cannot release them. - `examples/file_close.rs` explains cancellation clones, keeping the operation future alive through cancellation, completion races, and explicit close before propagating an I/O result. Dropping an operation future abandons completion and may leave backend work running; io_uring requests cancellation, but immediate close can still panic until backend access ends. diff --git a/crates/executor/Cargo.toml b/crates/executor/Cargo.toml index 651ae38..4056c62 100644 --- a/crates/executor/Cargo.toml +++ b/crates/executor/Cargo.toml @@ -16,6 +16,7 @@ path = "src/lib.rs" async-task = "4.7.1" async-io = { version = "2.6.0", optional = true } blocking = { version = "1.6.2", optional = true } +socket2 = { version = "0.6.3", optional = true } crossbeam-deque = "0.8.7" event-listener = "5.4.2" futures-channel = "0.3.31" @@ -27,7 +28,7 @@ libc = { version = "0.2.189", optional = true } signal-hook = { version = "0.4.5", optional = true, default-features = false, features = ["iterator"] } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61.2", optional = true, features = ["Win32_Foundation"] } +windows-sys = { version = "0.61.2", optional = true, features = ["Win32_Foundation", "Win32_Networking_WinSock"] } [target.'cfg(target_os = "linux")'.dependencies] io-uring = { version = "0.7.11", optional = true, features = ["io_safety"] } @@ -35,10 +36,10 @@ polling = { version = "3.11.0", optional = true } [features] default = ["native"] -native = ["dep:async-io", "dep:blocking", "dep:libc", "dep:windows-sys"] +native = ["dep:socket2", "dep:async-io", "dep:blocking", "dep:libc", "dep:windows-sys"] io-uring = ["native", "dep:io-uring", "dep:libc", "dep:polling"] signals = ["dep:signal-hook"] -tokio = ["dep:tokio", "dep:libc", "dep:windows-sys"] +tokio = ["dep:socket2", "dep:async-io", "dep:tokio", "dep:libc", "dep:windows-sys"] [dev-dependencies] tokio = { version = "1.53.1", features = ["test-util"] } diff --git a/crates/executor/design.md b/crates/executor/design.md index 27db239..87f558a 100644 --- a/crates/executor/design.md +++ b/crates/executor/design.md @@ -20,7 +20,7 @@ Pass the implementation into libraries explicitly. A library should require the ## Capability traits and static dispatch -`File`, `Socket`, and `Clock` name scheduler capabilities. `Spawn` provides primitive task dispatch and returns an owning task handle. `Blocking` adds optional execution of owned synchronous closures with bounded asynchronous admission. `File::File`, `Socket::Socket`, and `Socket::Listener` are associated resource types chosen by each implementation. `Socket` replaces the earlier capability name `Network`. +`File`, `Socket`, and `Clock` name scheduler capabilities. `Spawn` provides primitive task dispatch and returns an owning task handle. `Blocking` adds optional execution of owned synchronous closures with bounded asynchronous admission. `File::File` and `Socket::Socket` are associated resource types chosen by each implementation. Native and Tokio use concrete `FileDescriptor` and `SocketDescriptor` types. `Socket` replaces the earlier capability name `Network`. Prefer generic trait bounds, concrete resource types, and concrete future types. These allow compile-time capability checking, monomorphization, and inlining without requiring a boxed future for each operation. The compiler decides which calls to inline; the interface should make that optimization possible. @@ -34,11 +34,13 @@ Do not create one trait per syscall automatically. Group operations around capab Generic code takes a scheduler such as `&S`, with bounds describing the portion of the interface it uses. A task group may require `S: Spawn`; a timed primitive may additionally require `Clock`; an HTTP server may require `Spawn + Socket`. These bounds describe the same chosen scheduler implementation. A named aggregate trait can make a standard bundle convenient once its required capabilities are settled; it should not force a task-only primitive to require file or socket support. The current public `Scheduler` is the concrete native implementation, not an aggregate scheduler trait. -Resource creation belongs inside the corresponding capability. `File` declares an associated file type, and file creation returns that type. `Socket` declares associated socket and listener types, with a combined TCP `socket_listen` operation and `connect`. Generic consumers use those associated types rather than creating a `std::fs::File` or `std::net::TcpListener` themselves. Separate socket creation, binding, options, and listening remain extensions to specify. +Resource creation belongs inside the corresponding capability. `File` declares an associated file type, and file creation returns that type. `Socket` declares one associated socket type for unconnected, bound, listening, connected, and datagram sockets. Its `socket_open`, `socket_bind`, `socket_listen`, and `socket_connect` operations follow the corresponding system-call boundaries; `socket_accept` returns another value of that same type. `socket_listen` takes an existing socket and backlog and leaves bind behavior to the operating system, including implicit binding where supported. Generic consumers use these associated resources rather than creating a `std::fs::File` or `std::net::TcpListener` themselves. + +The initial socket arguments select IPv4 or IPv6, stream or datagram, and an optional TCP or UDP protocol. Address parameters use `std::net::SocketAddr`. This keeps common IP socket setup available to generic code while leaving non-IP families, socket options, message metadata, and ancillary data for explicit extensions. File and socket descriptors remain distinct; no common descriptor trait is required. Existing std TCP resources can still enter through the optional `SocketRegistration` interoperability trait. The interface does not require `Arc` around every resource. Concrete implementations choose their resource representation and how to retain it during in-flight work. They may use OS handles, registered-resource tables, shared ownership, or simulated resource identifiers. Taking a resource by reference does not remove the backend's obligation to retain everything still accessible by a submitted operation after its waiting future is dropped. -These resource contracts are implemented. `File::file_open` accepts public `FileOptions`; seeking, stateful, and positioned operations borrow `&Self::File`. The native and Tokio implementations return an owned, non-`Clone` `FileHandle`, with private `Arc` retention for backend access. `Socket` includes listener/local/peer address inspection. Optional `SocketRegistration` imports existing `std::net` resources and is not required of simulated implementations. +These resource contracts are implemented. `File::file_open` accepts public `FileOptions`; seeking, stateful, and positioned operations borrow `&Self::File`. The native and Tokio implementations return an owned, non-`Clone` `FileDescriptor`, with private `Arc` retention for backend access. Their socket operations use one non-`Clone` `SocketDescriptor` for every socket state. Optional `SocketRegistration` imports existing `std::net` TCP resources and is not required of simulated implementations. ## Platform support and fallbacks @@ -186,7 +188,14 @@ scheduler.file_open(path, options, cancellation) scheduler.file_read(file, buffer, cancellation) scheduler.file_read_at(file, buffer, offset, cancellation) scheduler.file_seek(file, position, cancellation) -scheduler.socket_accept(listener, cancellation) +scheduler.socket_open(domain, kind, protocol, cancellation) +scheduler.socket_bind(socket, address, cancellation) +scheduler.socket_listen(socket, backlog, cancellation) +scheduler.socket_connect(socket, address, cancellation) +scheduler.socket_accept(socket, cancellation) +scheduler.socket_read(socket, buffer, cancellation) +scheduler.socket_write(socket, buffer, cancellation) +scheduler.socket_wait(socket, interest, cancellation) scheduler.socket_sendmsg(socket, message, flags, cancellation) scheduler.address_resolve(name, options, cancellation) scheduler.now() @@ -194,7 +203,7 @@ scheduler.sleep_until(deadline, cancellation) scheduler.sleep(duration, cancellation) ``` -This list includes proposed extensions. The implemented `File` methods are `file_open`, `file_seek`, `file_sync`, `file_truncate`, shared-cursor `file_read`/`file_write`, and explicit-offset `file_read_at`/`file_write_at`. The implemented TCP methods are `socket_listen`, `connect`, `accept`, `io_read`, `io_write`, and `io_wait`, plus synchronous `listener_addr`, `socket_local_addr`, and `socket_peer_addr`. Async I/O methods take `&Cancellation`; buffer operations return `(OperationResult, B)` on success, error, and cancellation. +This list includes proposed extensions. The implemented `File` methods are `file_open`, `file_seek`, `file_sync`, `file_truncate`, shared-cursor `file_read`/`file_write`, and explicit-offset `file_read_at`/`file_write_at`. The implemented `Socket` methods are `socket_open`, `socket_bind`, `socket_listen`, `socket_connect`, `socket_accept`, `socket_read`, `socket_write`, and `socket_wait`, plus synchronous `socket_local_addr` and `socket_peer_addr`. Initial creation supports IPv4/IPv6 stream and datagram sockets with TCP/UDP protocol selection. Connected UDP preserves datagram boundaries; address-based message operations and ancillary data remain extensions. Async I/O methods take `&Cancellation`; buffer operations return `(OperationResult, B)` on success, error, and cancellation. An optional context pairing a scheduler reference with a cancellation signal could reduce repetitive arguments. Such a context should preserve explicit dependency and cancellation boundaries; it is a convenience, not a new computation abstraction. @@ -360,35 +369,65 @@ pub trait File: Send + Sync { pub trait Socket: Send + Sync { type Socket: Send + Sync; - type Listener: Send + Sync; - fn socket_accept( + fn socket_open( &self, - listener: &Self::Listener, + domain: SocketDomain, + kind: SocketType, + protocol: Option, cancellation: &Cancellation, - ) -> impl Future> + Send; + ) -> impl Future> + Send; + + fn socket_bind( + &self, + socket: &Self::Socket, + address: SocketAddr, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + fn socket_listen( + &self, + socket: &Self::Socket, + backlog: i32, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + fn socket_local_addr(&self, socket: &Self::Socket) -> io::Result; + fn socket_peer_addr(&self, socket: &Self::Socket) -> io::Result; fn socket_connect( &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> impl Future> + Send; + ) -> impl Future> + Send; + + fn socket_accept( + &self, + socket: &Self::Socket, + cancellation: &Cancellation, + ) -> impl Future> + Send; - fn socket_recv( + fn socket_read( &self, socket: &Self::Socket, buffer: B, - flags: RecvFlags, cancellation: &Cancellation, ) -> impl Future> + Send; - fn socket_send( + fn socket_write( &self, socket: &Self::Socket, buffer: B, - flags: SendFlags, cancellation: &Cancellation, ) -> impl Future> + Send; + + fn socket_wait( + &self, + socket: &Self::Socket, + interest: Interest, + cancellation: &Cancellation, + ) -> impl Future> + Send; } pub trait Clock: Send + Sync { @@ -408,7 +447,9 @@ pub trait Clock: Send + Sync { } ``` -The associated file resource, truncation, synchronization, and both stateful and positioned file operations above are implemented. Current TCP operations use `connect`, `accept`, `io_read`, and `io_write`, without flags; the sketch shows possible syscall-oriented extensions. The connect convenience creates and connects a socket; a lower-level variant taking an existing socket may be needed for callers that configure it before connecting. Combined TCP `socket_listen`, address inspection, and optional `SocketRegistration` are implemented; separate socket setup, message ownership, and portable flags still need signatures. Explicit close consumes its handle and follows the shared admission contract below. +The associated file resource, truncation, synchronization, and both stateful and positioned file operations above are implemented. The initial socket contract has explicit creation, bind, listen, connect, accept, stream/datagram read and write, readiness, and local/peer address operations. `socket_listen` receives an existing socket and a backlog. Socket close, options, message/address I/O, and portable flags remain separate design work. Explicit file close consumes its handle and follows the shared admission contract below. + +Protocol buffering belongs above the descriptor-level scheduler interface. A protocol can use a specialized transport adapter or a generic buffering layer around `S::Socket`, with ordinary static dispatch as the default. Dynamic dispatch is an option when applications need runtime selection or type erasure; it should not add indirection to every primitive operation. The scheduler itself does not promise line, frame, or message buffering. Additional capability shapes follow the same convention: @@ -804,11 +845,11 @@ Registration does not itself guarantee zero-copy transport. Zero-copy sends can ## Interface review and validation -The initial public contracts now cover associated resources and creation, owned buffer ranges, explicit I/O cancellation, and transferable task ownership. The same generic application and contract tests exercise native, Tokio, and in-memory implementations. This validates a useful core without committing every scheduler to the wider proposed syscall set. +The public contracts now cover associated resources and creation, owned buffer ranges, explicit I/O cancellation, and transferable task ownership. The same generic application and contract tests exercise native, Tokio, and in-memory implementations. This validates a useful core without committing every scheduler to the wider proposed syscall set. Remaining design work includes: -1. **Resource lifecycle and wider I/O:** separate socket creation/options, explicit close/submission, message I/O, and extension boundaries. +1. **Socket lifecycle and wider I/O:** explicit socket shutdown/close, message/address I/O, ancillary data, socket options, and extension boundaries. 2. **Blocking pool integration:** the public `Blocking` capability now provides bounded admission and owned completion. Existing file fallbacks still use backend pools; sharing, idle thread retirement, and further scheduling policies remain separate decisions. 3. **Backend retirement:** account for all accepted work, including `_submit` jobs without application waiters. File-close cleanup is now accounted for; extend this to subsequent submission-only operations. Application completion remains explicit through owned task handles; there is no implicit graceful join of all spawned work. 4. **Timer engine integration:** the monotonic `Clock` and cancellable deadline contract are implemented. Native waits still use async-io, and the adapter uses its selected Tokio runtime, including paused time. Integrating `socketry-timers` into scheduler driving and parking remains separate work. @@ -842,13 +883,13 @@ The simulator has bounded in-memory file sizes and unbounded byte queues. It doe | Cancellation | `Cancellation`, `Cancelled`, `defer_cancel`, and cancellation-aware file/socket operations implemented. Application shutdown is cooperative. | | Signal notifications | `Notify`, `Signals`, and optional Unix registration guards implemented for native and Tokio; each guard owns its dispatch task. | | Runtime lifetime | Application waits are explicit. Runtime shutdown abandons futures; backend retention protects submitted I/O. Accepted file-close submissions survive abandonment and are drained by graceful shutdown. | -| Capabilities | Public `File`, `Socket`, `SocketRegistration`, `Clock`, `Spawn`, and `Blocking`; associated resources and initial file/TCP creation implemented. Broader hooks remain design work. | +| Capabilities | Public `File`, `Socket`, `SocketRegistration`, `Clock`, `Spawn`, and `Blocking`; associated resources and initial file/socket creation implemented. Broader hooks remain design work. | | Blocking execution | Dedicated native/Tokio pools, configurable worker and admission limits, queued cancellation, and owning completion handles implemented. Started closures finish independently; graceful completion requires awaiting them. | | Linked operations | Prepared chains and `Linked` remain proposed; native linking, whole-chain blocking jobs, and readiness sequencing are not implemented. | | File operations | `file_open`, `file_seek`, `file_sync`, `file_truncate`, shared-cursor `file_read`/`file_write`, and explicit-offset `file_read_at`/`file_write_at` implemented with owned buffers. Optional `FileAllocate` implemented on Linux with `Extend` and `KeepSize`. `file_close`/`file_close_submit` implemented with exclusive ownership and bounded cleanup; metadata hooks pending. | | Submission and completion | File close and its `_submit` variant share bounded admission and persistent cleanup jobs. Socket shutdown/close, advice pairs, and safe CQE suppression remain proposed. The public blocking pool has its own bounded admission. | -| Socket operations | TCP listen, connect, accept, address inspection, generic read/write, readiness, and optional OS registration implemented. General sockets and message I/O pending. | -| Backends | Native readiness, blocking file fallbacks, Linux io_uring, and an adapter to an existing Tokio runtime implemented; the example simulator implements the public I/O traits. | +| Socket operations | `socket_open`, `socket_bind`, syscall-like `socket_listen`, `socket_connect`, `socket_accept`, address inspection, read/write, and readiness implemented for IPv4/IPv6 stream and datagram sockets. Message/address I/O and explicit socket close remain pending. | +| Backends | Native readiness, blocking file fallbacks, Linux io_uring, and an adapter to an existing Tokio runtime implemented. Tokio uses `AsyncFd` on Unix and async-io readiness on Windows; the example simulator implements the public I/O traits. | | Buffers | Unsafe `IoBuf`/`IoBufMut`, `Vec`, `Box<[u8]>`, and owned `BufferSlice` implemented. Registered leases and pools remain proposed. | | Buffer forwarding | `with_previous_buffer` is hypothetical, with no selected implementation target. | | Optimization | io_uring uses an unbounded command channel, a boxed request, and a completion channel per operation; pooling/backpressure pending. | diff --git a/crates/executor/examples/interface_probe.rs b/crates/executor/examples/interface_probe.rs index 7908a82..614785c 100644 --- a/crates/executor/examples/interface_probe.rs +++ b/crates/executor/examples/interface_probe.rs @@ -7,7 +7,10 @@ #[path = "interface_probe/simulated.rs"] mod simulated; -use socketry_executor::{Cancellation, File, FileOptions, IoBuf, OperationResult, Socket}; +use socketry_executor::{ + Cancellation, File, FileOptions, IoBuf, OperationResult, Socket, SocketDomain, SocketProtocol, + SocketType, +}; use std::{io, path::Path}; static NEVER: Cancellation = Cancellation::never(); @@ -68,7 +71,7 @@ async fn send_all( let mut written = 0; while written < buffer.len() { let (result, returned) = scheduler - .io_write(socket, buffer.slice(written..), cancellation) + .socket_write(socket, buffer.slice(written..), cancellation) .await; buffer = returned.into_inner(); let count = result?; @@ -81,12 +84,12 @@ async fn send_all( } /// Serve one connection until peer EOF, echoing each received prefix. -async fn serve(scheduler: &S, listener: &S::Listener) -> OperationResult { - let (socket, _) = scheduler.accept(listener, &NEVER).await?; +async fn serve(scheduler: &S, listener: &S::Socket) -> OperationResult { + let (socket, _) = scheduler.socket_accept(listener, &NEVER).await?; let mut total = 0; loop { let (result, mut buffer) = scheduler - .io_read(&socket, vec![0; BUFFER_SIZE], &NEVER) + .socket_read(&socket, vec![0; BUFFER_SIZE], &NEVER) .await; let count = result?; if count == 0 { @@ -99,17 +102,33 @@ async fn serve(scheduler: &S, listener: &S::Listener) -> OperationRes } async fn exchange(scheduler: &S) -> OperationResult<()> { + let address = "127.0.0.1:0".parse().unwrap(); let listener = scheduler - .socket_listen("127.0.0.1:0".parse().unwrap(), &NEVER) + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &NEVER, + ) .await?; - let address = scheduler.listener_addr(&listener)?; + scheduler.socket_bind(&listener, address, &NEVER).await?; + scheduler.socket_listen(&listener, 128, &NEVER).await?; + let address = scheduler.socket_local_addr(&listener)?; let client = async { - let socket = scheduler.connect(address, &NEVER).await?; + let socket = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &NEVER, + ) + .await?; + scheduler.socket_connect(&socket, address, &NEVER).await?; send_all(scheduler, &socket, PAYLOAD.to_vec(), &NEVER).await?; let mut received = Vec::new(); while received.len() < PAYLOAD.len() { let (result, buffer) = scheduler - .io_read(&socket, vec![0; BUFFER_SIZE], &NEVER) + .socket_read(&socket, vec![0; BUFFER_SIZE], &NEVER) .await; let count = result?; if count == 0 { diff --git a/crates/executor/examples/interface_probe/simulated.rs b/crates/executor/examples/interface_probe/simulated.rs index e835008..965f0a0 100644 --- a/crates/executor/examples/interface_probe/simulated.rs +++ b/crates/executor/examples/interface_probe/simulated.rs @@ -6,7 +6,7 @@ use super::{Cancellation, File, FileOptions, OperationResult, Socket}; use event_listener::Event; use socketry_executor::{AllocationMode, BufferResult, FileAllocate, SyncMode}; -use socketry_executor::{Interest, IoBuf, IoBufMut}; +use socketry_executor::{Interest, IoBuf, IoBufMut, SocketDomain, SocketProtocol, SocketType}; use std::collections::{HashMap, VecDeque}; use std::io::{self, SeekFrom}; use std::net::SocketAddr; @@ -20,6 +20,18 @@ use std::{ type Bytes = Arc>>; +fn available_port( + listeners: &HashMap>, + mut address: SocketAddr, +) -> io::Result { + (10000..=u16::MAX) + .find(|port| { + address.set_port(*port); + !listeners.contains_key(&address) + }) + .ok_or_else(|| io::Error::from(io::ErrorKind::AddrNotAvailable)) +} + pub struct Scheduler { files: Mutex>, listeners: Mutex>>, @@ -27,7 +39,7 @@ pub struct Scheduler { write_limit: usize, } -pub struct FileHandle { +pub struct FileDescriptor { position: Mutex, bytes: Bytes, writable: bool, @@ -36,13 +48,25 @@ pub struct FileHandle { #[derive(Default)] struct Incoming { - sockets: Mutex>, + sockets: Mutex>, ready: Event, } -pub struct Listener { - address: SocketAddr, - incoming: Arc, +pub struct SocketDescriptor { + state: Mutex, +} + +enum SocketState { + Open { + domain: SocketDomain, + kind: SocketType, + local: SocketAddr, + }, + Listening { + address: SocketAddr, + incoming: Arc, + }, + Connected(Connection), } #[derive(Default)] @@ -58,19 +82,22 @@ struct Pipe { ready: Event, } -pub struct Connection { +struct Connection { input: Arc, output: Arc, local: SocketAddr, peer: SocketAddr, } -impl Drop for Connection { +impl Drop for SocketDescriptor { fn drop(&mut self) { - self.input.state.lock().unwrap().reader_closed = true; - self.output.state.lock().unwrap().writer_closed = true; - self.input.ready.notify(usize::MAX); - self.output.ready.notify(usize::MAX); + let state = self.state.get_mut().unwrap(); + if let SocketState::Connected(socket) = state { + socket.input.state.lock().unwrap().reader_closed = true; + socket.output.state.lock().unwrap().writer_closed = true; + socket.input.ready.notify(usize::MAX); + socket.output.ready.notify(usize::MAX); + } } } @@ -138,7 +165,7 @@ impl FileAllocate for Scheduler { } impl File for Scheduler { - type File = FileHandle; + type File = FileDescriptor; async fn file_close(&self, file: Self::File) -> io::Result<()> { drop(file); @@ -151,7 +178,7 @@ impl File for Scheduler { async fn file_truncate( &self, - file: &FileHandle, + file: &FileDescriptor, length: u64, cancellation: &Cancellation, ) -> OperationResult<()> { @@ -182,7 +209,7 @@ impl File for Scheduler { async fn file_seek( &self, - file: &FileHandle, + file: &FileDescriptor, position: SeekFrom, cancellation: &Cancellation, ) -> OperationResult { @@ -205,7 +232,7 @@ impl File for Scheduler { path: &Path, options: FileOptions, cancellation: &Cancellation, - ) -> OperationResult { + ) -> OperationResult { cancellation.check()?; if (!options.read && !options.write) || ((options.create || options.create_new || options.truncate) && !options.write) @@ -223,7 +250,7 @@ impl File for Scheduler { if options.truncate { bytes.lock().unwrap().clear(); } - Ok(FileHandle { + Ok(FileDescriptor { position: Mutex::new(0), bytes, writable: options.write, @@ -233,7 +260,7 @@ impl File for Scheduler { async fn file_read( &self, - file: &FileHandle, + file: &FileDescriptor, buffer: B, cancellation: &Cancellation, ) -> BufferResult { @@ -247,7 +274,7 @@ impl File for Scheduler { async fn file_read_at( &self, - file: &FileHandle, + file: &FileDescriptor, buffer: B, offset: u64, cancellation: &Cancellation, @@ -256,7 +283,7 @@ impl File for Scheduler { } async fn file_write( &self, - file: &FileHandle, + file: &FileDescriptor, buffer: B, cancellation: &Cancellation, ) -> BufferResult { @@ -270,7 +297,7 @@ impl File for Scheduler { async fn file_write_at( &self, - file: &FileHandle, + file: &FileDescriptor, buffer: B, offset: u64, cancellation: &Cancellation, @@ -280,89 +307,123 @@ impl File for Scheduler { } impl Socket for Scheduler { - type Socket = Connection; - type Listener = Listener; + type Socket = SocketDescriptor; - async fn socket_listen( + async fn socket_open( + &self, + domain: SocketDomain, + kind: SocketType, + protocol: Option, + cancellation: &Cancellation, + ) -> OperationResult { + cancellation.check()?; + match (kind, protocol) { + (SocketType::Stream, None | Some(SocketProtocol::Tcp)) => {} + (SocketType::Datagram, None | Some(SocketProtocol::Udp)) => { + return Err(io::Error::from(io::ErrorKind::Unsupported).into()); + } + _ => return Err(io::Error::from(io::ErrorKind::InvalidInput).into()), + } + let local = match domain { + SocketDomain::Ipv4 => SocketAddr::from(([0, 0, 0, 0], 0)), + SocketDomain::Ipv6 => SocketAddr::from(([0; 16], 0)), + }; + Ok(SocketDescriptor { + state: Mutex::new(SocketState::Open { + domain, + kind, + local, + }), + }) + } + + async fn socket_bind( &self, + socket: &SocketDescriptor, mut address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { + ) -> OperationResult<()> { cancellation.check()?; + let mut state = socket.state.lock().unwrap(); + let SocketState::Open { + domain, + kind: SocketType::Stream, + local, + } = &mut *state + else { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + }; + if SocketDomain::from(address) != *domain { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + if local.port() != 0 { + return Err(io::Error::from(io::ErrorKind::AddrInUse).into()); + } let mut listeners = self.listeners.lock().unwrap(); + // Expired weak registrations do not reserve their former addresses. listeners.retain(|_, incoming| incoming.strong_count() > 0); if address.port() == 0 { - let port = (10000..=u16::MAX) - .find(|port| { - let mut candidate = address; - candidate.set_port(*port); - !listeners.contains_key(&candidate) - }) - .ok_or_else(|| io::Error::from(io::ErrorKind::AddrNotAvailable))?; - address.set_port(port); - } - if listeners.contains_key(&address) { + address.set_port(available_port(&listeners, address)?); + } else if listeners.contains_key(&address) { return Err(io::Error::from(io::ErrorKind::AddrInUse).into()); } - let incoming = Arc::new(Incoming::default()); - listeners.insert(address, Arc::downgrade(&incoming)); - Ok(Listener { address, incoming }) - } - - fn listener_addr(&self, listener: &Listener) -> io::Result { - Ok(listener.address) + *local = address; + Ok(()) } - fn socket_local_addr(&self, socket: &Connection) -> io::Result { - Ok(socket.local) - } - fn socket_peer_addr(&self, socket: &Connection) -> io::Result { - Ok(socket.peer) - } - async fn io_wait( + async fn socket_listen( &self, - socket: &Connection, - interest: Interest, + socket: &SocketDescriptor, + _backlog: i32, cancellation: &Cancellation, ) -> OperationResult<()> { cancellation.check()?; - if matches!(interest, Interest::Writable) { - return Ok(()); + let mut state = socket.state.lock().unwrap(); + let (kind, mut local) = match &*state { + SocketState::Open { kind, local, .. } => (*kind, *local), + _ => return Err(io::Error::from(io::ErrorKind::InvalidInput).into()), + }; + if kind != SocketType::Stream { + return Err(io::Error::from(io::ErrorKind::Unsupported).into()); } - loop { - let ready = socket.input.ready.listen(); - { - let state = socket.input.state.lock().unwrap(); - if !state.bytes.is_empty() || state.writer_closed { - return Ok(()); - } - } - wait(ready, cancellation).await?; + let mut listeners = self.listeners.lock().unwrap(); + listeners.retain(|_, incoming| incoming.strong_count() > 0); + if local.port() == 0 { + local.set_port(available_port(&listeners, local)?); + } + if listeners.contains_key(&local) { + return Err(io::Error::from(io::ErrorKind::AddrInUse).into()); } + let incoming = Arc::new(Incoming::default()); + listeners.insert(local, Arc::downgrade(&incoming)); + *state = SocketState::Listening { + address: local, + incoming, + }; + Ok(()) } - async fn accept( - &self, - listener: &Listener, - cancellation: &Cancellation, - ) -> OperationResult<(Connection, SocketAddr)> { - cancellation.check()?; - loop { - // Register before checking state; await and notify outside locks. - let ready = listener.incoming.ready.listen(); - let socket = listener.incoming.sockets.lock().unwrap().pop_front(); - if let Some(socket) = socket { - let peer = socket.peer; - return Ok((socket, peer)); - } - wait(ready, cancellation).await?; + + fn socket_local_addr(&self, socket: &SocketDescriptor) -> io::Result { + match &*socket.state.lock().unwrap() { + SocketState::Open { local, .. } => Ok(*local), + SocketState::Listening { address, .. } => Ok(*address), + SocketState::Connected(connection) => Ok(connection.local), } } - async fn connect( + fn socket_peer_addr(&self, socket: &SocketDescriptor) -> io::Result { + match &*socket.state.lock().unwrap() { + SocketState::Connected(connection) => Ok(connection.peer), + _ => Err(io::Error::from(io::ErrorKind::NotConnected)), + } + } + + async fn socket_connect( &self, + socket: &SocketDescriptor, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { + ) -> OperationResult<()> { cancellation.check()?; let incoming = self .listeners @@ -371,39 +432,137 @@ impl Socket for Scheduler { .get(&address) .and_then(Weak::upgrade) .ok_or_else(|| io::Error::from(io::ErrorKind::ConnectionRefused))?; + + let mut state = socket.state.lock().unwrap(); + let (domain, kind, mut local) = match &*state { + SocketState::Open { + domain, + kind, + local, + } => (*domain, *kind, *local), + _ => return Err(io::Error::from(io::ErrorKind::InvalidInput).into()), + }; + if kind != SocketType::Stream { + return Err(io::Error::from(io::ErrorKind::Unsupported).into()); + } + if SocketDomain::from(address) != domain { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + if local.port() == 0 { + local = match domain { + SocketDomain::Ipv4 => SocketAddr::from(([127, 0, 0, 1], 40000)), + SocketDomain::Ipv6 => { + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], 40000)) + } + }; + } let input = Arc::new(Pipe::default()); let output = Arc::new(Pipe::default()); - let mut client_address = address; - client_address.set_port(40000); - let server = Connection { - local: address, - peer: client_address, - input: output.clone(), - output: input.clone(), + let server = SocketDescriptor { + state: Mutex::new(SocketState::Connected(Connection { + local: address, + peer: local, + input: output.clone(), + output: input.clone(), + })), }; incoming.sockets.lock().unwrap().push_back(server); incoming.ready.notify(usize::MAX); - Ok(Connection { + *state = SocketState::Connected(Connection { input, output, - local: client_address, + local, peer: address, - }) + }); + Ok(()) + } + + async fn socket_accept( + &self, + socket: &SocketDescriptor, + cancellation: &Cancellation, + ) -> OperationResult<(SocketDescriptor, SocketAddr)> { + cancellation.check()?; + let incoming = match &*socket.state.lock().unwrap() { + SocketState::Listening { incoming, .. } => Arc::clone(incoming), + _ => return Err(io::Error::from(io::ErrorKind::InvalidInput).into()), + }; + loop { + let ready = incoming.ready.listen(); + let accepted = incoming.sockets.lock().unwrap().pop_front(); + if let Some(socket) = accepted { + let peer = self.socket_peer_addr(&socket)?; + return Ok((socket, peer)); + } + wait(ready, cancellation).await?; + } } - async fn io_read( + async fn socket_wait( &self, - socket: &Connection, + socket: &SocketDescriptor, + interest: Interest, + cancellation: &Cancellation, + ) -> OperationResult<()> { + cancellation.check()?; + match interest { + Interest::Writable => Ok(()), + Interest::Readable => { + let (input, incoming) = match &*socket.state.lock().unwrap() { + SocketState::Connected(connection) => { + (Some(Arc::clone(&connection.input)), None) + } + SocketState::Listening { incoming, .. } => (None, Some(Arc::clone(incoming))), + SocketState::Open { .. } => { + return Err(io::Error::from(io::ErrorKind::NotConnected).into()); + } + }; + if let Some(input) = input { + loop { + let ready = input.ready.listen(); + { + let state = input.state.lock().unwrap(); + if !state.bytes.is_empty() || state.writer_closed { + return Ok(()); + } + } + wait(ready, cancellation).await?; + } + } + let incoming = incoming.unwrap(); + loop { + let ready = incoming.ready.listen(); + if !incoming.sockets.lock().unwrap().is_empty() { + return Ok(()); + } + wait(ready, cancellation).await?; + } + } + } + } + + async fn socket_read( + &self, + socket: &SocketDescriptor, mut buffer: B, cancellation: &Cancellation, ) -> BufferResult { if let Err(error) = cancellation.check() { return (Err(error.into()), buffer); } + let input = match &*socket.state.lock().unwrap() { + SocketState::Connected(connection) => Arc::clone(&connection.input), + _ => { + return ( + Err(io::Error::from(io::ErrorKind::NotConnected).into()), + buffer, + ); + } + }; loop { - let ready = socket.input.ready.listen(); + let ready = input.ready.listen(); { - let mut state = socket.input.state.lock().unwrap(); + let mut state = input.state.lock().unwrap(); let count = buffer .as_ref() .len() @@ -422,18 +581,27 @@ impl Socket for Scheduler { } } - async fn io_write( + async fn socket_write( &self, - socket: &Connection, + socket: &SocketDescriptor, buffer: B, cancellation: &Cancellation, ) -> BufferResult { if let Err(error) = cancellation.check() { return (Err(error.into()), buffer); } + let output = match &*socket.state.lock().unwrap() { + SocketState::Connected(connection) => Arc::clone(&connection.output), + _ => { + return ( + Err(io::Error::from(io::ErrorKind::NotConnected).into()), + buffer, + ); + } + }; let count = buffer.as_ref().len().min(self.write_limit); { - let mut state = socket.output.state.lock().unwrap(); + let mut state = output.state.lock().unwrap(); if state.reader_closed { return ( Err(io::Error::from(io::ErrorKind::BrokenPipe).into()), @@ -442,7 +610,7 @@ impl Socket for Scheduler { } state.bytes.extend(&buffer.as_ref()[..count]); } - socket.output.ready.notify(usize::MAX); + output.ready.notify(usize::MAX); (Ok(count), buffer) } } @@ -468,7 +636,7 @@ async fn wait( impl Scheduler { fn read_file_at( &self, - file: &FileHandle, + file: &FileDescriptor, mut buffer: B, offset: u64, cancellation: &Cancellation, @@ -500,7 +668,7 @@ impl Scheduler { fn write_file_at( &self, - file: &FileHandle, + file: &FileDescriptor, buffer: B, offset: u64, cancellation: &Cancellation, diff --git a/crates/executor/examples/interface_probe/tests.rs b/crates/executor/examples/interface_probe/tests.rs index 5d340c2..d8e5c3b 100644 --- a/crates/executor/examples/interface_probe/tests.rs +++ b/crates/executor/examples/interface_probe/tests.rs @@ -3,7 +3,7 @@ use super::*; use futures_lite::future::block_on; -use socketry_executor::{IoBufMut, SyncMode}; +use socketry_executor::{IoBufMut, SocketDomain, SocketProtocol, SocketType, SyncMode}; use std::future::Future; use std::pin::pin; use std::sync::{ @@ -12,6 +12,39 @@ use std::sync::{ }; use std::task::{Context, Wake, Waker}; +async fn open_listener( + scheduler: &simulated::Scheduler, + address: std::net::SocketAddr, +) -> OperationResult { + let socket = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &NEVER, + ) + .await?; + scheduler.socket_bind(&socket, address, &NEVER).await?; + scheduler.socket_listen(&socket, 128, &NEVER).await?; + Ok(socket) +} + +async fn open_client( + scheduler: &simulated::Scheduler, + address: std::net::SocketAddr, +) -> OperationResult { + let socket = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &NEVER, + ) + .await?; + scheduler.socket_connect(&socket, address, &NEVER).await?; + Ok(socket) +} + #[path = "tests/stateful_file.rs"] mod stateful_file; @@ -304,32 +337,33 @@ fn pending_accept_and_receive_wake_and_drop_produces_eof() { let waker = Waker::from(wakes.clone()); let mut context = Context::from_waker(&waker); let scheduler = simulated::Scheduler::new(2, 2); - let listener = scheduler - .socket_listen("127.0.0.1:0".parse().unwrap(), &NEVER) + let listener = open_listener(&scheduler, "127.0.0.1:0".parse().unwrap()) .await .unwrap(); - let address = scheduler.listener_addr(&listener).unwrap(); - let mut accept = pin!(scheduler.accept(&listener, &NEVER)); + let address = scheduler.socket_local_addr(&listener).unwrap(); + let mut accept = pin!(scheduler.socket_accept(&listener, &NEVER)); assert!(accept.as_mut().poll(&mut context).is_pending()); - let client = scheduler.connect(address, &NEVER).await.unwrap(); + let client = open_client(&scheduler, address).await.unwrap(); assert!(wakes.0.swap(0, Ordering::Relaxed) > 0); let (server, _) = accept.await.unwrap(); - let mut read = pin!(scheduler.io_read(&server, vec![0; 8], &NEVER)); + let mut read = pin!(scheduler.socket_read(&server, vec![0; 8], &NEVER)); assert!(read.as_mut().poll(&mut context).is_pending()); - let (result, _) = scheduler.io_write(&client, b"abc".to_vec(), &NEVER).await; + let (result, _) = scheduler + .socket_write(&client, b"abc".to_vec(), &NEVER) + .await; assert_eq!(result.unwrap(), 2); assert!(wakes.0.swap(0, Ordering::Relaxed) > 0); let (result, buffer) = read.await; assert_eq!(result.unwrap(), 2); assert_eq!(&buffer[..2], b"ab"); - let mut read = pin!(scheduler.io_read(&server, buffer, &NEVER)); + let mut read = pin!(scheduler.socket_read(&server, buffer, &NEVER)); assert!(read.as_mut().poll(&mut context).is_pending()); drop(client); assert!(wakes.0.swap(0, Ordering::Relaxed) > 0); let (result, buffer) = read.await; assert_eq!(result.unwrap(), 0); let allocation = buffer.as_ptr(); - let (result, buffer) = scheduler.io_write(&server, buffer, &NEVER).await; + let (result, buffer) = scheduler.socket_write(&server, buffer, &NEVER).await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); assert_eq!(buffer.as_ptr(), allocation); }); @@ -339,22 +373,31 @@ fn pending_accept_and_receive_wake_and_drop_produces_eof() { fn listener_drop_releases_address_and_pending_connections() { block_on(async { let scheduler = simulated::Scheduler::new(2, 2); - let listener = scheduler - .socket_listen("127.0.0.1:12345".parse().unwrap(), &NEVER) + let listener = open_listener(&scheduler, "127.0.0.1:12345".parse().unwrap()) + .await + .unwrap(); + let address = scheduler.socket_local_addr(&listener).unwrap(); + let duplicate = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &NEVER, + ) .await .unwrap(); - let address = scheduler.listener_addr(&listener).unwrap(); assert!( - matches!(scheduler.socket_listen(address, &NEVER).await, Err(error) if error.kind() == io::ErrorKind::AddrInUse) + matches!(scheduler.socket_bind(&duplicate, address, &NEVER).await, Err(error) if error.kind() == io::ErrorKind::AddrInUse) ); - let client = scheduler.connect(address, &NEVER).await.unwrap(); + let client = open_client(&scheduler, address).await.unwrap(); drop(listener); assert!( - matches!(scheduler.connect(address, &NEVER).await, Err(error) if error.kind() == io::ErrorKind::ConnectionRefused) + matches!(open_client(&scheduler, address).await, Err(error) if error.kind() == io::ErrorKind::ConnectionRefused) ); - let (result, _) = scheduler.io_read(&client, vec![0; 1], &NEVER).await; + let (result, _) = scheduler.socket_read(&client, vec![0; 1], &NEVER).await; assert_eq!(result.unwrap(), 0); - scheduler.socket_listen(address, &NEVER).await.unwrap(); + let listener = open_listener(&scheduler, address).await.unwrap(); + assert_eq!(scheduler.socket_local_addr(&listener).unwrap(), address); }); } diff --git a/crates/executor/examples/portable_io.rs b/crates/executor/examples/portable_io.rs index 1f1acbe..76c391c 100644 --- a/crates/executor/examples/portable_io.rs +++ b/crates/executor/examples/portable_io.rs @@ -6,7 +6,7 @@ static NEVER: socketry_executor::Cancellation = socketry_executor::Cancellation::never(); #[cfg(any(feature = "native", feature = "tokio"))] -use socketry_executor::{Socket, Spawn}; +use socketry_executor::{Socket, SocketDomain, SocketProtocol, SocketType, Spawn}; #[cfg(any(feature = "native", feature = "tokio"))] use std::io; @@ -16,18 +16,29 @@ where SchedulerType: Socket + Spawn + Clone + 'static, { let listener = scheduler - .socket_listen("127.0.0.1:0".parse().unwrap(), &NEVER) + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &NEVER, + ) .await?; - let address = scheduler.listener_addr(&listener)?; + scheduler + .socket_bind(&listener, "127.0.0.1:0".parse().unwrap(), &NEVER) + .await?; + scheduler.socket_listen(&listener, 128, &NEVER).await?; + let address = scheduler.socket_local_addr(&listener)?; let server_scheduler = scheduler.clone(); let server = scheduler .spawn(async move { - let (socket, _) = server_scheduler.accept(&listener, &NEVER).await?; - let (result, buffer) = server_scheduler.io_read(&socket, vec![0; 1], &NEVER).await; + let (socket, _) = server_scheduler.socket_accept(&listener, &NEVER).await?; + let (result, buffer) = server_scheduler + .socket_read(&socket, vec![0; 1], &NEVER) + .await; if result? != 1 { return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); } - let (result, _) = server_scheduler.io_write(&socket, buffer, &NEVER).await; + let (result, _) = server_scheduler.socket_write(&socket, buffer, &NEVER).await; if result? != 1 { return Err(io::Error::from(io::ErrorKind::WriteZero)); } @@ -35,12 +46,20 @@ where }) .map_err(io::Error::other)?; - let socket = scheduler.connect(address, &NEVER).await?; - let (result, buffer) = scheduler.io_write(&socket, vec![42], &NEVER).await; + let socket = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &NEVER, + ) + .await?; + scheduler.socket_connect(&socket, address, &NEVER).await?; + let (result, buffer) = scheduler.socket_write(&socket, vec![42], &NEVER).await; if result? != 1 { return Err(io::Error::from(io::ErrorKind::WriteZero)); } - let (result, buffer) = scheduler.io_read(&socket, buffer, &NEVER).await; + let (result, buffer) = scheduler.socket_read(&socket, buffer, &NEVER).await; if result? != 1 { return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); } diff --git a/crates/executor/readme.md b/crates/executor/readme.md index 838e0e0..51f1e0d 100644 --- a/crates/executor/readme.md +++ b/crates/executor/readme.md @@ -168,12 +168,14 @@ Queues allocate backing storage as needed. Rescheduling reuses the existing task The public `scheduler` module contains portable `Socket`, `File`, and `Clock` traits, the Socketry implementation in `socketry.rs`, the optional Tokio adapter in `tokio.rs`, and native implementations under `selector/`. -- `native` (default) supplies TCP connect/accept/read/write/readiness through async-io's process-wide reactor: epoll on Linux, kqueue on Apple/BSD, and IOCP/AFD socket readiness on Windows. The three platform modules expose the shared implementation; they do not duplicate its registration machinery. +- `native` (default) supplies nonblocking IPv4/IPv6 stream and datagram sockets through async-io's process-wide reactor: epoll on Linux, kqueue on Apple/BSD, and IOCP/AFD socket readiness on Windows. The three platform modules expose the shared implementation; they do not duplicate its registration machinery. - `io-uring` selects a dedicated Linux completion selector for socket and file reads/writes. Connection setup, accept, readiness and timers use async-io. On other supported platforms this feature leaves the platform default intact. -- `tokio` adds an adapter using a supplied `tokio::runtime::Handle`. It does not construct or drive a runtime. Enable that runtime's I/O and time facilities. -- With default features disabled, the executor and public contracts still build. Enabling only `tokio` avoids the native I/O dependencies. +- `tokio` adds an adapter using a supplied `tokio::runtime::Handle`. It uses Tokio `AsyncFd` on Unix. Windows uses async-io's process-wide reactor for generic socket readiness because Tokio has no matching generic socket wrapper; this fallback is not driven by Tokio's I/O driver. The adapter does not construct or drive a runtime. Enable that runtime's I/O and time facilities for the operations that use it. +- With default features disabled, the executor and public contracts still build. Enabling only `tokio` omits the native blocking and io\_uring backends; async-io remains a dependency for Tokio's generic Windows socket-readiness fallback. -`Scheduler` and `SchedulerHandle` implement the traits. `file_open` returns `File::File`; `socket_listen` creates, binds, and listens on a TCP socket, and `listener_addr` reports its bound address. `connect` and `accept` return associated socket resources, with `socket_local_addr` and `socket_peer_addr` for inspection. Importing existing standard-library TCP resources uses the separate optional `SocketRegistration` trait. Returned operation futures are Send. Registrations remain with their original reactor as tasks migrate; they are not re-created per poll. Tokio resources passed to an adapter for another runtime return InvalidInput. +`Scheduler` and `SchedulerHandle` implement the traits. `file_open` returns `File::File`; `socket_open` returns the backend's single associated socket resource, `Socket::Socket`. Call `socket_bind` and then `socket_listen(socket, backlog, cancellation)` to listen, or `socket_connect(socket, address, cancellation)` to connect an opened socket. `socket_accept` returns another resource of the same type. Native and Tokio use non-`Clone` `SocketDescriptor` values. Importing existing standard-library TCP resources uses the separate optional `SocketRegistration` trait. Returned operation futures are Send. Registrations remain with their original reactor as tasks migrate; they are not re-created per poll. Tokio resources passed to an adapter for another runtime return InvalidInput. + +The initial API selects IPv4 or IPv6, stream or datagram, and an optional TCP or UDP protocol. Connected datagram reads and writes preserve message boundaries. The higher-level `Socket` trait deliberately does not provide protocol buffering: line, frame, and message semantics belong in protocol adapters built above it. Generic static dispatch is the default, with dynamic dispatch available to consumers that need runtime selection or type erasure. Read/write operations take an owned `IoBuf` (`IoBufMut` for reads) and return `BufferResult = (OperationResult, B)`, including errors and cancellation. `OperationError` distinguishes `Cancelled` and `Io`. `Vec` and `Box<[u8]>` are supported. `buffer.slice(range)` creates an owned `BufferSlice` without copying; `into_inner()` recovers its original owner for reuse. Read buffers must have an initialized length (`vec![0; capacity]`); capacity alone supplies no writable bytes. Buffer length is unchanged; only the first returned byte count contains new data. Reads and writes can be partial. Each read/write call is one operation, not a read-exact/write-all convenience method. @@ -270,7 +272,7 @@ async fn read_header( `File::file_close(file).await` consumes the handle, waits for bounded admission and OS completion, and reports close errors. `file_close_submit(file).await` waits only for admission; accepted work finishes even if its waiter is dropped, and graceful scheduler shutdown drains it. Neither form takes a cancellation token. Close does not synchronize data and does not retry an OS close error. `_submit` execution errors are discarded in the current backends. -Native and Tokio resources are owned, non-`Clone` `FileHandle` values with private backend retention. Existing concrete callers using `Arc` must instead move an OS file into `FileHandle::from(file)` or use `file_open`. `AsRef` permits synchronous platform access; generic consumers continue to use `S::File`. External `File` implementations must provide both close methods. +Native and Tokio resources are owned, non-`Clone` `FileDescriptor` values with private backend retention. Existing concrete callers using `Arc` must instead move an OS file into `FileDescriptor::from(file)` or use `file_open`. `AsRef` permits synchronous platform access; generic consumers continue to use `S::File`. External `File` implementations must provide both close methods. Finish outstanding operations before explicit close. Request cancellation, keep awaiting the operation, then close; dropping its future alone does not establish that the kernel or blocking worker has finished. Closing a handle still retained by an abandoned operation panics. Ordinary `Drop` is a fallback that releases ownership and discards close errors without joining outstanding operations. @@ -298,7 +300,7 @@ An unexpected io\_uring selector failure after submission cannot return memory w Readiness registrations and buffers are reused. io\_uring currently uses a dedicated selector thread, a command channel, one boxed request for its concrete buffer type, and one completion channel per operation; it does not pool operation records or register buffers. The Tokio file fallback retains an Arc/Mutex buffer owner to return the buffer even when a queued blocking job is cancelled during runtime shutdown. Neither fallback copies the buffer bytes just to transfer ownership. -Native overlapped Windows file operations, arbitrary descriptor/UDP APIs, operation and buffer pools, and scheduler integration with `socketry-timers` remain future work. There is no thread-local non-Send task facility. No I/O throughput claim has been established by benchmarks. +Native overlapped Windows file operations, raw descriptor import beyond std TCP, socket message/address APIs, operation and buffer pools, and scheduler integration with `socketry-timers` remain future work. There is no thread-local non-Send task facility. No I/O throughput claim has been established by benchmarks. The portable example uses the same generic TCP exchange with either runtime: diff --git a/crates/executor/src/file_handle.rs b/crates/executor/src/file_descriptor.rs similarity index 82% rename from crates/executor/src/file_handle.rs rename to crates/executor/src/file_descriptor.rs index c095953..3bf9d8a 100644 --- a/crates/executor/src/file_handle.rs +++ b/crates/executor/src/file_descriptor.rs @@ -8,28 +8,28 @@ use std::{fs::File, sync::Arc}; /// The backend privately retains the file while an operation uses it. Dropping an operation future does not establish completion; request cancellation and await the operation before explicitly closing the file. Ordinary handle destruction releases ownership without waiting or reporting close errors. /// /// ```compile_fail -/// use socketry_executor::FileHandle; -/// fn duplicate(file: &FileHandle) -> FileHandle { +/// use socketry_executor::FileDescriptor; +/// fn duplicate(file: &FileDescriptor) -> FileDescriptor { /// file.clone() // Cloning this reference cannot produce another owned handle. /// } /// ``` #[derive(Debug)] -pub struct FileHandle(pub(crate) Arc); +pub struct FileDescriptor(pub(crate) Arc); -impl From for FileHandle { +impl From for FileDescriptor { fn from(file: File) -> Self { Self(Arc::new(file)) } } -impl AsRef for FileHandle { +impl AsRef for FileDescriptor { fn as_ref(&self) -> &File { &self.0 } } #[cfg(any(feature = "native", feature = "tokio"))] -impl FileHandle { +impl FileDescriptor { pub(crate) fn retain(&self) -> Arc { Arc::clone(&self.0) } diff --git a/crates/executor/src/lib.rs b/crates/executor/src/lib.rs index 31bbd80..a612f21 100644 --- a/crates/executor/src/lib.rs +++ b/crates/executor/src/lib.rs @@ -17,7 +17,7 @@ mod buffer_slice; mod cancellation; mod cancelled; mod defer_cancel; -mod file_handle; +mod file_descriptor; mod file_options; mod io_buf; mod io_buf_mut; @@ -26,6 +26,9 @@ mod operation_error; pub mod scheduler; #[cfg(all(unix, feature = "signals", any(feature = "native", feature = "tokio")))] mod signal_registration; +mod socket_domain; +mod socket_protocol; +mod socket_type; mod spawn; mod sync_mode; mod task; @@ -40,7 +43,7 @@ pub use buffer_slice::BufferSlice; pub use cancellation::Cancellation; pub use cancelled::Cancelled; pub use defer_cancel::defer_cancel; -pub use file_handle::FileHandle; +pub use file_descriptor::FileDescriptor; pub use file_options::FileOptions; pub use io_buf::IoBuf; pub use io_buf_mut::IoBufMut; @@ -54,6 +57,12 @@ pub use scheduler::{ pub use signal_hook::consts::signal; #[cfg(all(unix, feature = "signals", any(feature = "native", feature = "tokio")))] pub use signal_registration::SignalRegistration; +pub use socket_domain::SocketDomain; +pub use socket_protocol::SocketProtocol; +pub use socket_type::SocketType; pub use spawn::{Spawn, SpawnError}; pub use sync_mode::SyncMode; pub use task::{Task, TaskError, TaskHandle, yield_now}; + +#[cfg(feature = "native")] +pub use scheduler::selector::readiness::SocketDescriptor; diff --git a/crates/executor/src/scheduler.rs b/crates/executor/src/scheduler.rs index 91dc79a..c1d92f3 100644 --- a/crates/executor/src/scheduler.rs +++ b/crates/executor/src/scheduler.rs @@ -49,3 +49,6 @@ pub use clock::Clock; mod signals; pub use signals::Signals; + +#[cfg(any(feature = "native", feature = "tokio"))] +mod socket_io; diff --git a/crates/executor/src/scheduler/file_close.rs b/crates/executor/src/scheduler/file_close.rs index 4e072cd..f313457 100644 --- a/crates/executor/src/scheduler/file_close.rs +++ b/crates/executor/src/scheduler/file_close.rs @@ -1,21 +1,21 @@ // Released under the MIT License. // Copyright, 2026, by Samuel Williams. -use crate::{BlockingError, FileHandle, TaskError, blocking_pool::BlockingPool}; +use crate::{BlockingError, FileDescriptor, TaskError, blocking_pool::BlockingPool}; use futures_channel::oneshot; use std::io; type Completion = oneshot::Receiver, TaskError>>; -pub(crate) async fn close(pool: &BlockingPool, file: FileHandle) -> io::Result<()> { +pub(crate) async fn close(pool: &BlockingPool, file: FileDescriptor) -> io::Result<()> { wait(submit(pool, file).await?).await } -pub(crate) async fn submit_only(pool: &BlockingPool, file: FileHandle) -> io::Result<()> { +pub(crate) async fn submit_only(pool: &BlockingPool, file: FileDescriptor) -> io::Result<()> { submit(pool, file).await.map(drop) } -async fn submit(pool: &BlockingPool, file: FileHandle) -> io::Result { +async fn submit(pool: &BlockingPool, file: FileDescriptor) -> io::Result { // Establish exclusivity before admission. Never wait for or interrupt other uses of this handle. let file = file.into_file(); pool.submit(move || super::file_io::close(file)) diff --git a/crates/executor/src/scheduler/file_close/tests.rs b/crates/executor/src/scheduler/file_close/tests.rs index 5527b5a..e39ca7e 100644 --- a/crates/executor/src/scheduler/file_close/tests.rs +++ b/crates/executor/src/scheduler/file_close/tests.rs @@ -9,7 +9,7 @@ use std::{panic::AssertUnwindSafe, sync::Arc}; #[test] fn exclusive_close_is_lazy_and_reports_closed_admission() { let pool = BlockingPool::new(BlockingOptions::default()); - let file = FileHandle::from(tempfile::tempfile().unwrap()); + let file = FileDescriptor::from(tempfile::tempfile().unwrap()); let retained = Arc::downgrade(&file.0); let close = submit(&pool, file); assert_eq!(retained.strong_count(), 1); @@ -40,7 +40,7 @@ fn exclusive_close_is_lazy_and_reports_closed_admission() { #[test] fn outstanding_use_panics_without_closing_the_retained_file() { let pool = BlockingPool::new(BlockingOptions::default()); - let file = FileHandle::from(tempfile::tempfile().unwrap()); + let file = FileDescriptor::from(tempfile::tempfile().unwrap()); let retained = file.retain(); let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| block_on(submit(&pool, file)))); assert!(outcome.is_err()); diff --git a/crates/executor/src/scheduler/selector/epoll.rs b/crates/executor/src/scheduler/selector/epoll.rs index 6295251..c23e7e1 100644 --- a/crates/executor/src/scheduler/selector/epoll.rs +++ b/crates/executor/src/scheduler/selector/epoll.rs @@ -3,4 +3,4 @@ //! Linux/Android readiness selector. `async-io` uses `polling`'s epoll backend. //! Reads and writes share the nonblocking readiness fallback. -pub use super::readiness::{Listener, Selector, Socket}; +pub use super::readiness::{Selector, SocketDescriptor}; diff --git a/crates/executor/src/scheduler/selector/io_uring.rs b/crates/executor/src/scheduler/selector/io_uring.rs index 397995b..5549df2 100644 --- a/crates/executor/src/scheduler/selector/io_uring.rs +++ b/crates/executor/src/scheduler/selector/io_uring.rs @@ -14,9 +14,9 @@ //! buffers with the kernel. //! //! Allocation and truncation use native requests when their opcodes are available, otherwise the blocking file fallback preserves the same public contract. -use super::readiness::{self, Listener, Socket}; +use super::readiness::{self, SocketDescriptor}; use crate::scheduler::{BufferResult, Clock, File, Interest, Socket as SocketOperations}; -use crate::{AllocationMode, FileAllocate, FileHandle, SyncMode}; +use crate::{AllocationMode, FileAllocate, FileDescriptor, SyncMode}; use crate::{Cancellation, FileOptions, IoBuf, IoBufMut, OperationResult, SocketRegistration}; use event_listener::{Event as CompletionEvent, Listener as _}; use futures_channel::oneshot; @@ -122,7 +122,7 @@ where } enum Resource { - Socket(Socket), + Socket(SocketDescriptor), // None uses the shared OS cursor; Some is a validated explicit offset. File(Arc, Option), } @@ -148,7 +148,7 @@ impl Request { .expect("request retains its resource") { Resource::Socket(socket) => { - let descriptor = types::Fd(socket.0.get_ref().as_raw_fd()); + let descriptor = types::Fd(socket.get_ref().as_raw_fd()); if self.write { opcode::Send::new(descriptor, pointer, length) .flags(libc::MSG_NOSIGNAL) @@ -554,7 +554,7 @@ impl Selector { buffer, ); } - if buffer.as_ref().is_empty() { + if buffer.as_ref().is_empty() && matches!(&resource, Resource::File(_, _)) { return (Ok(0), buffer); } let identifier = match self.next_identifier() { @@ -576,7 +576,7 @@ impl Selector { async fn file_operation( &self, - file: &FileHandle, + file: &FileDescriptor, operation: FileOperation, cancellation: &Cancellation, ) -> OperationResult<()> { @@ -661,59 +661,84 @@ fn operation_result(result: io::Result, cancellation: &Cancellation) -> Op } } impl SocketRegistration for Selector { - fn register_socket(&self, socket: TcpStream) -> io::Result { + fn register_socket(&self, socket: TcpStream) -> io::Result { self.check_open()?; readiness::Selector.register_socket(socket) } - fn register_listener(&self, listener: TcpListener) -> io::Result { + fn register_listener(&self, listener: TcpListener) -> io::Result { self.check_open()?; readiness::Selector.register_listener(listener) } } impl SocketOperations for Selector { - type Socket = Socket; - type Listener = Listener; - async fn socket_listen( + type Socket = SocketDescriptor; + + async fn socket_open( + &self, + domain: crate::SocketDomain, + kind: crate::SocketType, + protocol: Option, + cancellation: &Cancellation, + ) -> OperationResult { + self.check_open()?; + readiness::Selector + .socket_open(domain, kind, protocol, cancellation) + .await + } + async fn socket_bind( &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { + ) -> OperationResult<()> { self.check_open()?; readiness::Selector - .socket_listen(address, cancellation) + .socket_bind(socket, address, cancellation) .await } - fn listener_addr(&self, listener: &Listener) -> io::Result { + async fn socket_listen( + &self, + socket: &Self::Socket, + backlog: i32, + cancellation: &Cancellation, + ) -> OperationResult<()> { self.check_open()?; - listener.local_addr() + readiness::Selector + .socket_listen(socket, backlog, cancellation) + .await } - fn socket_local_addr(&self, socket: &Socket) -> io::Result { + fn socket_local_addr(&self, socket: &Self::Socket) -> io::Result { self.check_open()?; - socket.local_addr() + readiness::Selector.socket_local_addr(socket) } - fn socket_peer_addr(&self, socket: &Socket) -> io::Result { + fn socket_peer_addr(&self, socket: &Self::Socket) -> io::Result { self.check_open()?; - socket.peer_addr() + readiness::Selector.socket_peer_addr(socket) } - async fn connect( + async fn socket_connect( &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { + ) -> OperationResult<()> { self.check_open()?; - readiness::Selector.connect(address, cancellation).await + readiness::Selector + .socket_connect(socket, address, cancellation) + .await } - async fn accept( + async fn socket_accept( &self, - listener: &Listener, + socket: &Self::Socket, cancellation: &Cancellation, - ) -> OperationResult<(Socket, SocketAddr)> { + ) -> OperationResult<(Self::Socket, SocketAddr)> { self.check_open()?; - readiness::Selector.accept(listener, cancellation).await + readiness::Selector + .socket_accept(socket, cancellation) + .await } - async fn io_read( + async fn socket_read( &self, - socket: &Socket, + socket: &SocketDescriptor, buffer: B, cancellation: &Cancellation, ) -> BufferResult { @@ -721,7 +746,7 @@ impl SocketOperations for Selector { buffer, |buffer| { self.operation( - Resource::Socket(socket.clone()), + Resource::Socket(socket.retain()), buffer, false, |buffer| buffer.as_mut().as_mut_ptr(), @@ -730,7 +755,7 @@ impl SocketOperations for Selector { }, || async { readiness::Selector - .io_wait(socket, Interest::Readable, cancellation) + .socket_wait(socket, Interest::Readable, cancellation) .await .map_err(Into::into) }, @@ -739,9 +764,9 @@ impl SocketOperations for Selector { .await; (operation_result(result, cancellation), buffer) } - async fn io_write( + async fn socket_write( &self, - socket: &Socket, + socket: &SocketDescriptor, buffer: B, cancellation: &Cancellation, ) -> BufferResult { @@ -749,7 +774,7 @@ impl SocketOperations for Selector { buffer, |buffer| { self.operation( - Resource::Socket(socket.clone()), + Resource::Socket(socket.retain()), buffer, true, |buffer| buffer.as_ref().as_ptr().cast_mut(), @@ -758,7 +783,7 @@ impl SocketOperations for Selector { }, || async { readiness::Selector - .io_wait(socket, Interest::Writable, cancellation) + .socket_wait(socket, Interest::Writable, cancellation) .await .map_err(Into::into) }, @@ -767,15 +792,15 @@ impl SocketOperations for Selector { .await; (operation_result(result, cancellation), buffer) } - async fn io_wait( + async fn socket_wait( &self, - socket: &Socket, + socket: &SocketDescriptor, interest: Interest, cancellation: &Cancellation, ) -> OperationResult<()> { self.check_open()?; readiness::Selector - .io_wait(socket, interest, cancellation) + .socket_wait(socket, interest, cancellation) .await } } @@ -811,7 +836,7 @@ impl FileAllocate for Selector { } impl File for Selector { - type File = FileHandle; + type File = FileDescriptor; async fn file_close(&self, file: Self::File) -> io::Result<()> { self.check_open()?; crate::scheduler::file_close::close(&self.controller.cleanup, file).await diff --git a/crates/executor/src/scheduler/selector/io_uring/tests.rs b/crates/executor/src/scheduler/selector/io_uring/tests.rs index d07bc66..cc9df88 100644 --- a/crates/executor/src/scheduler/selector/io_uring/tests.rs +++ b/crates/executor/src/scheduler/selector/io_uring/tests.rs @@ -256,7 +256,7 @@ fn initialization_propagates_each_system_failure() { fn rejected_operations_preserve_buffer_allocations() { async_io::block_on(async { let (selector, _peer) = disconnected_selector(); - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); let (_, buffer) = selector.file_read_at(&file, Vec::new(), 0, &NEVER).await; assert!(buffer.is_empty()); let buffer = vec![7; 8]; @@ -361,8 +361,18 @@ fn closed_selector_rejects_its_direct_network_operations() { selector.wait_closed(); assert!(selector.register_socket(raw_socket).is_err()); assert!(selector.register_listener(raw_listener).is_err()); - assert!(selector.socket_listen(address, &NEVER).await.is_err()); - assert!(selector.listener_addr(&listener).is_err()); + assert!( + selector + .socket_open( + crate::SocketDomain::Ipv4, + crate::SocketType::Stream, + Some(crate::SocketProtocol::Tcp), + &NEVER, + ) + .await + .is_err() + ); + assert!(selector.socket_local_addr(&listener).is_err()); assert!(selector.socket_local_addr(&socket).is_err()); assert!(selector.socket_peer_addr(&socket).is_err()); let directory = tempfile::tempdir().unwrap(); @@ -374,7 +384,7 @@ fn closed_selector_rejects_its_direct_network_operations() { .is_err() ); assert!(!path.exists()); - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); assert_eq!( selector .file_seek(&file, SeekFrom::Start(1), &NEVER) @@ -404,11 +414,16 @@ fn closed_selector_rejects_its_direct_network_operations() { ); } assert_eq!(file.as_ref().metadata().unwrap().len(), 0); - assert!(selector.connect(address, &NEVER).await.is_err()); - assert!(selector.accept(&listener, &NEVER).await.is_err()); assert!( selector - .io_wait(&socket, Interest::Readable, &NEVER) + .socket_connect(&socket, address, &NEVER) + .await + .is_err() + ); + assert!(selector.socket_accept(&listener, &NEVER).await.is_err()); + assert!( + selector + .socket_wait(&socket, Interest::Readable, &NEVER) .await .is_err() ); @@ -540,7 +555,7 @@ fn socket_readiness_retries_preserve_allocations() { let buffer = vec![0; 5]; let allocation = buffer.as_ptr(); let failures = Failures::new([("operation", io::ErrorKind::WouldBlock.into())]); - let (result, buffer) = selector.io_read(&socket, buffer, &NEVER).await; + let (result, buffer) = selector.socket_read(&socket, buffer, &NEVER).await; let count = result.unwrap(); assert!(count > 0); assert_eq!(&buffer[..count], &b"ready"[..count]); @@ -550,7 +565,7 @@ fn socket_readiness_retries_preserve_allocations() { let buffer = b"write".to_vec(); let allocation = buffer.as_ptr(); let _failures = Failures::new([("operation", io::ErrorKind::WouldBlock.into())]); - let (result, buffer) = selector.io_write(&socket, buffer, &NEVER).await; + let (result, buffer) = selector.socket_write(&socket, buffer, &NEVER).await; let count = result.unwrap(); assert!(count > 0); assert_eq!(buffer.as_ptr(), allocation); @@ -609,7 +624,7 @@ fn cooperative_cancellation_waits_for_original_completion_and_preserves_progress }), }), }; - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); let cancelled = Cancellation::new(); let dropped = Arc::new(AtomicUsize::new(0)); let buffer = CountedBuffer { @@ -662,7 +677,7 @@ fn cancellation_before_submission_and_retry_returns_owned_buffers() { let cancellation = Cancellation::new(); cancellation.cancel(); let (selector, _peer) = disconnected_selector(); - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); let buffer = vec![5; 3]; let allocation = buffer.as_ptr(); let (result, buffer) = selector @@ -726,7 +741,7 @@ fn failed_selector_retains_in_flight_sync_file_before_notifying_caller() { fn sync_rejects_disconnected_and_exhausted_selectors() { async_io::block_on(async { let (selector, _peer) = disconnected_selector(); - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); assert_eq!( selector .file_sync(&file, SyncMode::All, &NEVER) @@ -829,7 +844,7 @@ fn buffer_free_file_operations_are_lazy_and_retain_the_file_until_original_compl }), }), }; - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); let cancellation = Cancellation::new(); drop(run_file_operation( &selector, @@ -883,7 +898,7 @@ fn buffer_free_file_operations_are_lazy_and_retain_the_file_until_original_compl async fn run_file_operation( selector: &Selector, - file: &FileHandle, + file: &FileDescriptor, operation: FileOperation, cancellation: &Cancellation, ) -> OperationResult<()> { @@ -986,7 +1001,7 @@ fn truncate_preparation_is_inert_and_failed_admission_preserves_file() { assert_eq!(entry.get_opcode(), u32::from(opcode::Ftruncate::CODE)); assert_eq!(request.file.as_ref().unwrap().metadata().unwrap().len(), 0); let (selector, _peer) = disconnected_selector(); - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); assert_eq!( selector .file_truncate(&file, 13, &NEVER) @@ -1028,7 +1043,7 @@ fn allocation_native_and_fallback_preserve_contract_and_reserve_blocks() { for backend in [&selector, &fallback] { let directory = tempfile::tempdir().unwrap(); allocation_contract::check(backend, directory.path()).await; - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); let before = file.as_ref().metadata().unwrap().blocks(); backend .file_allocate(&file, 4096, 128 * 1024, AllocationMode::KeepSize, &NEVER) @@ -1039,7 +1054,7 @@ fn allocation_native_and_fallback_preserve_contract_and_reserve_blocks() { } selector.shutdown().await; for backend in [&selector, &fallback] { - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); assert_eq!( backend .file_allocate(&file, 0, 4096, AllocationMode::Extend, &NEVER) @@ -1069,7 +1084,7 @@ fn allocation_preparation_is_inert_and_failed_admission_preserves_file() { assert_eq!(request.file.as_ref().unwrap().metadata().unwrap().len(), 0); } let (selector, _peer) = disconnected_selector(); - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); assert_eq!( selector .file_allocate(&file, 0, 4096, AllocationMode::Extend, &NEVER) diff --git a/crates/executor/src/scheduler/selector/iocp.rs b/crates/executor/src/scheduler/selector/iocp.rs index 46bdc7e..46e3d39 100644 --- a/crates/executor/src/scheduler/selector/iocp.rs +++ b/crates/executor/src/scheduler/selector/iocp.rs @@ -4,4 +4,4 @@ //! Windows socket readiness selector, using `polling`'s IOCP/AFD backend through //! `async-io`. File operations use the blocking pool. This does not yet submit //! native overlapped file reads or writes. -pub use super::readiness::{Listener, Selector, Socket}; +pub use super::readiness::{Selector, SocketDescriptor}; diff --git a/crates/executor/src/scheduler/selector/kqueue.rs b/crates/executor/src/scheduler/selector/kqueue.rs index 05a474e..30b7319 100644 --- a/crates/executor/src/scheduler/selector/kqueue.rs +++ b/crates/executor/src/scheduler/selector/kqueue.rs @@ -3,4 +3,4 @@ //! Apple/BSD readiness selector. `async-io` uses `polling`'s kqueue backend. //! Registrations are reused across operations and task migration. -pub use super::readiness::{Listener, Selector, Socket}; +pub use super::readiness::{Selector, SocketDescriptor}; diff --git a/crates/executor/src/scheduler/selector/readiness.rs b/crates/executor/src/scheduler/selector/readiness.rs index 21afefa..79d708c 100644 --- a/crates/executor/src/scheduler/selector/readiness.rs +++ b/crates/executor/src/scheduler/selector/readiness.rs @@ -13,39 +13,16 @@ use crate::scheduler::{BufferResult, Clock, File, Interest, Socket as SocketOper #[cfg(target_os = "linux")] use crate::{AllocationMode, FileAllocate}; use crate::{Cancellation, FileOptions, IoBuf, IoBufMut, OperationResult, SocketRegistration}; -use crate::{FileHandle, SyncMode}; -use async_io::Async; -use std::io::{self, Read, SeekFrom, Write}; +use crate::{FileDescriptor, SyncMode}; +use std::io::{self, SeekFrom}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::Path; -use std::sync::Arc; use std::time::Instant; -/// A reusable socket registration. Clones share one underlying registration. -#[derive(Clone)] -pub struct Socket(pub(crate) Arc>); +mod socket_descriptor; +pub use socket_descriptor::SocketDescriptor; -impl Socket { - pub fn local_addr(&self) -> io::Result { - self.0.get_ref().local_addr() - } - - pub fn peer_addr(&self) -> io::Result { - self.0.get_ref().peer_addr() - } -} - -/// A reusable listening socket registration. -pub struct Listener(Async); - -impl Listener { - pub fn local_addr(&self) -> io::Result { - self.0.get_ref().local_addr() - } -} - -/// Compile-time platform selection with a shared readiness reactor. -/// All instances share the reactor and may use each other's registrations. +/// Compile-time platform selection with a shared readiness reactor. All instances share registrations. #[derive(Clone, Copy, Default)] pub struct Selector; @@ -55,118 +32,134 @@ impl Selector { } } -fn wrap_socket(socket: io::Result>) -> io::Result { - socket.map(|socket| Socket(Arc::new(socket))) -} - -fn wrap_listener(listener: io::Result>) -> io::Result { - listener.map(Listener) -} - -fn wrap_accepted( - accepted: io::Result<(Async, SocketAddr)>, -) -> io::Result<(Socket, SocketAddr)> { - accepted.map(|(socket, address)| (Socket(Arc::new(socket)), address)) -} - impl SocketRegistration for Selector { - fn register_socket(&self, socket: TcpStream) -> io::Result { - wrap_socket(Async::new(socket)) + fn register_socket(&self, socket: TcpStream) -> io::Result { + SocketDescriptor::new(socket.into()) } - - fn register_listener(&self, listener: TcpListener) -> io::Result { - wrap_listener(Async::new(listener)) + fn register_listener(&self, listener: TcpListener) -> io::Result { + SocketDescriptor::new(listener.into()) } } impl SocketOperations for Selector { - type Socket = Socket; - type Listener = Listener; - async fn socket_listen( + type Socket = SocketDescriptor; + async fn socket_open( + &self, + domain: crate::SocketDomain, + kind: crate::SocketType, + protocol: Option, + cancellation: &Cancellation, + ) -> OperationResult { + cancellation.check()?; + Ok(SocketDescriptor::new(crate::scheduler::socket_io::open( + domain, kind, protocol, + )?)?) + } + async fn socket_bind( &self, + socket: &SocketDescriptor, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { + ) -> OperationResult<()> { cancellation.check()?; - self.register_listener(TcpListener::bind(address)?) - .map_err(Into::into) + Ok(socket.get_ref().bind(&address.into())?) } - fn listener_addr(&self, listener: &Listener) -> io::Result { - listener.local_addr() + async fn socket_listen( + &self, + socket: &SocketDescriptor, + backlog: i32, + cancellation: &Cancellation, + ) -> OperationResult<()> { + cancellation.check()?; + Ok(socket.get_ref().listen(backlog)?) } - fn socket_local_addr(&self, socket: &Socket) -> io::Result { + fn socket_local_addr(&self, socket: &SocketDescriptor) -> io::Result { socket.local_addr() } - fn socket_peer_addr(&self, socket: &Socket) -> io::Result { + fn socket_peer_addr(&self, socket: &SocketDescriptor) -> io::Result { socket.peer_addr() } - async fn connect( + async fn socket_connect( &self, + socket: &SocketDescriptor, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { - cancellable(cancellation, async { - wrap_socket(Async::::connect(address).await) - }) - .await + ) -> OperationResult<()> { + cancellation.check()?; + match socket.get_ref().connect(&address.into()) { + Ok(()) => Ok(()), + Err(error) if crate::scheduler::socket_io::connect_pending(&error) => { + cancellable( + cancellation, + socket + .inner + .write_with(crate::scheduler::socket_io::connected), + ) + .await + } + Err(error) => Err(error.into()), + } } - async fn accept( + async fn socket_accept( &self, - listener: &Listener, + socket: &SocketDescriptor, cancellation: &Cancellation, - ) -> OperationResult<(Socket, SocketAddr)> { + ) -> OperationResult<(SocketDescriptor, SocketAddr)> { cancellable(cancellation, async { - wrap_accepted(listener.0.accept().await) + let (accepted, address) = socket.inner.read_with(|socket| socket.accept()).await?; + Ok(( + SocketDescriptor::new(accepted)?, + crate::scheduler::socket_io::address(address), + )) }) .await } - async fn io_read( + async fn socket_read( &self, - socket: &Socket, + socket: &SocketDescriptor, mut buffer: B, cancellation: &Cancellation, ) -> BufferResult { let result = cancellable( cancellation, socket - .0 - .read_with(|mut socket| socket.read(buffer.as_mut())), + .inner + .read_with(|socket| crate::scheduler::socket_io::recv(socket, buffer.as_mut())), ) .await; (result, buffer) } - async fn io_write( + async fn socket_write( &self, - socket: &Socket, - mut buffer: B, + socket: &SocketDescriptor, + buffer: B, cancellation: &Cancellation, ) -> BufferResult { - let owned = &mut buffer; + let send_buffer = crate::scheduler::socket_io::SendBuffer::new(&buffer); let result = cancellable( cancellation, socket - .0 - .write_with(move |mut socket| socket.write(owned.as_ref())), + .inner + .write_with(move |socket| send_buffer.send(socket)), ) .await; (result, buffer) } - async fn io_wait( + async fn socket_wait( &self, - socket: &Socket, + socket: &SocketDescriptor, interest: Interest, cancellation: &Cancellation, ) -> OperationResult<()> { cancellable(cancellation, async { match interest { - Interest::Readable => socket.0.readable().await, - Interest::Writable => socket.0.writable().await, + Interest::Readable => socket.inner.readable().await, + Interest::Writable => socket.inner.writable().await, } }) .await } } - #[cfg(target_os = "linux")] impl FileAllocate for Selector { async fn file_allocate( @@ -190,7 +183,7 @@ impl FileAllocate for Selector { } impl File for Selector { - type File = FileHandle; + type File = FileDescriptor; async fn file_close(&self, file: Self::File) -> io::Result<()> { crate::scheduler::file_close::close(crate::scheduler::file_close::standalone_pool(), file) .await @@ -266,7 +259,7 @@ impl File for Selector { let cancellation = cancellation.clone(); blocking::unblock(move || { crate::scheduler::blocking::run(&cancellation, || { - options.open(&path).map(FileHandle::from) + options.open(&path).map(FileDescriptor::from) }) }) .await diff --git a/crates/executor/src/scheduler/selector/readiness/socket_descriptor.rs b/crates/executor/src/scheduler/selector/readiness/socket_descriptor.rs new file mode 100644 index 0000000..34b618e --- /dev/null +++ b/crates/executor/src/scheduler/selector/readiness/socket_descriptor.rs @@ -0,0 +1,44 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use async_io::Async; +use std::{io, net::SocketAddr}; + +#[cfg(all(feature = "io-uring", target_os = "linux"))] +use std::sync::Arc; + +/// An owned nonblocking IP socket with one persistent native readiness registration. Unconnected, listening, connected, and datagram sockets use the same resource type. It is deliberately not `Clone`; backend requests privately retain ownership while in flight. +pub struct SocketDescriptor { + #[cfg(all(feature = "io-uring", target_os = "linux"))] + pub(crate) inner: Arc>, + #[cfg(not(all(feature = "io-uring", target_os = "linux")))] + pub(crate) inner: Async, +} + +impl SocketDescriptor { + pub(crate) fn new(socket: socket2::Socket) -> io::Result { + let inner = Async::new(socket)?; + #[cfg(all(feature = "io-uring", target_os = "linux"))] + let inner = Arc::new(inner); + Ok(Self { inner }) + } + #[cfg(all(feature = "io-uring", target_os = "linux"))] + pub(crate) fn retain(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } + pub(crate) fn get_ref(&self) -> &socket2::Socket { + self.inner.get_ref() + } + pub fn local_addr(&self) -> io::Result { + self.get_ref() + .local_addr() + .map(crate::scheduler::socket_io::address) + } + pub fn peer_addr(&self) -> io::Result { + self.get_ref() + .peer_addr() + .map(crate::scheduler::socket_io::address) + } +} diff --git a/crates/executor/src/scheduler/selector/readiness/tests.rs b/crates/executor/src/scheduler/selector/readiness/tests.rs index 48bad16..bfbe57a 100644 --- a/crates/executor/src/scheduler/selector/readiness/tests.rs +++ b/crates/executor/src/scheduler/selector/readiness/tests.rs @@ -3,57 +3,66 @@ static NEVER: crate::Cancellation = crate::Cancellation::never(); -use super::{wrap_accepted, wrap_listener, wrap_socket}; -use std::io; - -fn injected_error(result: io::Result) { - let error = match result { - Err(error) => error, - Ok(_) => panic!("injected error was not preserved"), - }; - - assert_eq!(error.kind(), io::ErrorKind::Other); - assert_eq!(error.to_string(), "injected selector failure"); -} - -fn selector_error() -> io::Result { - Err(io::Error::other("injected selector failure")) -} - -#[test] -fn registration_and_network_errors_are_preserved() { - injected_error(wrap_socket(selector_error())); - injected_error(wrap_listener(selector_error())); - injected_error(wrap_accepted(selector_error())); -} - #[test] fn readiness_operations_remain_available_with_other_backends_selected() { use super::*; async_io::block_on(async { let selector = Selector::new().unwrap(); let listener = selector - .socket_listen("127.0.0.1:0".parse().unwrap(), &NEVER) + .socket_open( + crate::SocketDomain::Ipv4, + crate::SocketType::Stream, + Some(crate::SocketProtocol::Tcp), + &NEVER, + ) + .await + .unwrap(); + selector + .socket_bind(&listener, "127.0.0.1:0".parse().unwrap(), &NEVER) + .await + .unwrap(); + selector + .socket_listen(&listener, 128, &NEVER) + .await + .unwrap(); + let address = selector.socket_local_addr(&listener).unwrap(); + let duplicate = selector + .socket_open( + crate::SocketDomain::Ipv4, + crate::SocketType::Stream, + Some(crate::SocketProtocol::Tcp), + &NEVER, + ) .await .unwrap(); - let address = selector.listener_addr(&listener).unwrap(); assert_eq!( selector - .socket_listen(address, &NEVER) + .socket_bind(&duplicate, address, &NEVER) .await - .err() - .unwrap() + .unwrap_err() .kind(), io::ErrorKind::AddrInUse ); - let socket = selector.connect(address, &NEVER).await.unwrap(); - let (peer, peer_address) = selector.accept(&listener, &NEVER).await.unwrap(); + let socket = selector + .socket_open( + crate::SocketDomain::Ipv4, + crate::SocketType::Stream, + Some(crate::SocketProtocol::Tcp), + &NEVER, + ) + .await + .unwrap(); + selector + .socket_connect(&socket, address, &NEVER) + .await + .unwrap(); + let (peer, peer_address) = selector.socket_accept(&listener, &NEVER).await.unwrap(); assert_eq!(selector.socket_local_addr(&socket).unwrap(), peer_address); assert_eq!(selector.socket_peer_addr(&socket).unwrap(), address); let mut written = 0; while written < 5 { let (result, buffer) = selector - .io_write(&socket, b"hello"[written..].to_vec(), &NEVER) + .socket_write(&socket, b"hello"[written..].to_vec(), &NEVER) .await; let count = result.unwrap(); assert!(count > 0); @@ -63,14 +72,14 @@ fn readiness_operations_remain_available_with_other_backends_selected() { let mut received = Vec::new(); while received.len() < 5 { let (result, buffer) = selector - .io_read(&peer, vec![0; 5 - received.len()], &NEVER) + .socket_read(&peer, vec![0; 5 - received.len()], &NEVER) .await; let count = result.unwrap(); assert!(count > 0); received.extend_from_slice(&buffer[..count]); } assert_eq!(received, b"hello"); - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); let (result, buffer) = selector .file_write_at(&file, b"file".to_vec(), 3, &NEVER) .await; @@ -97,7 +106,7 @@ fn direct_readiness_cancellation_returns_buffers_before_submitting_work() { .await, Err(crate::OperationError::Cancelled(_)) )); - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); let buffer = vec![1; 4]; let allocation = buffer.as_ptr(); let (result, buffer) = selector diff --git a/crates/executor/src/scheduler/socket.rs b/crates/executor/src/scheduler/socket.rs index 996cbf5..dcb50ef 100644 --- a/crates/executor/src/scheduler/socket.rs +++ b/crates/executor/src/scheduler/socket.rs @@ -1,53 +1,80 @@ // Released under the MIT License. // Copyright, 2026, by Samuel Williams. -use crate::{BufferResult, Cancellation, Interest, IoBuf, IoBufMut, OperationResult}; +use crate::{ + BufferResult, Cancellation, Interest, IoBuf, IoBufMut, OperationResult, SocketDomain, + SocketProtocol, SocketType, +}; use std::{future::Future, io, net::SocketAddr}; -/// Portable TCP operations through the chosen scheduler. Resources have -/// backend-defined representations; OS resource importing is a separate -/// `SocketRegistration` capability. This initial TCP interface combines -/// creation/bind/listen; general socket types and options remain extensions. +/// IP socket operations through the chosen scheduler. Each backend supplies one descriptor type for unconnected, listening, and connected sockets. Operations have the same state requirements as their system calls; invalid transitions report OS errors. /// -/// Pre-cancelled operations do not start. Readiness cancellation stops waiting; -/// completion backends retain their buffers until the original completion. -/// Successful progress wins a racing cancellation. Dropping the waiting future -/// abandons its result and does not undo I/O already submitted to the kernel. +/// Creation, binding, listening, and connecting are separate operations. All operation futures are lazy. Pre-cancellation prevents a syscall; successful progress wins a cancellation race. Cancelling or abandoning a pending connect does not roll back kernel connection state: discard that socket before trying another connection. Completion backends retain owned buffers until the original operation completes. +/// +/// File and socket resources remain distinct. There is no common descriptor trait, protocol buffering, or automatic close coordination. Non-IP families and message/address APIs remain extensions. pub trait Socket: Send + Sync { type Socket: Send + Sync; - type Listener: Send + Sync; - fn socket_listen( + /// Open a nonblocking socket without binding, listening, or connecting. `None` selects the OS default protocol for the requested domain and type. + fn socket_open( + &self, + domain: SocketDomain, + kind: SocketType, + protocol: Option, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + /// Bind an existing socket to a local address. Port zero requests an OS-selected port, available through `socket_local_addr`. + fn socket_bind( &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> impl Future> + Send; - fn listener_addr(&self, listener: &Self::Listener) -> io::Result; + ) -> impl Future> + Send; + + /// Put an existing socket into listening mode using the supplied backlog. This does not create or explicitly bind the socket. OS-specific implicit binding, backlog limits, and repeated-listen behavior are preserved. + fn socket_listen( + &self, + socket: &Self::Socket, + backlog: i32, + cancellation: &Cancellation, + ) -> impl Future> + Send; + fn socket_local_addr(&self, socket: &Self::Socket) -> io::Result; fn socket_peer_addr(&self, socket: &Self::Socket) -> io::Result; - fn connect( + + /// Connect an existing socket, preserving any prior local binding or configuration. A cancelled connect can still complete in the kernel; it does not make the descriptor reusable for another connection attempt. + fn socket_connect( &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> impl Future> + Send; - fn accept( + ) -> impl Future> + Send; + + /// Accept one connection from a listening socket, returning an owned nonblocking socket and its peer address. + fn socket_accept( &self, - listener: &Self::Listener, + socket: &Self::Socket, cancellation: &Cancellation, ) -> impl Future> + Send; - fn io_read( + + /// Read once, returning the result and the original buffer. Short transfers are preserved. For datagrams, a small buffer can truncate and discard the remainder of a message. + fn socket_read( &self, socket: &Self::Socket, buffer: B, cancellation: &Cancellation, ) -> impl Future> + Send; - fn io_write( + + /// Write once, returning the result and the original buffer. Stream callers handle short transfers; datagram callers submit one complete message per operation. + fn socket_write( &self, socket: &Self::Socket, buffer: B, cancellation: &Cancellation, ) -> impl Future> + Send; - fn io_wait( + + fn socket_wait( &self, socket: &Self::Socket, interest: Interest, diff --git a/crates/executor/src/scheduler/socket_io.rs b/crates/executor/src/scheduler/socket_io.rs new file mode 100644 index 0000000..189d756 --- /dev/null +++ b/crates/executor/src/scheduler/socket_io.rs @@ -0,0 +1,93 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +//! Shared nonblocking socket syscalls. Readiness and runtime registration belong to the selected backend. + +use crate::{IoBuf, SocketDomain, SocketProtocol, SocketType}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::{io, marker::PhantomData, net::SocketAddr, slice}; + +pub(super) struct SendBuffer<'buffer> { + pointer: *const u8, + length: usize, + owner: PhantomData<&'buffer [u8]>, +} + +// SAFETY: IoBuf keeps the pointed-to allocation stable, and the owning operation retains its buffer while this value is used. +unsafe impl Send for SendBuffer<'_> {} + +impl<'buffer> SendBuffer<'buffer> { + pub(super) fn new(buffer: &'buffer B) -> Self { + Self { + pointer: buffer.as_ref().as_ptr(), + length: buffer.as_ref().len(), + owner: PhantomData, + } + } + + pub(super) fn send(&self, socket: &Socket) -> io::Result { + // SAFETY: `owner` borrows the IoBuf owner, which remains alive until this synchronous syscall returns. + let bytes = unsafe { slice::from_raw_parts(self.pointer, self.length) }; + socket.send(bytes) + } +} + +pub(super) fn open( + domain: SocketDomain, + kind: SocketType, + protocol: Option, +) -> io::Result { + let domain = match domain { + SocketDomain::Ipv4 => Domain::IPV4, + SocketDomain::Ipv6 => Domain::IPV6, + }; + let kind = match kind { + SocketType::Stream => Type::STREAM, + SocketType::Datagram => Type::DGRAM, + }; + let protocol = protocol.map(|protocol| match protocol { + SocketProtocol::Tcp => Protocol::TCP, + SocketProtocol::Udp => Protocol::UDP, + }); + let socket = Socket::new(domain, kind, protocol)?; + socket.set_nonblocking(true)?; + Ok(socket) +} + +pub(super) fn address(address: socket2::SockAddr) -> SocketAddr { + address.as_socket().expect("IP socket address") +} + +pub(super) fn recv(socket: &Socket, buffer: &mut [u8]) -> io::Result { + // `socket2::Socket::recv` accepts uninitialized storage. The buffer contract keeps this allocation stable and exclusively borrowed while the syscall writes into it. + let buffer = unsafe { + slice::from_raw_parts_mut( + buffer.as_mut_ptr().cast::>(), + buffer.len(), + ) + }; + socket.recv(buffer) +} + +pub(super) fn connect_pending(error: &io::Error) -> bool { + #[cfg(unix)] + let pending = error.raw_os_error() == Some(libc::EINPROGRESS); + #[cfg(windows)] + let pending = + error.raw_os_error() == Some(windows_sys::Win32::Networking::WinSock::WSAEWOULDBLOCK); + pending || error.kind() == io::ErrorKind::WouldBlock +} + +/// A readiness notification can predate `connect`, so check both the pending error and peer association before reporting success. +pub(super) fn connected(socket: &Socket) -> io::Result<()> { + if let Some(error) = socket.take_error()? { + return Err(error); + } + match socket.peer_addr() { + Ok(_) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotConnected => { + Err(io::ErrorKind::WouldBlock.into()) + } + Err(error) => Err(error), + } +} diff --git a/crates/executor/src/scheduler/socket_registration.rs b/crates/executor/src/scheduler/socket_registration.rs index c733a41..01273a2 100644 --- a/crates/executor/src/scheduler/socket_registration.rs +++ b/crates/executor/src/scheduler/socket_registration.rs @@ -7,10 +7,10 @@ use std::{ net::{TcpListener, TcpStream}, }; -/// Optional interoperability with existing standard-library TCP resources. +/// Optional interoperability with existing standard-library TCP resources. Streams and listeners become the same backend socket descriptor. /// Register once; the backend sets the nonblocking mode it needs. Do not change /// that mode through another handle. Simulated schedulers need not implement it. pub trait SocketRegistration: Socket { fn register_socket(&self, socket: TcpStream) -> io::Result; - fn register_listener(&self, listener: TcpListener) -> io::Result; + fn register_listener(&self, listener: TcpListener) -> io::Result; } diff --git a/crates/executor/src/scheduler/socketry/operations.rs b/crates/executor/src/scheduler/socketry/operations.rs index ffe34a9..bcc783a 100644 --- a/crates/executor/src/scheduler/socketry/operations.rs +++ b/crates/executor/src/scheduler/socketry/operations.rs @@ -82,22 +82,43 @@ impl SocketRegistration for SchedulerHandle { fn register_socket(&self, socket: TcpStream) -> io::Result { self.selector()?.register_socket(socket) } - fn register_listener(&self, listener: TcpListener) -> io::Result { + fn register_listener(&self, listener: TcpListener) -> io::Result { self.selector()?.register_listener(listener) } } impl Socket for SchedulerHandle { type Socket = ::Socket; - type Listener = ::Listener; - async fn socket_listen( + + async fn socket_open( &self, + domain: crate::SocketDomain, + kind: crate::SocketType, + protocol: Option, + cancellation: &Cancellation, + ) -> OperationResult { + self.selector()? + .socket_open(domain, kind, protocol, cancellation) + .await + } + async fn socket_bind( + &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { - self.selector()?.socket_listen(address, cancellation).await + ) -> OperationResult<()> { + self.selector()? + .socket_bind(socket, address, cancellation) + .await } - fn listener_addr(&self, listener: &Self::Listener) -> io::Result { - self.selector()?.listener_addr(listener) + async fn socket_listen( + &self, + socket: &Self::Socket, + backlog: i32, + cancellation: &Cancellation, + ) -> OperationResult<()> { + self.selector()? + .socket_listen(socket, backlog, cancellation) + .await } fn socket_local_addr(&self, socket: &Self::Socket) -> io::Result { self.selector()?.socket_local_addr(socket) @@ -105,50 +126,53 @@ impl Socket for SchedulerHandle { fn socket_peer_addr(&self, socket: &Self::Socket) -> io::Result { self.selector()?.socket_peer_addr(socket) } - async fn connect( + async fn socket_connect( &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { - self.selector()?.connect(address, cancellation).await + ) -> OperationResult<()> { + self.selector()? + .socket_connect(socket, address, cancellation) + .await } - async fn accept( + async fn socket_accept( &self, - listener: &Self::Listener, + socket: &Self::Socket, cancellation: &Cancellation, ) -> OperationResult<(Self::Socket, SocketAddr)> { - self.selector()?.accept(listener, cancellation).await + self.selector()?.socket_accept(socket, cancellation).await } - async fn io_read( + async fn socket_read( &self, socket: &Self::Socket, buffer: B, cancellation: &Cancellation, ) -> BufferResult { match self.selector() { - Ok(selector) => selector.io_read(socket, buffer, cancellation).await, + Ok(selector) => selector.socket_read(socket, buffer, cancellation).await, Err(error) => (Err(error.into()), buffer), } } - async fn io_write( + async fn socket_write( &self, socket: &Self::Socket, buffer: B, cancellation: &Cancellation, ) -> BufferResult { match self.selector() { - Ok(selector) => selector.io_write(socket, buffer, cancellation).await, + Ok(selector) => selector.socket_write(socket, buffer, cancellation).await, Err(error) => (Err(error.into()), buffer), } } - async fn io_wait( + async fn socket_wait( &self, socket: &Self::Socket, interest: Interest, cancellation: &Cancellation, ) -> OperationResult<()> { self.selector()? - .io_wait(socket, interest, cancellation) + .socket_wait(socket, interest, cancellation) .await } } @@ -301,22 +325,41 @@ impl SocketRegistration for Scheduler { fn register_socket(&self, socket: TcpStream) -> io::Result { self.handle.register_socket(socket) } - fn register_listener(&self, listener: TcpListener) -> io::Result { + fn register_listener(&self, listener: TcpListener) -> io::Result { self.handle.register_listener(listener) } } impl Socket for Scheduler { type Socket = ::Socket; - type Listener = ::Listener; - async fn socket_listen( + + async fn socket_open( &self, + domain: crate::SocketDomain, + kind: crate::SocketType, + protocol: Option, + cancellation: &Cancellation, + ) -> OperationResult { + self.handle + .socket_open(domain, kind, protocol, cancellation) + .await + } + async fn socket_bind( + &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { - self.handle.socket_listen(address, cancellation).await + ) -> OperationResult<()> { + self.handle.socket_bind(socket, address, cancellation).await } - fn listener_addr(&self, listener: &Self::Listener) -> io::Result { - self.handle.listener_addr(listener) + async fn socket_listen( + &self, + socket: &Self::Socket, + backlog: i32, + cancellation: &Cancellation, + ) -> OperationResult<()> { + self.handle + .socket_listen(socket, backlog, cancellation) + .await } fn socket_local_addr(&self, socket: &Self::Socket) -> io::Result { self.handle.socket_local_addr(socket) @@ -324,43 +367,48 @@ impl Socket for Scheduler { fn socket_peer_addr(&self, socket: &Self::Socket) -> io::Result { self.handle.socket_peer_addr(socket) } - async fn connect( + async fn socket_connect( &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { - self.handle.connect(address, cancellation).await + ) -> OperationResult<()> { + self.handle + .socket_connect(socket, address, cancellation) + .await } - async fn accept( + async fn socket_accept( &self, - listener: &Self::Listener, + socket: &Self::Socket, cancellation: &Cancellation, ) -> OperationResult<(Self::Socket, SocketAddr)> { - self.handle.accept(listener, cancellation).await + self.handle.socket_accept(socket, cancellation).await } - async fn io_read( + async fn socket_read( &self, socket: &Self::Socket, buffer: B, cancellation: &Cancellation, ) -> BufferResult { - self.handle.io_read(socket, buffer, cancellation).await + self.handle.socket_read(socket, buffer, cancellation).await } - async fn io_write( + async fn socket_write( &self, socket: &Self::Socket, buffer: B, cancellation: &Cancellation, ) -> BufferResult { - self.handle.io_write(socket, buffer, cancellation).await + self.handle.socket_write(socket, buffer, cancellation).await } - async fn io_wait( + async fn socket_wait( &self, socket: &Self::Socket, interest: Interest, cancellation: &Cancellation, ) -> OperationResult<()> { - self.handle.io_wait(socket, interest, cancellation).await + self.handle + .socket_wait(socket, interest, cancellation) + .await } } #[cfg(target_os = "linux")] diff --git a/crates/executor/src/scheduler/socketry/operations/tests.rs b/crates/executor/src/scheduler/socketry/operations/tests.rs index 62e5ccf..1215e21 100644 --- a/crates/executor/src/scheduler/socketry/operations/tests.rs +++ b/crates/executor/src/scheduler/socketry/operations/tests.rs @@ -6,6 +6,7 @@ static NEVER: crate::Cancellation = crate::Cancellation::never(); use super::{DefaultSelector, Scheduler, check_initialized_open, check_open}; use crate::SocketRegistration; use crate::scheduler::{Clock, File, Interest, Socket}; +use crate::{OperationResult, SocketDomain, SocketProtocol, SocketType}; use std::fs::OpenOptions; use std::io; use std::net::{SocketAddr, TcpListener, TcpStream}; @@ -26,6 +27,19 @@ fn socket_pair() -> (TcpStream, TcpStream) { (client, server) } +async fn connect(scheduler: &S, address: SocketAddr) -> OperationResult { + let socket = scheduler + .socket_open( + SocketDomain::from(address), + SocketType::Stream, + Some(SocketProtocol::Tcp), + &NEVER, + ) + .await?; + scheduler.socket_connect(&socket, address, &NEVER).await?; + Ok(socket) +} + struct Cleanup(std::path::PathBuf); impl Drop for Cleanup { @@ -116,9 +130,9 @@ fn scheduler_and_handle_forward_network_file_and_clock_operations() { let (_first_client, _first_server) = std::thread::scope(|scope| { let handle = handle.clone(); let scheduler = &scheduler; - let connect = scope.spawn(move || scheduler.block_on(handle.connect(address, &NEVER))); + let connect = scope.spawn(move || scheduler.block_on(connect(&handle, address))); let (server, _) = scheduler - .block_on(scheduler.accept(&listener, &NEVER)) + .block_on(scheduler.socket_accept(&listener, &NEVER)) .unwrap(); let client = connect.join().unwrap().unwrap(); (client, server) @@ -128,9 +142,9 @@ fn scheduler_and_handle_forward_network_file_and_clock_operations() { let listener = handle.register_listener(raw_listener).unwrap(); let (client, server) = std::thread::scope(|scope| { let scheduler = &scheduler; - let connect = scope.spawn(move || scheduler.block_on(scheduler.connect(address, &NEVER))); + let connect = scope.spawn(move || scheduler.block_on(connect(scheduler, address))); let (server, _) = scheduler - .block_on(handle.accept(&listener, &NEVER)) + .block_on(handle.socket_accept(&listener, &NEVER)) .unwrap(); let client = connect.join().unwrap().unwrap(); (client, server) @@ -147,7 +161,7 @@ fn scheduler_and_handle_forward_network_file_and_clock_operations() { NEXT_FILE.fetch_add(1, Ordering::Relaxed) )); let _cleanup = Cleanup(path.clone()); - let file = crate::FileHandle::from( + let file = crate::FileDescriptor::from( OpenOptions::new() .create_new(true) .read(true) @@ -157,25 +171,25 @@ fn scheduler_and_handle_forward_network_file_and_clock_operations() { ); scheduler.block_on(async { - let (result, buffer) = handle.io_write(&client, vec![42], &NEVER).await; + let (result, buffer) = handle.socket_write(&client, vec![42], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); handle - .io_wait(&server, Interest::Readable, &NEVER) + .socket_wait(&server, Interest::Readable, &NEVER) .await .unwrap(); - let (result, buffer) = scheduler.io_read(&server, vec![0], &NEVER).await; + let (result, buffer) = scheduler.socket_read(&server, vec![0], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); - let (result, buffer) = scheduler.io_write(&client, vec![43], &NEVER).await; + let (result, buffer) = scheduler.socket_write(&client, vec![43], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![43]); scheduler - .io_wait(&server, Interest::Readable, &NEVER) + .socket_wait(&server, Interest::Readable, &NEVER) .await .unwrap(); - let (result, buffer) = handle.io_read(&server, vec![0], &NEVER).await; + let (result, buffer) = handle.socket_read(&server, vec![0], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![43]); diff --git a/crates/executor/src/scheduler/tokio.rs b/crates/executor/src/scheduler/tokio.rs index a04ec9b..8ce5cd4 100644 --- a/crates/executor/src/scheduler/tokio.rs +++ b/crates/executor/src/scheduler/tokio.rs @@ -19,7 +19,7 @@ use crate::{ Blocking, BlockingError, BlockingOptions, BlockingTask, Cancellation, FileOptions, IoBuf, IoBufMut, OperationResult, SocketRegistration, Spawn, SpawnError, TaskError, }; -use crate::{FileHandle, SyncMode}; +use crate::{FileDescriptor, SyncMode}; use ::tokio::runtime::Handle; use ::tokio::task::{AbortHandle, JoinHandle}; use event_listener::Event; @@ -40,6 +40,9 @@ thread_local! { static CURRENT_SCHEDULER: RefCell> = const { RefCell::new(Weak::new()) }; } +mod socket_descriptor; +pub use socket_descriptor::SocketDescriptor; + struct SchedulerScope(Weak); impl Drop for SchedulerScope { fn drop(&mut self) { @@ -187,67 +190,6 @@ impl Shared { } } -fn register_socket_with( - runtime: &Handle, - socket: TcpStream, - set_nonblocking: impl FnOnce(&TcpStream) -> io::Result<()>, - convert: impl FnOnce(TcpStream) -> io::Result<::tokio::net::TcpStream>, -) -> io::Result { - set_nonblocking(&socket)?; - let _scope = runtime.enter(); - Ok(Socket { - inner: Arc::new(convert(socket)?), - runtime: runtime.id(), - }) -} - -fn register_listener_with( - runtime: &Handle, - listener: TcpListener, - set_nonblocking: impl FnOnce(&TcpListener) -> io::Result<()>, - convert: impl FnOnce(TcpListener) -> io::Result<::tokio::net::TcpListener>, -) -> io::Result { - set_nonblocking(&listener)?; - let _scope = runtime.enter(); - Ok(Listener { - inner: convert(listener)?, - runtime: runtime.id(), - }) -} - -async fn connect_with( - runtime: Handle, - runtime_identifier: ::tokio::runtime::Id, - future: FutureType, -) -> io::Result -where - FutureType: Future>, -{ - let inner = InRuntime { runtime, future }.await?; - Ok(Socket { - inner: Arc::new(inner), - runtime: runtime_identifier, - }) -} - -async fn accept_with( - runtime: Handle, - runtime_identifier: ::tokio::runtime::Id, - future: FutureType, -) -> io::Result<(Socket, SocketAddr)> -where - FutureType: Future>, -{ - let (inner, address) = InRuntime { runtime, future }.await?; - Ok(( - Socket { - inner: Arc::new(inner), - runtime: runtime_identifier, - }, - address, - )) -} - struct Completion { scheduler: Weak, identifier: u64, @@ -300,6 +242,7 @@ fn abort_if_cancelled(cancelled: bool, abort: impl FnOnce()) { } } +#[cfg(test)] async fn read_with( buffer: &mut [u8], mut read: Read, @@ -323,6 +266,7 @@ where } } +#[cfg(test)] async fn write_with( buffer: &[u8], mut write: Write, @@ -541,33 +485,6 @@ fn assert_outside_scheduler(shared: &Arc) { ); } -/// A Tokio socket registered with the adapter's runtime. -#[derive(Clone)] -pub struct Socket { - inner: Arc<::tokio::net::TcpStream>, - runtime: ::tokio::runtime::Id, -} - -impl Socket { - pub fn local_addr(&self) -> io::Result { - self.inner.local_addr() - } - pub fn peer_addr(&self) -> io::Result { - self.inner.peer_addr() - } -} - -pub struct Listener { - inner: ::tokio::net::TcpListener, - runtime: ::tokio::runtime::Id, -} - -impl Listener { - pub fn local_addr(&self) -> io::Result { - self.inner.local_addr() - } -} - pin_project! { struct InRuntime { runtime: Handle, @@ -585,144 +502,173 @@ impl Future for InRuntime { } impl SocketRegistration for SchedulerHandle { - fn register_socket(&self, socket: TcpStream) -> io::Result { + fn register_socket(&self, socket: TcpStream) -> io::Result { self.shared.check_open()?; - register_socket_with( - &self.shared.runtime, - socket, - |socket| socket.set_nonblocking(true), - ::tokio::net::TcpStream::from_std, - ) + SocketDescriptor::new(&self.shared.runtime, socket.into()) } - fn register_listener(&self, listener: TcpListener) -> io::Result { + fn register_listener(&self, listener: TcpListener) -> io::Result { self.shared.check_open()?; - register_listener_with( - &self.shared.runtime, - listener, - |listener| listener.set_nonblocking(true), - ::tokio::net::TcpListener::from_std, - ) + SocketDescriptor::new(&self.shared.runtime, listener.into()) } } impl SocketOperations for SchedulerHandle { - type Socket = Socket; - type Listener = Listener; - async fn socket_listen( + type Socket = SocketDescriptor; + + async fn socket_open( &self, - address: SocketAddr, + domain: crate::SocketDomain, + kind: crate::SocketType, + protocol: Option, cancellation: &Cancellation, - ) -> OperationResult { + ) -> OperationResult { cancellation.check()?; self.shared.check_open()?; - self.register_listener(TcpListener::bind(address)?) - .map_err(Into::into) + SocketDescriptor::new( + &self.shared.runtime, + crate::scheduler::socket_io::open(domain, kind, protocol)?, + ) + .map_err(Into::into) } - fn listener_addr(&self, listener: &Listener) -> io::Result { - self.check_runtime(listener.runtime)?; - listener.local_addr() + + async fn socket_bind( + &self, + socket: &Self::Socket, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult<()> { + self.check_runtime(socket.runtime())?; + cancellation.check()?; + socket.get_ref().bind(&address.into())?; + Ok(()) + } + + async fn socket_listen( + &self, + socket: &Self::Socket, + backlog: i32, + cancellation: &Cancellation, + ) -> OperationResult<()> { + self.check_runtime(socket.runtime())?; + cancellation.check()?; + socket.get_ref().listen(backlog)?; + Ok(()) } - fn socket_local_addr(&self, socket: &Socket) -> io::Result { - self.check_runtime(socket.runtime)?; + + fn socket_local_addr(&self, socket: &Self::Socket) -> io::Result { + self.check_runtime(socket.runtime())?; socket.local_addr() } - fn socket_peer_addr(&self, socket: &Socket) -> io::Result { - self.check_runtime(socket.runtime)?; + + fn socket_peer_addr(&self, socket: &Self::Socket) -> io::Result { + self.check_runtime(socket.runtime())?; socket.peer_addr() } - async fn connect( + + async fn socket_connect( &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { - self.shared.check_open()?; - cancellable( - cancellation, - connect_with( - self.shared.runtime.clone(), - self.shared.runtime.id(), - ::tokio::net::TcpStream::connect(address), - ), - ) - .await + ) -> OperationResult<()> { + self.check_runtime(socket.runtime())?; + cancellation.check()?; + match socket.get_ref().connect(&address.into()) { + Ok(()) => Ok(()), + Err(error) if crate::scheduler::socket_io::connect_pending(&error) => cancellable( + cancellation, + socket.io(Interest::Writable, crate::scheduler::socket_io::connected), + ) + .await + .map_err(Into::into), + Err(error) => Err(error.into()), + } } - async fn accept( + + async fn socket_accept( &self, - listener: &Listener, + socket: &Self::Socket, cancellation: &Cancellation, - ) -> OperationResult<(Socket, SocketAddr)> { - self.check_runtime(listener.runtime)?; - cancellable( - cancellation, - accept_with( - self.shared.runtime.clone(), - listener.runtime, - listener.inner.accept(), - ), - ) - .await + ) -> OperationResult<(Self::Socket, SocketAddr)> { + self.check_runtime(socket.runtime())?; + let accepted = cancellable(cancellation, async { + socket + .io(Interest::Readable, |socket| socket.accept()) + .await + }) + .await?; + let (accepted, address) = accepted; + let accepted = Self::Socket::new(&self.shared.runtime, accepted)?; + Ok((accepted, crate::scheduler::socket_io::address(address))) } - async fn io_read( + + async fn socket_read( &self, - socket: &Socket, + socket: &Self::Socket, mut buffer: B, cancellation: &Cancellation, ) -> BufferResult { - if let Err(error) = self.check_runtime(socket.runtime) { + if let Err(error) = self.check_runtime(socket.runtime()) { return (Err(error.into()), buffer); } + let empty = buffer.as_ref().is_empty(); let result = cancellable(cancellation, async { - if buffer.as_ref().is_empty() { - return Ok(0); + let mut receive = |socket: &socket2::Socket| { + crate::scheduler::socket_io::recv(socket, buffer.as_mut()) + }; + if empty { + match receive(socket.get_ref()) { + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + socket.io(Interest::Readable, &mut receive).await + } + result => result, + } + } else { + socket.io(Interest::Readable, receive).await } - read_with( - buffer.as_mut(), - |buffer| socket.inner.try_read(buffer), - || socket.inner.readable(), - ) - .await }) .await; (result, buffer) } - async fn io_write( + + async fn socket_write( &self, - socket: &Socket, + socket: &Self::Socket, buffer: B, cancellation: &Cancellation, ) -> BufferResult { - if let Err(error) = self.check_runtime(socket.runtime) { + if let Err(error) = self.check_runtime(socket.runtime()) { return (Err(error.into()), buffer); } - let bytes = buffer.as_ref(); + let send_buffer = crate::scheduler::socket_io::SendBuffer::new(&buffer); + let length = buffer.as_ref().len(); + let empty = length == 0; let result = cancellable(cancellation, async { - if bytes.is_empty() { - return Ok(0); + let mut send = move |socket: &socket2::Socket| send_buffer.send(socket); + if empty { + match send(socket.get_ref()) { + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + socket.io(Interest::Writable, &mut send).await + } + result => result, + } + } else { + socket.io(Interest::Writable, send).await } - write_with( - bytes, - |buffer| socket.inner.try_write(buffer), - || socket.inner.writable(), - ) - .await }) .await; (result, buffer) } - async fn io_wait( + + async fn socket_wait( &self, - socket: &Socket, + socket: &Self::Socket, interest: Interest, cancellation: &Cancellation, ) -> OperationResult<()> { - self.check_runtime(socket.runtime)?; - cancellable(cancellation, async { - match interest { - Interest::Readable => socket.inner.readable().await, - Interest::Writable => socket.inner.writable().await, - } - }) - .await + self.check_runtime(socket.runtime())?; + cancellable(cancellation, socket.wait(interest)).await?; + Ok(()) } } @@ -754,7 +700,7 @@ impl FileAllocate for SchedulerHandle { } impl File for SchedulerHandle { - type File = FileHandle; + type File = FileDescriptor; async fn file_close(&self, file: Self::File) -> io::Result<()> { self.shared.check_open()?; super::file_close::close(&self.shared.cleanup, file).await @@ -840,7 +786,7 @@ impl File for SchedulerHandle { .runtime .spawn_blocking(move || { super::blocking::run(&cancellation, || { - options.open(&path).map(FileHandle::from) + options.open(&path).map(FileDescriptor::from) }) }) .await, @@ -997,22 +943,41 @@ impl SocketRegistration for Scheduler { fn register_socket(&self, socket: TcpStream) -> io::Result { self.handle.register_socket(socket) } - fn register_listener(&self, listener: TcpListener) -> io::Result { + fn register_listener(&self, listener: TcpListener) -> io::Result { self.handle.register_listener(listener) } } impl SocketOperations for Scheduler { type Socket = ::Socket; - type Listener = ::Listener; - async fn socket_listen( + + async fn socket_open( &self, + domain: crate::SocketDomain, + kind: crate::SocketType, + protocol: Option, + cancellation: &Cancellation, + ) -> OperationResult { + self.handle + .socket_open(domain, kind, protocol, cancellation) + .await + } + async fn socket_bind( + &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { - self.handle.socket_listen(address, cancellation).await + ) -> OperationResult<()> { + self.handle.socket_bind(socket, address, cancellation).await } - fn listener_addr(&self, listener: &Self::Listener) -> io::Result { - self.handle.listener_addr(listener) + async fn socket_listen( + &self, + socket: &Self::Socket, + backlog: i32, + cancellation: &Cancellation, + ) -> OperationResult<()> { + self.handle + .socket_listen(socket, backlog, cancellation) + .await } fn socket_local_addr(&self, socket: &Self::Socket) -> io::Result { self.handle.socket_local_addr(socket) @@ -1020,43 +985,48 @@ impl SocketOperations for Scheduler { fn socket_peer_addr(&self, socket: &Self::Socket) -> io::Result { self.handle.socket_peer_addr(socket) } - async fn connect( + async fn socket_connect( &self, + socket: &Self::Socket, address: SocketAddr, cancellation: &Cancellation, - ) -> OperationResult { - self.handle.connect(address, cancellation).await + ) -> OperationResult<()> { + self.handle + .socket_connect(socket, address, cancellation) + .await } - async fn accept( + async fn socket_accept( &self, - listener: &Self::Listener, + socket: &Self::Socket, cancellation: &Cancellation, ) -> OperationResult<(Self::Socket, SocketAddr)> { - self.handle.accept(listener, cancellation).await + self.handle.socket_accept(socket, cancellation).await } - async fn io_read( + async fn socket_read( &self, socket: &Self::Socket, buffer: B, cancellation: &Cancellation, ) -> BufferResult { - self.handle.io_read(socket, buffer, cancellation).await + self.handle.socket_read(socket, buffer, cancellation).await } - async fn io_write( + async fn socket_write( &self, socket: &Self::Socket, buffer: B, cancellation: &Cancellation, ) -> BufferResult { - self.handle.io_write(socket, buffer, cancellation).await + self.handle.socket_write(socket, buffer, cancellation).await } - async fn io_wait( + async fn socket_wait( &self, socket: &Self::Socket, interest: Interest, cancellation: &Cancellation, ) -> OperationResult<()> { - self.handle.io_wait(socket, interest, cancellation).await + self.handle + .socket_wait(socket, interest, cancellation) + .await } } #[cfg(target_os = "linux")] diff --git a/crates/executor/src/scheduler/tokio/socket_descriptor.rs b/crates/executor/src/scheduler/tokio/socket_descriptor.rs new file mode 100644 index 0000000..98092fd --- /dev/null +++ b/crates/executor/src/scheduler/tokio/socket_descriptor.rs @@ -0,0 +1,110 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::Interest; +use std::{io, net::SocketAddr}; +use tokio::runtime::{Handle, Id}; + +#[cfg(unix)] +type RegisteredSocket = tokio::io::unix::AsyncFd; + +#[cfg(windows)] +type RegisteredSocket = async_io::Async; + +/// A nonblocking socket registered with the adapter's Tokio runtime. +/// +/// A descriptor can represent an unconnected, bound, listening, connected, or datagram socket. It is deliberately not `Clone`; operation futures borrow it for their duration. +pub struct SocketDescriptor { + inner: RegisteredSocket, + runtime: Id, +} + +impl SocketDescriptor { + pub(super) fn new(runtime: &Handle, socket: socket2::Socket) -> io::Result { + socket.set_nonblocking(true)?; + #[cfg(unix)] + let inner = { + let _scope = runtime.enter(); + RegisteredSocket::new(socket)? + }; + + #[cfg(windows)] + let inner = RegisteredSocket::new(socket)?; + + Ok(Self { + inner, + runtime: runtime.id(), + }) + } + + pub(super) fn runtime(&self) -> Id { + self.runtime + } + + pub(super) fn get_ref(&self) -> &socket2::Socket { + #[cfg(unix)] + { + self.inner.get_ref() + } + + #[cfg(windows)] + { + self.inner.get_ref() + } + } + + pub(super) async fn io( + &self, + interest: Interest, + mut operation: Operation, + ) -> io::Result + where + Operation: FnMut(&socket2::Socket) -> io::Result + Send, + { + #[cfg(unix)] + { + let interest = match interest { + Interest::Readable => tokio::io::Interest::READABLE, + Interest::Writable => tokio::io::Interest::WRITABLE, + }; + self.inner + .async_io(interest, |_| operation(self.inner.get_ref())) + .await + } + + #[cfg(windows)] + { + match interest { + Interest::Readable => self.inner.read_with(|socket| operation(socket)).await, + Interest::Writable => self.inner.write_with(|socket| operation(socket)).await, + } + } + } + + pub(super) async fn wait(&self, interest: Interest) -> io::Result<()> { + #[cfg(unix)] + { + self.io(interest, |_| Ok(())).await + } + + #[cfg(windows)] + { + match interest { + Interest::Readable => self.inner.readable().await, + Interest::Writable => self.inner.writable().await, + } + } + } + + pub fn local_addr(&self) -> io::Result { + self.get_ref() + .local_addr() + .map(crate::scheduler::socket_io::address) + } + + pub fn peer_addr(&self) -> io::Result { + self.get_ref() + .peer_addr() + .map(crate::scheduler::socket_io::address) + } +} diff --git a/crates/executor/src/scheduler/tokio/tests.rs b/crates/executor/src/scheduler/tokio/tests.rs index 0751e40..1abe092 100644 --- a/crates/executor/src/scheduler/tokio/tests.rs +++ b/crates/executor/src/scheduler/tokio/tests.rs @@ -65,13 +65,6 @@ fn socket_pair() -> (TcpStream, TcpStream) { (client, server) } -fn expect_error(result: io::Result) -> io::Error { - match result { - Ok(_) => panic!("operation unexpectedly succeeded"), - Err(error) => error, - } -} - fn shared(runtime: &::tokio::runtime::Runtime) -> Arc { Arc::new(Shared { cleanup: BlockingPool::new(BlockingOptions::default()), @@ -280,68 +273,6 @@ fn shutdown_cancels_a_task_registered_before_tokio_spawn() { assert_eq!(shared.remaining.load(Ordering::Acquire), 0); } -#[test] -fn socket_registration_preserves_configuration_and_conversion_errors() { - let runtime = runtime(); - let (socket, _peer) = socket_pair(); - let error = expect_error(register_socket_with( - runtime.handle(), - socket, - |_| Err(io::Error::other("socket mode failed")), - |_| panic!("conversion should not run after configuration fails"), - )); - assert_eq!(error.to_string(), "socket mode failed"); - - let (socket, _peer) = socket_pair(); - let error = expect_error(register_socket_with( - runtime.handle(), - socket, - |_| Ok(()), - |_| Err(io::Error::other("socket conversion failed")), - )); - assert_eq!(error.to_string(), "socket conversion failed"); -} - -#[test] -fn listener_registration_preserves_configuration_and_conversion_errors() { - let runtime = runtime(); - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let error = expect_error(register_listener_with( - runtime.handle(), - listener, - |_| Err(io::Error::other("listener mode failed")), - |_| panic!("conversion should not run after configuration fails"), - )); - assert_eq!(error.to_string(), "listener mode failed"); - - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let error = expect_error(register_listener_with( - runtime.handle(), - listener, - |_| Ok(()), - |_| Err(io::Error::other("listener conversion failed")), - )); - assert_eq!(error.to_string(), "listener conversion failed"); -} - -#[test] -fn connection_and_accept_errors_are_propagated() { - let runtime = runtime(); - let error = runtime.block_on(connect_with( - runtime.handle().clone(), - runtime.handle().id(), - ready(Err(io::Error::other("connect failed"))), - )); - assert_eq!(expect_error(error).to_string(), "connect failed"); - - let error = runtime.block_on(accept_with( - runtime.handle().clone(), - runtime.handle().id(), - ready(Err(io::Error::other("accept failed"))), - )); - assert_eq!(expect_error(error).to_string(), "accept failed"); -} - #[test] fn poisoned_file_buffers_are_recovered_for_operations_and_return() { let buffer = Mutex::new(vec![1]); @@ -459,10 +390,10 @@ fn socket_write_waits_until_the_send_buffer_is_writable() { let fill = vec![0; 64 * 1024]; runtime.block_on(async { - socket.inner.writable().await.unwrap(); + socket.wait(Interest::Writable).await.unwrap(); let mut written = 0; loop { - match socket.inner.try_write(&fill) { + match socket.get_ref().send(&fill) { Ok(0) => panic!("socket write made no progress"), Ok(count) => written += count, Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, @@ -502,7 +433,7 @@ fn socket_write_waits_until_the_send_buffer_is_writable() { total }); - let mut write = Box::pin(handle.io_write(&socket, vec![42], &NEVER)); + let mut write = Box::pin(handle.socket_write(&socket, vec![42], &NEVER)); runtime.block_on(async { std::future::poll_fn(|context| { assert!(write.as_mut().poll(context).is_pending()); @@ -670,7 +601,7 @@ fn cancelling_queued_blocking_work_preserves_buffers_cursor_and_prevents_creatio .unwrap(); let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("cancelled-open"); - let file = crate::FileHandle::from(tempfile::tempfile().unwrap()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); let cancellation = Cancellation::new(); runtime.block_on(async { let buffer = vec![42; 4]; diff --git a/crates/executor/src/socket_domain.rs b/crates/executor/src/socket_domain.rs new file mode 100644 index 0000000..6d4789c --- /dev/null +++ b/crates/executor/src/socket_domain.rs @@ -0,0 +1,20 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +/// The address family of an IP socket. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SocketDomain { + /// Internet Protocol version 4. + Ipv4, + /// Internet Protocol version 6. + Ipv6, +} + +impl From for SocketDomain { + fn from(address: std::net::SocketAddr) -> Self { + match address { + std::net::SocketAddr::V4(_) => Self::Ipv4, + std::net::SocketAddr::V6(_) => Self::Ipv6, + } + } +} diff --git a/crates/executor/src/socket_protocol.rs b/crates/executor/src/socket_protocol.rs new file mode 100644 index 0000000..8159256 --- /dev/null +++ b/crates/executor/src/socket_protocol.rs @@ -0,0 +1,11 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +/// An explicit IP transport protocol. Pass `None` to `socket_open` for the operating system default for the domain and type. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SocketProtocol { + /// Transmission Control Protocol. + Tcp, + /// User Datagram Protocol. + Udp, +} diff --git a/crates/executor/src/socket_type.rs b/crates/executor/src/socket_type.rs new file mode 100644 index 0000000..67c7034 --- /dev/null +++ b/crates/executor/src/socket_type.rs @@ -0,0 +1,11 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +/// The transport semantics requested when opening a socket. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SocketType { + /// A byte stream, normally TCP. + Stream, + /// Datagrams, normally UDP. Connected datagram reads and writes preserve message boundaries; message/address APIs remain separate extensions. + Datagram, +} diff --git a/crates/executor/tests/contracts.rs b/crates/executor/tests/contracts.rs index ea83b8b..8d098f1 100644 --- a/crates/executor/tests/contracts.rs +++ b/crates/executor/tests/contracts.rs @@ -3,7 +3,7 @@ use socketry_executor::{ Cancellation, File, FileOptions, Interest, IoBuf, OperationError, OperationResult, Socket, - Spawn, + SocketDomain, SocketProtocol, SocketType, Spawn, }; use std::{ future::{Future, poll_fn}, @@ -100,27 +100,69 @@ async fn resources(scheduler: &S, root: &Path) { let address = "127.0.0.1:0".parse().unwrap(); assert!(matches!( - scheduler.socket_listen(address, &cancelled).await, + scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &cancelled + ) + .await, Err(OperationError::Cancelled(_)) )); - let listener = scheduler.socket_listen(address, &never).await.unwrap(); - let address = scheduler.listener_addr(&listener).unwrap(); + let listener = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &never, + ) + .await + .unwrap(); + scheduler + .socket_bind(&listener, address, &never) + .await + .unwrap(); + scheduler + .socket_listen(&listener, 128, &never) + .await + .unwrap(); + let address = scheduler.socket_local_addr(&listener).unwrap(); assert_ne!(address.port(), 0); + let duplicate = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &never, + ) + .await + .unwrap(); assert_eq!( scheduler - .socket_listen(address, &never) + .socket_bind(&duplicate, address, &never) .await - .err() - .unwrap() + .unwrap_err() .kind(), io::ErrorKind::AddrInUse ); + let cancelled_client = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &never, + ) + .await + .unwrap(); assert!(matches!( - scheduler.connect(address, &cancelled).await, + scheduler + .socket_connect(&cancelled_client, address, &cancelled) + .await, Err(OperationError::Cancelled(_)) )); let stopping = Cancellation::new(); - let mut accept = pin!(scheduler.accept(&listener, &stopping)); + let mut accept = pin!(scheduler.socket_accept(&listener, &stopping)); poll_fn(|context| { assert!(accept.as_mut().poll(context).is_pending()); Poll::Ready(()) @@ -129,26 +171,38 @@ async fn resources(scheduler: &S, root: &Path) { stopping.cancel(); assert!(matches!(accept.await, Err(OperationError::Cancelled(_)))); - let client = scheduler.connect(address, &never).await.unwrap(); - let (server, peer) = scheduler.accept(&listener, &never).await.unwrap(); + let client = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + &never, + ) + .await + .unwrap(); + scheduler + .socket_connect(&client, address, &never) + .await + .unwrap(); + let (server, peer) = scheduler.socket_accept(&listener, &never).await.unwrap(); assert_eq!(scheduler.socket_local_addr(&server).unwrap(), address); assert_eq!(scheduler.socket_peer_addr(&client).unwrap(), address); assert_eq!(scheduler.socket_local_addr(&client).unwrap(), peer); assert_eq!(scheduler.socket_peer_addr(&server).unwrap(), peer); scheduler - .io_wait(&client, Interest::Writable, &never) + .socket_wait(&client, Interest::Writable, &never) .await .unwrap(); assert!(matches!( scheduler - .io_wait(&server, Interest::Readable, &cancelled) + .socket_wait(&server, Interest::Readable, &cancelled) .await, Err(OperationError::Cancelled(_)) )); let buffer = vec![9; 4]; let allocation = buffer.as_ptr(); let stopping = Cancellation::new(); - let mut read = pin!(scheduler.io_read(&server, buffer, &stopping)); + let mut read = pin!(scheduler.socket_read(&server, buffer, &stopping)); poll_fn(|context| { assert!(read.as_mut().poll(context).is_pending()); Poll::Ready(()) @@ -159,17 +213,19 @@ async fn resources(scheduler: &S, root: &Path) { assert!(matches!(result, Err(OperationError::Cancelled(_)))); assert_eq!(buffer.as_ptr(), allocation); assert_eq!(buffer, vec![9; 4]); - let (result, _) = scheduler.io_write(&client, vec![0], &cancelled).await; + let (result, _) = scheduler.socket_write(&client, vec![0], &cancelled).await; assert!(matches!(result, Err(OperationError::Cancelled(_)))); let (result, _) = scheduler - .io_write(&client, b"-ok-".to_vec().slice(1..3), &never) + .socket_write(&client, b"-ok-".to_vec().slice(1..3), &never) .await; assert_eq!(result.unwrap(), 2); scheduler - .io_wait(&server, Interest::Readable, &never) + .socket_wait(&server, Interest::Readable, &never) .await .unwrap(); - let (result, buffer) = scheduler.io_read(&server, buffer.slice(1..3), &never).await; + let (result, buffer) = scheduler + .socket_read(&server, buffer.slice(1..3), &never) + .await; assert_eq!(result.unwrap(), 2); assert_eq!(buffer.into_inner(), vec![9, b'o', b'k', 9]); } diff --git a/crates/executor/tests/io.rs b/crates/executor/tests/io.rs index 401d0ac..bbf6fd2 100644 --- a/crates/executor/tests/io.rs +++ b/crates/executor/tests/io.rs @@ -7,7 +7,9 @@ mod support; static NEVER: socketry_executor::Cancellation = socketry_executor::Cancellation::never(); -use socketry_executor::{Clock, File, Interest, Socket, Spawn}; +use socketry_executor::{ + Clock, File, Interest, OperationResult, Socket, SocketDomain, SocketProtocol, SocketType, Spawn, +}; use socketry_executor::{SocketRegistration, SyncMode}; use std::fs::File as StdFile; use std::future::{Future, poll_fn}; @@ -50,6 +52,25 @@ fn socket_pair() -> (TcpStream, TcpStream) { (client, server) } +async fn connect( + scheduler: &S, + address: SocketAddr, + cancellation: &socketry_executor::Cancellation, +) -> OperationResult { + let socket = scheduler + .socket_open( + SocketDomain::from(address), + SocketType::Stream, + Some(SocketProtocol::Tcp), + cancellation, + ) + .await?; + scheduler + .socket_connect(&socket, address, cancellation) + .await?; + Ok(socket) +} + #[cfg(feature = "tokio")] fn error_kind>( result: Result, @@ -69,16 +90,16 @@ where let server = scheduler.clone(); let server = scheduler .spawn(async move { - let (socket, _) = server.accept(&listener, &NEVER).await.unwrap(); + let (socket, _) = server.socket_accept(&listener, &NEVER).await.unwrap(); let mut buffer = vec![0; 1]; let allocation = buffer.as_ptr() as usize; for expected in 0..64u8 { - let (result, returned) = server.io_read(&socket, buffer, &NEVER).await; + let (result, returned) = server.socket_read(&socket, buffer, &NEVER).await; buffer = returned; assert_eq!(result.unwrap(), 1); assert_eq!(buffer[0], expected); assert_eq!(buffer.as_ptr() as usize, allocation); - let (result, returned) = server.io_write(&socket, buffer, &NEVER).await; + let (result, returned) = server.socket_write(&socket, buffer, &NEVER).await; buffer = returned; assert_eq!(result.unwrap(), 1); assert_eq!(buffer.as_ptr() as usize, allocation); @@ -86,26 +107,26 @@ where }) .unwrap(); - let socket = scheduler.connect(address, &NEVER).await.unwrap(); + let socket = connect(&scheduler, address, &NEVER).await.unwrap(); let mut buffer = vec![0; 1]; let allocation = buffer.as_ptr() as usize; for value in 0..64u8 { buffer[0] = value; - let (result, returned) = scheduler.io_write(&socket, buffer, &NEVER).await; + let (result, returned) = scheduler.socket_write(&socket, buffer, &NEVER).await; buffer = returned; assert_eq!(result.unwrap(), 1); scheduler - .io_wait(&socket, Interest::Readable, &NEVER) + .socket_wait(&socket, Interest::Readable, &NEVER) .await .unwrap(); - let (result, returned) = scheduler.io_read(&socket, buffer, &NEVER).await; + let (result, returned) = scheduler.socket_read(&socket, buffer, &NEVER).await; buffer = returned; assert_eq!(result.unwrap(), 1); assert_eq!(buffer[0], value); assert_eq!(buffer.as_ptr() as usize, allocation); } server.await.unwrap(); - let (result, _) = scheduler.io_read(&socket, vec![0; 1], &NEVER).await; + let (result, _) = scheduler.socket_read(&socket, vec![0; 1], &NEVER).await; assert_eq!(result.unwrap(), 0, "peer closure must report EOF"); } @@ -116,7 +137,7 @@ where let (client, server) = socket_pair(); let socket = scheduler.register_socket(client).unwrap(); { - let mut read = pin!(scheduler.io_read(&socket, vec![0; 1024], &NEVER)); + let mut read = pin!(scheduler.socket_read(&socket, vec![0; 1024], &NEVER)); poll_fn(|context| { assert!(read.as_mut().poll(context).is_pending()); Poll::Ready(()) @@ -126,7 +147,7 @@ where // Cancellation must not prevent later use of the same registration. // Close the peer: both the abandoned read and any later read can finish. drop(server); - let (result, buffer) = scheduler.io_read(&socket, vec![0; 1], &NEVER).await; + let (result, buffer) = scheduler.socket_read(&socket, vec![0; 1], &NEVER).await; assert_eq!(result.unwrap(), 0); assert_eq!(buffer.len(), 1); } @@ -144,7 +165,7 @@ where let handle = scheduler .clone() .spawn(async move { - let (result, buffer) = scheduler.io_read(&socket, vec![0; 1], &NEVER).await; + let (result, buffer) = scheduler.socket_read(&socket, vec![0; 1], &NEVER).await; assert_eq!(result.unwrap(), 1); buffer[0] }) @@ -288,7 +309,7 @@ mod native { let (started, waiting) = std::sync::mpsc::channel(); let task = scheduler .spawn(async move { - let mut read = pin!(handle.io_read(&socket, vec![0; 1024], &NEVER)); + let mut read = pin!(handle.socket_read(&socket, vec![0; 1024], &NEVER)); let mut started = Some(started); poll_fn(|context| -> Poll<()> { assert!(read.as_mut().poll(context).is_pending()); @@ -325,9 +346,9 @@ mod native { assert_eq!(listener.local_addr().unwrap(), address); let (accepted, client) = std::thread::scope(|scope| { - let connect = scope.spawn(|| scheduler.block_on(scheduler.connect(address, &NEVER))); + let connect = scope.spawn(|| scheduler.block_on(connect(&scheduler, address, &NEVER))); let accepted = scheduler - .block_on(scheduler.accept(&listener, &NEVER)) + .block_on(scheduler.socket_accept(&listener, &NEVER)) .expect("accept connected socket"); let client = connect.join().unwrap().expect("connect to listener"); (accepted, client) @@ -338,18 +359,18 @@ mod native { assert_eq!(server.peer_addr().unwrap(), peer_address); scheduler.block_on(async { - let (result, buffer) = scheduler.io_write(&client, vec![42], &NEVER).await; + let (result, buffer) = scheduler.socket_write(&client, vec![42], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); scheduler - .io_wait(&server, Interest::Readable, &NEVER) + .socket_wait(&server, Interest::Readable, &NEVER) .await .unwrap(); - let (result, buffer) = scheduler.io_read(&server, vec![0], &NEVER).await; + let (result, buffer) = scheduler.socket_read(&server, vec![0], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); scheduler - .io_wait(&client, Interest::Writable, &NEVER) + .socket_wait(&client, Interest::Writable, &NEVER) .await .unwrap(); scheduler @@ -378,17 +399,17 @@ mod native { let socket = handle.register_socket(client).unwrap(); let (raw_listener, address) = listening_socket(); let registered_listener = handle.register_listener(raw_listener).unwrap(); - let file = socketry_executor::FileHandle::from( + let file = socketry_executor::FileDescriptor::from( StdFile::open(std::env::current_exe().unwrap()).unwrap(), ); scheduler.shutdown(); futures_lite::future::block_on(async { - let close_file = socketry_executor::FileHandle::from(tempfile::tempfile().unwrap()); + let close_file = socketry_executor::FileDescriptor::from(tempfile::tempfile().unwrap()); assert_eq!( handle.file_close(close_file).await.unwrap_err().kind(), io::ErrorKind::BrokenPipe ); - let close_file = socketry_executor::FileHandle::from(tempfile::tempfile().unwrap()); + let close_file = socketry_executor::FileDescriptor::from(tempfile::tempfile().unwrap()); assert_eq!( handle .file_close_submit(close_file) @@ -412,7 +433,7 @@ mod native { assert_eq!( handle - .listener_addr(®istered_listener) + .socket_local_addr(®istered_listener) .unwrap_err() .kind(), io::ErrorKind::BrokenPipe @@ -429,7 +450,12 @@ mod native { executor.block_on(async { assert_eq!( handle - .socket_listen(address, &NEVER) + .socket_open( + SocketDomain::from(address), + SocketType::Stream, + Some(SocketProtocol::Tcp), + &NEVER, + ) .await .err() .unwrap() @@ -449,30 +475,30 @@ mod native { .kind(), io::ErrorKind::BrokenPipe ); - let error = match handle.connect(address, &NEVER).await { + let error = match connect(&handle, address, &NEVER).await { Ok(_) => panic!("closed scheduler accepted a connection"), Err(error) => error, }; assert_eq!(error.kind(), io::ErrorKind::BrokenPipe); - let error = match handle.accept(®istered_listener, &NEVER).await { + let error = match handle.socket_accept(®istered_listener, &NEVER).await { Ok(_) => panic!("closed scheduler accepted a listener"), Err(error) => error, }; assert_eq!(error.kind(), io::ErrorKind::BrokenPipe); assert_eq!( handle - .io_wait(&socket, Interest::Readable, &NEVER) + .socket_wait(&socket, Interest::Readable, &NEVER) .await .unwrap_err() .kind(), io::ErrorKind::BrokenPipe ); - let (result, buffer) = handle.io_read(&socket, vec![1, 2], &NEVER).await; + let (result, buffer) = handle.socket_read(&socket, vec![1, 2], &NEVER).await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); assert_eq!(buffer, vec![1, 2]); - let (result, buffer) = handle.io_write(&socket, vec![3, 4], &NEVER).await; + let (result, buffer) = handle.socket_write(&socket, vec![3, 4], &NEVER).await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); assert_eq!(buffer, vec![3, 4]); let (result, buffer) = handle.file_read_at(&file, vec![5], 0, &NEVER).await; @@ -491,7 +517,8 @@ mod native { io::ErrorKind::BrokenPipe ); - let resize_file = socketry_executor::FileHandle::from(tempfile::tempfile().unwrap()); + let resize_file = + socketry_executor::FileDescriptor::from(tempfile::tempfile().unwrap()); assert_eq!( handle .file_truncate(&resize_file, 13, &NEVER) @@ -577,8 +604,13 @@ mod tokio_adapter { let server = executor .spawn(async move { assert!(tokio::runtime::Handle::try_current().is_err()); - let (socket, _) = server_scheduler.accept(&listener, &NEVER).await.unwrap(); - let (result, _) = server_scheduler.io_write(&socket, vec![42], &NEVER).await; + let (socket, _) = server_scheduler + .socket_accept(&listener, &NEVER) + .await + .unwrap(); + let (result, _) = server_scheduler + .socket_write(&socket, vec![42], &NEVER) + .await; assert_eq!(result.unwrap(), 1); assert!(tokio::runtime::Handle::try_current().is_err()); }) @@ -588,12 +620,14 @@ mod tokio_adapter { let client = executor .spawn(async move { assert!(tokio::runtime::Handle::try_current().is_err()); - let socket = client_scheduler.connect(address, &NEVER).await.unwrap(); + let socket = connect(&client_scheduler, address, &NEVER).await.unwrap(); client_scheduler - .io_wait(&socket, Interest::Readable, &NEVER) + .socket_wait(&socket, Interest::Readable, &NEVER) .await .unwrap(); - let (result, buffer) = client_scheduler.io_read(&socket, vec![0; 1], &NEVER).await; + let (result, buffer) = client_scheduler + .socket_read(&socket, vec![0; 1], &NEVER) + .await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); client_scheduler @@ -689,33 +723,38 @@ mod tokio_adapter { let server_scheduler = Arc::clone(&scheduler); let server = scheduler .spawn(async move { - let (socket, _) = server_scheduler.accept(&listener, &NEVER).await.unwrap(); + let (socket, _) = server_scheduler + .socket_accept(&listener, &NEVER) + .await + .unwrap(); assert_eq!(socket.local_addr().unwrap(), address); assert!(socket.peer_addr().is_ok()); - let (result, buffer) = server_scheduler.io_read(&socket, vec![0], &NEVER).await; + let (result, buffer) = server_scheduler.socket_read(&socket, vec![0], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); - let (result, _) = server_scheduler.io_write(&socket, vec![24], &NEVER).await; + let (result, _) = server_scheduler + .socket_write(&socket, vec![24], &NEVER) + .await; assert_eq!(result.unwrap(), 1); }) .unwrap(); runtime.block_on(async { - let client = scheduler.connect(address, &NEVER).await.unwrap(); + let client = connect(scheduler.as_ref(), address, &NEVER).await.unwrap(); assert!(client.local_addr().is_ok()); assert_eq!(client.peer_addr().unwrap(), address); - let (result, buffer) = scheduler.io_write(&client, vec![42], &NEVER).await; + let (result, buffer) = scheduler.socket_write(&client, vec![42], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); scheduler - .io_wait(&client, Interest::Readable, &NEVER) + .socket_wait(&client, Interest::Readable, &NEVER) .await .unwrap(); - let (result, buffer) = scheduler.io_read(&client, vec![0], &NEVER).await; + let (result, buffer) = scheduler.socket_read(&client, vec![0], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![24]); scheduler - .io_wait(&client, Interest::Writable, &NEVER) + .socket_wait(&client, Interest::Writable, &NEVER) .await .unwrap(); scheduler @@ -723,10 +762,10 @@ mod tokio_adapter { .await .unwrap(); - let (result, buffer) = scheduler.io_read(&client, Vec::new(), &NEVER).await; + let (result, buffer) = scheduler.socket_read(&client, Vec::new(), &NEVER).await; assert_eq!(result.unwrap(), 0); assert!(buffer.is_empty()); - let (result, buffer) = scheduler.io_write(&client, Vec::new(), &NEVER).await; + let (result, buffer) = scheduler.socket_write(&client, Vec::new(), &NEVER).await; assert_eq!(result.unwrap(), 0); assert!(buffer.is_empty()); @@ -766,7 +805,7 @@ mod tokio_adapter { let socket = handle.register_socket(client).unwrap(); let (listener, address) = listening_socket(); let listener = handle.register_listener(listener).unwrap(); - let file = socketry_executor::FileHandle::from( + let file = socketry_executor::FileDescriptor::from( StdFile::open(std::env::current_exe().unwrap()).unwrap(), ); runtime.block_on(scheduler.shutdown()); @@ -801,7 +840,7 @@ mod tokio_adapter { ); assert_eq!( - handle.listener_addr(&listener).unwrap_err().kind(), + handle.socket_local_addr(&listener).unwrap_err().kind(), io::ErrorKind::BrokenPipe ); assert_eq!( @@ -815,7 +854,12 @@ mod tokio_adapter { runtime.block_on(async { assert_eq!( handle - .socket_listen(address, &NEVER) + .socket_open( + SocketDomain::from(address), + SocketType::Stream, + Some(SocketProtocol::Tcp), + &NEVER, + ) .await .err() .unwrap() @@ -836,25 +880,25 @@ mod tokio_adapter { io::ErrorKind::BrokenPipe ); assert_eq!( - error_kind(handle.connect(address, &NEVER).await), + error_kind(connect(&handle, address, &NEVER).await), io::ErrorKind::BrokenPipe ); assert_eq!( - error_kind(handle.accept(&listener, &NEVER).await), + error_kind(handle.socket_accept(&listener, &NEVER).await), io::ErrorKind::BrokenPipe ); assert_eq!( handle - .io_wait(&socket, Interest::Readable, &NEVER) + .socket_wait(&socket, Interest::Readable, &NEVER) .await .unwrap_err() .kind(), io::ErrorKind::BrokenPipe ); - let (result, buffer) = handle.io_read(&socket, vec![1], &NEVER).await; + let (result, buffer) = handle.socket_read(&socket, vec![1], &NEVER).await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); assert_eq!(buffer, vec![1]); - let (result, buffer) = handle.io_write(&socket, vec![2], &NEVER).await; + let (result, buffer) = handle.socket_write(&socket, vec![2], &NEVER).await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); assert_eq!(buffer, vec![2]); let (result, buffer) = handle.file_read_at(&file, vec![3], 0, &NEVER).await; @@ -873,7 +917,8 @@ mod tokio_adapter { io::ErrorKind::BrokenPipe ); - let resize_file = socketry_executor::FileHandle::from(tempfile::tempfile().unwrap()); + let resize_file = + socketry_executor::FileDescriptor::from(tempfile::tempfile().unwrap()); assert_eq!( handle .file_truncate(&resize_file, 13, &NEVER) @@ -962,7 +1007,8 @@ mod tokio_adapter { let second = Scheduler::new(second_runtime.handle().clone()); let (client, _server) = socket_pair(); let socket = first.register_socket(client).unwrap(); - let (result, buffer) = second_runtime.block_on(second.io_read(&socket, vec![0; 7], &NEVER)); + let (result, buffer) = + second_runtime.block_on(second.socket_read(&socket, vec![0; 7], &NEVER)); assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput); assert_eq!(buffer.len(), 7); } @@ -971,7 +1017,7 @@ mod tokio_adapter { fn handles_and_resources_are_send_and_sync() { fn assert_send_sync() {} assert_send_sync::(); - assert_send_sync::(); + assert_send_sync::(); } } @@ -985,7 +1031,7 @@ fn ring_drains_more_than_one_submission_batch_after_cancellation() { let (client, _server) = socket_pair(); let socket = selector.register_socket(client).unwrap(); let mut reads: Vec<_> = (0..1024) - .map(|_| Box::pin(selector.io_read(&socket, vec![0; 16], &NEVER))) + .map(|_| Box::pin(selector.socket_read(&socket, vec![0; 16], &NEVER))) .collect(); poll_fn(|context| { for read in &mut reads { @@ -1008,16 +1054,16 @@ fn ring_shutdown_racing_submissions_returns_owned_buffers() { for _ in 0..16 { let selector = Selector::new().unwrap(); let (client, _server) = socket_pair(); - let socket = selector.register_socket(client).unwrap(); + let socket = Arc::new(selector.register_socket(client).unwrap()); let mut tasks = Vec::new(); for _ in 0..64 { let selector = selector.clone(); - let socket = socket.clone(); + let socket = Arc::clone(&socket); tasks.push( executor .spawn(async move { let (result, buffer) = - selector.io_read(&socket, vec![42; 32], &NEVER).await; + selector.socket_read(&socket, vec![42; 32], &NEVER).await; assert!(result.is_err()); assert_eq!(buffer, vec![42; 32]); }) @@ -1033,10 +1079,10 @@ fn ring_shutdown_racing_submissions_returns_owned_buffers() { } #[cfg(unix)] -async fn sync_errors>(scheduler: &S) { +async fn sync_errors>(scheduler: &S) { use std::os::{fd::OwnedFd, unix::net::UnixStream}; let (socket, _peer) = UnixStream::pair().unwrap(); - let file = socketry_executor::FileHandle::from(StdFile::from(OwnedFd::from(socket))); + let file = socketry_executor::FileDescriptor::from(StdFile::from(OwnedFd::from(socket))); for mode in [SyncMode::All, SyncMode::Data] { let expected = match mode { SyncMode::All => file.as_ref().sync_all(), diff --git a/examples/echo/client.rs b/examples/echo/client.rs index b5b8356..2e16e8e 100644 --- a/examples/echo/client.rs +++ b/examples/echo/client.rs @@ -2,7 +2,10 @@ // Copyright, 2026, by Samuel Williams. // `Socket` and `IoBuf` are traits: importing them makes their methods available. -use socketry::{Cancellation, IoBuf, OperationResult, Scheduler, Socket}; +use socketry::{ + Cancellation, IoBuf, OperationResult, Scheduler, Socket, SocketDomain, SocketProtocol, + SocketType, +}; use std::io; // This function works with any scheduler implementing the `Socket` trait. `&S` and `&Cancellation` borrow values owned by `main()`. The `Vec` is owned by this future and returned with the echoed bytes. @@ -11,9 +14,17 @@ async fn echo( mut buffer: Vec, cancellation: &Cancellation, ) -> OperationResult> { - // This is the address used by `server.rs`. `.await` suspends the future until connecting finishes. + // This is the address used by `server.rs`. Open the socket first, then await the nonblocking connect operation. let socket = scheduler - .connect("127.0.0.1:9000".parse().unwrap(), cancellation) + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + cancellation, + ) + .await?; + scheduler + .socket_connect(&socket, "127.0.0.1:9000".parse().unwrap(), cancellation) .await?; let length = buffer.len(); @@ -25,7 +36,7 @@ async fn echo( // `slice` moves the buffer into an owned range view without copying. The operation returns `(result, view)`, even when it fails. let (result, returned) = scheduler - .io_write(&socket, buffer.slice(offset..end), cancellation) + .socket_write(&socket, buffer.slice(offset..end), cancellation) .await; buffer = returned.into_inner(); @@ -41,7 +52,7 @@ async fn echo( while received < end { // Reuse the part of the buffer we have already sent, replacing it with the reply. Bytes not yet sent remain untouched. let (result, returned) = scheduler - .io_read(&socket, buffer.slice(received..end), cancellation) + .socket_read(&socket, buffer.slice(received..end), cancellation) .await; buffer = returned.into_inner(); diff --git a/examples/echo/server.rs b/examples/echo/server.rs index c29e821..4180afe 100644 --- a/examples/echo/server.rs +++ b/examples/echo/server.rs @@ -6,8 +6,8 @@ // Traits such as `Socket`, `Signals`, and `IoBuf` provide methods used below. Importing a trait makes its methods available on implementing types. #[cfg(unix)] use socketry::{ - Cancellation, IoBuf, OperationError, OperationResult, Scheduler, Signals, Socket, - signal::SIGINT, + Cancellation, IoBuf, OperationError, OperationResult, Scheduler, Signals, Socket, SocketDomain, + SocketProtocol, SocketType, signal::SIGINT, }; #[cfg(unix)] @@ -47,7 +47,7 @@ async fn echo( // The operation returns `(success/error result, original buffer)`. // // Keeping the buffer outside `Result` lets us recover it even when the operation fails or is cancelled. - let (result, returned) = scheduler.io_read(&socket, buffer, shutdown).await; + let (result, returned) = scheduler.socket_read(&socket, buffer, shutdown).await; // Take ownership of the returned buffer for reuse. buffer = returned; @@ -70,7 +70,7 @@ async fn echo( // // `&drain` makes these writes ignore shutdown requests. let (result, returned) = scheduler - .io_write(&socket, buffer.slice(sent..count), &drain) + .socket_write(&socket, buffer.slice(sent..count), &drain) .await; // The operation returned our owned range view. Recover the complete original buffer from it. @@ -123,18 +123,24 @@ fn main() -> Result<(), Box> { // Save the result rather than using `?` here: even if accepting fails, we want to cancel and await existing clients before returning. let result: OperationResult<()> = scheduler.block_on(async { let listener = scheduler - .socket_listen( - // Parse the fixed address into a `SocketAddr`. `unwrap()` panics on failure; this literal is known valid. - "127.0.0.1:9000".parse().unwrap(), + .socket_open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), &shutdown, ) .await?; + // Bind the opened socket before putting it into listening mode. + scheduler + .socket_bind(&listener, "127.0.0.1:9000".parse().unwrap(), &shutdown) + .await?; + scheduler.socket_listen(&listener, 128, &shutdown).await?; loop { // `match` handles each possible operation result. // - // `accept` returns `(socket, peer_address)`. We ignore the address using `_`. - let (socket, _) = match scheduler.accept(&listener, &shutdown).await { + // `socket_accept` returns `(socket, peer_address)`. We ignore the address using `_`. + let (socket, _) = match scheduler.socket_accept(&listener, &shutdown).await { Ok(connection) => connection, // `_` ignores the cancellation error's inner value. diff --git a/readme.md b/readme.md index 72214ad..8775c5d 100644 --- a/readme.md +++ b/readme.md @@ -119,11 +119,11 @@ The Tokio adapter needs a live runtime with I/O and time enabled. Its task handl ## Current scope -The commented [echo client and server](examples/echo/readme.md) demonstrate TCP I/O, owned buffer reuse, and cooperative SIGINT shutdown using the public scheduler interface. +The commented [echo client and server](examples/echo/readme.md) demonstrate TCP I/O, explicit socket open/connect/listen steps, owned buffer reuse, and cooperative SIGINT shutdown using the public scheduler interface. -TCP connect/accept/read/write/readiness, positioned file reads/writes, and sleep are implemented. Regular files use blocking pools except for Linux io\_uring. Windows socket readiness uses IOCP/AFD; native overlapped file operations are not implemented. Socketry currently uses async-io's shared readiness reactor and timers. The independent [socketry-timers package](crates/timers/readme.md), re-exported as `socketry::timers`, provides io-event's batched timer queue plus optional worker assistance and lazy sleep futures; integration with the scheduler clock remains future work. +Nonblocking IPv4/IPv6 stream and datagram socket creation, bind, listen, connect, accept, read/write, and readiness are implemented, along with positioned file reads/writes and sleep. Regular files use blocking pools except for Linux io\_uring. Windows socket readiness uses IOCP/AFD; native overlapped file operations are not implemented. Socketry currently uses async-io's shared readiness reactor and timers. The independent [socketry-timers package](crates/timers/readme.md), re-exported as `socketry::timers`, provides io-event's batched timer queue plus optional worker assistance and lazy sleep futures; integration with the scheduler clock remains future work. -The io\_uring selector owns a dedicated thread, retains buffers until terminal completions, and drains cancellation during shutdown. Connection setup and readiness waits still use async-io. Kernel support is probed when the selector is first needed; failures are returned without silently falling back. Operation pooling, registered buffers, UDP, arbitrary descriptor APIs, and a local `!Send` task executor remain future work. +The io\_uring selector owns a dedicated thread, retains buffers and descriptors until terminal completions, and drains cancellation during shutdown. Socket setup, bind, listen, connect, accept, and readiness waits still use async-io. Kernel support is probed when the selector is first needed; failures are returned without silently falling back. Message/address socket APIs, registered buffers, arbitrary descriptor APIs, and a local `!Send` task executor remain future work. The former coroutine implementation is preserved on branch `coroutine`, at commit `b520f3d`. Its native sources, stack allocation, nested synchronous `wait` and task transfer are absent from the future executor. diff --git a/releases.md b/releases.md index 6442562..4f7cba8 100644 --- a/releases.md +++ b/releases.md @@ -2,10 +2,12 @@ ## Unreleased -- Add required `File::file_close` and `file_close_submit`, owned non-`Clone` native/Tokio `FileHandle` resources, bounded cleanup admission, and graceful cleanup draining. Finish outstanding operations before explicit close; abandoned backend use causes a panic rather than implicit cancellation or joining. Add a commented cancellation/close example and release io_uring file references before completion wakeups. External `File` implementations must add both methods; concrete callers using `Arc` must migrate to `FileHandle`. +- Add required `File::file_close` and `file_close_submit`, owned non-`Clone` native/Tokio `FileDescriptor` resources, bounded cleanup admission, and graceful cleanup draining. Finish outstanding operations before explicit close; abandoned backend use causes a panic rather than implicit cancellation or joining. Add a commented cancellation/close example and release io_uring file references before completion wakeups. External `File` implementations must add both methods; concrete callers using `Arc` must migrate to `FileDescriptor`. - Add optional `FileAllocate` with `AllocationMode::Extend`/`KeepSize`, cancellation, and cursor preservation. Linux native and Tokio backends reserve space with `fallocate`; io\_uring uses native requests when supported. Other targets omit the capability, and filesystem errors remain observable. +- Replace separate TCP listener resources and combined listen/connect helpers with one associated socket descriptor and syscall-shaped `socket_open`, `socket_bind`, `socket_listen`, `socket_connect`, and `socket_accept` operations. Add IPv4/IPv6 stream and datagram selection with optional TCP/UDP protocol selection. `Socket` trait implementors must migrate to the new resource and operation methods. + - Add required `File::file_truncate` to resize writable files with cancellation, zero-filled extension, and cursor preservation. Use native io\_uring truncation when supported, otherwise blocking file pools. External `File` implementations must provide this method. - Add required `File::file_sync` with `SyncMode::All`/`Data`, cancellation, and completion/error reporting. io\_uring uses native synchronization; readiness and Tokio use blocking file pools. External `File` implementations must provide this method. diff --git a/src/lib.rs b/src/lib.rs index fdf8cff..bd14c2e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,10 +7,10 @@ pub use socketry_executor as executor; pub use socketry_executor::{ AllocationMode, Blocking, BlockingError, BlockingOptions, BlockingTask, BufferResult, - BufferSlice, Cancellation, Cancelled, Clock, File, FileAllocate, FileHandle, FileOptions, + BufferSlice, Cancellation, Cancelled, Clock, File, FileAllocate, FileDescriptor, FileOptions, Interest, IoBuf, IoBufMut, Notify, OperationError, OperationResult, Scheduler, SchedulerHandle, - Signals, Socket, SocketRegistration, Spawn, SpawnError, SyncMode, Task, TaskError, TaskHandle, - defer_cancel, yield_now, + Signals, Socket, SocketDomain, SocketProtocol, SocketRegistration, SocketType, Spawn, + SpawnError, SyncMode, Task, TaskError, TaskHandle, defer_cancel, yield_now, }; #[cfg(all(unix, feature = "signals", any(feature = "native", feature = "tokio")))] @@ -21,3 +21,6 @@ pub use socketry_executor::signal; pub use socketry_timers as timers; pub use socketry_executor::scheduler; + +#[cfg(feature = "native")] +pub use socketry_executor::SocketDescriptor; From cf79e78be15a80713ee4cfc68a41182e16c84975 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 17 Oct 2026 22:47:18 +1300 Subject: [PATCH 2/5] Suppress SIGPIPE in socket writes. --- crates/executor/src/scheduler/socket_io.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/executor/src/scheduler/socket_io.rs b/crates/executor/src/scheduler/socket_io.rs index 189d756..fd21790 100644 --- a/crates/executor/src/scheduler/socket_io.rs +++ b/crates/executor/src/scheduler/socket_io.rs @@ -28,6 +28,10 @@ impl<'buffer> SendBuffer<'buffer> { pub(super) fn send(&self, socket: &Socket) -> io::Result { // SAFETY: `owner` borrows the IoBuf owner, which remains alive until this synchronous syscall returns. let bytes = unsafe { slice::from_raw_parts(self.pointer, self.length) }; + #[cfg(unix)] + return socket.send_with_flags(bytes, libc::MSG_NOSIGNAL); + + #[cfg(not(unix))] socket.send(bytes) } } From a9f95356f306fc26eebfa63e61e3acb4a62c7278 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 17 Oct 2026 23:17:49 +1300 Subject: [PATCH 3/5] Fix socket descriptor edge cases. --- .../examples/interface_probe/simulated.rs | 81 +++++-- .../src/scheduler/selector/io_uring.rs | 2 +- .../src/scheduler/selector/io_uring/tests.rs | 33 +++ .../src/scheduler/selector/readiness.rs | 114 +++++++-- .../selector/readiness/socket_descriptor.rs | 48 +++- .../src/scheduler/selector/readiness/tests.rs | 19 ++ crates/executor/src/scheduler/socket_io.rs | 216 +++++++++++++++++- crates/executor/src/scheduler/tokio.rs | 159 +++++++++---- .../src/scheduler/tokio/socket_descriptor.rs | 136 ++++++++++- crates/executor/src/scheduler/tokio/tests.rs | 42 ++-- crates/executor/tests/contracts.rs | 188 +++++++++++++++ crates/executor/tests/io.rs | 154 ++++++++++++- 12 files changed, 1059 insertions(+), 133 deletions(-) diff --git a/crates/executor/examples/interface_probe/simulated.rs b/crates/executor/examples/interface_probe/simulated.rs index 965f0a0..b40dfae 100644 --- a/crates/executor/examples/interface_probe/simulated.rs +++ b/crates/executor/examples/interface_probe/simulated.rs @@ -21,19 +21,36 @@ use std::{ type Bytes = Arc>>; fn available_port( - listeners: &HashMap>, + reservations: &HashMap>, mut address: SocketAddr, ) -> io::Result { (10000..=u16::MAX) .find(|port| { address.set_port(*port); - !listeners.contains_key(&address) + !reservations.contains_key(&address) }) .ok_or_else(|| io::Error::from(io::ErrorKind::AddrNotAvailable)) } +fn reserve_address( + reservations: &mut HashMap>, + mut address: SocketAddr, +) -> io::Result<(SocketAddr, Arc)> { + reservations.retain(|_, reservation| reservation.strong_count() > 0); + if address.port() == 0 { + address.set_port(available_port(reservations, address)?); + } else if reservations.contains_key(&address) { + return Err(io::Error::from(io::ErrorKind::AddrInUse)); + } + + let reservation = Arc::new(Reservation); + reservations.insert(address, Arc::downgrade(&reservation)); + Ok((address, reservation)) +} + pub struct Scheduler { files: Mutex>, + reservations: Mutex>>, listeners: Mutex>>, read_limit: usize, write_limit: usize, @@ -46,10 +63,10 @@ pub struct FileDescriptor { readable: bool, } -#[derive(Default)] struct Incoming { sockets: Mutex>, ready: Event, + reservation: Arc, } pub struct SocketDescriptor { @@ -61,6 +78,7 @@ enum SocketState { domain: SocketDomain, kind: SocketType, local: SocketAddr, + reservation: Option>, }, Listening { address: SocketAddr, @@ -87,8 +105,11 @@ struct Connection { output: Arc, local: SocketAddr, peer: SocketAddr, + _reservation: Option>, } +struct Reservation; + impl Drop for SocketDescriptor { fn drop(&mut self) { let state = self.state.get_mut().unwrap(); @@ -106,6 +127,7 @@ impl Scheduler { assert!(read_limit > 0 && write_limit > 0); Self { files: Mutex::new(HashMap::new()), + reservations: Mutex::new(HashMap::new()), listeners: Mutex::new(HashMap::new()), read_limit, write_limit, @@ -333,6 +355,7 @@ impl Socket for Scheduler { domain, kind, local, + reservation: None, }), }) } @@ -340,7 +363,7 @@ impl Socket for Scheduler { async fn socket_bind( &self, socket: &SocketDescriptor, - mut address: SocketAddr, + address: SocketAddr, cancellation: &Cancellation, ) -> OperationResult<()> { cancellation.check()?; @@ -349,6 +372,7 @@ impl Socket for Scheduler { domain, kind: SocketType::Stream, local, + reservation, } = &mut *state else { return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); @@ -359,15 +383,9 @@ impl Socket for Scheduler { if local.port() != 0 { return Err(io::Error::from(io::ErrorKind::AddrInUse).into()); } - let mut listeners = self.listeners.lock().unwrap(); - // Expired weak registrations do not reserve their former addresses. - listeners.retain(|_, incoming| incoming.strong_count() > 0); - if address.port() == 0 { - address.set_port(available_port(&listeners, address)?); - } else if listeners.contains_key(&address) { - return Err(io::Error::from(io::ErrorKind::AddrInUse).into()); - } + let (address, binding) = reserve_address(&mut self.reservations.lock().unwrap(), address)?; *local = address; + *reservation = Some(binding); Ok(()) } @@ -379,22 +397,34 @@ impl Socket for Scheduler { ) -> OperationResult<()> { cancellation.check()?; let mut state = socket.state.lock().unwrap(); - let (kind, mut local) = match &*state { - SocketState::Open { kind, local, .. } => (*kind, *local), + let (kind, mut local, mut reservation) = match &*state { + SocketState::Open { + kind, + local, + reservation, + .. + } => (*kind, *local, reservation.clone()), _ => return Err(io::Error::from(io::ErrorKind::InvalidInput).into()), }; if kind != SocketType::Stream { return Err(io::Error::from(io::ErrorKind::Unsupported).into()); } + if reservation.is_none() { + let (address, binding) = + reserve_address(&mut self.reservations.lock().unwrap(), local)?; + local = address; + reservation = Some(binding); + } let mut listeners = self.listeners.lock().unwrap(); listeners.retain(|_, incoming| incoming.strong_count() > 0); - if local.port() == 0 { - local.set_port(available_port(&listeners, local)?); - } if listeners.contains_key(&local) { return Err(io::Error::from(io::ErrorKind::AddrInUse).into()); } - let incoming = Arc::new(Incoming::default()); + let incoming = Arc::new(Incoming { + sockets: Mutex::new(VecDeque::new()), + ready: Event::default(), + reservation: reservation.unwrap(), + }); listeners.insert(local, Arc::downgrade(&incoming)); *state = SocketState::Listening { address: local, @@ -434,12 +464,13 @@ impl Socket for Scheduler { .ok_or_else(|| io::Error::from(io::ErrorKind::ConnectionRefused))?; let mut state = socket.state.lock().unwrap(); - let (domain, kind, mut local) = match &*state { + let (domain, kind, mut local, mut reservation) = match &*state { SocketState::Open { domain, kind, local, - } => (*domain, *kind, *local), + reservation, + } => (*domain, *kind, *local, reservation.clone()), _ => return Err(io::Error::from(io::ErrorKind::InvalidInput).into()), }; if kind != SocketType::Stream { @@ -450,11 +481,15 @@ impl Socket for Scheduler { } if local.port() == 0 { local = match domain { - SocketDomain::Ipv4 => SocketAddr::from(([127, 0, 0, 1], 40000)), + SocketDomain::Ipv4 => SocketAddr::from(([127, 0, 0, 1], 0)), SocketDomain::Ipv6 => { - SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], 40000)) + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], 0)) } }; + let (address, binding) = + reserve_address(&mut self.reservations.lock().unwrap(), local)?; + local = address; + reservation = Some(binding); } let input = Arc::new(Pipe::default()); let output = Arc::new(Pipe::default()); @@ -464,6 +499,7 @@ impl Socket for Scheduler { peer: local, input: output.clone(), output: input.clone(), + _reservation: Some(Arc::clone(&incoming.reservation)), })), }; incoming.sockets.lock().unwrap().push_back(server); @@ -473,6 +509,7 @@ impl Socket for Scheduler { output, local, peer: address, + _reservation: reservation, }); Ok(()) } diff --git a/crates/executor/src/scheduler/selector/io_uring.rs b/crates/executor/src/scheduler/selector/io_uring.rs index 5549df2..e68876f 100644 --- a/crates/executor/src/scheduler/selector/io_uring.rs +++ b/crates/executor/src/scheduler/selector/io_uring.rs @@ -554,7 +554,7 @@ impl Selector { buffer, ); } - if buffer.as_ref().is_empty() && matches!(&resource, Resource::File(_, _)) { + if matches!(&resource, Resource::File(_, _)) && buffer.as_ref().is_empty() { return (Ok(0), buffer); } let identifier = match self.next_identifier() { diff --git a/crates/executor/src/scheduler/selector/io_uring/tests.rs b/crates/executor/src/scheduler/selector/io_uring/tests.rs index cc9df88..5a4be1e 100644 --- a/crates/executor/src/scheduler/selector/io_uring/tests.rs +++ b/crates/executor/src/scheduler/selector/io_uring/tests.rs @@ -281,6 +281,27 @@ fn rejected_operations_preserve_buffer_allocations() { }); } +#[test] +fn empty_file_buffers_complete_without_selector_admission() { + async_io::block_on(async { + let (selector, _peer) = disconnected_selector(); + let file = Arc::new(tempfile::tempfile().unwrap()); + + let (result, buffer) = selector + .operation( + Resource::File(file, Some(0)), + Vec::new(), + false, + |buffer| buffer.as_mut_ptr(), + &NEVER, + ) + .await; + + assert_eq!(result.unwrap(), 0); + assert!(buffer.is_empty()); + }); +} + #[test] fn failure_retains_in_flight_resources_before_notifying_callers() { let (request, mut receiver) = request(0); @@ -372,6 +393,18 @@ fn closed_selector_rejects_its_direct_network_operations() { .await .is_err() ); + assert!( + selector + .socket_bind(&socket, address, &NEVER) + .await + .is_err() + ); + assert!( + selector + .socket_listen(&listener, 128, &NEVER) + .await + .is_err() + ); assert!(selector.socket_local_addr(&listener).is_err()); assert!(selector.socket_local_addr(&socket).is_err()); assert!(selector.socket_peer_addr(&socket).is_err()); diff --git a/crates/executor/src/scheduler/selector/readiness.rs b/crates/executor/src/scheduler/selector/readiness.rs index 79d708c..95d2d3f 100644 --- a/crates/executor/src/scheduler/selector/readiness.rs +++ b/crates/executor/src/scheduler/selector/readiness.rs @@ -22,6 +22,30 @@ use std::time::Instant; mod socket_descriptor; pub use socket_descriptor::SocketDescriptor; +async fn accept_with( + socket: &SocketDescriptor, + cancellation: &Cancellation, + accept: impl FnMut(&socket2::Socket) -> io::Result<(socket2::Socket, socket2::SockAddr)> + Send, + register: impl FnOnce(socket2::Socket) -> io::Result, +) -> OperationResult<(SocketDescriptor, SocketAddr)> { + cancellable(cancellation, async { + let (accepted, address) = socket.inner.read_with(accept).await?; + register_accepted(accepted, address, register) + }) + .await +} + +fn register_accepted( + accepted: socket2::Socket, + address: socket2::SockAddr, + register: impl FnOnce(socket2::Socket) -> io::Result, +) -> io::Result<(SocketDescriptor, SocketAddr)> { + Ok(( + register(accepted)?, + crate::scheduler::socket_io::address(address), + )) +} + /// Compile-time platform selection with a shared readiness reactor. All instances share registrations. #[derive(Clone, Copy, Default)] pub struct Selector; @@ -51,9 +75,13 @@ impl SocketOperations for Selector { cancellation: &Cancellation, ) -> OperationResult { cancellation.check()?; - Ok(SocketDescriptor::new(crate::scheduler::socket_io::open( - domain, kind, protocol, - )?)?) + crate::scheduler::socket_io::open_descriptor( + domain, + kind, + protocol, + crate::scheduler::socket_io::open, + SocketDescriptor::new, + ) } async fn socket_bind( &self, @@ -86,32 +114,29 @@ impl SocketOperations for Selector { cancellation: &Cancellation, ) -> OperationResult<()> { cancellation.check()?; - match socket.get_ref().connect(&address.into()) { - Ok(()) => Ok(()), - Err(error) if crate::scheduler::socket_io::connect_pending(&error) => { - cancellable( - cancellation, - socket - .inner - .write_with(crate::scheduler::socket_io::connected), - ) - .await - } - Err(error) => Err(error.into()), - } + let result = socket.get_ref().connect(&address.into()); + crate::scheduler::socket_io::connect_result( + result, + cancellable( + cancellation, + socket + .inner + .write_with(crate::scheduler::socket_io::connected), + ), + ) + .await } async fn socket_accept( &self, socket: &SocketDescriptor, cancellation: &Cancellation, ) -> OperationResult<(SocketDescriptor, SocketAddr)> { - cancellable(cancellation, async { - let (accepted, address) = socket.inner.read_with(|socket| socket.accept()).await?; - Ok(( - SocketDescriptor::new(accepted)?, - crate::scheduler::socket_io::address(address), - )) - }) + accept_with( + socket, + cancellation, + |socket| socket.accept(), + SocketDescriptor::new, + ) .await } async fn socket_read( @@ -160,6 +185,49 @@ impl SocketOperations for Selector { .await } } + +#[cfg(test)] +mod socket_tests { + use super::*; + + #[test] + fn accepted_socket_registration_errors_are_returned() { + let accepted = + socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap(); + let address = socket2::SockAddr::from("127.0.0.1:1".parse::().unwrap()); + + assert_eq!( + register_accepted(accepted, address, |_| { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + }) + .err() + .unwrap() + .kind(), + io::ErrorKind::PermissionDenied + ); + } + + #[test] + fn accept_errors_are_returned() { + let socket = SocketDescriptor::new( + socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap(), + ) + .unwrap(); + + let result = futures_lite::future::block_on(accept_with( + &socket, + &Cancellation::never(), + |_| Err(io::Error::from(io::ErrorKind::PermissionDenied)), + |_| unreachable!("accept errors before descriptor registration"), + )); + + assert_eq!( + result.err().unwrap().kind(), + io::ErrorKind::PermissionDenied + ); + } +} + #[cfg(target_os = "linux")] impl FileAllocate for Selector { async fn file_allocate( diff --git a/crates/executor/src/scheduler/selector/readiness/socket_descriptor.rs b/crates/executor/src/scheduler/selector/readiness/socket_descriptor.rs index 34b618e..956d0d2 100644 --- a/crates/executor/src/scheduler/selector/readiness/socket_descriptor.rs +++ b/crates/executor/src/scheduler/selector/readiness/socket_descriptor.rs @@ -17,7 +17,14 @@ pub struct SocketDescriptor { impl SocketDescriptor { pub(crate) fn new(socket: socket2::Socket) -> io::Result { - let inner = Async::new(socket)?; + Self::new_with(socket, Async::new) + } + + fn new_with( + socket: socket2::Socket, + register: impl FnOnce(socket2::Socket) -> io::Result>, + ) -> io::Result { + let inner = register(socket)?; #[cfg(all(feature = "io-uring", target_os = "linux"))] let inner = Arc::new(inner); Ok(Self { inner }) @@ -42,3 +49,42 @@ impl SocketDescriptor { .map(crate::scheduler::socket_io::address) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registration_errors_are_returned() { + let socket = + socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap(); + + assert_eq!( + SocketDescriptor::new_with(socket, |_| { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + }) + .err() + .unwrap() + .kind(), + io::ErrorKind::PermissionDenied + ); + } + + #[test] + fn registered_socket_exposes_local_and_peer_addresses() { + let socket = + socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap(); + socket + .bind(&socket2::SockAddr::from( + "127.0.0.1:0".parse::().unwrap(), + )) + .unwrap(); + let descriptor = SocketDescriptor::new(socket).unwrap(); + + assert_ne!(descriptor.local_addr().unwrap().port(), 0); + assert_eq!( + descriptor.peer_addr().unwrap_err().kind(), + io::ErrorKind::NotConnected + ); + } +} diff --git a/crates/executor/src/scheduler/selector/readiness/tests.rs b/crates/executor/src/scheduler/selector/readiness/tests.rs index bfbe57a..039564a 100644 --- a/crates/executor/src/scheduler/selector/readiness/tests.rs +++ b/crates/executor/src/scheduler/selector/readiness/tests.rs @@ -79,6 +79,25 @@ fn readiness_operations_remain_available_with_other_backends_selected() { received.extend_from_slice(&buffer[..count]); } assert_eq!(received, b"hello"); + let (result, _) = selector.socket_write(&peer, vec![99], &NEVER).await; + assert_eq!(result.unwrap(), 1); + selector + .socket_wait(&socket, Interest::Readable, &NEVER) + .await + .unwrap(); + let (result, buffer) = selector.socket_read(&socket, Vec::new(), &NEVER).await; + #[cfg(windows)] + assert!(result.is_err()); + #[cfg(not(windows))] + assert_eq!(result.unwrap(), 0); + assert!(buffer.is_empty()); + let (result, buffer) = selector.socket_read(&socket, vec![0], &NEVER).await; + assert_eq!(result.unwrap(), 1); + assert_eq!(buffer, vec![99]); + let (result, buffer) = selector.socket_write(&socket, Vec::new(), &NEVER).await; + assert_eq!(result.unwrap(), 0); + assert!(buffer.is_empty()); + let file = crate::FileDescriptor::from(tempfile::tempfile().unwrap()); let (result, buffer) = selector .file_write_at(&file, b"file".to_vec(), 3, &NEVER) diff --git a/crates/executor/src/scheduler/socket_io.rs b/crates/executor/src/scheduler/socket_io.rs index fd21790..cf16b1f 100644 --- a/crates/executor/src/scheduler/socket_io.rs +++ b/crates/executor/src/scheduler/socket_io.rs @@ -3,8 +3,9 @@ //! Shared nonblocking socket syscalls. Readiness and runtime registration belong to the selected backend. -use crate::{IoBuf, SocketDomain, SocketProtocol, SocketType}; +use crate::{IoBuf, OperationResult, SocketDomain, SocketProtocol, SocketType}; use socket2::{Domain, Protocol, Socket, Type}; +use std::future::Future; use std::{io, marker::PhantomData, net::SocketAddr, slice}; pub(super) struct SendBuffer<'buffer> { @@ -53,11 +54,36 @@ pub(super) fn open( SocketProtocol::Tcp => Protocol::TCP, SocketProtocol::Udp => Protocol::UDP, }); - let socket = Socket::new(domain, kind, protocol)?; - socket.set_nonblocking(true)?; + open_with(domain, kind, protocol, Socket::new, set_nonblocking) +} + +fn set_nonblocking(socket: &Socket) -> io::Result<()> { + socket.set_nonblocking(true) +} + +fn open_with( + domain: Domain, + kind: Type, + protocol: Option, + create: impl FnOnce(Domain, Type, Option) -> io::Result, + set_nonblocking: impl FnOnce(&Socket) -> io::Result<()>, +) -> io::Result { + let socket = create(domain, kind, protocol)?; + set_nonblocking(&socket)?; Ok(socket) } +pub(super) fn open_descriptor( + domain: SocketDomain, + kind: SocketType, + protocol: Option, + open: impl FnOnce(SocketDomain, SocketType, Option) -> io::Result, + register: impl FnOnce(Socket) -> io::Result, +) -> OperationResult { + let socket = open(domain, kind, protocol)?; + Ok(register(socket)?) +} + pub(super) fn address(address: socket2::SockAddr) -> SocketAddr { address.as_socket().expect("IP socket address") } @@ -82,12 +108,31 @@ pub(super) fn connect_pending(error: &io::Error) -> bool { pending || error.kind() == io::ErrorKind::WouldBlock } +pub(super) async fn connect_result( + result: io::Result<()>, + pending: impl Future>, +) -> OperationResult<()> { + match result { + Ok(()) => Ok(()), + Err(error) if connect_pending(&error) => pending.await, + Err(error) => Err(error.into()), + } +} + /// A readiness notification can predate `connect`, so check both the pending error and peer association before reporting success. pub(super) fn connected(socket: &Socket) -> io::Result<()> { - if let Some(error) = socket.take_error()? { + connected_with(socket, Socket::take_error, Socket::peer_addr) +} + +fn connected_with( + socket: &Socket, + take_error: impl FnOnce(&Socket) -> io::Result>, + peer_addr: impl FnOnce(&Socket) -> io::Result, +) -> io::Result<()> { + if let Some(error) = take_error(socket)? { return Err(error); } - match socket.peer_addr() { + match peer_addr(socket) { Ok(_) => Ok(()), Err(error) if error.kind() == io::ErrorKind::NotConnected => { Err(io::ErrorKind::WouldBlock.into()) @@ -95,3 +140,164 @@ pub(super) fn connected(socket: &Socket) -> io::Result<()> { Err(error) => Err(error), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn socket_creation_errors_are_returned() { + let error = io::Error::from(io::ErrorKind::PermissionDenied); + let returned = open_with( + Domain::IPV4, + Type::STREAM, + Some(Protocol::TCP), + |_, _, _| Err(error), + set_nonblocking, + ) + .unwrap_err(); + + assert_eq!(returned.kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn nonblocking_configuration_errors_are_returned() { + let error = io::Error::from(io::ErrorKind::PermissionDenied); + let returned = open_with( + Domain::IPV4, + Type::STREAM, + Some(Protocol::TCP), + Socket::new, + |_| Err(error), + ) + .unwrap_err(); + + assert_eq!(returned.kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn descriptor_open_propagates_socket_creation_errors() { + let result = open_descriptor::<()>( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + |_, _, _| Err(io::Error::from(io::ErrorKind::PermissionDenied)), + |_| unreachable!("registration follows successful socket creation"), + ); + + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn descriptor_open_propagates_registration_errors() { + let result = open_descriptor::<()>( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + open, + |_| Err(io::Error::from(io::ErrorKind::PermissionDenied)), + ); + + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn connect_pending_recognizes_platform_and_standard_errors() { + #[cfg(unix)] + assert!(connect_pending(&io::Error::from_raw_os_error( + libc::EINPROGRESS + ))); + + #[cfg(windows)] + assert!(connect_pending(&io::Error::from_raw_os_error( + windows_sys::Win32::Networking::WinSock::WSAEWOULDBLOCK as i32 + ))); + + assert!(connect_pending(&io::Error::from(io::ErrorKind::WouldBlock))); + assert!(!connect_pending(&io::Error::from(io::ErrorKind::Other))); + } + + #[test] + fn connection_result_preserves_immediate_errors_and_waits_for_pending_results() { + let immediate_success = + futures_lite::future::block_on(connect_result(Ok(()), std::future::pending())); + assert!(immediate_success.is_ok()); + + let direct_error = futures_lite::future::block_on(connect_result( + Err(io::Error::from(io::ErrorKind::ConnectionRefused)), + std::future::pending(), + )); + assert_eq!( + direct_error.unwrap_err().kind(), + io::ErrorKind::ConnectionRefused + ); + + let pending_result = futures_lite::future::block_on(connect_result( + Err(io::Error::from(io::ErrorKind::WouldBlock)), + std::future::ready(Ok(())), + )); + assert!(pending_result.is_ok()); + } + + #[test] + fn connection_check_propagates_socket_errors_and_pending_state() { + let socket = open( + SocketDomain::Ipv4, + SocketType::Stream, + Some(SocketProtocol::Tcp), + ) + .unwrap(); + let error = io::Error::from(io::ErrorKind::ConnectionRefused); + + assert_eq!( + connected_with( + &socket, + |_| Err(io::Error::from(io::ErrorKind::PermissionDenied)), + |_| unreachable!("peer address is not checked after an SO_ERROR failure"), + ) + .unwrap_err() + .kind(), + io::ErrorKind::PermissionDenied + ); + assert_eq!( + connected_with( + &socket, + |_| Ok(Some(error)), + |_| unreachable!("peer address is not checked after SO_ERROR"), + ) + .unwrap_err() + .kind(), + io::ErrorKind::ConnectionRefused + ); + assert_eq!( + connected_with( + &socket, + |_| Ok(None), + |_| { Err(io::ErrorKind::NotConnected.into()) } + ) + .unwrap_err() + .kind(), + io::ErrorKind::WouldBlock + ); + assert_eq!( + connected_with( + &socket, + |_| Ok(None), + |_| { Err(io::ErrorKind::ConnectionRefused.into()) } + ) + .unwrap_err() + .kind(), + io::ErrorKind::ConnectionRefused + ); + let socket = open( + SocketDomain::Ipv4, + SocketType::Datagram, + Some(SocketProtocol::Udp), + ) + .unwrap(); + socket + .connect(&"127.0.0.1:9".parse::().unwrap().into()) + .unwrap(); + assert!(connected(&socket).is_ok()); + } +} diff --git a/crates/executor/src/scheduler/tokio.rs b/crates/executor/src/scheduler/tokio.rs index 8ce5cd4..5812322 100644 --- a/crates/executor/src/scheduler/tokio.rs +++ b/crates/executor/src/scheduler/tokio.rs @@ -43,6 +43,30 @@ thread_local! { mod socket_descriptor; pub use socket_descriptor::SocketDescriptor; +async fn accept_with( + socket: &SocketDescriptor, + runtime: &Handle, + cancellation: &Cancellation, + accept: impl FnMut(&socket2::Socket) -> io::Result<(socket2::Socket, socket2::SockAddr)> + Send, + register: impl FnOnce(&Handle, socket2::Socket) -> io::Result, +) -> OperationResult<(SocketDescriptor, SocketAddr)> { + let (accepted, address) = + cancellable(cancellation, socket.io(Interest::Readable, accept)).await?; + register_accepted(runtime, accepted, address, register).map_err(Into::into) +} + +fn register_accepted( + runtime: &Handle, + accepted: socket2::Socket, + address: socket2::SockAddr, + register: impl FnOnce(&Handle, socket2::Socket) -> io::Result, +) -> io::Result<(SocketDescriptor, SocketAddr)> { + Ok(( + register(runtime, accepted)?, + crate::scheduler::socket_io::address(address), + )) +} + struct SchedulerScope(Weak); impl Drop for SchedulerScope { fn drop(&mut self) { @@ -524,11 +548,13 @@ impl SocketOperations for SchedulerHandle { ) -> OperationResult { cancellation.check()?; self.shared.check_open()?; - SocketDescriptor::new( - &self.shared.runtime, - crate::scheduler::socket_io::open(domain, kind, protocol)?, + crate::scheduler::socket_io::open_descriptor( + domain, + kind, + protocol, + crate::scheduler::socket_io::open, + |socket| SocketDescriptor::new(&self.shared.runtime, socket), ) - .map_err(Into::into) } async fn socket_bind( @@ -573,16 +599,15 @@ impl SocketOperations for SchedulerHandle { ) -> OperationResult<()> { self.check_runtime(socket.runtime())?; cancellation.check()?; - match socket.get_ref().connect(&address.into()) { - Ok(()) => Ok(()), - Err(error) if crate::scheduler::socket_io::connect_pending(&error) => cancellable( + let result = socket.get_ref().connect(&address.into()); + crate::scheduler::socket_io::connect_result( + result, + cancellable( cancellation, socket.io(Interest::Writable, crate::scheduler::socket_io::connected), - ) - .await - .map_err(Into::into), - Err(error) => Err(error.into()), - } + ), + ) + .await } async fn socket_accept( @@ -591,15 +616,14 @@ impl SocketOperations for SchedulerHandle { cancellation: &Cancellation, ) -> OperationResult<(Self::Socket, SocketAddr)> { self.check_runtime(socket.runtime())?; - let accepted = cancellable(cancellation, async { - socket - .io(Interest::Readable, |socket| socket.accept()) - .await - }) - .await?; - let (accepted, address) = accepted; - let accepted = Self::Socket::new(&self.shared.runtime, accepted)?; - Ok((accepted, crate::scheduler::socket_io::address(address))) + accept_with( + socket, + &self.shared.runtime, + cancellation, + |socket| socket.accept(), + Self::Socket::new, + ) + .await } async fn socket_read( @@ -611,21 +635,12 @@ impl SocketOperations for SchedulerHandle { if let Err(error) = self.check_runtime(socket.runtime()) { return (Err(error.into()), buffer); } - let empty = buffer.as_ref().is_empty(); let result = cancellable(cancellation, async { - let mut receive = |socket: &socket2::Socket| { - crate::scheduler::socket_io::recv(socket, buffer.as_mut()) - }; - if empty { - match receive(socket.get_ref()) { - Err(error) if error.kind() == io::ErrorKind::WouldBlock => { - socket.io(Interest::Readable, &mut receive).await - } - result => result, - } - } else { - socket.io(Interest::Readable, receive).await - } + socket + .io(Interest::Readable, |socket| { + crate::scheduler::socket_io::recv(socket, buffer.as_mut()) + }) + .await }) .await; (result, buffer) @@ -641,21 +656,10 @@ impl SocketOperations for SchedulerHandle { return (Err(error.into()), buffer); } let send_buffer = crate::scheduler::socket_io::SendBuffer::new(&buffer); - let length = buffer.as_ref().len(); - let empty = length == 0; - let result = cancellable(cancellation, async { - let mut send = move |socket: &socket2::Socket| send_buffer.send(socket); - if empty { - match send(socket.get_ref()) { - Err(error) if error.kind() == io::ErrorKind::WouldBlock => { - socket.io(Interest::Writable, &mut send).await - } - result => result, - } - } else { - socket.io(Interest::Writable, send).await - } - }) + let result = cancellable( + cancellation, + socket.io(Interest::Writable, move |socket| send_buffer.send(socket)), + ) .await; (result, buffer) } @@ -672,6 +676,61 @@ impl SocketOperations for SchedulerHandle { } } +#[cfg(test)] +mod socket_accept_tests { + use super::*; + + #[test] + fn accepted_socket_registration_errors_are_returned() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let accepted = + socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap(); + let address = socket2::SockAddr::from("127.0.0.1:1".parse::().unwrap()); + + assert_eq!( + register_accepted(runtime.handle(), accepted, address, |_, _| { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + }) + .err() + .unwrap() + .kind(), + io::ErrorKind::PermissionDenied + ); + } + + #[test] + fn accept_errors_are_returned() { + use std::io::Write; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let peer = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (socket, _) = listener.accept().unwrap(); + let mut peer = peer; + peer.write_all(b"ready").unwrap(); + let socket = SocketDescriptor::new(runtime.handle(), socket.into()).unwrap(); + + let result = runtime.block_on(accept_with( + &socket, + runtime.handle(), + &Cancellation::never(), + |_| Err(io::Error::from(io::ErrorKind::PermissionDenied)), + |_, _| unreachable!("accept errors before descriptor registration"), + )); + + assert_eq!( + result.err().unwrap().kind(), + io::ErrorKind::PermissionDenied + ); + } +} + #[cfg(target_os = "linux")] impl FileAllocate for SchedulerHandle { async fn file_allocate( diff --git a/crates/executor/src/scheduler/tokio/socket_descriptor.rs b/crates/executor/src/scheduler/tokio/socket_descriptor.rs index 98092fd..a66718e 100644 --- a/crates/executor/src/scheduler/tokio/socket_descriptor.rs +++ b/crates/executor/src/scheduler/tokio/socket_descriptor.rs @@ -21,15 +21,34 @@ pub struct SocketDescriptor { impl SocketDescriptor { pub(super) fn new(runtime: &Handle, socket: socket2::Socket) -> io::Result { - socket.set_nonblocking(true)?; - #[cfg(unix)] - let inner = { - let _scope = runtime.enter(); - RegisteredSocket::new(socket)? - }; + Self::new_with( + runtime, + socket, + |socket| socket.set_nonblocking(true), + |runtime, socket| { + #[cfg(unix)] + { + let _scope = runtime.enter(); + RegisteredSocket::new(socket) + } + + #[cfg(windows)] + { + let _ = runtime; + RegisteredSocket::new(socket) + } + }, + ) + } - #[cfg(windows)] - let inner = RegisteredSocket::new(socket)?; + fn new_with( + runtime: &Handle, + socket: socket2::Socket, + set_nonblocking: impl FnOnce(&socket2::Socket) -> io::Result<()>, + register: impl FnOnce(&Handle, socket2::Socket) -> io::Result, + ) -> io::Result { + set_nonblocking(&socket)?; + let inner = register(runtime, socket)?; Ok(Self { inner, @@ -61,6 +80,13 @@ impl SocketDescriptor { where Operation: FnMut(&socket2::Socket) -> io::Result + Send, { + // Try the syscall before waiting so invalid socket states return their + // immediate OS error instead of waiting for readiness that cannot occur. + let result = operation(self.get_ref()); + if !matches!(&result, Err(error) if error.kind() == io::ErrorKind::WouldBlock) { + return result; + } + #[cfg(unix)] { let interest = match interest { @@ -84,7 +110,10 @@ impl SocketDescriptor { pub(super) async fn wait(&self, interest: Interest) -> io::Result<()> { #[cfg(unix)] { - self.io(interest, |_| Ok(())).await + match interest { + Interest::Readable => self.inner.readable().await.map(|_| ()), + Interest::Writable => self.inner.writable().await.map(|_| ()), + } } #[cfg(windows)] @@ -108,3 +137,92 @@ impl SocketDescriptor { .map(crate::scheduler::socket_io::address) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + future::{Future, poll_fn}, + pin::pin, + task::Poll, + time::Duration, + }; + + fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + } + + fn socket() -> socket2::Socket { + socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap() + } + + #[test] + fn nonblocking_configuration_errors_are_returned() { + let runtime = runtime(); + let error = SocketDescriptor::new_with( + runtime.handle(), + socket(), + |_| Err(io::Error::from(io::ErrorKind::PermissionDenied)), + |_, _| unreachable!("socket registration follows nonblocking configuration"), + ) + .err() + .unwrap(); + + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn registration_errors_are_returned() { + let runtime = runtime(); + let error = SocketDescriptor::new_with( + runtime.handle(), + socket(), + |_| Ok(()), + |_, _| Err(io::Error::from(io::ErrorKind::PermissionDenied)), + ) + .err() + .unwrap(); + + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn invalid_accept_returns_without_waiting_for_readiness() { + let runtime = runtime(); + let socket = + runtime.block_on(async { SocketDescriptor::new(runtime.handle(), socket()).unwrap() }); + + let result = runtime.block_on(async { + tokio::time::timeout( + Duration::from_secs(1), + socket.io(Interest::Readable, |socket| socket.accept().map(|_| ())), + ) + .await + }); + + assert!( + result + .expect("invalid accept waited for readiness") + .is_err() + ); + } + + #[test] + fn wait_does_not_complete_before_readiness() { + let runtime = runtime(); + let socket = + runtime.block_on(async { SocketDescriptor::new(runtime.handle(), socket()).unwrap() }); + + runtime.block_on(async { + let mut wait = pin!(socket.wait(Interest::Readable)); + poll_fn(|context| { + assert!(wait.as_mut().poll(context).is_pending()); + Poll::Ready(()) + }) + .await; + }); + } +} diff --git a/crates/executor/src/scheduler/tokio/tests.rs b/crates/executor/src/scheduler/tokio/tests.rs index 1abe092..f8d20b6 100644 --- a/crates/executor/src/scheduler/tokio/tests.rs +++ b/crates/executor/src/scheduler/tokio/tests.rs @@ -380,7 +380,7 @@ fn write_retries_interrupted_and_would_block_operations() { } #[test] -fn socket_write_waits_until_the_send_buffer_is_writable() { +fn socket_write_completes_with_a_full_send_buffer() { let runtime = runtime(); let scheduler = Scheduler::new(runtime.handle().clone()); let handle = scheduler.handle(); @@ -400,9 +400,23 @@ fn socket_write_waits_until_the_send_buffer_is_writable() { Err(error) => panic!("failed to fill socket send buffer: {error}"), } } + let probe = vec![0; 1]; + let send = crate::scheduler::socket_io::SendBuffer::new(&probe); + loop { + match send.send(socket.get_ref()) { + Ok(0) => panic!("socket write made no progress"), + Ok(count) => written += count, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) => panic!("failed to fill socket send buffer: {error}"), + } + } assert!(written > 0); }); + let (result, buffer) = runtime.block_on(handle.socket_write(&socket, Vec::new(), &NEVER)); + assert_eq!(result.unwrap(), 0); + assert!(buffer.is_empty()); + let (start_reader, wait_for_start) = mpsc::sync_channel(0); let (finish_reader, wait_for_finish) = mpsc::sync_channel(0); let (first_read, wait_for_first_read) = mpsc::channel(); @@ -434,18 +448,20 @@ fn socket_write_waits_until_the_send_buffer_is_writable() { }); let mut write = Box::pin(handle.socket_write(&socket, vec![42], &NEVER)); - runtime.block_on(async { - std::future::poll_fn(|context| { - assert!(write.as_mut().poll(context).is_pending()); - start_reader.send(()).unwrap(); - Poll::Ready(()) - }) - .await; - - let (result, buffer) = write.await; - assert_eq!(result.unwrap(), 1); - assert_eq!(buffer, [42]); - }); + let completed_before_read = runtime.block_on(std::future::poll_fn(|context| { + let result = match write.as_mut().poll(context) { + Poll::Ready(result) => Some(result), + Poll::Pending => None, + }; + start_reader.send(()).unwrap(); + Poll::Ready(result) + })); + let (result, buffer) = match completed_before_read { + Some(result) => result, + None => runtime.block_on(write), + }; + assert_eq!(result.unwrap(), 1); + assert_eq!(buffer, [42]); // Kernel buffering can complete the write before the reader gets CPU time. Observe an actual read before asking it to stop. wait_for_first_read diff --git a/crates/executor/tests/contracts.rs b/crates/executor/tests/contracts.rs index 8d098f1..d6629aa 100644 --- a/crates/executor/tests/contracts.rs +++ b/crates/executor/tests/contracts.rs @@ -119,6 +119,70 @@ async fn resources(scheduler: &S, root: &Path) { ) .await .unwrap(); + let unbound = scheduler + .socket_open(SocketDomain::Ipv4, SocketType::Stream, None, &never) + .await + .unwrap(); + assert!(matches!( + scheduler.socket_bind(&unbound, address, &cancelled).await, + Err(OperationError::Cancelled(_)) + )); + assert!(matches!( + scheduler.socket_listen(&unbound, 128, &cancelled).await, + Err(OperationError::Cancelled(_)) + )); + let reserved = scheduler + .socket_open(SocketDomain::Ipv4, SocketType::Stream, None, &never) + .await + .unwrap(); + scheduler + .socket_bind(&reserved, address, &never) + .await + .unwrap(); + let reserved_address = scheduler.socket_local_addr(&reserved).unwrap(); + let another_reserved = scheduler + .socket_open(SocketDomain::Ipv4, SocketType::Stream, None, &never) + .await + .unwrap(); + scheduler + .socket_bind(&another_reserved, address, &never) + .await + .unwrap(); + assert_ne!( + scheduler.socket_local_addr(&another_reserved).unwrap(), + reserved_address + ); + let duplicate = scheduler + .socket_open(SocketDomain::Ipv4, SocketType::Stream, None, &never) + .await + .unwrap(); + assert_eq!( + scheduler + .socket_bind(&duplicate, reserved_address, &never) + .await + .unwrap_err() + .kind(), + io::ErrorKind::AddrInUse + ); + drop(reserved); + scheduler + .socket_bind(&duplicate, reserved_address, &never) + .await + .unwrap(); + let ipv6 = scheduler + .socket_open( + SocketDomain::from("[::1]:0".parse::().unwrap()), + SocketType::Stream, + None, + &never, + ) + .await + .unwrap(); + scheduler + .socket_bind(&ipv6, "[::1]:0".parse().unwrap(), &never) + .await + .unwrap(); + assert!(scheduler.socket_local_addr(&ipv6).unwrap().is_ipv6()); scheduler .socket_bind(&listener, address, &never) .await @@ -228,6 +292,130 @@ async fn resources(scheduler: &S, root: &Path) { .await; assert_eq!(result.unwrap(), 2); assert_eq!(buffer.into_inner(), vec![9, b'o', b'k', 9]); + + let datagram = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Datagram, + Some(SocketProtocol::Udp), + &never, + ) + .await; + match datagram { + Ok(datagram) => { + let receiver = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Datagram, + Some(SocketProtocol::Udp), + &never, + ) + .await + .unwrap(); + scheduler + .socket_bind(&receiver, "127.0.0.1:0".parse().unwrap(), &never) + .await + .unwrap(); + let address = scheduler.socket_local_addr(&receiver).unwrap(); + scheduler + .socket_connect(&datagram, address, &never) + .await + .unwrap(); + assert!(scheduler.socket_listen(&datagram, 1, &never).await.is_err()); + + let (result, buffer) = scheduler.socket_write(&datagram, Vec::new(), &never).await; + assert_eq!(result.unwrap(), 0); + assert!(buffer.is_empty()); + let (result, _) = scheduler + .socket_write(&datagram, b"later".to_vec(), &never) + .await; + assert_eq!(result.unwrap(), 5); + let (result, buffer) = scheduler.socket_read(&receiver, vec![0; 8], &never).await; + assert_eq!(result.unwrap(), 0); + assert_eq!(buffer, vec![0; 8]); + let (result, buffer) = scheduler.socket_read(&receiver, vec![0; 8], &never).await; + assert_eq!(result.unwrap(), 5); + assert_eq!(&buffer[..5], b"later"); + + let zero_receiver = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Datagram, + Some(SocketProtocol::Udp), + &never, + ) + .await + .unwrap(); + scheduler + .socket_bind(&zero_receiver, "127.0.0.1:0".parse().unwrap(), &never) + .await + .unwrap(); + let zero_sender = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Datagram, + Some(SocketProtocol::Udp), + &never, + ) + .await + .unwrap(); + scheduler + .socket_connect( + &zero_sender, + scheduler.socket_local_addr(&zero_receiver).unwrap(), + &never, + ) + .await + .unwrap(); + let (result, _) = scheduler + .socket_write(&zero_sender, b"drop".to_vec(), &never) + .await; + assert_eq!(result.unwrap(), 4); + scheduler + .socket_wait(&zero_receiver, Interest::Readable, &never) + .await + .unwrap(); + let (result, buffer) = scheduler + .socket_read(&zero_receiver, Vec::new(), &never) + .await; + #[cfg(windows)] + assert!(matches!(result, Err(OperationError::Io(_)))); + #[cfg(not(windows))] + assert_eq!(result.unwrap(), 0); + assert!(buffer.is_empty()); + + // The readiness wait confirms a datagram is queued before the empty receive. + let stopping = Cancellation::new(); + let buffer = vec![0; 8]; + let allocation = buffer.as_ptr(); + let mut read = pin!(scheduler.socket_read(&zero_receiver, buffer, &stopping)); + poll_fn(|context| match read.as_mut().poll(context) { + Poll::Pending => Poll::Ready(()), + Poll::Ready(result) => { + panic!("unexpected datagram after empty receive: {result:?}") + } + }) + .await; + stopping.cancel(); + let (result, buffer) = read.await; + assert!( + matches!(result, Err(OperationError::Cancelled(_))), + "unexpected datagram read result: {result:?}" + ); + assert_eq!(buffer.as_ptr(), allocation); + assert_eq!(buffer, vec![0; 8]); + } + Err(error) => assert_eq!(error.kind(), io::ErrorKind::Unsupported), + } + + assert_eq!( + SocketDomain::from("127.0.0.1:0".parse::().unwrap()), + SocketDomain::Ipv4 + ); + assert_eq!( + SocketDomain::from("[::1]:0".parse::().unwrap()), + SocketDomain::Ipv6 + ); } struct ReportDrop(Option>); diff --git a/crates/executor/tests/io.rs b/crates/executor/tests/io.rs index bbf6fd2..e4a3523 100644 --- a/crates/executor/tests/io.rs +++ b/crates/executor/tests/io.rs @@ -71,6 +71,44 @@ async fn connect( Ok(socket) } +async fn socket_variants(scheduler: &S) { + let ipv4 = scheduler + .socket_open(SocketDomain::Ipv4, SocketType::Stream, None, &NEVER) + .await + .unwrap(); + let ipv6 = scheduler + .socket_open(SocketDomain::Ipv6, SocketType::Stream, None, &NEVER) + .await + .unwrap(); + scheduler + .socket_bind(&ipv6, "[::1]:0".parse().unwrap(), &NEVER) + .await + .unwrap(); + assert!(scheduler.socket_local_addr(&ipv6).unwrap().is_ipv6()); + + let datagram = scheduler + .socket_open( + SocketDomain::Ipv4, + SocketType::Datagram, + Some(SocketProtocol::Udp), + &NEVER, + ) + .await + .unwrap(); + scheduler + .socket_connect(&datagram, "127.0.0.1:9".parse().unwrap(), &NEVER) + .await + .unwrap(); + assert!(scheduler.socket_peer_addr(&datagram).is_ok()); + assert!(scheduler.socket_listen(&datagram, 1, &NEVER).await.is_err()); + assert!( + scheduler + .socket_connect(&ipv4, "[::1]:9".parse().unwrap(), &NEVER) + .await + .is_err() + ); +} + #[cfg(feature = "tokio")] fn error_kind>( result: Result, @@ -263,6 +301,10 @@ mod native { let scheduler = Scheduler::with_workers(2).unwrap(); let handle = scheduler.handle(); scheduler.block_on(within(&handle, round_trip(handle.clone()))); + scheduler.block_on(socket_variants(&handle)); + + let readiness = socketry_executor::scheduler::selector::readiness::Selector::new().unwrap(); + scheduler.block_on(socket_variants(&readiness)); } #[test] @@ -481,6 +523,31 @@ mod native { }; assert_eq!(error.kind(), io::ErrorKind::BrokenPipe); + assert_eq!( + handle + .socket_bind(&socket, address, &NEVER) + .await + .unwrap_err() + .kind(), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + handle + .socket_listen(®istered_listener, 128, &NEVER) + .await + .unwrap_err() + .kind(), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + handle + .socket_connect(&socket, address, &NEVER) + .await + .unwrap_err() + .kind(), + io::ErrorKind::BrokenPipe + ); + let error = match handle.socket_accept(®istered_listener, &NEVER).await { Ok(_) => panic!("closed scheduler accepted a listener"), Err(error) => error, @@ -588,6 +655,7 @@ mod tokio_adapter { let scheduler = Scheduler::new(runtime.handle().clone()); let handle = scheduler.handle(); runtime.block_on(within(&handle, round_trip(handle.clone()))); + runtime.block_on(socket_variants(&handle)); runtime.block_on(scheduler.shutdown()); } @@ -736,6 +804,10 @@ mod tokio_adapter { .socket_write(&socket, vec![24], &NEVER) .await; assert_eq!(result.unwrap(), 1); + let (result, _) = server_scheduler + .socket_write(&socket, vec![99], &NEVER) + .await; + assert_eq!(result.unwrap(), 1); }) .unwrap(); @@ -754,17 +826,19 @@ mod tokio_adapter { assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![24]); scheduler - .socket_wait(&client, Interest::Writable, &NEVER) - .await - .unwrap(); - scheduler - .sleep(std::time::Duration::ZERO, &NEVER) + .socket_wait(&client, Interest::Readable, &NEVER) .await .unwrap(); let (result, buffer) = scheduler.socket_read(&client, Vec::new(), &NEVER).await; + #[cfg(windows)] + assert!(result.is_err()); + #[cfg(not(windows))] assert_eq!(result.unwrap(), 0); assert!(buffer.is_empty()); + let (result, buffer) = scheduler.socket_read(&client, vec![0], &NEVER).await; + assert_eq!(result.unwrap(), 1); + assert_eq!(buffer, vec![99]); let (result, buffer) = scheduler.socket_write(&client, Vec::new(), &NEVER).await; assert_eq!(result.unwrap(), 0); assert!(buffer.is_empty()); @@ -883,6 +957,18 @@ mod tokio_adapter { error_kind(connect(&handle, address, &NEVER).await), io::ErrorKind::BrokenPipe ); + assert_eq!( + error_kind(handle.socket_bind(&socket, address, &NEVER).await), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + error_kind(handle.socket_listen(&listener, 128, &NEVER).await), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + error_kind(handle.socket_connect(&socket, address, &NEVER).await), + io::ErrorKind::BrokenPipe + ); assert_eq!( error_kind(handle.socket_accept(&listener, &NEVER).await), io::ErrorKind::BrokenPipe @@ -1007,10 +1093,60 @@ mod tokio_adapter { let second = Scheduler::new(second_runtime.handle().clone()); let (client, _server) = socket_pair(); let socket = first.register_socket(client).unwrap(); - let (result, buffer) = - second_runtime.block_on(second.socket_read(&socket, vec![0; 7], &NEVER)); - assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput); - assert_eq!(buffer.len(), 7); + let (raw_listener, _) = listening_socket(); + let listener = first.register_listener(raw_listener).unwrap(); + second_runtime.block_on(async { + let (result, buffer) = second.socket_read(&socket, vec![0; 7], &NEVER).await; + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput); + assert_eq!(buffer.len(), 7); + let (result, buffer) = second.socket_write(&socket, vec![1; 7], &NEVER).await; + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput); + assert_eq!(buffer, vec![1; 7]); + assert_eq!( + second + .socket_bind(&socket, "127.0.0.1:0".parse().unwrap(), &NEVER) + .await + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + second + .socket_listen(&listener, 128, &NEVER) + .await + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + error_kind( + second + .socket_connect(&socket, "127.0.0.1:9".parse().unwrap(), &NEVER) + .await + ), + io::ErrorKind::InvalidInput + ); + assert_eq!( + error_kind(second.socket_accept(&listener, &NEVER).await), + io::ErrorKind::InvalidInput + ); + assert_eq!( + second + .socket_wait(&socket, Interest::Readable, &NEVER) + .await + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + second.socket_local_addr(&socket).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + second.socket_peer_addr(&socket).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + }); } #[test] From a5d6575d5f02f1be324d14fceb78a09d3c1f7f10 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 18 Oct 2026 00:29:17 +1300 Subject: [PATCH 4/5] Correct the zero-length socket read expectation. --- crates/executor/src/scheduler/selector/readiness/tests.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/executor/src/scheduler/selector/readiness/tests.rs b/crates/executor/src/scheduler/selector/readiness/tests.rs index 039564a..2b20e42 100644 --- a/crates/executor/src/scheduler/selector/readiness/tests.rs +++ b/crates/executor/src/scheduler/selector/readiness/tests.rs @@ -86,9 +86,6 @@ fn readiness_operations_remain_available_with_other_backends_selected() { .await .unwrap(); let (result, buffer) = selector.socket_read(&socket, Vec::new(), &NEVER).await; - #[cfg(windows)] - assert!(result.is_err()); - #[cfg(not(windows))] assert_eq!(result.unwrap(), 0); assert!(buffer.is_empty()); let (result, buffer) = selector.socket_read(&socket, vec![0], &NEVER).await; From a540f68b90156d0ec0b1503f82b87e05da4c8c5c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 18 Oct 2026 00:36:45 +1300 Subject: [PATCH 5/5] Correct the Tokio empty-read test expectation on Windows. --- crates/executor/tests/io.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/executor/tests/io.rs b/crates/executor/tests/io.rs index e4a3523..aa01946 100644 --- a/crates/executor/tests/io.rs +++ b/crates/executor/tests/io.rs @@ -831,9 +831,6 @@ mod tokio_adapter { .unwrap(); let (result, buffer) = scheduler.socket_read(&client, Vec::new(), &NEVER).await; - #[cfg(windows)] - assert!(result.is_err()); - #[cfg(not(windows))] assert_eq!(result.unwrap(), 0); assert!(buffer.is_empty()); let (result, buffer) = scheduler.socket_read(&client, vec![0], &NEVER).await;