diff --git a/.agents/conventions.md b/.agents/conventions.md index 5bb21e7..47c7619 100644 --- a/.agents/conventions.md +++ b/.agents/conventions.md @@ -5,6 +5,6 @@ - Keep `socketry-executor` in `crates/executor`. Split other packages only when they have useful code and a clear dependency boundary. - New concurrency work uses ordinary futures, not a private coroutine stack per task. The coroutine prototype remains on branch `coroutine`. - Keep native I/O backends under `scheduler/selector/` and runtime adapters alongside their runtime implementation. -- Pass task owners explicitly when libraries spawn child tasks. Preserve barrier ownership, cancellation, and shutdown behavior. +- Keep task dispatch primitive: `Spawn::spawn(future)` returns a task handle without a group or owner argument. Task groups belong above this interface. - Keep public implementation guidance in `context/implementation.md` and architecture decisions in `context/design.md`. - Keep repository-only conventions under `.agents/`; use the Socketry Project releasing skill for the shared publishing process. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7073443..d6f1427 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: - run: cargo clippy --workspace --all-targets --locked -- -D warnings if: matrix.runner == 'ubuntu-24.04' - name: Run tests and require complete source-region coverage - run: cargo bake --locked test:coverage --all-targets true --features tokio + run: cargo bake --locked test:coverage --all-targets true --features tokio,signals test: name: ${{ matrix.name }} @@ -59,13 +59,15 @@ jobs: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - name: Run workspace tests - run: cargo test --workspace --all-targets --features tokio --locked + run: cargo test --workspace --all-targets --features tokio,signals --locked - name: Run documentation tests - run: cargo test --workspace --doc --features tokio --locked + run: cargo test --workspace --doc --features tokio,signals --locked - name: Check executor-only and Tokio-only configurations run: | cargo check --workspace --all-targets --no-default-features --locked cargo check --workspace --all-targets --no-default-features --features tokio --locked + cargo check --workspace --all-targets --no-default-features --features signals --locked + cargo check --workspace --all-targets --no-default-features --features tokio,signals --locked io-uring: name: io_uring ${{ matrix.runner }} @@ -107,7 +109,7 @@ jobs: rustup component add llvm-tools-preview cargo install socketry-cargo-bake --locked cargo install cargo-llvm-cov --locked - cargo bake --locked test:coverage --all-targets true --features tokio + cargo bake --locked test:coverage --all-targets true --features tokio,signals sanitizer: name: Linux x86-64 ${{ matrix.name }} diff --git a/Cargo.lock b/Cargo.lock index 934c37a..68a5143 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,7 +378,10 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ + "fastrand", "futures-core", + "futures-io", + "parking", "pin-project-lite", ] @@ -932,6 +935,26 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -978,11 +1001,13 @@ dependencies = [ "crossbeam-deque", "event-listener", "futures-channel", + "futures-lite", "io-uring", "libc", "loom", "pin-project-lite", "polling", + "signal-hook", "tempfile", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index a2c6d97..e182f5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,3 +33,4 @@ default = ["native"] native = ["socketry-executor/native"] io-uring = ["native", "socketry-executor/io-uring"] tokio = ["socketry-executor/tokio"] +signals = ["socketry-executor/signals"] diff --git a/context/design.md b/context/design.md index a1d12e0..5a4bdba 100644 --- a/context/design.md +++ b/context/design.md @@ -1,6 +1,6 @@ # Future foundation -This document records the direction for Socketry's Rust foundation. The executor now polls futures directly using async-task and Crossbeam queues, with worker threads, work stealing and explicit task owners. The coroutine prototype is preserved in commit `b520f3d` on branch `coroutine`. Portable TCP, file and clock capabilities, native selectors and a Tokio adapter are now implemented. The independent io-event timer queue is implemented in `socketry-timers`; scheduler integration and further optimizations remain planned. +This document records the direction for Socketry's Rust foundation. The executor now polls futures directly using async-task and Crossbeam queues, with worker threads, work stealing and transferable task ownership. The coroutine prototype is preserved in commit `b520f3d` on branch `coroutine`. Portable TCP, file and clock capabilities, native selectors and a Tokio adapter are now implemented. The independent io-event timer queue is implemented in `socketry-timers`; scheduler integration and further optimizations remain planned. ## Goals @@ -15,7 +15,7 @@ This document records the direction for Socketry's Rust foundation. The executor Rust already standardizes the interface between a future and an executor: `Future::poll`, `Context`, and `Waker`. Awaiting a future does not inherently select a particular scheduler. Spawning tasks, creating timers, and opening sockets require additional services, which are not all standardized by `std::future`. -Make those services explicit. A library should receive the capabilities it uses: a task owner, a clock, a connector, or an existing stream. Avoid requiring a complete runtime just to parse a protocol or operate on an existing stream. +Make those services explicit. A library should receive the capabilities it uses: task dispatch, a clock, a connector, or an existing stream. Avoid requiring a complete runtime just to parse a protocol or operate on an existing stream. Zig's `std.Io` is a useful reference for passing an implementation into code. The Rust translation uses future-returning operations and ordinary `.await`. It does not reproduce Zig's synchronous-looking suspension mechanism. @@ -30,7 +30,7 @@ All published workspace packages inherit one version from `[workspace.package]` | Package | Responsibility | | --- | --- | | `socketry` | Public facade, common exports, conventions, and agent context. | -| `socketry-executor` | Future execution, workers, task ownership, barriers, cancellation, and integration with I/O selectors and timers. | +| `socketry-executor` | Future execution, workers, task dispatch, ownership, cancellation, and integration with I/O selectors and timers. | | `socketry-io` | Portable I/O contracts, resource and buffer ownership, and stream interfaces. | | `socketry-timers` | Timer ordering, cancellation, batching, lazy sleeps and worker queue assistance, independent of an executor or OS selector. | | `socketry-tokio` | Tokio implementations of the portable contracts and focused compatibility adapters. | @@ -41,34 +41,47 @@ Keep protocol parsing and serialization independent of the runtime wherever poss The executor polls pinned future state on worker threads' ordinary stacks. Use Send futures for tasks eligible to run on different workers. A local task facility can accept non-Send futures with explicit thread restrictions. Pinned future storage stays at a stable address while the worker allowed to poll it may change. Only one worker may poll a task at a time. -The scheduler owns top-level tasks. A parent task explicitly owns child groups through barriers. Both a scheduler and a barrier expose the spawning contract, so accepting connections can take either as its task owner. The owner controls the task lifetime; execution still goes through the selected executor. +Returned task handles own execution lifetimes; the scheduler retains task storage until completion or destruction. Schedulers and their handles expose the primitive `Spawn` contract: dispatch accepts a future and returns a task handle, without a group or owner argument. Independently spawned tasks have no implicit parent-child lifecycle. -- Spawning registers ownership before a task can run. -- Joining reports completion and failures to the owner. -- Waiting for a barrier waits for its owned children. -- Stopping requests cancellation and waits for termination and cleanup. -- Closing an owner prevents new children from escaping into a stopping group. -- Dropping a public handle must have a documented policy; it must not accidentally detach a child that still belongs to a barrier. +- Register the task before making it runnable. +- Await its handle to observe completion and failures. +- Moving the handle transfers ownership. Dropping it requests future destruction after any active poll, without waiting. +- Destroying a task drops handles it still owns. Handles moved elsewhere remain owned there, independently of their spawning task. +- Closing the scheduler prevents new submissions; shutdown joins task destruction. + +Higher-level groups can be built above dispatch. The executor does not provide a Barrier or require group membership for spawning. Use owned, `'static` futures for independently spawned tasks initially. A parent-child relationship alone does not make borrowing a parent's local variables safe. Scoped borrowing requires a separate lifetime design that remains sound when futures or handles are dropped or forgotten. ### Cleanup in a future executor -Stable Rust's Drop implementation cannot await. A barrier destructor can request cancellation, but cannot promise that children on other workers have finished cleanup when it returns. An explicit `stop().await` can wait. - -For automatic ownership cleanup, the executor must retain the task/group record while children drain. It can delay reporting the parent as fully terminated until descendants finish. This is a task lifecycle guarantee; it does not keep every local variable in a dropped parent future alive. +Ordinary Drop cannot await asynchronous cleanup. Task handles provide explicit completion tracking while the scheduler remains alive and continues polling. Any higher-level group must define how its caller confirms draining; dropping a group alone cannot promise completion on other workers. Distinguish cooperative cancellation, dropping a future, and waiting for termination. Dropping a future runs synchronous destructors but does not run the remainder of its async body. Graceful asynchronous shutdown requires a live future that the runtime continues to poll. Runtime shutdown must define how cancellation and pending I/O completions are drained. Contextual APIs such as `Scheduler::current()` can remain conveniences. Set and restore their context around every poll, including when polling panics; do not assume a future always runs on the thread where it was created. +### Cooperative cancellation + +The shutdown direction is cooperative: request graceful termination and let the application continue polling until its work completes. Cooperative requests have no automatic abort, second-request escalation, or grace-period deadline. Explicitly dropping an owning task handle abandons its future and bypasses asynchronous cleanup. If a process refuses to stop, its supervisor can follow SIGTERM with SIGKILL. + +`Cancellation` is implemented in `socketry-executor` and re-exported by `socketry`. It works independently of the executor. Clones share a persistent signal; child signals receive ancestor cancellation without propagating it to parents or siblings. This explicit cancellation tree expresses service boundaries without imposing an implicit task hierarchy. Dropping a signal does not cancel it. Rust destructors release retained resources, but asynchronous draining still requires live futures and explicit completion tracking through task handles. + +`cancel()` signals intent; `check()` returns `Cancelled` at cooperative cancellation points; `cancelled().await` suspends using wakers. `never()` represents work without cooperative cancellation. File and socket operations now accept explicit signals and return cancellation separately from OS errors. Monotonic, cancellation-aware Clock signatures remain planned. + +`defer_cancel(&signal, future, on_cancel)` observes the signal, invokes a synchronous shutdown callback once, and keeps awaiting the future's normal output. The callback tells the protected work to stop accepting new work or begin draining. Async cleanup belongs in that future, with cancellation inputs that allow it to finish. The wrapper uses standard future polling, handles pinned futures, and neither busy-waits nor prevents its own destruction. + +The intended application sequence is to request application or service cancellation, await task completion while runtime services remain available, then release the scheduler. Applications explicitly await the tasks they want to finish; there is no implicit wait for every child. Backends still retain resources for outstanding operations and must honour accepted submissions. Existing Task cancellation and scheduler shutdown still destroy futures and bypass deferred async cleanup; they are distinct from cooperative cancellation. + +`Signals::signal_notify` explicitly connects signals to an ordinary `Notify` target. Cancellation requests shutdown; an unbounded channel queues other events such as reload requests. Waiting remains specific to the destination. An associated, must-use registration guard owns the subscription; the scheduler/runtime must remain alive and driven for delivery. Dropping it stops admission without awaiting already-dispatching callbacks. The optional Unix `signals` backend spawns one task per subscription, asynchronously reading signal-hook's self-pipe through the chosen runtime's I/O services. The guard retains the task handle; removal drops it after stopping dispatch admission, and scheduler shutdown also ends delivery. It creates no dedicated signal thread. It never calls user notification code in a raw signal handler. OS/backend coalescing and persistent process-wide signal dispositions remain part of the contract; no forced termination or escalation policy is installed. + ### Current executor The implementation currently lives in socketry-executor. async-task owns pinned future storage and its runnable/waker state. Socketry supplies thread management, ownership, cancellation flags, task identity and queue selection. Each worker has a FIFO Crossbeam queue plus an incoming injector for remote wakeups. External submissions enter a global injector; worker submissions go to that worker's queue. Wakeups target the last worker. Only workers without their own work steal from other local queues or incoming queues. Periodic external checks prevent a busy local queue from starving submissions. Idle flags and a post-registration queue search coordinate parking and wakeups. Task registration and completion use an ownership mutex; ordinary polling and wakeups use the task state and queues without that mutex. -Scheduler, SchedulerHandle and Barrier implement Spawn. Its associated handle type permits runtime-specific join implementations. Public TaskHandle drop abandons a result while leaving execution owned. Awaiting it reports output, cancellation or a panic payload. Barrier wait/stop join direct children; parent termination does not yet automatically wait for descendants. Explicitly await barriers when that guarantee is required. +Scheduler and SchedulerHandle implement Spawn. Its associated handle type permits runtime-specific join implementations. Public TaskHandle drop abandons execution; moving the handle transfers its lifetime to the recipient. Awaiting it reports output, cancellation or a panic payload. There is no implicit task hierarchy; group coordination belongs above dispatch. Native I/O and sleep capabilities now accompany the executor. Regular-file fallbacks use blocking pools; there is no public blocking-task API or local non-Send task executor. Coroutine sources and historical tests are preserved on their branch and are no longer built by this workspace. @@ -84,7 +97,7 @@ Tokio and futures-io expose different AsyncRead and AsyncWrite traits. `tokio-ut Entering a Tokio runtime context provides access to its services; entering alone does not drive the runtime. Some mixed execution is possible when the required services are running, but must be established for the concrete APIs being used. Do not advertise universal Tokio compatibility from a Waker or stream adapter alone. -The optional `scheduler::tokio` adapter implements Network, FileIo, Clock and Spawn against an existing runtime. It preserves direct-child barrier ownership and joins owned task destruction on asynchronous shutdown. The same generic TCP program runs on Socketry and Tokio. The adapter scopes runtime context to individual polls when registering resources; its futures can also be polled by Socketry workers while Tokio drives the underlying services. Socketry contextual lookups still identify Socketry execution; portable code passes handles explicitly. +The optional `scheduler::tokio` adapter implements Socket, File, Clock and Spawn against an existing runtime. It preserves scheduler task ownership and joins owned task destruction on asynchronous shutdown. The same generic TCP program runs on Socketry and Tokio. The adapter scopes runtime context to individual polls when registering resources; its futures can also be polled by Socketry workers while Tokio drives the underlying services. Socketry contextual lookups still identify Socketry execution; portable code passes handles explicitly. For existing libraries tied to Tokio, either keep their work on Tokio and bridge owned messages/results, or provide the particular trait adapter they consume. Avoid a broad imitation of Tokio's API. @@ -116,10 +129,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. 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 current owned-buffer API uses Vec values and returns `(io::Result, -Vec)`. 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. Socket and listener types are associated with the concrete implementation. Tokio registrations reject use through another runtime's adapter. -This is an initial implementation: each ring operation uses 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. 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. ## Timer port @@ -157,9 +169,9 @@ The algorithm's existing Ruby performance motivates the port. Rust performance c 1. Record boundaries and reusable Rust conventions (implemented). 2. Replace stackful execution with async-task and Crossbeam worker queues (implemented). Keep the coroutine prototype in its saved branch. -3. Implement explicit owners, barriers, cancellation and shutdown (implemented for direct children). Automatic descendant draining remains future work. -4. Establish minimal clock and I/O contracts with concrete consumers (implemented with Network, FileIo, Clock and a portable TCP example). -5. Implement native readiness and the Tokio adapter, running the same consumers with both (implemented). Native sleep uses async-io until the timer port. +3. Implement primitive task dispatch, scheduler ownership, cancellation and shutdown (implemented). Runtime-independent cooperative signals and deferred cancellation are implemented; cancellation-aware file/socket operations and transferable task ownership are implemented. Clock cancellation and submission-only cleanup remain future work. Higher-level task groups are outside the executor for now. +4. Establish minimal clock and I/O contracts with concrete consumers (implemented with Socket, File, Clock and a portable TCP example). +5. Implement native readiness and the Tokio adapter, running the same consumers with both (implemented). Native sleep uses async-io until scheduler integration with `socketry-timers`. 6. Implement io\_uring's owned-buffer lifecycle, socket/file operations, cancellation and runtime probing (implemented). Improve operation reuse, buffer registration and submission backpressure in subsequent work. 7. Port the timer queue with upstream attribution and deterministic verification (implemented in `socketry-timers`). Integrate a driven sleep future with the scheduler in subsequent work; see the [timer package](../crates/timers/readme.md) and [benchmarks](../crates/timers/context/benchmarks.md). 8. Measure the executor and complete system. Multiple workers and work stealing are implemented, but performance claims require benchmarks. diff --git a/context/gaps.md b/context/gaps.md new file mode 100644 index 0000000..214b629 --- /dev/null +++ b/context/gaps.md @@ -0,0 +1,31 @@ +# Scheduler Gaps + +Socketry has a working future executor, owning task handles, portable I/O capabilities, and a Tokio adapter. These four gaps describe the remaining architectural work toward a scheduler inspired by [Zig's `std.Io` model](https://ziglang.org/download/0.16.0/release-notes.html#I-O-as-an-Interface), while retaining ordinary Rust futures and `.await`. + +## 1. Graceful shutdown requires explicit coordination + +Task cancellation currently drops the future before its next poll. An active poll must return first. Synchronous destructors run, but the remainder of the async body, including asynchronous cleanup, does not execute. + +`Cancellation` and `defer_cancel` now provide runtime-independent cooperative requests and deferred cleanup. Signals can be shared or linked through child signals to shut down the application or an individual service. They signal intent; task handles still confirm completion. + +File and socket operations now accept these signals. Applications explicitly await tasks they want to finish; abandoning an owning task handle requests destruction. Graceful shutdown must keep workers, I/O services, and the scheduler available until draining completes. Cooperative cancellation has no automatic forced abort or internal escalation; a process supervisor handles SIGTERM followed by SIGKILL. Existing task cancellation and scheduler shutdown still destroy futures, so callers must request cooperative cancellation and await work first. + +## 2. Higher-level task grouping is deferred + +`Spawn::spawn(future)` is primitive dispatch shared by schedulers and their handles. It registers a task and returns its owning handle. Moving that handle transfers its lifetime; destroying a task drops handles it still owns; callers await individual handles when completion matters. + +The executor does not expose a Barrier or require a group/owner argument. Libraries can eventually provide groups above this primitive, coordinating cooperative cancellation and completion without adding an implicit hierarchy to dispatch. A portable higher-level group lifecycle remains unspecified and is outside the executor for now. + +## 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 positioned I/O. File/socket operations support cancellation and owned buffer views. `Clock` currently supplies relative sleep. + +General socket creation/options, DNS, deadlines, randomness, and processes remain outside the portable boundary. 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 + +The default selector delegates readiness and timers to async-io's shared reactor. Linux io\_uring uses a dedicated selector thread and retains buffers until original operations complete; cancellation completions alone do not release them. + +The io\_uring implementation currently uses an unbounded command channel, a boxed typed request, and a completion channel per operation. Operation records are not pooled, and buffers are not registered with the kernel. Add submission backpressure and measure allocation, contention, throughput, and retained memory before choosing optimizations or making performance claims. + +See the [design guide](design.md) and [implementation guide](implementation.md) for the current contracts and implementation sequence. diff --git a/context/implementation.md b/context/implementation.md index 89ac351..54de576 100644 --- a/context/implementation.md +++ b/context/implementation.md @@ -17,26 +17,47 @@ This guide describes the current implementation and its boundaries. Read [the de ## Ownership -- Scheduler and Barrier implement Spawn, with a runtime-specific associated handle type. Adapters need to preserve its ownership and result contract. +- Scheduler and SchedulerHandle implement Spawn, with a runtime-specific associated handle type. Adapters need to preserve its ownership and result contract. - Register ownership before publishing a runnable task. -- Dropping TaskHandle abandons the result without cancelling the owner's task. +- TaskHandle owns execution lifetime. Moving it transfers ownership; Drop requests destruction after any active poll, without waiting or running asynchronous cleanup. - Task cancellation sets a flag and wakes it. The future is destroyed after an in-progress poll returns. TaskHandle::cancel awaits that destruction. -- Barrier::close prevents admission; wait awaits direct children; stop closes, cancels and waits. Drop requests cancellation without waiting. -- Awaited task results report cancellation or the original panic payload. Barrier waits and Scheduler::run do not aggregate child errors. -- Parents must explicitly await barriers for joined cleanup. Automatic waiting for descendants after parent destruction is not implemented. +- Awaited task results report cancellation or the original panic payload. Scheduler::run does not aggregate task errors. +- Dispatch takes a future and returns a handle without a group or owner argument. There is no implicit task tree: destruction drops handles still owned by a future; transferred handles remain owned elsewhere. Higher-level groups belong above Spawn and are outside the executor for now. - Scheduler Drop cancels all tasks, joining threads outside a worker. On a worker it requests shutdown without joining. Surviving handles reject spawn. +## Cooperative cancellation + +- `cancellation.rs`, `cancelled.rs`, and `defer_cancel.rs` implement runtime-independent primitives, exported by both `socketry-executor` and the `socketry` facade. +- Cancellation clones share state. Children receive ancestor cancellation, retain ancestors after intermediate handles are dropped, and never cancel parents or siblings. Dropping signals does not cancel work. `Cancellation::never()` allocates nothing; its children are independent cancellable signals. +- Each family serializes child registration and cancellation with one mutex. Atomic flags support cheap checks. Nodes hold weak child registrations; child destruction unregisters them. Cancellation and exclusively owned ancestor destruction are iterative, avoiding stack exhaustion on deep chains. +- Register event listeners before checking cancellation to avoid lost notifications. Dropping a wait unregisters its listener. Release all signal locks before invoking wakers so wakeups can reenter cancellation and child registration. +- defer\_cancel pins the work and its signal wait. Observed cancellation consumes the synchronous callback once, before polling the work again, and the wrapper preserves the work's normal output. Callback panics propagate; dropping the wrapper destroys its future. It does not mask operation signals or intercept task destruction. +- For graceful shutdown, request cancellation and await task handles before releasing the scheduler or shutting down runtime services. Signals express intent rather than completion. There is no forced abort or escalation in this cooperative contract; a supervisor can enforce SIGTERM followed by SIGKILL. +- Existing Task cancellation and scheduler shutdown retain their destruction semantics. File/socket methods accept cancellation. Applications explicitly await completion before destroying services; a general implicit child drain is not required. Accepted backend work retains its resources independently of abandoned callers. + +## Signal notifications + +- `Notify` is a synchronous destination, independent of waiting. Cancellation ignores the value; standard unbounded mpsc and futures-channel senders queue it and discard values after receiver closure. Arc forwards to a destination. Bounded channels require an explicit application overflow policy. +- `Signals` is a separate scheduler capability with associated signal and registration types. The optional `signals` feature implements it on Unix for native and Tokio schedulers and their handles, using their respective I/O services. Other platforms lack the implementation at compile time. +- Signal registration is explicit and fallible. Check the complete signal set for forbidden and out-of-range values before installing handlers; report further platform-specific registration errors from the backend. No signal handlers are installed merely by creating a scheduler. Closed scheduler handles reject new registrations. +- `SignalRegistration` retains an owning task handle from the chosen scheduler. It uses signal-hook's self-pipe registered with async-io or the supplied Tokio runtime, without a dedicated thread or hidden cross-runtime reactor. Call Notify outside raw signal handlers and all scheduler locks. The task owns its target; callback panics follow normal scheduler handling. Targets should return promptly. +- Dropping the non-clonable, must-use guard stops dispatch admission and abandons the owned task without joining it. A notification admitted before removal can finish. Other subscriptions remain active. Scheduler/runtime shutdown also ends delivery and releases the subscription. Keep services alive through graceful draining; no implicit shutdown or escalation is installed. +- The task checks cancellation before dispatch and yields after each batch to avoid monopolizing a worker under continuous signal traffic. The reader must clear backend readiness through actual asynchronous reads; observing readiness alone can spin. EOF or a terminal read error ends the subscription task. +- Signals may coalesce in the OS and backend before being queued. Cleanup does not restore original process dispositions, even after a partially successful setup. Coordinate with existing process-wide handlers. signalfd/kqueue optimizations remain future work. +- Actual signal delivery tests run in a deadline-bounded subprocess to avoid changing the parent test process's signal dispositions. + ## I/O and runtime boundaries -- `scheduler.rs` re-exports Network, FileIo, Interest and Clock from `scheduler/network.rs`, `file_io.rs`, `interest.rs` and `clock.rs`. Operations return concrete Send futures; portable consumers receive the required capabilities. +- `scheduler.rs` re-exports Socket, File, Interest and Clock from `scheduler/socket.rs`, `file.rs`, `interest.rs` and `clock.rs`. Operations return concrete Send futures; portable consumers receive the required capabilities. +- `File` declares an associated resource and opens it with `FileOptions`; native/Tokio choose `Arc` internally. `Socket` creates TCP listeners and exposes address inspection; standard-library resource import belongs to `SocketRegistration`. Blocking positioned file helpers live in `scheduler/positioned_file.rs`. - `scheduler/socketry.rs` owns the executor; `socketry/operations.rs` forwards capabilities to its lazily initialized, compile-time selected selector. - `scheduler/selector/` contains readiness, epoll, kqueue, iocp and io\_uring. Platform readiness modules share async-io's persistent registrations and process-wide reactor. Registered sockets remain usable as tasks migrate. - Default feature `native` provides TCP, positioned files and sleep. Feature `io-uring` selects Linux completion reads/writes; other supported platforms retain readiness. Feature `tokio` enables the separate runtime adapter. No default features builds the executor and contracts without native I/O. -- Read/write buffers are owned Vec values, returned with ordinary errors as well as success. Reads use the initialized length and leave it unchanged. Operations can be partial; cancellation can consume or transmit bytes. +- Read/write buffers implement unsafe `IoBuf`/`IoBufMut` contracts guaranteeing stable initialized storage. Vec, boxed slices, and owned `BufferSlice` ranges are supported and returned with errors or cancellation. Reads use the initialized length and leave it unchanged. Operations can be partial; cancellation can consume or transmit bytes. - io\_uring's dedicated selector thread owns buffers and descriptors until the original terminal completion, never merely a cancellation completion. Numeric identifiers are not reused. Pending, unsubmitted requests can return buffers immediately. Unexpected failure retains kernel-accessible resources and panics waiting operations; do not free buffers with unknown completion. - Ring shutdown closes admission, cancels and drains completions. Scheduler Drop outside a worker waits for this drain; Drop on a worker only requests it. Shutdown racing lazy initialization must still close the new selector. - Regular files use blocking pools except with io\_uring. An already-started blocking operation can outlive its waiting task. Use non-append regular files and explicit offsets; the Windows fallback also changes the shared cursor. -- `scheduler/tokio.rs` adapts an existing runtime with I/O and time enabled. It preserves Spawn/barrier ownership, but does not own or drive that runtime. Its shutdown joins owned task destruction, not the runtime's blocking pool. +- `scheduler/tokio.rs` adapts an existing runtime with I/O and time enabled. Its Spawn handles own execution lifetimes; the adapter does not own or drive that runtime. Its shutdown joins owned task destruction, not the runtime's blocking pool. - Register Tokio task ownership before spawn, but release the registry mutex before calling into Tokio: a stopped runtime may synchronously drop a future. Future destruction precedes ownership completion. Never hold an EnterGuard across await; enter per poll for operations that register resources. - Tokio resources carry runtime identity. Explicit handles select that runtime; Socketry's Task::current and Scheduler::current remain Socketry-specific. @@ -48,6 +69,10 @@ This guide describes the current implementation and its boundaries. Read [the de - Do not introduce unsafe Send, stack migration or nested blocking waits. - Native coroutine sources, sanitizer hooks and historical tests belong to the preserved coroutine branch, not the future executor's build. +## 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`. + ## Verification Public behavior is covered in crates/executor/tests. Deterministic channels force stealing, migration, concurrent wakeups and cancellation during polling. The parking test uses Loom to model the queue-publication/idle-registration handshake. Keep its atomics and fence order aligned with the implementation; this models the handshake, not Crossbeam or async-task internals. When verification is requested, run workspace tests and doctests with `tokio` enabled; on Linux also run all features to exercise io\_uring. Check executor-only and Tokio-only feature combinations. The same TCP/file consumers exercise both implementations; Linux tests cover cancellation batches and shutdown races. CI covers Linux, macOS, Windows and FreeBSD, with separate Linux io\_uring and sanitizer jobs; distinguish configured CI from executed results. diff --git a/crates/executor/Cargo.toml b/crates/executor/Cargo.toml index 2541178..b818e5a 100644 --- a/crates/executor/Cargo.toml +++ b/crates/executor/Cargo.toml @@ -6,7 +6,7 @@ description = "Owned asynchronous tasks and a work-stealing future scheduler" license.workspace = true repository.workspace = true readme = "readme.md" -include = ["Cargo.toml", "license.md", "readme.md", "src/**", "examples/**"] +include = ["Cargo.toml", "license.md", "readme.md", "design.md", "src/**", "examples/**"] [lib] name = "socketry_executor" @@ -18,21 +18,27 @@ async-io = { version = "2.6.0", optional = true } blocking = { version = "1.6.2", optional = true } crossbeam-deque = "0.8.7" event-listener = "5.4.2" -futures-channel = { version = "0.3.31", optional = true } +futures-channel = "0.3.31" pin-project-lite = "0.2.17" tokio = { version = "1.53.1", optional = true, features = ["rt", "net", "fs", "time", "io-util"] } +[target.'cfg(unix)'.dependencies] +signal-hook = { version = "0.4.5", optional = true, default-features = false, features = ["iterator"] } + [target.'cfg(target_os = "linux")'.dependencies] -io-uring = { version = "0.7.11", optional = true, features = ["io_safety"] } libc = { version = "0.2.189", optional = true } +io-uring = { version = "0.7.11", optional = true, features = ["io_safety"] } polling = { version = "3.11.0", optional = true } [features] default = ["native"] native = ["dep:async-io", "dep:blocking"] -io-uring = ["native", "dep:io-uring", "dep:futures-channel", "dep:libc", "dep:polling"] +io-uring = ["native", "dep:io-uring", "dep:libc", "dep:polling"] +signals = ["dep:signal-hook"] tokio = ["dep:tokio"] [dev-dependencies] +libc = "0.2.189" +futures-lite = "2.6.1" tempfile = "3" loom = "0.7.2" diff --git a/crates/executor/design.md b/crates/executor/design.md new file mode 100644 index 0000000..4f86f39 --- /dev/null +++ b/crates/executor/design.md @@ -0,0 +1,785 @@ +# Executor Design + +This document records the agreed direction for `socketry-executor`: an explicit scheduler and I/O interface inspired by [Zig 0.16's I/O model](https://ziglang.org/download/0.16.0/release-notes.html#I-O-as-an-Interface), expressed through ordinary Rust futures, capability traits, and cooperative cancellation. It includes proposed interfaces; the implementation status below distinguishes them from APIs that exist today. + +## Primary goal + +Expose an abstract scheduler and I/O interface with one or more concrete implementations. Generic concurrency primitives and libraries receive an explicit scheduler implementation and build on its primitive operations. Their code should work with native, runtime-adapter, or simulated implementations without depending on concrete runtime types or operating-system resource creation. + +Capability traits describe parts of this common interface on the same scheduler type. They let a consumer state the operations it needs while retaining compile-time checking and static dispatch. They must share compatible resource, cancellation, completion, and ownership contracts so higher-level code can compose them through one scheduler dependency. + +Task groups, barriers, channels, and protocol helpers belong above the primitive boundary. A group can use dispatch, cooperative cancellation, and task handles; a channel can suspend and wake futures through Rust's standard future/waker contract. Primitives that need deadlines or I/O additionally require the corresponding scheduler capabilities. Higher-level code should not select a runtime or create OS resources directly. + +## Execution model + +Use Rust's existing `Future`, `Context`, and `Waker` contracts. A separate computation trait is unnecessary for the current design. Operations construct futures; awaiting them allows the executor to poll them and suspend until a wakeup permits progress. Polling does not mean busy waiting. Selected request operations distinguish waiting for admission from waiting for execution to complete, as described below; constructing their futures does not submit work. + +The scheduler executes pinned futures on ordinary worker stacks. Independently spawned tasks are `Send + 'static` and can migrate between workers, while their pinned storage remains stationary. Only one worker polls a task at a time. Blocking system calls must run outside the workers that poll futures. + +Pass the implementation into libraries explicitly. A library should require the capabilities it actually uses rather than implicitly selecting a global runtime. Task dispatch takes a future and returns a task handle; it does not require a group or owner argument. Existing resources can be passed directly to code that does not need to create them. + +## Capability traits and static dispatch + +`File`, `Socket`, and `Clock` name scheduler capabilities. `Spawn` provides primitive task dispatch and returns an owning task handle. `File::File`, `Socket::Socket`, and `Socket::Listener` are associated resource types chosen by each implementation. `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. + +Keep core traits small and dependable. Implementing a core trait requires working implementations of its operations for the documented resource types and inputs. Avoid default methods that simply return `Unsupported`. + +Additional capabilities belong in extension traits where their semantics or platform availability differ. A consumer requiring an extension declares that requirement through its trait bounds. An incompatible scheduler then fails at compile time. For example, `S: File + FileAllocate` illustrates how a consumer might require allocation; `FileAllocate` is a proposed name, not an existing trait or a settled trait boundary. + +Do not create one trait per syscall automatically. Group operations around capabilities that real consumers need, and keep inherently platform-specific types and flags in platform extensions. + +### Common scheduler dependency and resource creation + +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. + +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`; positioned operations borrow `&Self::File`. The native and Tokio implementations choose `Arc` internally. `Socket` includes listener/local/peer address inspection. Optional `SocketRegistration` imports existing `std::net` resources and is not required of simulated implementations. + +## Platform support and fallbacks + +Backend selection belongs inside scheduler construction or resource initialization, rather than in application error handling around every operation. + +| Situation | Intended contract | +| --- | --- | +| Native asynchronous operation exists | Use the native implementation. | +| Equivalent blocking operation exists | Run it on a blocking pool and preserve the same future and result semantics. | +| Entire capability has no valid implementation | Do not implement the corresponding trait for that scheduler/platform. | +| Capability depends on the running kernel or a registration facility | Probe when initializing the backend or creating the capability/resource. | +| A particular filesystem, resource, or flag combination rejects an operation | Return an ordinary operation error, preserving the OS error where available. | +| Interface is inherently platform-specific | Expose it through an extension trait or platform module. | + +A trait bound guarantees an implementation exists; it cannot guarantee that every filesystem supports every allocation mode or that every operation succeeds. Runtime failures remain part of the result contract. `io::ErrorKind::Unsupported` can represent a genuine resource or feature limitation, but should not be the routine result of calling a core operation on a supported platform. + +Use a blocking pool when native asynchronous support is unavailable. This is acceptable on Windows and other platforms. A fallback may change performance, but must preserve the documented semantics. Allocation cannot silently become truncation, message I/O cannot discard ancillary data, and a positioned operation cannot alter the shared cursor if its contract promises to preserve it. + +Bound blocking concurrency and provide asynchronous admission/backpressure. For ordinary cancellation-aware operations, cancellation can prevent queued work from starting. The non-cancellable submission/completion pairs below retain responsibility once admitted. Once a blocking syscall has started, the operation may have to finish; retain its resources and buffer throughout. A cancellation result must not imply that completed side effects were undone. + +An application explicitly selecting a specialized backend may receive an initialization failure instead of automatic fallback. The policy must be clear at that boundary. The current Linux io\_uring backend follows this approach: it probes its requirements and fails initialization when they are unavailable. Automatic backend fallback remains a design option, not implemented behaviour. + +## Cancellation and task ownership + +Cancellation signals intent. Task handles establish completion. Resource ownership determines what remains alive. Keep these responsibilities distinct. + +Rust's destruction semantics help release owned resources and make an implicit cancellation hierarchy unnecessary. They do not replace task ownership or asynchronous cleanup: ordinary `Drop` cannot await, and dropping a future does not execute the rest of its async body. An owning task handle controls whether an independently spawned future remains live, and awaiting the handle establishes its completion. + +Use explicit cancellation scopes to describe shutdown relationships. A process can have a root signal and each service can have a child signal. This cancellation tree need not mirror the task ownership graph. A task tree is not imposed on every future. + +### Cancellation interface + +`Cancellation` lives in `socketry-executor` and is re-exported by the `socketry` facade. It works independently of a scheduler. + +| Operation | Behaviour | +| --- | --- | +| `Cancellation::new()` / `Default` | Create an independent, uncancelled signal. | +| `clone()` | Share the same signal. | +| `child()` | Create a distinct signal that also receives ancestor cancellation. | +| `cancel() -> bool` | Request cancellation; return true only for the first request on that signal. | +| `is_cancelled()` | Observe the persistent state. | +| `check() -> Result<(), Cancelled>` | Check at an explicit cooperative cancellation point. | +| `cancelled().await` | Wait using normal future polling and wakeups. | +| `Cancellation::never()` | Supply an allocation-free signal that cannot be cancelled. | + +Cancelling a child leaves its parent and siblings running. A child created after its parent is cancelled starts cancelled. Dropping a signal does not cancel it. A child of `never()` is an independent cancellable signal. Repeated cancellation requests are idempotent and never escalate. + +### System and service shutdown + +The agreed shutdown contract is cooperative. Process signal integration should translate SIGINT/SIGTERM into a request on the application's root signal. Cancelling a service's child signal requests shutdown of that service without shutting down unrelated work. + +Graceful shutdown proceeds as follows: + +1. Request cancellation for the application or service scope. +2. Stop accepting new work into that scope. +3. Keep polling existing work so it can drain and perform asynchronous cleanup. +4. Await task handles to confirm completion. +5. Release resources and shut down runtime services after draining finishes. + +Workers, selectors, timers, blocking-operation services, and the scheduler must remain available throughout draining. A process-level cancellation request must not immediately disable the services that cleanup needs. + +There is no default forced abort, grace-period deadline, or escalation on a second request. Work may choose to ignore cancellation. A process supervisor can enforce termination by following SIGTERM with SIGKILL. An application can also abandon futures and destroy its runtime, giving up asynchronous cleanup; this does not remove the runtime's obligation to retain memory still accessible by the kernel. + +### Signal notifications and registration lifetime + +Signals are inputs to application policy, not inherently cancellation requests. The implemented `Notify` trait has one synchronous method, `notify(&self, value)`. Cancellation ignores the value and requests shutdown; unbounded `std::sync::mpsc::Sender` and `futures_channel::mpsc::UnboundedSender` queue it. A closed receiver discards values. Bounded senders require an application wrapper that defines overflow behavior. `Arc` forwards to its target. Waiting stays target-specific: `Cancellation::cancelled()` or a channel receive; there is no shared waiting trait. + +The implemented scheduler capability is: + +```rust,ignore +pub trait Signals: Send + Sync { + type Signal: Copy + Send + 'static; + type Registration: Send + 'static; + + fn signal_notify( + &self, + signals: &[Self::Signal], + target: Target, + ) -> io::Result + where + Target: Notify + 'static; +} +``` + +Registration is explicit, synchronous, and fallible. The optional `signals` feature supplies Unix implementations for native and Tokio schedulers and their handles, with `c_int` signal identifiers and a `#[must_use] SignalRegistration`. The trait remains available for other implementations; unsupported platforms have no built-in implementation. Scheduler construction installs no handlers. Closed scheduler handles reject new registrations. + +```rust,ignore +use socketry_executor::{Cancellation, Signals, signal::{SIGINT, SIGTERM, SIGHUP}}; + +let shutdown = Cancellation::new(); +let shutdown_registration = scheduler.signal_notify(&[SIGINT, SIGTERM], shutdown.clone())?; +let (reload, reloads) = futures_channel::mpsc::unbounded(); +let reload_registration = scheduler.signal_notify(&[SIGHUP], reload)?; +// Consume reloads through the channel; request shutdown through Cancellation. +// Keep both registrations alive while the application drains. +drop(reload_registration); +drop(shutdown_registration); +``` + +Dropping the guard stops new dispatch admission for that subscription and requests backend cleanup. Already-admitted dispatch may finish and must retain its destination until completion. Drop does not join callbacks or await cleanup. Other subscriptions remain active. Each registration retains the owning handle of a task executed by its scheduler. The application must keep both the guard and scheduler/runtime alive and driven throughout graceful draining. Scheduler shutdown also ends delivery and releases the subscription, even if a guard survives. Repeated shutdown signals have no automatic escalation behavior. + +`Notify` runs in ordinary thread context, outside raw signal handlers and scheduler locks. It may enqueue or wake work without being async-signal-safe, and should return promptly. The Unix backend uses signal-hook's self-pipe with an asynchronous reader in a spawned scheduler task: async-io for native, or the supplied Tokio runtime for the adapter. There is no dedicated thread per registration and no hidden cross-runtime reactor. Guard removal stops dispatch admission and drops the task handle; the task releases its subscription once any active poll finishes. The task yields after each batch for fairness. Callback panics follow the scheduler's normal task panic handling; EOF or a terminal read error also ends delivery. Linux signalfd or kqueue can be future implementations with the same public contract, subject to their process/thread setup requirements. + +The OS and backend can coalesce repeated signals before dispatch and need not preserve ordering between different signals. A queue retains delivered values while its consumer is busy; it cannot reconstruct every original occurrence. Removing a registration does not restore original process-wide signal dispositions, even after partial setup failure. Applications must coordinate signal ownership with other libraries and existing handlers. + +### Deferred cancellation + +The implemented wrapper is: + +```text +defer_cancel(&cancellation, future, on_cancel) +``` + +It observes cancellation while polling the protected future. On the first observation, it invokes the synchronous `FnOnce()` callback and continues polling the future to its normal output. The callback can stop an accept loop, request draining, or wake the protected work. Asynchronous cleanup stays inside the future. + +The callback runs before the next protected poll, including the first poll if the signal is already cancelled. If cancellation and completion are observed in the same wrapper poll, the callback runs and the normal output is returned. Callback panics propagate. + +The wrapper does not mask cancellation inputs passed to operations inside the future. Cleanup must use inputs that permit it to complete, such as a separate scope or `Cancellation::never()`. Dropping the wrapper still drops its future; it does not intercept task destruction. + +### Owning task handles and runtime lifetime + +`Spawn::spawn(future)` returns a movable, awaitable owning handle. Awaiting it explicitly waits for the task result. Dropping it abandons unfinished work: the runtime destroys the future after any currently active poll returns. Drop does not wait, run code after an await, or request graceful cancellation through an application's `Cancellation`. + +```rust,ignore +let child = scheduler.spawn(async move { do_work().await })?; +let waiter = scheduler.spawn(async move { + // Ownership moves here; this task now decides whether to await or drop it. + child.await +})?; +let result = waiter.await?; +``` + +There is no implicit parent/child tree. Destroying an owner drops the handles it still contains, which in turn abandon their tasks. A handle moved into another task or returned to a caller survives the original owner's destruction. Handles are `Send + 'static`; forgetting one deliberately relinquishes normal destructor cleanup and does not establish completion. + +Native handles request task destruction through the scheduler; Tokio handles abort the underlying task on drop. The existing explicit native `Task::cancel` also requests destruction. Scheduler shutdown and destruction abandon remaining futures. These mechanisms can bypass asynchronous cleanup; graceful callers instead request cooperative cancellation and await the handles they own before shutting down runtime services. Higher-level groups can implement that policy above `Spawn`. + +The scheduler does not automatically wait for all application work to finish gracefully. Backend resource retirement is a separate obligation: submitted I/O must retain buffers and descriptors until kernel access ends even after the task and its handle disappear. Future `_submit` operations will also require backend accounting for admitted work without application waiters. + +## Scheduler operation interface + +The intended low-level shape for ordinary I/O is a method on the scheduler capability that returns a future and accepts an explicit cancellation input. Cleanup and selected request operations use the submission/completion contract below. Keep operations recognizable from their system interfaces, for example: + +```text +scheduler.file_open(path, options, cancellation) +scheduler.file_read(file, buffer, cancellation) +scheduler.file_read_at(file, buffer, offset, cancellation) +scheduler.socket_accept(listener, cancellation) +scheduler.socket_sendmsg(socket, message, flags, cancellation) +scheduler.address_resolve(name, options, cancellation) +scheduler.now() +scheduler.sleep_until(deadline, cancellation) +scheduler.sleep(duration, cancellation) +``` + +This list includes proposed extensions. The implemented `File` methods are `file_open`, `file_read_at`, and `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. + +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. + +`Clock` supplies synchronous monotonic time access and cancellable timer futures, as specified below. Current `Clock::sleep` only accepts a duration and returns `()`; `now`, `sleep_until`, and cancellation-aware waits remain proposed. + +Ordinary async synchronization uses `Future`, `Poll::Pending`, and `Waker`. Channels, locks, and other primitives own their state and protect registration against lost wakeups; they do not need scheduler-specific `block` or `unblock` methods. This is separate from executing synchronous work on a blocking pool, which may warrant an optional scheduler capability. + +## Clock and timer composition + +`Clock::now()` returns monotonic, nondecreasing time in the scheduler's clock domain. Use it for durations and deadlines. Wall-clock timestamps would belong to a separate capability; changing calendar time must not change these deadlines. + +Use `std::time::Instant` for instants and `std::time::Duration` for intervals initially. Generic code obtains every current-time observation through `scheduler.now()`, including elapsed-time measurements: + +```rust +let started = scheduler.now(); +// Perform work using this scheduler. +let elapsed = scheduler.now().duration_since(started); +``` + +Calling `Instant::now()` or an instant's `elapsed()` directly bypasses the scheduler's clock. The standard type permits those calls, so this is an interface convention rather than a type-enforced restriction. Deadline arithmetic should use `checked_add` and handle an unrepresentable deadline rather than relying on overflowing addition. The public error mapping for that case remains to be specified. Do not promise uniform behaviour across system suspend, which Rust leaves platform-dependent. See the [standard Instant contract](https://doc.rust-lang.org/std/time/struct.Instant.html). + +A simulated scheduler can choose an initial instant and advance an offset under test control. The Tokio adapter can obtain time from its selected runtime's clock and convert Tokio instants through `into_std` and `from_std`, preserving virtual-time behaviour when that runtime pauses or advances time. These conversions are supported by [Tokio's Instant](https://docs.rs/tokio/latest/tokio/time/struct.Instant.html). Deadlines and observations must belong to the same clock domain; sharing the standard value type does not make independently advancing simulated clocks interchangeable. + +`now` and `sleep_until` are the fundamental timer operations. `sleep(duration, cancellation)` is a convenience that can compute a deadline and delegate to `sleep_until`. The proposed timer contract is: + +- Timer futures are lazy. Relative sleep measures its duration from the first poll; an absolute deadline stays fixed regardless of when its future is first polled. +- A successful wait completes at or after its deadline. Scheduling can delay resumption, and the interface does not promise precise execution at the deadline. +- An already-cancelled signal returns `Cancelled` without registering a timer. Otherwise, a deadline already reached can complete immediately. Zero-duration sleep does not promise a scheduler yield. +- Observed cancellation ends the timer wait with `OperationError::Cancelled`; it does not cancel the surrounding task. +- Dropping a timer future abandons the wait and safely releases its registration. Backend reclamation may be deferred, but racing wakeups must not access destroyed future state or a reused registration. +- Completion returns `OperationResult<()>`; callers measure elapsed time separately through the scheduler. + +### Composing a deadline with I/O + +A cooperative deadline wrapper can poll an operation and a timer within the same task. Each future registers wakeups for the enclosing task; no monitoring task or busy waiting is required. For a file read, give the operation a child cancellation signal and follow this sequence: + +1. Poll the read and `sleep_until(deadline, cancellation)` together. +2. If the read completes first, return its result and buffer, dropping the timer. +3. If the timer expires first, request cancellation on the read's child signal and continue polling the read until it completes. + +The deadline determines when cancellation is requested, not when the wrapper must return. Submitted I/O may still access its buffer or finish successfully after that request; preserve the operation's eventual result and returned buffer. A successful read observed alongside timer expiry remains useful progress. Work that ignores cancellation may continue beyond the deadline. Cancelling the parent signal also reaches the operation through its child signal. Abandoning the wrapper drops its futures under the ordinary operation-lifetime contract and gives up cooperative draining. + +Timeouts, intervals, retry policies, and callback scheduling are higher-level operations built on this interface. Periodic work can advance an absolute deadline to avoid drift from repeated relative sleeps and choose its own missed-tick policy. Timer heaps, batching, registration storage, and wakeup mechanisms remain backend details. A reusable timer/reset interface can be considered later if measurements justify it. + +## High-level interface sketch + +The results and owned-buffer contracts below are implemented. The wider capability sketch includes proposed stateful file operations, syscall-oriented socket names and flags, and clock changes; consult the operation list above or the exported traits for the current subset. Pool, close/submission, and prepared-chain examples remain proposals. + +### Results and ownership + +Use a common operation error that distinguishes cooperative cancellation from an OS error, while preserving the latter for inspection: + +```rust +pub enum OperationError { + Cancelled(Cancelled), + Io(std::io::Error), +} + +pub type OperationResult = Result; +pub type BufferResult = (OperationResult, B); +``` + +Ordinary cancellation-aware operations borrowing a resource return `OperationResult`. Operations taking a caller-owned buffer return `BufferResult`, with the buffer outside the `Result` so it can be recovered on failure. Reads and writes return partial transfer counts. A read's count identifies the valid received prefix; a write sends only initialized bytes. Read-exact, write-all, and send-all loops belong above this interface. The non-cancellable submission/completion pairs below return `std::io::Result<()>` instead. + +An already-cancelled input prevents new ordinary work from starting. For work already in flight, cancellation requests a cooperative stop; awaiting the operation still establishes its completion and resource ownership. Report a successful transfer that races cancellation as progress rather than replacing its count with `Cancelled`. Cancellation never promises to undo side effects. + +The sketches borrow resource handles and cancellation signals. In-flight operations must retain the underlying resources for as long as necessary, even if the waiting future is abandoned. Resource handles belong to their creating scheduler/backend; cross-instance compatibility is checked at the appropriate boundary. + +### Core capability shapes + +Keep the receiver, resource, operation arguments, and cancellation input explicit: + +```rust +pub trait File: Send + Sync { + type File: Send + Sync; + + fn file_open( + &self, + path: &Path, + options: FileOptions, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + fn file_read( + &self, + file: &Self::File, + buffer: B, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + fn file_write( + &self, + file: &Self::File, + buffer: B, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + fn file_read_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + fn file_write_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> impl Future> + Send; +} + +pub trait Socket: Send + Sync { + type Socket: Send + Sync; + type Listener: Send + Sync; + + fn socket_accept( + &self, + listener: &Self::Listener, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + fn socket_connect( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + fn socket_recv( + &self, + socket: &Self::Socket, + buffer: B, + flags: RecvFlags, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + fn socket_send( + &self, + socket: &Self::Socket, + buffer: B, + flags: SendFlags, + cancellation: &Cancellation, + ) -> impl Future> + Send; +} + +pub trait Clock: Send + Sync { + fn now(&self) -> Instant; + + fn sleep_until( + &self, + deadline: Instant, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + fn sleep( + &self, + duration: Duration, + cancellation: &Cancellation, + ) -> impl Future> + Send; +} +``` + +The associated file resource and positioned file operations are implemented. Stateful `file_read`/`file_write` above remain proposed. 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. + +Additional capability shapes follow the same convention: + +| Capability | Proposed operation shape | Output | +| --- | --- | --- | +| File allocation | `file_allocate(file, offset, length, mode, cancellation)` | `OperationResult<()>` | +| File synchronization | `file_sync(file, mode, cancellation)` | `OperationResult<()>` | +| Message send | `socket_sendmsg(socket, owned_message, flags, cancellation)` | Transfer result together with the owned message | +| Message receive | `socket_recvmsg(socket, owned_message, flags, cancellation)` | Receive result including address/control/status, together with the owned message | +| Address resolution | `address_resolve(name, options, cancellation)` | `OperationResult` | + +These rows identify extension capabilities; they do not require every scheduler to implement every operation. Synchronization builds on ordinary futures and wakers, with lost-wakeup protection inside each primitive. `Spawn::spawn(future)` is primitive dispatch with no group or owner argument. It returns an awaitable task handle; dispatch failures are reported before execution. Grouping and group lifecycle belong above this interface. + +### Completion and explicit submission + +Cleanup and selected request operations have two proposed forms: + +| Await admission and completion | Await admission only | Resource input | +| --- | --- | --- | +| `file_close(file).await` | `file_close_submit(file).await` | Consume the file handle. | +| `socket_close(socket).await` | `socket_close_submit(socket).await` | Consume the socket handle. | +| `socket_shutdown(socket, how).await` | `socket_shutdown_submit(socket, how).await` | Borrow the socket; retain its backend resource while pending. | +| `file_advise(file, offset, length, advice).await` | `file_advise_submit(file, offset, length, advice).await` | Borrow the file; retain its backend resource while pending. | + +Both forms return lazy futures yielding `std::io::Result<()>`. Polling first waits for admission capacity and transfers responsibility for the job to the scheduler. If the bounded submission queue is full, the future suspends through wakeups until capacity becomes available; it does not block an executor worker. Constructing or discarding an unpolled future does not enqueue the job. + +The ordinary, unsuffixed form waits for admission and OS completion, reporting admission or execution errors. This keeps `.await` consistent with ordinary reads and writes: successful return establishes that execution has completed. The exceptional `_submit` form returns `Ok(())` once the scheduler accepts responsibility. This confirms admission, not that the syscall has started or finished; errors from this form concern admission. Both forms use the same admission path. + +For example, these are alternative ways to close an owned handle: + +```rust +scheduler.file_close(file).await?; // Completed; execution errors reported. +// Or, when only admission needs to be observed: +scheduler.file_close_submit(file).await?; // Accepted for execution. +``` + +All four operation pairs use the same admission queue and job ownership mechanism. Close consumes its handle, while shutdown and advice retain the borrowed resource's underlying backend ownership for the job. This contract does not require a separate per-fd retirement mechanism. Queue capacity and fairness remain implementation choices; bounding a scheduler queue does not itself bound the number of caller-owned futures waiting for admission. + +These methods omit cancellation inputs. Before acceptance, the admission future owns its inputs: dropping it, or rejecting admission, releases those inputs according to their ordinary destruction rules. For close, this means dropping the consumed handle; no asynchronous close job has been accepted. The resource's ordinary destruction policy remains to be specified and is not replaced by constructing a close future. + +Once the scheduler accepts the job, it must carry out the request even if the caller drops either form's future or its cancellation scope is cancelled. Acceptance is the ownership boundary even if the caller abandons the future before observing its result. Dropping a waiter after acceptance does not return a consumed handle or undo submission. + +Submitting work does not establish completion order. Preserve the required dependencies on earlier I/O through operation tracking or explicit chains, especially for close and write-side shutdown. Retain underlying resources until dependent backend access ends, and do not reuse a registered descriptor slot merely because close was queued. Graceful runtime draining must include outstanding submitted jobs even when they have no application waiter. + +Shutdown completion confirms the local shutdown operation, not remote receipt or processing. Advice completion confirms that the hint was processed, not that all requested data was prefetched; `WILLNEED` initiates nonblocking read-ahead. See [shutdown](https://man7.org/linux/man-pages/man2/shutdown.2.html) and [file advice](https://man7.org/linux/man-pages/man2/posix_fadvise.2.html). + +The contract is portable. A blocking-pool backend admits a job that owns or retains the required resources into its bounded queue. The `_submit` form returns after admission without waiting for execution. The ordinary future additionally receives that job's result through a completion notification; it does not join the worker thread. Workers remain reusable. A readiness backend can retain admitted requests in its state, while an io\_uring backend can queue them for SQE submission. All backends must preserve admission backpressure, ownership, ordering, bounded execution concurrency, and draining semantics. + +Not waiting at the application level does not automatically allow the backend to skip completion tracking. For eligible io\_uring requests, `IOSQE_CQE_SKIP_SUCCESS` can suppress successful CQEs; failures still produce CQEs that the selector must consume. Only use it when resource retirement and dependencies remain safe without the successful CQE. It is incompatible with `IOSQE_IO_DRAIN` in the same ring. The current selector expects a terminal CQE per request and needs changes before using this optimization. See [CQE suppression](https://man7.org/linux/man-pages/man2/io_uring_enter.2.html). + +Close errors can still be useful diagnostics, including delayed write failures. The ordinary form exposes them; the `_submit` form leaves execution error handling to the backend's diagnostics policy. Do not blindly retry close after an error: Linux normally releases the descriptor before reporting such failures. See [close errors](https://man7.org/linux/man-pages/man2/close.2.html). + +Keep `_submit` exceptional rather than adding a submission-only variant to every operation. Reads, opens, accepts, and address resolution produce outputs that callers need. Writes and sends expose partial counts and require buffer lifetime tracking. Allocation and file synchronization have meaningful completion/error guarantees, so retain their ordinary future/result contracts. Cancellation already separates the request (`cancel()`) from task completion through handles. + +### Pool receive capability + +A pool receive returns a selected filled lease, rather than accepting an already-selected buffer: + +```rust +pub trait PoolReceive: Socket { + type Pool: Send + Sync; + type Lease: IoBufMut; + + fn buffer_pool( + &self, + options: BufferPoolOptions, + cancellation: &Cancellation, + ) -> impl Future> + Send; + + fn socket_recv_with_pool( + &self, + socket: &Self::Socket, + pool: &Self::Pool, + flags: RecvFlags, + cancellation: &Cancellation, + ) -> impl Future> + Send; +} +``` + +The returned lease exposes only the initialized received payload for sending, and can therefore be passed directly to `socket_send`. Its readable length is the receive count, not the capacity of its backing allocation. For stream sockets, an empty successful lease represents EOF. The pool owns storage that does not escape as a lease: on error or cancellation, it is safely reclaimed only after backend access ends. This differs from an operation that must return a buffer supplied by the caller. If a receive has produced usable data, report that lease as progress rather than discarding it solely because cancellation raced completion. + +`BufferPoolOptions` would describe a bounded number of buffers and their capacities. Native selection or fixed-registration requirements belong in separate extensions, rather than changing the meaning of this portable capability. Pool message receives need a corresponding result carrying the filled lease and message metadata. + +### Example: positioned file read + +```rust +async fn read_header( + scheduler: &S, + path: &Path, + cancellation: &Cancellation, +) -> OperationResult> { + let file = scheduler + .file_open(path, FileOptions::READ, cancellation) + .await?; + + let (result, mut buffer) = scheduler + .file_read_at(&file, vec![0; 4096], 0, cancellation) + .await; + + let count = result?; + buffer.truncate(count); + Ok(buffer) +} +``` + +This deliberately performs one read and permits a short result. A caller needing a complete header would use a read-exact helper. Keeping `result` and `buffer` separate also lets a caller reuse the allocation after an error instead of propagating it immediately. + +### Example: forwarding pooled socket data + +```rust +async fn forward( + scheduler: &S, + source: &S::Socket, + destination: &S::Socket, + pool: &S::Pool, + cancellation: &Cancellation, +) -> OperationResult<()> { + loop { + let lease = scheduler + .socket_recv_with_pool(source, pool, RecvFlags::empty(), cancellation) + .await?; + + if lease.is_empty() { + return Ok(()); + } + + let (result, lease) = send_all( + scheduler, destination, lease, SendFlags::empty(), cancellation, + ).await; + + // Sending is complete; the lease can return to its pool. + drop(lease); + result?; + } +} +``` + +`send_all` here is a proposed higher-level helper, not a scheduler hook. It repeatedly calls `socket_send` with owned views of the unsent range, handles zero progress, and returns the lease on success or failure after all backend access ends. Ordinary buffers now support owned `BufferSlice` views; compatibility with registered leases remains to be specified. This example uses explicit sequential awaits and makes no assumption about `with_previous_buffer`. + +Create the pool once and share it across connections, for example: + +```rust +let pool = scheduler.buffer_pool( + BufferPoolOptions { buffers: 128, buffer_capacity: 16 * 1024 }, + &cancellation, +).await?; + +forward(&scheduler, &source, &destination, &pool, &cancellation).await?; +``` + +### Example: prepared accept followed by receive + +```rust +let accept = scheduler.prepare_socket_accept(&listener); + +let read = scheduler.prepare_socket_recv( + accept.socket(), + vec![0; 4096], + RecvFlags::empty(), +); + +let outcomes = scheduler + .submit(accept.link(read), &cancellation) + .await; +``` + +`accept.socket()` is proposed syntax for an owned, typed reference to the socket that accept will produce. It is not an already-accepted socket or a borrow that would prevent moving the prepared accept into the chain. The receive starts only after accept succeeds; a failed accept skips it. Outcomes must preserve the accepted socket even if the receive fails or is skipped, and return the receive buffer with its partial byte count or error. + +Unlike forwarding a selected receive buffer, this dependency has a native io\_uring implementation target: direct accept into a reserved, known descriptor slot, followed by a linked receive using that slot. The Rust output-reference API and its type/lifetime rules are still proposed and unimplemented. They must prevent a consumer from running before its producing accept, or without that dependency. See the direct-descriptor linking section below. + +### Example: prepared write followed by synchronization + +```rust +let write = scheduler.prepare_file_write_at(&file, buffer, offset); +let sync = scheduler.prepare_file_sync(&file, SyncMode::Data); + +let outcomes = scheduler.submit(write.link(sync), &cancellation).await; +``` + +This requires the proposed file-synchronization and `Linked` extensions. Both operations have known inputs. The intended example policy runs synchronization only after the complete requested write; errors or short writes skip it. `outcomes` must retain the write buffer and distinguish a completed successor from a skipped one. Its exact type and policy-selection syntax remain open, as detailed in the linked-operation section. This does not make a partial write transactional. + +### Example: cooperative service shutdown + +```rust +let system = Cancellation::new(); +let service = system.child(); +let drain = Cancellation::never(); + +defer_cancel( + &service, + server.run(&scheduler, &drain), + || server.request_shutdown(), +).await?; +``` + +`server` is an application-defined service. `request_shutdown` synchronously signals and wakes its accept loop; `run` stops accepting and awaits its owned connection tasks before returning. Process signal integration requests `system.cancel()`, while a local service stop requests `service.cancel()`. The service remains polled during draining, and operations needed to finish accepted work use the non-cancelled drain input. Cancellation-aware accept uses the service's stopping signal or equivalent notification. This example adds no forced abort or shutdown deadline. + +## Linked operations + +Linking is a portable dependency capability. A proposed `Linked` extension trait would express ordered execution of prepared I/O operations, allowing consumers to require it through a bound such as `S: Linked`. A scheduler can implement this capability through kernel linking, a blocking pool, or a readiness state machine. Implement the trait when the backend can preserve the dependency contract; an unavailable capability is a compile-time failure. + +### Preparation and submission + +Prepare the whole chain before submitting it. Preparation retains arguments, buffers, and resources without starting I/O. Submission returns an ordinary future. A chain can use concrete generic types to preserve each operation's result type and allow static dispatch and compiler specialization. + +An illustrative proposed API is: + +```text +let write = scheduler.prepare_file_write_at(&file, buffer, offset); +let sync = scheduler.prepare_file_sync(&file, SyncMode::Data); + +let results = scheduler.submit(write.link(sync), &cancellation).await; +``` + +The names and exact type signatures remain open. Existing single-operation methods can remain convenient wrappers around preparation and submission. This introduces a representation for scheduler I/O that can be composed before execution, while retaining `Future` as the execution interface. + +Ordinary sequential `.await` calls do not expose the whole dependency chain to the backend in advance. An executor cannot inspect an arbitrary future to recover that sequence and turn it into a kernel chain. Explicit prepared operations give the backend the information it needs. + +### Dependency contract + +A chain must define when each successor may start, which preceding outcomes stop execution, and how errors, partial transfers, skipped operations, and cancellation are reported. Return individual outcomes and owned buffers, including those belonging to operations that never started. The exact result representation and continuation policies remain to be chosen. + +Linking supplies ordering without transactional rollback. Kernel links do not automatically substitute an earlier operation's result into a later operation's arguments. A write followed by synchronization can be prepared in advance; a write whose length depends on the actual byte count of a preceding read generally needs a userspace continuation. + +io\_uring's `IOSQE_IO_LINK` starts a successor after its predecessor completes, and breaks the chain on errors or unexpected results, including short reads. Unstarted successors then complete with `-ECANCELED`. `IOSQE_IO_HARDLINK` permits continuation despite completion errors, although submission failures can still break the chain. These dependency policies are distinct from task shutdown. See [kernel link semantics](https://man7.org/linux/man-pages/man2/io_uring_enter.2.html). + +Use native linking only when its behaviour matches the defined chain contract. A fallback must apply the same outcome and continuation rules; submitting independent, unlinked operations concurrently would lose the ordering guarantee. + +### Direct-descriptor linking for accept and receive + +io\_uring supports a native accept-to-receive chain when the accepted socket is installed into a known slot in the ring's registered file table. This uses [io\_uring\_prep\_accept\_direct](https://man7.org/linux/man-pages/man3/io_uring_prep_accept_direct.3.html), rather than expecting an ordinary accept's returned OS descriptor to be substituted into the next SQE. + +The backend can reserve an unused slot before submission, prepare accept with that explicit `file_index` and `IOSQE_IO_LINK`, then prepare receive with its `fd` set to the same slot and `IOSQE_FIXED_FILE` set. Both requests are submitted as one chain. The receive resolves its socket after accept succeeds, allowing execution without userspace processing the accept completion first. + +This requires `IORING_FEAT_LINKED_FILE`, available since Linux 5.17, which defers descriptor lookup for dependent requests until they execute. The backend must also establish direct-accept support and initialize a suitable registered file table. See [dependent descriptor lookup](https://man7.org/linux/man-pages/man2/io_uring_setup.2.html). + +Use an explicitly reserved slot for this native mapping. `IORING_FILE_INDEX_ALLOC` chooses an index dynamically and reports it in the accept completion, so it does not provide the known index needed by a prebuilt successor. An explicit slot must be empty and exclusively reserved: direct accept can replace an existing entry. Direct descriptors are private to their ring, not ordinary OS descriptor numbers. + +The prepared chain must retain the slot through all dependent access and safely release it when the chain is abandoned. If accept succeeds but receive fails or is cancelled, ownership of the accepted socket must still be resolved; skipping the receive does not undo accept. A portable fallback can keep the accepted resource in chain state and pass it to the receive after completion. + +This is a specific resource dependency with a known native target, not general substitution of arbitrary completion results into later SQEs. It does not implement the hypothetical `with_previous_buffer` handoff of a selected buffer and actual received length. + +### Backend implementations + +| Backend | Linking strategy | +| --- | --- | +| io_uring | Submit the prepared operations together as linked SQEs. | +| Blocking pool | Submit the whole chain as one worker job that executes its operations sequentially. | +| Readiness | Advance the chain through successive asynchronous operations as their dependencies complete. | + +A blocking worker can retain the whole chain and return its results after execution, avoiding executor wakeups and job submission between operations. It can check cooperative cancellation between operations and before starting queued work. An already-running blocking syscall may need to finish. Keep resources alive through completion and allow cleanup chains to use cancellation inputs that permit draining. + +For io\_uring, send the chain to the selector as one command, reserve sufficient SQ capacity, and publish its entries contiguously in the same submission. Links cannot cross submission boundaries. Track every member's completion and retain shared resources until all relevant kernel access ends. The current per-operation command model must be extended to support this. See [linked request submission](https://man7.org/linux/man-pages/man7/io_uring_linked_requests.7.html). + +The portable `Linked` capability guarantees dependency behaviour. If a caller specifically requires kernel-linked submission, a separate native extension can express that requirement at compile time. SQE LINK is an implementation strategy for portable linking, rather than a restriction of linking to Linux. + +## Candidate I/O hooks + +Use the [liburing preparation helpers](https://github.com/axboe/liburing/blob/master/src/include/liburing.h) as an inventory of operations to consider. This is a candidate surface, not a requirement that every opcode become part of every scheduler's core traits. + +| Capability area | Candidate operations | +| --- | --- | +| File lifecycle | `file_open`, `file_open_at`, `file_close`/`file_close_submit` | +| File transfer | Stateful `file_read`/`file_write`, positioned `file_read_at`/`file_write_at`, vectored equivalents | +| File management | `file_seek`, `file_allocate`, `file_truncate`, file/data synchronization, range synchronization, `file_advise`/`file_advise_submit` | +| Metadata and paths | Stat, extended attributes, mkdir, unlink, rename, hard links, symbolic links | +| Socket lifecycle | Create, bind, listen, accept, connect, `socket_shutdown`/`socket_shutdown_submit`, `socket_close`/`socket_close_submit` | +| Socket transfer | Send/receive, sendto/recvfrom, `socket_sendmsg`/`socket_recvmsg` | +| Socket control | Options, local/peer addresses, readiness | +| Address resolution | `address_resolve` through an appropriate resolver implementation | +| Time and synchronization | Monotonic clock access, sleep, deadlines, wait/wake operations | +| Additional extensions | Process waits, pipes, splice/tee and other descriptor operations | + +The canonical message syscall names are `sendmsg` and `recvmsg`. `writemsg` is not the intended name. + +Not every useful scheduler operation has an io\_uring opcode; seek and address resolution can use other implementation strategies. Multishot operations, fixed descriptors, ring messaging, device commands, and specialized zero-copy operations should remain optional extensions with their own lifecycle contracts. + +### File semantics + +Stateful reads and writes use and advance the file's shared cursor. Positioned operations correspond to `pread`/`pwrite` and should leave that cursor unchanged. Expose both explicitly rather than encoding stateful access through a magic public offset. io\_uring internally supports current-position reads using offset `-1`; its documentation warns that asynchronous shared-cursor access requires serialization for predictable behaviour. See [io\_uring\_prep\_read](https://man7.org/linux/man-pages/man3/io_uring_prep_read.3.html). + +Allocation is distinct from truncation. An allocation interface needs to account for an offset, length, and supported modes. Platform-specific modes may require an extension rather than a universal flags type. See [io\_uring\_prep\_fallocate](https://man7.org/linux/man-pages/man3/io_uring_prep_fallocate.3.html). + +Current `File` opens regular files and provides positioned reads and writes, using offsets that fit in `i64`. `FileOptions` omits append mode so writes respect their supplied offsets. Its Windows fallback uses `seek_read`/`seek_write`, which update the shared cursor according to [Windows FileExt](https://doc.rust-lang.org/std/os/windows/fs/trait.FileExt.html). Resolving this difference is required before promising uniform cursor-preserving semantics. + +### Syscall proximity and messages + +Preserve partial transfer counts, relevant flags, addresses, ancillary data, receive status, and OS errors. Keep read-exact/write-all loops and protocol buffering as higher-level conveniences. + +A safe owned message representation should retain its payload buffers and all address, iovec, control, and header storage required by the backend for the duration of kernel access. It can construct native metadata without inherently copying the payload. Specific raw layouts and ancillary features may be platform extensions. See [io\_uring\_prep\_sendmsg](https://man7.org/linux/man-pages/man3/io_uring_prep_sendmsg.3.html). + +Cancellation is a request, not a transactional rollback: a cancelled read may consume bytes and a cancelled write may transmit bytes. Awaiting the operation returns its actual result and buffer after access ends; abandoning the future gives up that result while the backend retains any in-flight storage. + +## Buffer ownership + +Completion-based I/O requires a buffer whose address remains valid while the kernel uses it. The operation must retain buffers and descriptors even if its waiting future is dropped. A cancellation completion alone does not establish that the original operation has finished accessing memory. + +The implemented API accepts `B: IoBuf` for writes and `B: IoBufMut` for reads, and returns `(OperationResult, B)`. These unsafe traits extend `AsRef<[u8]>` and `AsMut<[u8]>`, with `Send + 'static` ownership. Implementors must retain a stable initialized allocation and range even when the owning value moves. Writable buffers must exclude all conflicting access by other owners. Inline storage that moves with its owner is unsuitable. + +`Vec`, `Box<[u8]>`, and `BufferSlice` implement these contracts. Reads use initialized length, leave that length unchanged, and report the valid received prefix separately. Spare capacity and uninitialized storage are not supported. Read-only buffers need not be `Sync`. + +`buffer.slice(range)` consumes an owner and creates a checked view without copying bytes. An operation returns that view; `into_inner()` recovers the original owner. Nested views are supported. Partial-write loops advance an owned view over the unwritten suffix without draining or moving bytes. A successful zero-byte write for nonempty input must terminate such a loop with `WriteZero`. + +No standard buffer trait establishes this complete ownership contract. Standard [`IoSlice`](https://doc.rust-lang.org/std/io/struct.IoSlice.html) and `IoSliceMut` borrow memory. Optional bytes types and registered leases can be considered later, but their implementations must uphold the same lifetime and aliasing rules. Uninitialized-capacity support requires a separate initialization contract. + +Cancellation is backend-specific beneath the public contract. Readiness operations can abandon a pending readiness wait safely. Blocking work checks cancellation before entering the syscall and preserves its actual result once running. The io\_uring backend requests cancellation once and still awaits the original terminal completion before returning the buffer; successful progress wins a racing cancellation. Dropping an operation abandons its result, while the backend retains any storage still accessible by a worker or kernel. + +The io\_uring command queue currently erases each generic request behind `Box`, retaining a typed completion sender and returning the original buffer without copying. This adds one boxed request per operation alongside the existing command/completion channels; request pooling and submission backpressure remain optimization work. + +## Buffer pools, registered buffers, and specialized I/O + +Buffer pools should be optional capabilities that compose with owned-buffer operations. Keep a portable pool contract separate from requirements for native registration or kernel buffer selection. A software pool is an acceptable fallback where it preserves the contract; a consumer specifically requiring a native facility should declare that through an extension trait. Probe kernel-dependent facilities when constructing the pool or registration. + +### Fixed registered buffers + +Fixed registered buffers identify pre-registered memory by index and can reduce repeated memory-mapping work for I/O. A pool can own registrations and issue leases retaining both the memory and its registration lifetime. Validate compatible ring identity before submission and prevent reuse while an operation still accesses the lease. See [buffer registration](https://man7.org/linux/man-pages/man3/io_uring_register_buffers.3.html). + +An operation using a fixed buffer normally receives a lease selected before submission. Registration optimizes access to that memory; it does not defer the assignment of a buffer to a waiting operation. + +### Provided-buffer pools + +Provided-buffer rings solve a different problem: many pending receives can share fewer payload buffers. The kernel selects an available buffer when a receive can make progress, rather than requiring one reserved buffer per idle socket. Registering a provided-buffer ring is distinct from registering fixed payload buffers. See [provided-buffer rings](https://man7.org/linux/man-pages/man3/io_uring_setup_buf_ring.3.html). + +The proposed portable receive interface accepts the pool itself and returns a filled buffer lease, including the valid received length. Acquiring a lease before waiting for data would lose the memory advantage. A lease must distinguish readable initialized bytes from writable capacity and retain its pool and storage until all consumers finish. + +For ordinary single-buffer selection, the lifecycle is: + +1. The pool offers available buffers to the backend. +2. A receive selects a buffer when data is available. +3. Completion identifies the selected buffer and the received byte count. +4. The caller owns a filled lease and may process it or transfer it into a send/write operation. +5. Once no consumer or kernel operation can access the buffer, it can be returned to the pool and offered again. + +Do not offer a leased buffer for another receive while it is still being read or sent. Dropping a waiting future or requesting cancellation must not recycle storage still accessible by the kernel. Pool shutdown must retain outstanding leases, registrations, and offered storage until their respective backend lifetimes have ended. + +Pool exhaustion requires an explicit backpressure and replenishment policy. io\_uring can return `-ENOBUFS` rather than waiting for a buffer; exhaustion can also terminate a multishot receive. The backend must replenish and rearm as appropriate, or expose the error according to the operation contract. See [provided-buffer ownership and exhaustion](https://man7.org/linux/man-pages/man7/io_uring_provided_buffers.7.html). + +A readiness fallback can wait for readability, acquire a lease, and attempt a nonblocking receive. If the receive returns `WouldBlock`, return the lease before waiting again. Waiting for pool availability must also suspend through wakeups. A blocking fallback may retain a lease throughout a running syscall; its bounded concurrency limits how many such leases are occupied. + +Multishot receives could expose a stream of filled leases as a separate extension. The implementation must distinguish individual buffer lifetimes from the lifetime of the receive request, and detect when it needs rearming. See [multishot receive completions](https://man7.org/linux/man-pages/man3/io_uring_prep_recv_multishot.3.html). Streams, bundles, and incremental buffer consumption need their own contracts before implementation. + +### Operation names and preparation + +Use `with_pool` for operations that receive a pool argument. The proposed naming pattern is: + +| Operation | Direct future | Prepared operation | +| --- | --- | --- | +| Receive into an owned buffer | `socket_recv` | `prepare_socket_recv` | +| Receive using a pool | `socket_recv_with_pool` | `prepare_socket_recv_with_pool` | +| Receive a message into owned storage | `socket_recvmsg` | `prepare_socket_recvmsg` | +| Receive a message using a pool | `socket_recvmsg_with_pool` | `prepare_socket_recvmsg_with_pool` | + +These names are proposed, not implemented APIs. Exact arguments, lease types, message metadata, and error/cancellation results remain to be chosen. Direct operations accept explicit cancellation; prepared operations retain the pool and resource inputs, with cancellation supplied at submission as described under linked operations. Preparation must not reserve a payload buffer for each waiting receive. + +`with_pool` describes how an operation obtains storage. The [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/naming.html) use `from_*` for conversions; they do not prescribe `from_pool` for this operation. + +Pool receives can participate in prepared chains whose successors have known inputs and preserve the agreed dependency rules. A successor that needs the selected buffer and actual received length has an additional data dependency that ordinary SQE linking does not resolve. + +### Hypothetical forwarding from a preceding operation + +`with_previous_buffer` is only a hypothetical interface. There is no selected implementation target, agreed signature, or commitment to add it to the proposed `Linked` capability. It could express that a successor consumes the compatible filled lease produced by its predecessor, using the initialized length rather than the original capacity. Any eventual design would need typed compatibility, error and cancellation behaviour, partial-transfer results, and ownership for skipped successors. + +Native SQE links provide ordering, not automatic forwarding of a receive's selected buffer and byte count. Send-side provided-buffer selection is supported on newer kernels, but it selects from an offered group rather than inheriting a preceding receive's result. See [send buffer selection](https://man7.org/linux/man-pages/man3/io_uring_prep_send.3.html). + +Relevant liburing discussions include [using a preceding receive's result as the send length (#58)](https://github.com/axboe/liburing/issues/58) and [moving selected receive buffers into a send ring (#1126)](https://github.com/axboe/liburing/issues/1126). Userspace can reuse the received storage by preparing a send after completion, or offering the filled range to a send buffer ring. This avoids an additional userspace payload copy but still requires userspace to arrange the handoff. + +A selector-managed continuation or a whole-chain blocking job might eventually implement forwarding without waking the application task between operations. These are possible strategies to investigate, not an implementation plan for `with_previous_buffer` or a promise of one kernel-linked submission. + +### Zero-copy lifetime + +Registration does not itself guarantee zero-copy transport. Zero-copy sends can report a transfer result before a subsequent notification allows buffer reuse, so one result is not necessarily the end of kernel access. See [io\_uring\_prep\_send\_zc](https://man7.org/linux/man-pages/man3/io_uring_prep_send_zc.3.html). The current selector's single-terminal-completion request model must be extended before exposing those operations safely. + +## 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. + +Remaining design work includes: + +1. **Resource lifecycle and wider I/O:** separate socket creation/options, stateful files, explicit close/submission, message I/O, and extension boundaries. +2. **Blocking execution:** an optional public capability for synchronous closures, with bounded asynchronous admission, cancellation before execution, and completion once execution starts. Existing private blocking fallbacks do not expose this capability. +3. **Backend retirement:** account for all accepted work, including future `_submit` jobs without application waiters. Application completion remains explicit through owned task handles; there is no implicit graceful join of all spawned work. +4. **Clock integration:** implement the monotonic clock and cancellable deadline contract above using the independent timers crate where appropriate. + +Process creation/waiting and pipes remain useful optional extensions. Prepared linking, registered buffers, and pools remain separate later work. + +### Executable interface experiment + +[interface\_probe.rs](examples/interface_probe.rs) runs the same `copy`, `serve`, and `exchange` functions through the public `File` and `Socket` traits with native, Tokio, and in-memory implementations: + +```sh +cargo run -p socketry-executor --example interface_probe --features tokio +cargo test -p socketry-executor --example interface_probe --features tokio +``` + +Using `--no-default-features` runs the simulation without either I/O backend; adding `--features tokio` includes the Tokio adapter. Native uses the selected backend, including io\_uring on Linux when enabled. The earlier example-local traits and real-adapter wrappers have been removed. + +Generic code opens files, binds listeners, and inspects addresses through public traits without naming an OS resource or `Arc`. The native and Tokio implementations retain their concrete file representation internally. The simulator implements the same traits with in-memory resources and cooperative cancellation, without fake descriptors or `Unsupported` stubs. + +File copy and send-all loops use owned buffer ranges for partial transfers. Client and server compose through `try_zip`; simulated accept/read waits use event registration and wakeups. No new synchronization dispatch primitive or task group is needed. The separate `portable_io` example exercises `Spawn`. + +Tests cover resource creation, options, empty files, short transfers, open/read/write failures, zero-progress writes, buffer allocation retention, accept/read wakeups, EOF, cancellation before execution and while waiting, and listener release. Task tests cover explicit waiting, transfer to a new owner, and dropping an owner with retained children. Backend tests exercise cancellation races and retention beyond abandonment. + +The simulator has bounded in-memory file sizes and unbounded byte queues. It does not model network backpressure, timers, task scheduling, or kernel completion after an abandoned future. Copy assumes distinct source and destination resources and does not promise durability. The existing Windows positioned-file fallback still changes the shared cursor. These are limits of the current implementations, not guarantees supplied by generic dispatch. + +## Current implementation and remaining work + +| Area | Current status | +| --- | --- | +| Executor | Future workers, work stealing, primitive `Spawn`, and owning handles implemented. Await joins; move transfers ownership; drop abandons unfinished work. Task groups remain above the primitives. | +| 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. Future submission-only jobs need further draining/accounting design. | +| Capabilities | Public `File`, `Socket`, `SocketRegistration`, `Clock`, and `Spawn`; associated resources and initial file/TCP creation implemented. Broader hooks remain design work. | +| Linked operations | Prepared chains and `Linked` remain proposed; native linking, whole-chain blocking jobs, and readiness sequencing are not implemented. | +| File operations | `file_open` and generic owned-buffer positioned read/write implemented. Stateful I/O, explicit close, allocation, and metadata hooks pending. | +| Submission and completion | Close, socket shutdown, and advice pairs remain proposed; bounded admission, `_submit`, and safe CQE suppression pending. | +| 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. | +| 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. | +| Timers | Independent `socketry-timers` implemented; scheduler integration pending. Existing `Clock::sleep(duration)` returns `()` without cancellation. Monotonic `now`, cancellable `sleep_until`/relative sleep, and deadline composition remain proposed. | + +The workspace's [architecture guide](../../context/design.md), [implementation guide](../../context/implementation.md), and [gaps](../../context/gaps.md) provide further context. The executor [README](readme.md) documents existing APIs. diff --git a/crates/executor/examples/interface_probe.rs b/crates/executor/examples/interface_probe.rs new file mode 100644 index 0000000..7908a82 --- /dev/null +++ b/crates/executor/examples/interface_probe.rs @@ -0,0 +1,188 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +//! The same generic application using the public File and Socket capabilities. +//! Run with `--features tokio` for native, Tokio, and simulated I/O. +//! With `--no-default-features`, only the in-memory implementation runs. +#[path = "interface_probe/simulated.rs"] +mod simulated; + +use socketry_executor::{Cancellation, File, FileOptions, IoBuf, OperationResult, Socket}; +use std::{io, path::Path}; +static NEVER: Cancellation = Cancellation::never(); + +const PAYLOAD: &[u8] = b"Explicit schedulers keep protocol code portable.\n"; +const BUFFER_SIZE: usize = 16; + +/// Application code only sees the backend's associated resources. +async fn copy( + scheduler: &S, + source: &Path, + destination: &Path, + cancellation: &Cancellation, +) -> OperationResult { + let source = scheduler + .file_open(source, FileOptions::READ, cancellation) + .await?; + let destination = scheduler + .file_open(destination, FileOptions::CREATE, cancellation) + .await?; + let mut buffer = vec![0; BUFFER_SIZE]; + let mut offset = 0; + loop { + let (result, returned) = scheduler + .file_read_at(&source, buffer, offset, cancellation) + .await; + buffer = returned; + let count = result?; + if count == 0 { + return Ok(offset); + } + let mut written = 0; + while written < count { + let (result, returned) = scheduler + .file_write_at( + &destination, + buffer.slice(written..count), + offset + written as u64, + cancellation, + ) + .await; + buffer = returned.into_inner(); + let count = result?; + if count == 0 { + return Err(io::Error::from(io::ErrorKind::WriteZero).into()); + } + written += count; + } + offset += count as u64; + } +} + +async fn send_all( + scheduler: &S, + socket: &S::Socket, + mut buffer: Vec, + cancellation: &Cancellation, +) -> OperationResult<()> { + let mut written = 0; + while written < buffer.len() { + let (result, returned) = scheduler + .io_write(socket, buffer.slice(written..), cancellation) + .await; + buffer = returned.into_inner(); + let count = result?; + if count == 0 { + return Err(io::Error::from(io::ErrorKind::WriteZero).into()); + } + written += count; + } + Ok(()) +} + +/// 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?; + let mut total = 0; + loop { + let (result, mut buffer) = scheduler + .io_read(&socket, vec![0; BUFFER_SIZE], &NEVER) + .await; + let count = result?; + if count == 0 { + return Ok(total); + } + total += count; + buffer.truncate(count); + send_all(scheduler, &socket, buffer, &NEVER).await?; + } +} + +async fn exchange(scheduler: &S) -> OperationResult<()> { + let listener = scheduler + .socket_listen("127.0.0.1:0".parse().unwrap(), &NEVER) + .await?; + let address = scheduler.listener_addr(&listener)?; + let client = async { + let socket = scheduler.connect(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) + .await; + let count = result?; + if count == 0 { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into()); + } + received.extend_from_slice(&buffer[..count]); + } + assert_eq!(received, PAYLOAD); + // Ordinary resource Drop closes this connection, allowing serve to end. + Ok(()) + }; + // Composition needs only Future/Waker, not a scheduler block/unblock API. + // Spawn is already exercised by portable_io; this probe focuses on I/O. + let (count, ()) = futures_lite::future::try_zip(serve(scheduler, &listener), client).await?; + assert_eq!(count, PAYLOAD.len()); + Ok(()) +} + +async fn exercise( + scheduler: &S, + source: &Path, + destination: &Path, +) -> OperationResult<()> { + assert_eq!( + copy(scheduler, source, destination, &NEVER).await?, + PAYLOAD.len() as u64 + ); + exchange(scheduler).await +} + +fn main() -> io::Result<()> { + let simulated = simulated::Scheduler::new(3, 2); + simulated.insert(Path::new("source"), PAYLOAD); + futures_lite::future::block_on(exercise(&simulated, Path::new("source"), Path::new("copy")))?; + assert_eq!(simulated.contents(Path::new("copy")), PAYLOAD); + println!( + "Simulated: copied and echoed {} bytes with short transfers", + PAYLOAD.len() + ); + + #[cfg(any(feature = "native", feature = "tokio"))] + let directory = tempfile::tempdir()?; + #[cfg(any(feature = "native", feature = "tokio"))] + let source = directory.path().join("source"); + #[cfg(any(feature = "native", feature = "tokio"))] + std::fs::write(&source, PAYLOAD)?; + + #[cfg(feature = "native")] + { + let scheduler = socketry_executor::Scheduler::with_workers(2)?; + let backend = scheduler.handle(); + let destination = directory.path().join("native-copy"); + scheduler.block_on(exercise(&backend, &source, &destination))?; + assert_eq!(std::fs::read(destination)?, PAYLOAD); + println!("Native: copied and echoed {} bytes", PAYLOAD.len()); + } + #[cfg(feature = "tokio")] + { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let scheduler = + socketry_executor::scheduler::tokio::Scheduler::new(runtime.handle().clone()); + let backend = scheduler.handle(); + let destination = directory.path().join("tokio-copy"); + runtime.block_on(exercise(&backend, &source, &destination))?; + assert_eq!(std::fs::read(destination)?, PAYLOAD); + runtime.block_on(scheduler.shutdown()); + println!("Tokio: copied and echoed {} bytes", PAYLOAD.len()); + } + Ok(()) +} + +#[cfg(test)] +#[path = "interface_probe/tests.rs"] +mod tests; diff --git a/crates/executor/examples/interface_probe/simulated.rs b/crates/executor/examples/interface_probe/simulated.rs new file mode 100644 index 0000000..24031a5 --- /dev/null +++ b/crates/executor/examples/interface_probe/simulated.rs @@ -0,0 +1,396 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +//! In-memory files and duplex byte streams. No OS resources or native runtime. +//! This is an interface probe, not a model of TCP scheduling or durability. +use super::{Cancellation, File, FileOptions, OperationResult, Socket}; +use event_listener::Event; +use socketry_executor::BufferResult; +use socketry_executor::{Interest, IoBuf, IoBufMut}; +use std::collections::{HashMap, VecDeque}; +use std::io; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, Weak}; +use std::{ + future::{Future, poll_fn}, + pin::pin, + task::Poll, +}; + +type Bytes = Arc>>; + +pub struct Scheduler { + files: Mutex>, + listeners: Mutex>>, + read_limit: usize, + write_limit: usize, +} + +pub struct FileHandle { + bytes: Bytes, + writable: bool, + readable: bool, +} + +#[derive(Default)] +struct Incoming { + sockets: Mutex>, + ready: Event, +} + +pub struct Listener { + address: SocketAddr, + incoming: Arc, +} + +#[derive(Default)] +struct PipeState { + bytes: VecDeque, + reader_closed: bool, + writer_closed: bool, +} + +#[derive(Default)] +struct Pipe { + state: Mutex, + ready: Event, +} + +pub struct Connection { + input: Arc, + output: Arc, + local: SocketAddr, + peer: SocketAddr, +} + +impl Drop for Connection { + 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); + } +} + +impl Scheduler { + pub fn new(read_limit: usize, write_limit: usize) -> Self { + assert!(read_limit > 0 && write_limit > 0); + Self { + files: Mutex::new(HashMap::new()), + listeners: Mutex::new(HashMap::new()), + read_limit, + write_limit, + } + } + + pub fn insert(&self, path: &Path, bytes: &[u8]) { + self.files + .lock() + .unwrap() + .insert(path.to_owned(), Arc::new(Mutex::new(bytes.to_vec()))); + } + + pub fn contents(&self, path: &Path) -> Vec { + self.files.lock().unwrap()[path].lock().unwrap().clone() + } + + #[cfg(test)] + pub fn open_files(&self) -> usize { + self.files + .lock() + .unwrap() + .values() + .map(|bytes| Arc::strong_count(bytes) - 1) + .sum() + } +} + +impl File for Scheduler { + type File = FileHandle; + + async fn file_open( + &self, + path: &Path, + options: FileOptions, + cancellation: &Cancellation, + ) -> OperationResult { + cancellation.check()?; + if (!options.read && !options.write) + || ((options.create || options.create_new || options.truncate) && !options.write) + { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + let mut files = self.files.lock().unwrap(); + if options.create_new && files.contains_key(path) { + return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()); + } + if !files.contains_key(path) && !(options.create || options.create_new) { + return Err(io::Error::from(io::ErrorKind::NotFound).into()); + } + let bytes = files.entry(path.to_owned()).or_default().clone(); + if options.truncate { + bytes.lock().unwrap().clear(); + } + Ok(FileHandle { + bytes, + writable: options.write, + readable: options.read, + }) + } + + async fn file_read_at( + &self, + file: &FileHandle, + mut buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + if let Err(error) = cancellation.check() { + return (Err(error.into()), buffer); + } + if !file.readable { + return ( + Err(io::Error::from(io::ErrorKind::PermissionDenied).into()), + buffer, + ); + } + let bytes = file.bytes.lock().unwrap(); + let count = if offset >= bytes.len() as u64 { + 0 + } else { + let offset = offset as usize; + let count = buffer + .as_ref() + .len() + .min(self.read_limit) + .min(bytes.len() - offset); + buffer.as_mut()[..count].copy_from_slice(&bytes[offset..offset + count]); + count + }; + (Ok(count), buffer) + } + + async fn file_write_at( + &self, + file: &FileHandle, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + if let Err(error) = cancellation.check() { + return (Err(error.into()), buffer); + } + if !file.writable { + return ( + Err(io::Error::from(io::ErrorKind::PermissionDenied).into()), + buffer, + ); + } + let count = buffer.as_ref().len().min(self.write_limit); + // Keep the model's filesystem bounded so malformed offsets cannot + // turn a test error into an unbounded allocation. + let end = offset + .checked_add(count as u64) + .filter(|end| *end <= 1024 * 1024); + let Some(end) = end else { + return ( + Err(io::Error::from(io::ErrorKind::InvalidInput).into()), + buffer, + ); + }; + if count > 0 { + let mut bytes = file.bytes.lock().unwrap(); + let length = bytes.len().max(end as usize); + bytes.resize(length, 0); + bytes[offset as usize..end as usize].copy_from_slice(&buffer.as_ref()[..count]); + } + (Ok(count), buffer) + } +} + +impl Socket for Scheduler { + type Socket = Connection; + type Listener = Listener; + + async fn socket_listen( + &self, + mut address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + cancellation.check()?; + let mut listeners = self.listeners.lock().unwrap(); + 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) { + 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) + } + + 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( + &self, + socket: &Connection, + interest: Interest, + cancellation: &Cancellation, + ) -> OperationResult<()> { + cancellation.check()?; + if matches!(interest, Interest::Writable) { + return Ok(()); + } + 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?; + } + } + 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?; + } + } + + async fn connect( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + cancellation.check()?; + let incoming = self + .listeners + .lock() + .unwrap() + .get(&address) + .and_then(Weak::upgrade) + .ok_or_else(|| io::Error::from(io::ErrorKind::ConnectionRefused))?; + 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(), + }; + incoming.sockets.lock().unwrap().push_back(server); + incoming.ready.notify(usize::MAX); + Ok(Connection { + input, + output, + local: client_address, + peer: address, + }) + } + + async fn io_read( + &self, + socket: &Connection, + mut buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { + if let Err(error) = cancellation.check() { + return (Err(error.into()), buffer); + } + loop { + let ready = socket.input.ready.listen(); + { + let mut state = socket.input.state.lock().unwrap(); + let count = buffer + .as_ref() + .len() + .min(self.read_limit) + .min(state.bytes.len()); + if count > 0 || buffer.as_ref().is_empty() || state.writer_closed { + for byte in &mut buffer.as_mut()[..count] { + *byte = state.bytes.pop_front().unwrap(); + } + return (Ok(count), buffer); + } + } + if let Err(error) = wait(ready, cancellation).await { + return (Err(error), buffer); + } + } + } + + async fn io_write( + &self, + socket: &Connection, + buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { + if let Err(error) = cancellation.check() { + return (Err(error.into()), buffer); + } + let count = buffer.as_ref().len().min(self.write_limit); + { + let mut state = socket.output.state.lock().unwrap(); + if state.reader_closed { + return ( + Err(io::Error::from(io::ErrorKind::BrokenPipe).into()), + buffer, + ); + } + state.bytes.extend(&buffer.as_ref()[..count]); + } + socket.output.ready.notify(usize::MAX); + (Ok(count), buffer) + } +} + +async fn wait( + ready: event_listener::EventListener, + cancellation: &Cancellation, +) -> OperationResult<()> { + let mut ready = pin!(ready); + let mut cancelled = pin!(cancellation.cancelled()); + poll_fn(|context| { + if ready.as_mut().poll(context).is_ready() { + return Poll::Ready(Ok(())); + } + cancelled + .as_mut() + .poll(context) + .map(|()| Err(socketry_executor::Cancelled.into())) + }) + .await +} diff --git a/crates/executor/examples/interface_probe/tests.rs b/crates/executor/examples/interface_probe/tests.rs new file mode 100644 index 0000000..bca8fbe --- /dev/null +++ b/crates/executor/examples/interface_probe/tests.rs @@ -0,0 +1,276 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use super::*; +use futures_lite::future::block_on; +use socketry_executor::IoBufMut; +use std::future::Future; +use std::pin::pin; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use std::task::{Context, Wake, Waker}; + +#[derive(Default)] +struct WakeCount(AtomicUsize); + +impl Wake for WakeCount { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +enum Failure { + Create, + Read, + Write, + WriteZero, +} + +struct FailingFiles { + scheduler: simulated::Scheduler, + failure: Failure, +} + +impl File for FailingFiles { + type File = ::File; + + async fn file_open( + &self, + path: &Path, + mode: FileOptions, + cancellation: &Cancellation, + ) -> OperationResult { + if matches!(self.failure, Failure::Create) && mode.create { + return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); + } + self.scheduler.file_open(path, mode, cancellation).await + } + + async fn file_read_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> socketry_executor::BufferResult { + if matches!(self.failure, Failure::Read) { + return ( + Err(io::Error::from(io::ErrorKind::PermissionDenied).into()), + buffer, + ); + } + self.scheduler + .file_read_at(file, buffer, offset, cancellation) + .await + } + + async fn file_write_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> socketry_executor::BufferResult { + // First make real progress, then fail. The already-written prefix must + // remain in the destination even though copy returns an error. + if offset > 0 { + match self.failure { + Failure::Write => { + return ( + Err(io::Error::from(io::ErrorKind::PermissionDenied).into()), + buffer, + ); + } + Failure::WriteZero => return (Ok(0), buffer), + _ => {} + } + } + self.scheduler + .file_write_at(file, buffer, offset, cancellation) + .await + } +} + +#[test] +fn failed_copies_release_handles_and_keep_completed_writes() { + block_on(async { + for failure in [ + Failure::Create, + Failure::Read, + Failure::Write, + Failure::WriteZero, + ] { + let scheduler = FailingFiles { + scheduler: simulated::Scheduler::new(3, 1), + failure, + }; + scheduler.scheduler.insert(Path::new("source"), PAYLOAD); + let error = copy( + &scheduler, + Path::new("source"), + Path::new("destination"), + &NEVER, + ) + .await + .unwrap_err(); + let expected = if matches!(scheduler.failure, Failure::WriteZero) { + io::ErrorKind::WriteZero + } else { + io::ErrorKind::PermissionDenied + }; + assert_eq!(error.kind(), expected); + assert_eq!(scheduler.scheduler.open_files(), 0); + if matches!(scheduler.failure, Failure::Write | Failure::WriteZero) { + assert_eq!( + scheduler.scheduler.contents(Path::new("destination")), + &PAYLOAD[..1] + ); + } + } + }); +} + +#[test] +fn same_application_runs_on_every_enabled_backend() { + main().unwrap(); +} + +#[test] +fn copy_handles_empty_short_and_multiple_buffers_and_releases_files() { + block_on(async { + for limit in [1, 3, BUFFER_SIZE, 1024] { + let scheduler = simulated::Scheduler::new(limit, 1); + for size in [0, 1, BUFFER_SIZE - 1, BUFFER_SIZE, BUFFER_SIZE + 1, 99] { + let expected: Vec = (0..size as u8).collect(); + scheduler.insert(Path::new("source"), &expected); + scheduler.insert(Path::new("destination"), &[255; 200]); + assert_eq!( + copy( + &scheduler, + Path::new("source"), + Path::new("destination"), + &NEVER + ) + .await + .unwrap(), + size as u64 + ); + assert_eq!(scheduler.contents(Path::new("destination")), expected); + assert_eq!(scheduler.open_files(), 0); + } + let error = copy( + &scheduler, + Path::new("missing"), + Path::new("destination"), + &NEVER, + ) + .await + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert_eq!(scheduler.open_files(), 0); + } + }); +} + +#[test] +fn file_results_preserve_allocations_on_partial_reads_eof_and_errors() { + block_on(async { + let scheduler = simulated::Scheduler::new(2, 2); + scheduler.insert(Path::new("source"), b"hello"); + let source = scheduler + .file_open(Path::new("source"), FileOptions::READ, &NEVER) + .await + .unwrap(); + let buffer = vec![0; 8]; + let allocation = buffer.as_ptr(); + let (result, buffer) = scheduler.file_read_at(&source, buffer, 1, &NEVER).await; + assert_eq!(result.unwrap(), 2); + assert_eq!(&buffer[..2], b"el"); + assert_eq!(buffer.len(), 8); + assert_eq!(buffer.as_ptr(), allocation); + let (result, buffer) = scheduler.file_read_at(&source, buffer, 100, &NEVER).await; + assert_eq!(result.unwrap(), 0); + assert_eq!(buffer.as_ptr(), allocation); + let (result, buffer) = scheduler.file_write_at(&source, buffer, 0, &NEVER).await; + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); + assert_eq!(buffer.as_ptr(), allocation); + let destination = scheduler + .file_open(Path::new("destination"), FileOptions::CREATE, &NEVER) + .await + .unwrap(); + let (result, buffer) = scheduler + .file_write_at(&destination, buffer, u64::MAX, &NEVER) + .await; + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput); + assert_eq!(buffer.as_ptr(), allocation); + let (result, buffer) = scheduler + .file_write_at(&destination, buffer, 3, &NEVER) + .await; + assert_eq!(result.unwrap(), 2); + assert_eq!(buffer.as_ptr(), allocation); + assert_eq!(scheduler.contents(Path::new("destination")), b"\0\0\0el"); + }); +} + +#[test] +fn pending_accept_and_receive_wake_and_drop_produces_eof() { + block_on(async { + let wakes = Arc::new(WakeCount::default()); + 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) + .await + .unwrap(); + let address = scheduler.listener_addr(&listener).unwrap(); + let mut accept = pin!(scheduler.accept(&listener, &NEVER)); + assert!(accept.as_mut().poll(&mut context).is_pending()); + let client = scheduler.connect(address, &NEVER).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)); + assert!(read.as_mut().poll(&mut context).is_pending()); + let (result, _) = scheduler.io_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)); + 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; + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); + assert_eq!(buffer.as_ptr(), allocation); + }); +} + +#[test] +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) + .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) + ); + let client = scheduler.connect(address, &NEVER).await.unwrap(); + drop(listener); + assert!( + matches!(scheduler.connect(address, &NEVER).await, Err(error) if error.kind() == io::ErrorKind::ConnectionRefused) + ); + let (result, _) = scheduler.io_read(&client, vec![0; 1], &NEVER).await; + assert_eq!(result.unwrap(), 0); + scheduler.socket_listen(address, &NEVER).await.unwrap(); + }); +} diff --git a/crates/executor/examples/portable_io.rs b/crates/executor/examples/portable_io.rs index d48aa0e..1f1acbe 100644 --- a/crates/executor/examples/portable_io.rs +++ b/crates/executor/examples/portable_io.rs @@ -3,27 +3,31 @@ //! The same TCP exchange, compiled against Socketry or Tokio. #[cfg(any(feature = "native", feature = "tokio"))] -use socketry_executor::{Network, Spawn}; +static NEVER: socketry_executor::Cancellation = socketry_executor::Cancellation::never(); + +#[cfg(any(feature = "native", feature = "tokio"))] +use socketry_executor::{Socket, Spawn}; #[cfg(any(feature = "native", feature = "tokio"))] -use std::{io, net::TcpListener}; +use std::io; #[cfg(any(feature = "native", feature = "tokio"))] async fn exchange(scheduler: SchedulerType) -> io::Result where - SchedulerType: Network + Spawn + Clone + 'static, + SchedulerType: Socket + Spawn + Clone + 'static, { - let listener = TcpListener::bind("127.0.0.1:0")?; - let address = listener.local_addr()?; - let listener = scheduler.register_listener(listener)?; + let listener = scheduler + .socket_listen("127.0.0.1:0".parse().unwrap(), &NEVER) + .await?; + let address = scheduler.listener_addr(&listener)?; let server_scheduler = scheduler.clone(); let server = scheduler .spawn(async move { - let (socket, _) = server_scheduler.accept(&listener).await?; - let (result, buffer) = server_scheduler.io_read(&socket, vec![0; 1]).await; + let (socket, _) = server_scheduler.accept(&listener, &NEVER).await?; + let (result, buffer) = server_scheduler.io_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).await; + let (result, _) = server_scheduler.io_write(&socket, buffer, &NEVER).await; if result? != 1 { return Err(io::Error::from(io::ErrorKind::WriteZero)); } @@ -31,12 +35,12 @@ where }) .map_err(io::Error::other)?; - let socket = scheduler.connect(address).await?; - let (result, buffer) = scheduler.io_write(&socket, vec![42]).await; + let socket = scheduler.connect(address, &NEVER).await?; + let (result, buffer) = scheduler.io_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).await; + let (result, buffer) = scheduler.io_read(&socket, buffer, &NEVER).await; if result? != 1 { return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); } @@ -66,7 +70,7 @@ fn main() -> Result<(), Box> { } #[cfg(not(any(feature = "native", feature = "tokio")))] { - return Err("enable the native or tokio feature".into()); + Err("enable the native or tokio feature".into()) } #[cfg(any(feature = "native", feature = "tokio"))] Ok(()) diff --git a/crates/executor/examples/work_stealing.rs b/crates/executor/examples/work_stealing.rs index 69a6f0b..a439b04 100644 --- a/crates/executor/examples/work_stealing.rs +++ b/crates/executor/examples/work_stealing.rs @@ -5,11 +5,10 @@ use socketry_executor::{Scheduler, Task, yield_now}; fn main() -> Result<(), Box> { let scheduler = Scheduler::with_workers(4)?; - let children = scheduler.barrier(); let mut handles = Vec::new(); for value in 0..8 { - handles.push(children.spawn(async move { + handles.push(scheduler.spawn(async move { let task = Task::current().expect("running in a task"); yield_now().await; println!( @@ -21,13 +20,11 @@ fn main() -> Result<(), Box> { })?); } - children.close(); let sum = scheduler.block_on(async { let mut sum = 0; for handle in handles { sum += handle.await?; } - children.wait().await; Ok::<_, socketry_executor::TaskError>(sum) })?; println!("sum: {sum}"); diff --git a/crates/executor/readme.md b/crates/executor/readme.md index 8d65fcc..a2014cb 100644 --- a/crates/executor/readme.md +++ b/crates/executor/readme.md @@ -1,7 +1,8 @@ # socketry-executor -Owned future tasks and a work-stealing scheduler for Socketry's Rust packages. -Use `socketry` as the common entry point, or depend on this package directly. +Owned future tasks and a work-stealing scheduler for Socketry's Rust packages. Use `socketry` as the common entry point, or depend on this package directly. + +The [executor design](design.md) records the agreed direction for cooperative cancellation, scheduler capabilities, portable I/O, and owned buffers, distinguishing implemented APIs from proposals. ## Execution @@ -9,8 +10,7 @@ Use `socketry` as the common entry point, or depend on this package directly. - `Scheduler::with_workers(count)` selects a nonzero worker count explicitly. - `scheduler.spawn(future)` registers an owned task and schedules it immediately. - `handle.await` returns `Result`. -- `scheduler.block_on(future)` polls a root future on the calling thread. This - root can borrow local data and need not be Send. It is not a spawned task. +- `scheduler.block_on(future)` polls a root future on the calling thread. This root can borrow local data and need not be Send. It is not a spawned task. - `scheduler.run()` waits until all owned tasks finish; it leaves admission open. - `scheduler.shutdown()` closes admission, cancels tasks and joins workers. @@ -19,181 +19,155 @@ use socketry_executor::{Scheduler, yield_now}; fn main() -> Result<(), Box> { let scheduler = Scheduler::with_workers(4)?; - let children = scheduler.barrier(); - let task = children.spawn(async { + let task = scheduler.spawn(async { yield_now().await; 42 })?; - children.close(); scheduler.block_on(async { assert_eq!(task.await?, 42); - children.wait().await; Ok::<(), socketry_executor::TaskError>(()) })?; Ok(()) } ``` -Workers poll pinned `Send + 'static` futures on ordinary thread stacks. A task -may migrate between polls; its pinned future stays at the same address. Only -one worker polls a given task at a time. Futures returning Pending must arrange -wakeups according to the standard Future contract. +Workers poll pinned `Send + 'static` futures on ordinary thread stacks. A task may migrate between polls; its pinned future stays at the same address. Only one worker polls a given task at a time. Futures returning Pending must arrange wakeups according to the standard Future contract. -`Scheduler::current()` returns a scheduler handle on workers and within a -`block_on` root. `Task::current()` returns the task being polled or destroyed; -it returns None at the root. `Task` references are thread-safe and support -identity and cancellation. They do not keep the task executing. +`Scheduler::current()` returns a scheduler handle on workers and within a `block_on` root. `Task::current()` returns the task being polled or destroyed; it returns None at the root. `Task` references are thread-safe and support identity and cancellation. They do not keep the task executing. -Blocking scheduler entry points panic on worker threads. Ordinary blocking -system calls still block a worker, and CPU-intensive code must yield explicitly. +Blocking scheduler entry points panic on worker threads. Ordinary blocking system calls still block a worker, and CPU-intensive code must yield explicitly. ## Queues and affinity -Each worker owns a FIFO `crossbeam-deque::Worker` and has a concurrent incoming -queue for remote wakeups. New tasks submitted outside a worker enter the global -injector. New tasks spawned by a worker enter its local queue. +Each worker owns a FIFO `crossbeam-deque::Worker` and has a concurrent incoming queue for remote wakeups. New tasks submitted outside a worker enter the global injector. New tasks spawned by a worker enter its local queue. -Once a task has run, wakeups target its last worker. That worker uses its local -queue when waking itself; other threads use its incoming queue. A worker checks -external work periodically even while its local queue stays busy. A worker -without work steals batches from other workers' local queues or incoming queues. -This preserves affinity until another worker needs work; it does not pin tasks -to threads. +Once a task has run, wakeups target its last worker. That worker uses its local queue when waking itself; other threads use its incoming queue. A worker checks external work periodically even while its local queue stays busy. A worker without work steals batches from other workers' local queues or incoming queues. This preserves affinity until another worker needs work; it does not pin tasks to threads. -Workers publish their sleeping state and recheck work before parking. Enqueuers -wake a sleeping worker after publishing work. Crossbeam's Retry result causes -another search rather than parking. These transitions preserve racing wakeups. -Paired sequentially consistent fences order publication against idle -registration. A Loom model covers that handshake; integration tests exercise -the actual queues and task implementation. +Workers publish their sleeping state and recheck work before parking. Enqueuers wake a sleeping worker after publishing work. Crossbeam's Retry result causes another search rather than parking. These transitions preserve racing wakeups. Paired sequentially consistent fences order publication against idle registration. A Loom model covers that handshake; integration tests exercise the actual queues and task implementation. ## Ownership and cancellation -Schedulers own top-level tasks. A `Barrier` explicitly owns its direct children -while the same scheduler executes them. Both implement `Spawn`; its associated -handle type leaves room for adapters using different runtimes. +Task handles own the execution lifetime of tasks dispatched through a scheduler. The scheduler retains their storage until completion or destruction. Both implement the primitive `Spawn` contract; its associated handle type leaves room for adapters using different runtimes. Dispatch takes a future and returns a task handle, without requiring a group or owner argument. -- Dropping a join handle abandons its result; it does not cancel the owned task. +- Moving a task handle transfers ownership. Dropping it abandons execution after any active poll returns; it does not wait. +- Await a handle explicitly for completion. Cooperative shutdown uses `cancellation.cancel()` followed by awaiting that handle. - `task.task().cancel()` requests cancellation without waiting. - `task.cancel().await` requests cancellation and awaits future destruction. -- `barrier.close()` prevents new children while existing children continue. -- `barrier.wait().await` waits until there are no direct children. Close first - when the set of children must remain closed. -- `barrier.stop().await` closes, cancels and waits. -- Dropping a barrier closes it and requests cancellation without waiting. - A scheduler's surviving handles reject submissions after shutdown begins. -Cancellation is observed before the next poll. A running poll may return a -successful result before cancellation is observed. Destruction waits until that -poll returns. Dropping the future runs ordinary destructors, not the remainder -of its async body. A task that never returns from poll prevents joined shutdown. +Cancellation is observed before the next poll. A running poll may return a successful result before cancellation is observed. Destruction waits until that poll returns. Dropping the future runs ordinary destructors, not the remainder of its async body. A task that never returns from poll prevents joined shutdown. + +Future panics become `TaskError::Panicked` containing the original payload. Await join handles to observe errors. Scheduler::run do not aggregate task results or propagate unobserved failures. + +There is no implicit task tree. Destroying a future drops task handles it still owns, which abandons those tasks. Handles moved to another task or container remain owned there. Await their handles when their completion matters. Higher-level task groups can be built above dispatch and are outside the executor for now. Owned tasks must be `'static`; ownership does not permit borrowing a parent's local variables. + +Dropping a scheduler outside a worker cancels tasks and joins workers. Dropping one on a Socketry worker requests cancellation and lets workers finish without joining synchronously, avoiding a worker waiting for itself. + +## Cooperative cancellation + +`Cancellation` is a runtime-independent shutdown signal. It does not own tasks or destroy futures. `cancel()` requests cancellation and returns true for the first request on that signal; subsequent requests return false and never escalate. `is_cancelled()` observes the persistent state, `check()` returns `Err(Cancelled)` when cancelled, and `cancelled().await` waits using ordinary polling and wakeups. + +Clones share state. `child()` creates an independent cancellation boundary that also receives ancestor cancellation. Cancelling a child leaves its parent and siblings running. Descendants retain this relationship even after intermediate handles are dropped. Dropping signals never requests cancellation. A child created after its parent is cancelled starts cancelled. `Cancellation::never()` requires no allocation and remains uncancelled; its children are independent cancellable signals. + +`defer_cancel(&signal, future, on_cancel)` invokes a synchronous `FnOnce()` callback when cancellation is observed, then continues polling the protected future to completion. Keep asynchronous cleanup in that future; the callback should request shutdown or wake it. The callback runs before the next protected poll, including the first poll when the signal is already cancelled. If both cancellation and completion are observed in the same poll, the callback runs and the wrapper returns the future's normal output. Cancellation arriving during the protected poll can lose that race to completion. -Future panics become `TaskError::Panicked` containing the original payload. -Await join handles to observe errors. Barrier waits and scheduler.run do not -aggregate task results or propagate unobserved failures. +```rust +use socketry_executor::{Cancellation, Scheduler, defer_cancel, yield_now}; + +let scheduler = Scheduler::with_workers(1)?; +let shutdown = Cancellation::new(); +let drain = Cancellation::new(); +shutdown.cancel(); +let output = scheduler.block_on(defer_cancel(&shutdown, async { + drain.cancelled().await; + yield_now().await; // Asynchronous cleanup remains executable. + 42 +}, || { drain.cancel(); })); +assert_eq!(output, 42); +# Ok::<(), std::io::Error>(()) +``` + +Use a root signal for process shutdown and child signals for individual services. Keep the scheduler and runtime alive until task handles confirm draining is complete. The protected work must use cancellation inputs that allow cleanup to continue; the wrapper does not mask signals passed into its operations. Callback panics propagate, and dropping the wrapper still drops its work. + +These primitives do not change `Task::cancel` or scheduler shutdown, which still destroy futures. File and socket operations accept explicit cancellation inputs and return `OperationError::Cancelled` separately from OS errors. Clock cancellation remains proposed. Cooperative requests have no forced abort or deadline escalation; a process supervisor can enforce SIGTERM followed by SIGKILL. -Parent tasks must explicitly await their barriers for joined child cleanup. -Dropping a parent can drop its barriers and request cancellation, but parent -completion does not automatically wait for descendants. Owned tasks must be -`'static`; ownership does not permit borrowing a parent's local variables. +## Signal notifications -Dropping a scheduler outside a worker cancels tasks and joins workers. Dropping -one on a Socketry worker requests cancellation and lets workers finish without -joining synchronously, avoiding a worker waiting for itself. +Enable the optional `signals` feature on Unix together with `native` (the default) or `tokio` to use `Signals::signal_notify` on Socketry's scheduler or handle, or the Tokio adapter. Registration is explicit, synchronous, and fallible. Scheduler construction installs no signal handlers. The returned `#[must_use]` guard owns one subscription; keep it alive during graceful draining and drop it to unsubscribe. + +```rust,no_run +# #[cfg(all(unix, feature = "signals", feature = "native"))] +# fn main() -> std::io::Result<()> { +use socketry_executor::{Cancellation, Scheduler, Signals, signal::{SIGINT, SIGTERM, SIGHUP}}; + +let scheduler = Scheduler::with_workers(1)?; +let shutdown = Cancellation::new(); +let shutdown_registration = scheduler.signal_notify(&[SIGINT, SIGTERM], shutdown.clone())?; + +let (reload, mut reloads) = futures_channel::mpsc::unbounded(); +let reload_registration = scheduler.signal_notify(&[SIGHUP], reload)?; +// Receive reloads using StreamExt::next(); shutdown is independent of reloads. +scheduler.block_on(shutdown.cancelled()); +// Drain application work while the scheduler and registrations remain alive. +drop(reload_registration); +drop(shutdown_registration); +# Ok(()) +# } +# #[cfg(not(all(unix, feature = "signals", feature = "native")))] +# fn main() {} +``` + +`Notify::notify(&self, value)` is a synchronous target interface called in ordinary thread context, outside raw signal handlers and scheduler locks. `Cancellation` ignores the value and requests cancellation. Standard unbounded `std::sync::mpsc::Sender` and `futures_channel::mpsc::UnboundedSender` enqueue it; a closed receiver discards it. `Arc` forwards to its target. Bounded channels need an application wrapper with an explicit overflow policy. A target should enqueue or wake work and return promptly. Waiting remains target-specific: `cancelled()` or the channel's receive operation. + +Each registration spawns one scheduler task that waits asynchronously on signal-hook's self-pipe using the chosen backend: async-io for native, or the supplied Tokio runtime for the adapter. It creates no dedicated thread. Dropping the guard stops new notification admission and abandons its owned task; after any active poll returns, task destruction releases the subscription. An already-admitted callback may finish, retaining its target until completion. Other subscriptions remain active. A callback panic ends its own task through normal scheduler panic handling. Scheduler/runtime shutdown also ends delivery, so keep those services alive during draining. No repeated-signal escalation is installed. + +OS signals and the backend can coalesce repeated occurrences before delivery; the channel retains delivered values but cannot count every occurrence or promise ordering across different signals. Unsubscribing releases the registration asynchronously and does not restore original process signal dispositions, including after partial setup failure. Coordinate process-wide signal ownership with other libraries and existing handlers. Linux signalfd and kqueue-specific delivery remain possible future backends. ## Implementation costs -`async-task` supplies pinned task storage, wakers, runnable state and join -handles. Socketry adds a separately allocated, reference-counted task record -for identity, cancellation, ownership and affinity. Each barrier also has a -shared owner record. The task registry retains a waker while the task is alive. +`async-task` supplies pinned task storage, wakers, runnable state and join handles. Socketry adds a separately allocated, reference-counted task record for identity, cancellation, ownership and affinity. The task registry retains a waker while the task is alive. + +Spawning and completion take the ownership registry mutex. Cancellation and scheduler closure also use it. Ordinary polling, wakeups and ready-queue operations do not take that mutex. Polling establishes task context with an Arc clone; explicit current-task/current-scheduler lookups clone shared references. -Spawning and completion take the ownership registry mutex. Cancellation and -owner closure also use it. Ordinary polling, wakeups and ready-queue operations -do not take that mutex. Polling establishes task context with an Arc clone; -explicit current-task/current-scheduler lookups clone shared references. +Each cancellable signal allocates a reference-counted node. A signal family shares a mutex for child registration and cancellation propagation; parent nodes hold weak child registrations, and children retain ancestors. Cancellation walks descendants iteratively and releases locks before waking listeners. Waiting uses the existing event-listener dependency. Dropped children remove their registrations, and deep ancestor chains are released iteratively. Ordinary signal checks use an atomic flag; `never()` needs no node or listener. -Queues allocate backing storage as needed. Rescheduling reuses the existing -task; it does not allocate another future or a coroutine stack. Idle worker -selection can scan worker flags, with a count allowing the scan to be skipped -when all workers are busy. No throughput or allocation benchmark is claimed yet. +Queues allocate backing storage as needed. Rescheduling reuses the existing task; it does not allocate another future or a coroutine stack. Idle worker selection can scan worker flags, with a count allowing the scan to be skipped when all workers are busy. No throughput or allocation benchmark is claimed yet. ## I/O and selectors -The public `scheduler` module contains portable `Network`, `FileIo`, 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. -- `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. - -`Scheduler` and `SchedulerHandle` implement the traits. Register an owned TCP -socket/listener once, or use `connect`/`accept`, and retain the resulting resource -across operations. 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. - -Read/write operations take ownership of a `Vec` and return the buffer with -the result, including ordinary errors. 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. - -`FileIo::file_read_at` and `file_write_at` accept an `Arc` and an -explicit offset. The readiness and Tokio implementations use blocking pools. -Use ordinary files opened without append mode, and offsets fitting i64. Unix -positioned operations leave the shared cursor unchanged; the Windows blocking -fallback updates it according to std's seek_read/seek_write semantics. - -`Clock::sleep` uses async-io timers or Tokio timers. The io-event timer algorithm -has not yet been ported. There is no general public blocking-task API yet. +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. +- `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. + +`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. + +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. + +`File::file_read_at` and `file_write_at` borrow `&Self::File` and take an explicit offset. `file_open(path, FileOptions, cancellation)` creates the associated resource. Current native/Tokio backends internally retain an `Arc`; generic callers do not require that representation. The readiness and Tokio implementations use blocking pools. Use ordinary files opened without append mode, and offsets fitting i64. Unix positioned operations leave the shared cursor unchanged; the Windows blocking fallback updates it according to std's seek\_read/seek\_write semantics. + +`Clock::sleep` uses async-io timers or Tokio timers. The independent `socketry-timers` crate implements the io-event timer algorithm; integration with the scheduler clock remains pending. There is no general public blocking-task API yet. ### Cancellation and shutdown -Dropping a read/write future abandons its result. A submitted operation may -still consume or transmit bytes. Ownership of the buffer and file/socket -continues until kernel access ends. Await a result when the byte count matters. +Pre-cancelled file/socket operations do not start. Readiness waits stop on cancellation. io\_uring requests cancellation but waits for the original completion before returning the buffer; successful progress wins a racing cancellation. Blocking jobs check cancellation again on their pool worker, before execution; a running syscall returns its actual result. + +Dropping a read/write future abandons its result. A submitted operation may still consume or transmit bytes. Ownership of the buffer and file/socket continues until kernel access ends. Await a result when the byte count matters. -The io_uring selector probes required opcodes and completion-overflow support. -It returns initialization errors rather than silently switching backend. -Cancellation requests and original completions have distinct identifiers; -only an original terminal completion releases the operation's resources. -Socketry shutdown outside a worker joins tasks and drains its ring. Dropping -the scheduler on a worker requests cancellation without blocking that worker. +The io\_uring selector probes required opcodes and completion-overflow support. It returns initialization errors rather than silently switching backend. Cancellation requests and original completions have distinct identifiers; only an original terminal completion releases the operation's resources. Socketry shutdown outside a worker joins tasks and drains its ring. Dropping the scheduler on a worker requests cancellation without blocking that worker. -Readiness resources use a process-wide reactor, which is not shut down with an -individual Socketry scheduler. An already-started blocking file operation can -continue after its waiting task is cancelled. The Tokio adapter's shutdown -joins its owned tasks, not the external runtime or its blocking-operation pool. +Readiness resources use a process-wide reactor, which is not shut down with an individual Socketry scheduler. An already-started blocking file operation can continue after its waiting task is cancelled. The Tokio adapter's shutdown joins its owned tasks, not the external runtime or its blocking-operation pool. -An unexpected io_uring selector failure after submission cannot return memory -whose kernel lifetime is unknown. That exceptional path retains the affected -resources and panics the waiting operation; it does not free in-flight buffers. +An unexpected io\_uring selector failure after submission cannot return memory whose kernel lifetime is unknown. That exceptional path retains the affected resources and panics the waiting operation; it does not free in-flight buffers. ### Costs and remaining work -Readiness registrations and buffers are reused. io_uring currently uses a -dedicated selector thread, a command channel 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. +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 the io-event timer port 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, 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. The portable example uses the same generic TCP exchange with either runtime: @@ -203,10 +177,19 @@ cargo run -p socketry-executor --example portable_io --no-default-features --fea cargo run -p socketry-executor --example portable_io --features io-uring # Linux ``` +The [interface experiment](examples/interface_probe.rs) runs generic file-copy and echo-server functions against native, Tokio, and in-memory backends: + +```sh +cargo run -p socketry-executor --example interface_probe --features tokio +cargo test -p socketry-executor --example interface_probe --features tokio +cargo run -p socketry-executor --example interface_probe --no-default-features +``` + +It uses the exported `File` and `Socket` traits, associated resources, cancellation, and owned buffer views. Its in-memory implementation supplies the same capabilities without importing OS descriptors. See the [design findings](design.md#executable-interface-experiment) for results and remaining contracts. + ## Preserved prototype -The native coroutine prototype, verbatim CRuby vendor sources and its tests are -preserved in commit `b520f3d` on branch `coroutine`. +The native coroutine prototype, verbatim CRuby vendor sources and its tests are preserved in commit `b520f3d` on branch `coroutine`. Run the example and tests with: diff --git a/crates/executor/src/barrier.rs b/crates/executor/src/barrier.rs deleted file mode 100644 index 8828302..0000000 --- a/crates/executor/src/barrier.rs +++ /dev/null @@ -1,106 +0,0 @@ -// Released under the MIT License. -// Copyright, 2026, by Samuel Williams. - -use crate::owner::Owner; -use crate::{SchedulerHandle, Spawn, SpawnError, Task, TaskHandle}; -use std::future::Future; -use std::sync::Arc; - -/// Explicit ownership of child tasks running on a scheduler. -/// -/// Dropping a barrier closes it and requests cancellation of its children. -/// Use `stop().await` to wait for their synchronous destructors. `wait().await` -/// waits for normal completion. Task results are observed through join handles. -/// All children must own their captured data (`Send + 'static`). -pub struct Barrier { - scheduler: SchedulerHandle, - owner: Arc, -} - -impl Barrier { - /// Create an empty child owner using this scheduler for execution. - pub fn new(scheduler: SchedulerHandle) -> Self { - Self { - scheduler, - owner: Arc::new(Owner::new()), - } - } - - /// Spawn a child owned by this barrier. - pub fn spawn( - &self, - future: FutureType, - ) -> Result, SpawnError> - where - FutureType: Future + Send + 'static, - FutureType::Output: Send + 'static, - { - Spawn::spawn(self, future) - } - - /// Prevent further children from being spawned. Existing children continue. - pub fn close(&self) { - self.scheduler.shared.close_owner(&self.owner, false); - } - - /// Wait until no children remain. Call `close` first for a closed set. - /// - /// This does not collect task outputs or propagate task panics; await each - /// join handle when its result matters. Cancelling this wait leaves children - /// running until the barrier is stopped or dropped. - /// - /// # Panics - /// Panics if called by a child belonging to this barrier (it cannot join itself). - pub async fn wait(&self) { - self.assert_not_child(); - self.owner.wait().await; - } - - /// Close the barrier, request cancellation, and wait for children to stop. - /// Dropping this future after it is polled leaves cancellation requested. - /// An in-progress poll must return before its task can be destroyed. - /// - /// # Panics - /// Panics if called by one of this barrier's children. - pub async fn stop(&self) { - self.assert_not_child(); - self.scheduler.shared.close_owner(&self.owner, true); - self.owner.wait().await; - } - - /// Whether all direct children have finished and been destroyed. - pub fn is_empty(&self) -> bool { - self.owner.is_empty() - } - - fn assert_not_child(&self) { - assert!( - Task::current().is_none_or(|task| !Arc::ptr_eq(&task.state.owner, &self.owner)), - "a task cannot wait for its own barrier" - ); - } -} - -impl Spawn for Barrier { - type Handle - = TaskHandle - where - Output: Send + 'static; - - fn spawn( - &self, - future: FutureType, - ) -> Result, SpawnError> - where - FutureType: Future + Send + 'static, - FutureType::Output: Send + 'static, - { - self.scheduler.shared.spawn(&self.owner, future) - } -} - -impl Drop for Barrier { - fn drop(&mut self) { - self.scheduler.shared.close_owner(&self.owner, true); - } -} diff --git a/crates/executor/src/buffer_slice.rs b/crates/executor/src/buffer_slice.rs new file mode 100644 index 0000000..20500fb --- /dev/null +++ b/crates/executor/src/buffer_slice.rs @@ -0,0 +1,57 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::{IoBuf, IoBufMut}; +use std::ops::{Bound, Range, RangeBounds}; + +/// An owned view into initialized storage. Dropping it drops its owner; +/// `into_inner` recovers the complete buffer after an operation returns it. +#[derive(Debug)] +pub struct BufferSlice { + buffer: B, + range: Range, +} + +impl BufferSlice { + pub(crate) fn new(buffer: B, range: impl RangeBounds) -> Self { + let start = match range.start_bound() { + Bound::Included(&start) => start, + Bound::Excluded(&start) => start.checked_add(1).expect("buffer range start overflow"), + Bound::Unbounded => 0, + }; + let end = match range.end_bound() { + Bound::Included(&end) => end.checked_add(1).expect("buffer range end overflow"), + Bound::Excluded(&end) => end, + Bound::Unbounded => buffer.as_ref().len(), + }; + assert!( + start <= end && end <= buffer.as_ref().len(), + "buffer range out of bounds" + ); + Self { + buffer, + range: start..end, + } + } + + pub fn into_inner(self) -> B { + self.buffer + } +} + +impl AsRef<[u8]> for BufferSlice { + fn as_ref(&self) -> &[u8] { + &self.buffer.as_ref()[self.range.clone()] + } +} + +impl AsMut<[u8]> for BufferSlice { + fn as_mut(&mut self) -> &mut [u8] { + &mut self.buffer.as_mut()[self.range.clone()] + } +} + +// SAFETY: The view retains its owner and always exposes the same validated +// subrange. Moving the view does not move or resize the backing allocation. +unsafe impl IoBuf for BufferSlice {} +unsafe impl IoBufMut for BufferSlice {} diff --git a/crates/executor/src/cancellation.rs b/crates/executor/src/cancellation.rs new file mode 100644 index 0000000..ada1c51 --- /dev/null +++ b/crates/executor/src/cancellation.rs @@ -0,0 +1,185 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::Cancelled; +use event_listener::Event; +use std::collections::HashMap; +use std::future::pending; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError, Weak}; + +struct Node { + cancelled: AtomicBool, + event: Event, + // Serialize child registration and cancellation across this signal family. + tree: Arc>, + // Children retain ancestors, but ancestors only hold weak child references. + parent: Option>, + children: Mutex>>, +} + +impl Node { + fn new(tree: Arc>, parent: Option>) -> Self { + Self { + cancelled: AtomicBool::new(false), + event: Event::new(), + tree, + parent, + children: Mutex::new(HashMap::new()), + } + } +} + +impl Drop for Node { + fn drop(&mut self) { + let mut identifier = self as *const Self as usize; + let mut parent = self.parent.take(); + // Release an exclusively owned ancestor chain without recursive drops. + while let Some(node) = parent { + // The allocation's address identifies its weak registration until + // destruction removes it, so recycled addresses cannot alias it. + node.children + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(&identifier); + identifier = Arc::as_ptr(&node) as usize; + match Arc::try_unwrap(node) { + Ok(mut node) => { + parent = node.parent.take(); + } + Err(_) => break, + } + } + } +} + +/// A runtime-independent, cooperative cancellation signal. +/// +/// Clones share a persistent cancellation state. Child signals receive ancestor +/// cancellation, but cancelling a child does not cancel its parent or siblings. +/// Dropping a signal does not request cancellation. Signals neither own tasks nor +/// abort futures; callers decide how to respond and separately await completion. +/// +/// ``` +/// use socketry_executor::{Cancellation, Cancelled}; +/// +/// let system = Cancellation::new(); +/// let server = system.child(); +/// assert!(server.cancel()); +/// assert_eq!(server.check(), Err(Cancelled)); +/// assert!(!system.is_cancelled()); +/// assert!(system.cancel()); +/// ``` +#[derive(Clone)] +pub struct Cancellation { + node: Option>, +} + +impl Cancellation { + /// Create an independent, uncancelled signal. + pub fn new() -> Self { + Self { + node: Some(Arc::new(Node::new(Arc::new(Mutex::new(())), None))), + } + } + + /// Create a signal which cannot be cancelled and requires no allocation. + /// `cancel` always returns false and `cancelled` remains pending forever. + pub const fn never() -> Self { + Self { node: None } + } + + /// Create a signal cancelled by this signal or any of its ancestors. + /// A child of an already-cancelled signal starts cancelled. A child of a + /// never-cancelled signal is an independent, cancellable signal. + pub fn child(&self) -> Self { + let Some(parent) = &self.node else { + return Self::new(); + }; + let _tree = parent.tree.lock().unwrap_or_else(PoisonError::into_inner); + let child = Arc::new(Node::new( + Arc::clone(&parent.tree), + Some(Arc::clone(parent)), + )); + if parent.cancelled.load(Ordering::Acquire) { + child.cancelled.store(true, Ordering::Release); + } else { + parent + .children + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(Arc::as_ptr(&child) as usize, Arc::downgrade(&child)); + } + Self { node: Some(child) } + } + + /// Request cancellation of this signal and all its descendants. + /// Returns true only for the first request on this signal. Repeated requests + /// never escalate. Waiters are woken after the entire family lock is released. + /// Concurrent observers may see propagation in progress, but all descendants + /// are cancelled when this call returns. This does not wait for their work. + pub fn cancel(&self) -> bool { + let Some(root) = &self.node else { + return false; + }; + let tree = root.tree.lock().unwrap_or_else(PoisonError::into_inner); + if root.cancelled.load(Ordering::Acquire) { + return false; + } + let mut pending = vec![Arc::clone(root)]; + let mut cancelled = Vec::new(); + while let Some(node) = pending.pop() { + node.cancelled.store(true, Ordering::Release); + pending.extend( + std::mem::take(&mut *node.children.lock().unwrap_or_else(PoisonError::into_inner)) + .into_values() + .filter_map(|child| child.upgrade()), + ); + cancelled.push(node); + } + drop(tree); + for node in cancelled { + node.event.notify(usize::MAX); + } + true + } + + /// Whether cancellation has been requested. + pub fn is_cancelled(&self) -> bool { + self.node + .as_ref() + .is_some_and(|node| node.cancelled.load(Ordering::Acquire)) + } + + /// Check for cancellation at an explicit cooperative cancellation point. + pub fn check(&self) -> Result<(), Cancelled> { + if self.is_cancelled() { + Err(Cancelled) + } else { + Ok(()) + } + } + + /// Wait for cancellation using standard future polling and wakeups. + /// This future is immediately ready if already cancelled. Dropping the wait + /// unregisters its listener without cancelling the signal or other waiters. + pub async fn cancelled(&self) { + let Some(node) = &self.node else { + return pending().await; + }; + let listener = node.event.listen(); + // Register before checking to preserve a racing cancellation notification. + if !node.cancelled.load(Ordering::Acquire) { + listener.await; + } + } +} + +impl Default for Cancellation { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/executor/src/cancellation/tests.rs b/crates/executor/src/cancellation/tests.rs new file mode 100644 index 0000000..1750a82 --- /dev/null +++ b/crates/executor/src/cancellation/tests.rs @@ -0,0 +1,39 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use super::*; + +#[test] +fn dropped_children_release_their_registrations_and_ancestors() { + let root = Cancellation::new(); + let node = root.node.as_ref().unwrap(); + for _ in 0..100 { + let child = root.child(); + let grandchild = child.child(); + drop(child); + drop(grandchild); + assert!(node.children.lock().unwrap().is_empty()); + assert_eq!(Arc::strong_count(node), 1); + } +} + +#[test] +fn an_exclusively_owned_deep_chain_is_released_without_recursive_destruction() { + std::thread::Builder::new() + .stack_size(64 * 1024) + .spawn(|| { + let root = Cancellation::new(); + let weak = Arc::downgrade(root.node.as_ref().unwrap()); + let mut leaf = root.clone(); + for _ in 0..10_000 { + leaf = leaf.child(); + } + drop(root); + assert!(weak.upgrade().is_some()); + drop(leaf); + assert!(weak.upgrade().is_none()); + }) + .unwrap() + .join() + .unwrap(); +} diff --git a/crates/executor/src/cancelled.rs b/crates/executor/src/cancelled.rs new file mode 100644 index 0000000..88f8762 --- /dev/null +++ b/crates/executor/src/cancelled.rs @@ -0,0 +1,16 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use std::fmt; + +/// An operation observed a cooperative cancellation request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Cancelled; + +impl fmt::Display for Cancelled { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("operation cancelled") + } +} + +impl std::error::Error for Cancelled {} diff --git a/crates/executor/src/defer_cancel.rs b/crates/executor/src/defer_cancel.rs new file mode 100644 index 0000000..66b9ecd --- /dev/null +++ b/crates/executor/src/defer_cancel.rs @@ -0,0 +1,56 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::Cancellation; +use std::future::{Future, poll_fn}; +use std::pin::pin; + +/// Notify protected work of cancellation and continue awaiting its completion. +/// +/// When cancellation is observed, call `on_cancel` exactly once before +/// polling the protected future again. This includes an already-cancelled signal +/// on the first poll. If completion and cancellation are both ready in that poll, +/// the callback runs before returning the future's normal output. A callback +/// panic propagates to the caller. +/// +/// The callback is synchronous: request graceful shutdown and wake the work; +/// keep asynchronous draining in the protected future. Its operations must use +/// cancellation inputs which allow draining. This wrapper does not mask signals, +/// prevent its own destruction, or intercept scheduler task cancellation. +/// +/// ``` +/// use socketry_executor::{Cancellation, Scheduler, defer_cancel, yield_now}; +/// +/// let scheduler = Scheduler::with_workers(1)?; +/// let shutdown = Cancellation::new(); +/// let drain = Cancellation::new(); +/// shutdown.cancel(); +/// let output = scheduler.block_on(defer_cancel(&shutdown, async { +/// drain.cancelled().await; +/// yield_now().await; +/// 42 +/// }, || { drain.cancel(); })); +/// assert_eq!(output, 42); +/// # Ok::<(), std::io::Error>(()) +/// ``` +pub async fn defer_cancel( + cancellation: &Cancellation, + future: FutureType, + on_cancel: OnCancel, +) -> FutureType::Output +where + FutureType: Future, + OnCancel: FnOnce(), +{ + let mut notification = pin!(cancellation.cancelled()); + let mut future = pin!(future); + let mut on_cancel = Some(on_cancel); + poll_fn(move |context| { + if on_cancel.is_some() && notification.as_mut().poll(context).is_ready() { + // Remove the callback before invoking user code, including on panic. + on_cancel.take().expect("cancellation callback is present")(); + } + future.as_mut().poll(context) + }) + .await +} diff --git a/crates/executor/src/file_options.rs b/crates/executor/src/file_options.rs new file mode 100644 index 0000000..a1f81da --- /dev/null +++ b/crates/executor/src/file_options.rs @@ -0,0 +1,42 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +/// Portable regular-file opening options. Fields are visible to alternative +/// implementations, including simulated filesystems. Append mode is omitted: +/// positioned writes must target the supplied offset. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct FileOptions { + pub read: bool, + pub write: bool, + pub create: bool, + pub create_new: bool, + pub truncate: bool, +} + +impl FileOptions { + pub const READ: Self = Self { + read: true, + write: false, + create: false, + create_new: false, + truncate: false, + }; + pub const CREATE: Self = Self { + read: false, + write: true, + create: true, + create_new: false, + truncate: true, + }; + + #[cfg(any(feature = "native", feature = "tokio"))] + pub(crate) fn open(self, path: &std::path::Path) -> std::io::Result { + std::fs::OpenOptions::new() + .read(self.read) + .write(self.write) + .create(self.create) + .create_new(self.create_new) + .truncate(self.truncate) + .open(path) + } +} diff --git a/crates/executor/src/io_buf.rs b/crates/executor/src/io_buf.rs new file mode 100644 index 0000000..a39ab4c --- /dev/null +++ b/crates/executor/src/io_buf.rs @@ -0,0 +1,29 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::BufferSlice; +use std::ops::RangeBounds; + +/// Owned, initialized storage suitable for completion-based I/O. +/// +/// # Safety +/// `as_ref()` must always expose the same initialized allocation and length +/// while an operation owns this value, even if the value moves. The allocation +/// must remain alive until this value is dropped, and must not be mutated +/// through another owner during an operation. Implementations must not expose +/// inline storage whose address changes when the owner moves. +pub unsafe trait IoBuf: AsRef<[u8]> + Send + 'static { + /// Limit an operation to an owned range without copying its bytes. + /// Panics if the range is outside the initialized buffer. + fn slice(self, range: impl RangeBounds) -> BufferSlice + where + Self: Sized, + { + BufferSlice::new(self, range) + } +} + +// SAFETY: Both owners retain a stable heap allocation as they move. Exclusive +// ownership prevents mutation by another owner while an operation holds them. +unsafe impl IoBuf for Vec {} +unsafe impl IoBuf for Box<[u8]> {} diff --git a/crates/executor/src/io_buf_mut.rs b/crates/executor/src/io_buf_mut.rs new file mode 100644 index 0000000..2d3e5b6 --- /dev/null +++ b/crates/executor/src/io_buf_mut.rs @@ -0,0 +1,17 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::IoBuf; + +/// Owned writable storage. Operations fill its existing initialized length; +/// they never expose uninitialized capacity or change the buffer length. +/// +/// # Safety +/// In addition to `IoBuf`, `as_mut()` must expose the same allocation and range +/// as `as_ref()`, with exclusive access throughout an operation. No other owner +/// may read or write that range while the operation owns the buffer. +pub unsafe trait IoBufMut: IoBuf + AsMut<[u8]> {} + +// SAFETY: Vec and Box provide exclusive access to their stable allocation. +unsafe impl IoBufMut for Vec {} +unsafe impl IoBufMut for Box<[u8]> {} diff --git a/crates/executor/src/lib.rs b/crates/executor/src/lib.rs index 3efe239..e42237a 100644 --- a/crates/executor/src/lib.rs +++ b/crates/executor/src/lib.rs @@ -4,17 +4,41 @@ //! Owned tasks and cooperative future polling on work-stealing workers. //! //! Tasks require `Send + 'static` and use the ordinary worker thread stack. -//! The scheduler owns spawned tasks even when their join handles are dropped. -//! Use a [`Barrier`] to explicitly own a group of child tasks. +//! Task handles own execution lifetimes; dropping one abandons its task. #![doc = include_str!("../readme.md")] -mod barrier; -mod owner; +mod buffer_slice; +mod cancellation; +mod cancelled; +mod defer_cancel; +mod file_options; +mod io_buf; +mod io_buf_mut; +mod notify; +mod operation_error; pub mod scheduler; +#[cfg(all(unix, feature = "signals", any(feature = "native", feature = "tokio")))] +mod signal_registration; +mod spawn; mod task; mod worker; -pub use barrier::Barrier; -pub use owner::{Spawn, SpawnError}; -pub use scheduler::{BufferResult, Clock, FileIo, Interest, Network, Scheduler, SchedulerHandle}; +pub use buffer_slice::BufferSlice; +pub use cancellation::Cancellation; +pub use cancelled::Cancelled; +pub use defer_cancel::defer_cancel; +pub use file_options::FileOptions; +pub use io_buf::IoBuf; +pub use io_buf_mut::IoBufMut; +pub use notify::Notify; +pub use operation_error::{OperationError, OperationResult}; +pub use scheduler::{ + BufferResult, Clock, File, Interest, Scheduler, SchedulerHandle, Signals, Socket, + SocketRegistration, +}; +#[cfg(all(unix, feature = "signals"))] +pub use signal_hook::consts::signal; +#[cfg(all(unix, feature = "signals", any(feature = "native", feature = "tokio")))] +pub use signal_registration::SignalRegistration; +pub use spawn::{Spawn, SpawnError}; pub use task::{Task, TaskError, TaskHandle, yield_now}; diff --git a/crates/executor/src/notify.rs b/crates/executor/src/notify.rs new file mode 100644 index 0000000..0b4760b --- /dev/null +++ b/crates/executor/src/notify.rs @@ -0,0 +1,42 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::Cancellation; +use std::sync::{Arc, mpsc}; + +/// A synchronous destination for a notification value. +/// +/// Called in ordinary thread context, never directly in a raw signal handler. +/// Implementations should return promptly: enqueue work or wake a future rather +/// than waiting for it to finish. Waiting remains specific to the destination. +/// +/// Channel implementations enqueue without waiting for capacity. A closed +/// receiver discards the value. Bounded senders have no default implementation: +/// an application must choose its own overflow policy in a wrapper. +pub trait Notify: Send + Sync { + fn notify(&self, value: Value); +} + +impl Notify for Cancellation { + fn notify(&self, _value: Value) { + self.cancel(); + } +} + +impl + ?Sized> Notify for Arc { + fn notify(&self, value: Value) { + (**self).notify(value); + } +} + +impl Notify for mpsc::Sender { + fn notify(&self, value: Value) { + let _ = self.send(value); + } +} + +impl Notify for futures_channel::mpsc::UnboundedSender { + fn notify(&self, value: Value) { + let _ = self.unbounded_send(value); + } +} diff --git a/crates/executor/src/operation_error.rs b/crates/executor/src/operation_error.rs new file mode 100644 index 0000000..7b2e687 --- /dev/null +++ b/crates/executor/src/operation_error.rs @@ -0,0 +1,71 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::Cancelled; +use std::{fmt, io}; + +/// Cooperative cancellation is distinct from an operating-system error. +#[derive(Debug)] +pub enum OperationError { + Cancelled(Cancelled), + Io(io::Error), +} + +pub type OperationResult = Result; + +impl OperationError { + /// I/O-compatible classification; match `Cancelled` to distinguish a + /// cooperative request from an OS interruption. + pub fn kind(&self) -> io::ErrorKind { + match self { + Self::Cancelled(_) => io::ErrorKind::Interrupted, + Self::Io(error) => error.kind(), + } + } + + pub fn raw_os_error(&self) -> Option { + match self { + Self::Cancelled(_) => None, + Self::Io(error) => error.raw_os_error(), + } + } +} + +impl fmt::Display for OperationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cancelled(error) => error.fmt(formatter), + Self::Io(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for OperationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(match self { + Self::Cancelled(error) => error, + Self::Io(error) => error, + }) + } +} + +impl From for OperationError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for OperationError { + fn from(error: Cancelled) -> Self { + Self::Cancelled(error) + } +} + +impl From for io::Error { + fn from(error: OperationError) -> Self { + match error { + OperationError::Io(error) => error, + OperationError::Cancelled(error) => Self::new(io::ErrorKind::Interrupted, error), + } + } +} diff --git a/crates/executor/src/owner.rs b/crates/executor/src/owner.rs deleted file mode 100644 index bda7b54..0000000 --- a/crates/executor/src/owner.rs +++ /dev/null @@ -1,87 +0,0 @@ -// Released under the MIT License. -// Copyright, 2026, by Samuel Williams. - -use crate::TaskError; -use event_listener::Event; -use std::fmt; -use std::future::Future; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - -/// The common spawning contract for a scheduler and an explicit child owner. -pub trait Spawn { - /// The runtime's awaitable task handle. Runtime adapters may use their own - /// representation while preserving the ownership and result contract. - type Handle: Future> + Send - where - Output: Send + 'static; - - /// Register a task with this owner before making it runnable. - /// - /// Dropping the returned join handle does not cancel the owned task. - /// If the owner is closed, the future is dropped without being polled. - fn spawn( - &self, - future: FutureType, - ) -> Result, SpawnError> - where - FutureType: Future + Send + 'static, - FutureType::Output: Send + 'static; -} - -/// A task could not be registered with its owner. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SpawnError { - /// The scheduler has begun shutting down. - SchedulerClosed, - /// The barrier has stopped accepting children. - OwnerClosed, - /// All task identifiers have been used. - IdentifiersExhausted, -} - -impl fmt::Display for SpawnError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - Self::SchedulerClosed => "the scheduler is closed", - Self::OwnerClosed => "the task owner is closed", - Self::IdentifiersExhausted => "task identifiers are exhausted", - }) - } -} - -impl std::error::Error for SpawnError {} - -pub(crate) struct Owner { - // Admission and closing are serialized by Shared::registry. - pub(crate) closed: AtomicBool, - pub(crate) remaining: AtomicUsize, - pub(crate) completion: Event, -} - -impl Owner { - pub(crate) fn new() -> Self { - Self { - closed: AtomicBool::new(false), - remaining: AtomicUsize::new(0), - completion: Event::new(), - } - } - - pub(crate) fn is_empty(&self) -> bool { - self.remaining.load(Ordering::Acquire) == 0 - } - - pub(crate) async fn wait(&self) { - loop { - // Register before checking the condition to retain racing completions. - let listener = self.completion.listen(); - if self.is_empty() { - return; - } - listener.await; - } - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/executor/src/scheduler.rs b/crates/executor/src/scheduler.rs index 7c75073..f485793 100644 --- a/crates/executor/src/scheduler.rs +++ b/crates/executor/src/scheduler.rs @@ -2,13 +2,12 @@ // Copyright, 2026, by Samuel Williams. //! Scheduler implementations and their I/O selectors. -use std::io; pub mod selector; pub mod socketry; #[cfg(any(feature = "native", feature = "tokio"))] -mod file; +mod positioned_file; #[cfg(feature = "tokio")] pub mod tokio; @@ -21,16 +20,27 @@ pub(crate) use socketry::{Shared, enter}; /// Reads fill the existing buffer length and leave its length unchanged. Only /// the first `result?` bytes contain newly read data. Writes may be partial. /// The buffer is returned on both success and failure. -pub type BufferResult = (io::Result, Vec); +pub type BufferResult> = (crate::OperationResult, B); + +#[cfg(any(feature = "native", feature = "tokio"))] +mod blocking; +#[cfg(any(feature = "native", feature = "tokio"))] +mod operation; + +mod socket_registration; +pub use socket_registration::SocketRegistration; mod interest; pub use interest::Interest; -mod network; -pub use network::Network; +mod socket; +pub use socket::Socket; -mod file_io; -pub use file_io::FileIo; +mod file; +pub use file::File; mod clock; pub use clock::Clock; + +mod signals; +pub use signals::Signals; diff --git a/crates/executor/src/scheduler/blocking.rs b/crates/executor/src/scheduler/blocking.rs new file mode 100644 index 0000000..8f0b883 --- /dev/null +++ b/crates/executor/src/scheduler/blocking.rs @@ -0,0 +1,18 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::{Cancellation, OperationResult}; +use std::io; + +/// Called on the selected pool worker, after queueing. A request can prevent +/// execution here; once the closure starts, its actual result is preserved. +pub(crate) fn run( + cancellation: &Cancellation, + operation: impl FnOnce() -> io::Result, +) -> OperationResult { + cancellation.check()?; + operation().map_err(Into::into) +} + +#[cfg(test)] +mod tests; diff --git a/crates/executor/src/scheduler/blocking/tests.rs b/crates/executor/src/scheduler/blocking/tests.rs new file mode 100644 index 0000000..735b44d --- /dev/null +++ b/crates/executor/src/scheduler/blocking/tests.rs @@ -0,0 +1,29 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use super::*; +use crate::OperationError; + +#[test] +fn cancellation_before_execution_prevents_work_but_cannot_undo_progress() { + let cancellation = Cancellation::new(); + cancellation.cancel(); + assert!(matches!( + run::<()>(&cancellation, || panic!("cancelled work ran")), + Err(OperationError::Cancelled(_)) + )); + let cancellation = Cancellation::new(); + assert_eq!( + run(&cancellation, || { + cancellation.cancel(); + Ok(7) + }) + .unwrap(), + 7 + ); + let error = run::<()>(&Cancellation::never(), || { + Err(io::ErrorKind::PermissionDenied.into()) + }) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); +} diff --git a/crates/executor/src/scheduler/file.rs b/crates/executor/src/scheduler/file.rs index c8c27e0..22c0cb7 100644 --- a/crates/executor/src/scheduler/file.rs +++ b/crates/executor/src/scheduler/file.rs @@ -1,62 +1,38 @@ // Released under the MIT License. // Copyright, 2026, by Samuel Williams. -//! Blocking positioned file operations shared by selector adapters. -use std::fs::File; -use std::io; +use crate::{BufferResult, Cancellation, FileOptions, IoBuf, IoBufMut, OperationResult}; +use std::{future::Future, path::Path}; -pub(crate) fn read_at(file: &File, buffer: &mut [u8], offset: u64) -> io::Result { - if offset > i64::MAX as u64 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "file offset exceeds i64::MAX", - )); - } - let operation = || { - #[cfg(unix)] - { - use std::os::unix::fs::FileExt; - file.read_at(buffer, offset) - } - #[cfg(windows)] - { - use std::os::windows::fs::FileExt; - file.seek_read(buffer, offset) - } - }; - retry_interrupted(operation) -} - -pub(crate) fn write_at(file: &File, buffer: &[u8], offset: u64) -> io::Result { - if offset > i64::MAX as u64 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "file offset exceeds i64::MAX", - )); - } - let operation = || { - #[cfg(unix)] - { - use std::os::unix::fs::FileExt; - file.write_at(buffer, offset) - } - #[cfg(windows)] - { - use std::os::windows::fs::FileExt; - file.seek_write(buffer, offset) - } - }; - retry_interrupted(operation) -} +/// Regular-file creation and positioned operations through the chosen scheduler. +/// The backend chooses its resource representation and retains resources used +/// by accepted operations even when the waiting future is destroyed. +/// +/// Offsets must fit in i64. Unix leaves the shared cursor unchanged; the current +/// Windows fallback uses std seek_read/seek_write and changes that cursor. +/// Cancellation before execution prevents I/O. Once a blocking call starts, +/// completion can win cancellation; buffers are returned only after access ends. +pub trait File: Send + Sync { + type File: Send + Sync; -fn retry_interrupted(mut operation: impl FnMut() -> io::Result) -> io::Result { - loop { - match operation() { - Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, - result => return result, - } - } + fn file_open( + &self, + path: &Path, + options: FileOptions, + cancellation: &Cancellation, + ) -> impl Future> + Send; + fn file_read_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> impl Future> + Send; + fn file_write_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> impl Future> + Send; } - -#[cfg(test)] -mod tests; diff --git a/crates/executor/src/scheduler/file_io.rs b/crates/executor/src/scheduler/file_io.rs deleted file mode 100644 index 9d20faa..0000000 --- a/crates/executor/src/scheduler/file_io.rs +++ /dev/null @@ -1,31 +0,0 @@ -// Released under the MIT License. -// Copyright, 2026, by Samuel Williams. - -use super::BufferResult; -use std::fs::File; -use std::future::Future; -use std::sync::Arc; - -/// Positioned file operations. A regular file does not support a universal -/// readiness fallback, so implementations use native completion or a blocking -/// pool. Use ordinary files opened without append mode, not pipes. Offsets -/// must fit in i64. The Unix implementation leaves the shared cursor unchanged; -/// the Windows blocking fallback updates it, as std's seek_read/seek_write do. -/// -/// Buffers and the file remain owned by an in-flight operation even if the -/// waiting future is dropped. A write can still complete after cancellation. -pub trait FileIo: Send + Sync { - fn file_read_at( - &self, - file: Arc, - buffer: Vec, - offset: u64, - ) -> impl Future + Send; - - fn file_write_at( - &self, - file: Arc, - buffer: Vec, - offset: u64, - ) -> impl Future + Send; -} diff --git a/crates/executor/src/scheduler/network.rs b/crates/executor/src/scheduler/network.rs deleted file mode 100644 index 605acbd..0000000 --- a/crates/executor/src/scheduler/network.rs +++ /dev/null @@ -1,56 +0,0 @@ -// Released under the MIT License. -// Copyright, 2026, by Samuel Williams. - -use super::{BufferResult, Interest}; -use std::future::Future; -use std::io; -use std::net::{SocketAddr, TcpListener, TcpStream}; - -/// Portable socket operations, selected through the concrete implementation. -/// -/// Registrations belong to their creating implementation. Keep a socket's -/// registration across operations and worker migration. Implementations must -/// return an error when a resource belongs to an incompatible runtime instance. -/// -/// Dropping an operation future abandons its result. It need not undo an I/O -/// operation already submitted to the kernel: a cancelled read can consume -/// bytes, and a cancelled write can transmit bytes. Implementations retain any -/// kernel-accessible memory until the operation finishes. Await completion when -/// the amount transferred matters. No asynchronous cleanup is promised by Drop. -pub trait Network: Send + Sync { - type Socket: Send + Sync; - type Listener: Send + Sync; - - /// Register an owned socket once. The implementation sets nonblocking mode - /// when required. Do not change its mode through another OS handle. - fn register_socket(&self, socket: TcpStream) -> io::Result; - - /// Register an owned listener once, with the same mode requirements. - fn register_listener(&self, listener: TcpListener) -> io::Result; - - fn connect(&self, address: SocketAddr) - -> impl Future> + Send; - - fn accept( - &self, - listener: &Self::Listener, - ) -> impl Future> + Send; - - fn io_read( - &self, - socket: &Self::Socket, - buffer: Vec, - ) -> impl Future + Send; - - fn io_write( - &self, - socket: &Self::Socket, - buffer: Vec, - ) -> impl Future + Send; - - fn io_wait( - &self, - socket: &Self::Socket, - interest: Interest, - ) -> impl Future> + Send; -} diff --git a/crates/executor/src/scheduler/operation.rs b/crates/executor/src/scheduler/operation.rs new file mode 100644 index 0000000..9f1ed8e --- /dev/null +++ b/crates/executor/src/scheduler/operation.rs @@ -0,0 +1,35 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::{Cancellation, OperationResult}; +use std::{ + future::{Future, poll_fn}, + io, + pin::pin, + task::Poll, +}; + +/// Only for readiness operations: dropping a pending operation leaves no +/// kernel access to its borrowed inputs. Completion and blocking backends must +/// retain their operation and await its terminal result instead. +pub(crate) async fn cancellable( + cancellation: &Cancellation, + operation: impl Future>, +) -> OperationResult { + cancellation.check()?; + let mut operation = pin!(operation); + let mut cancelled = pin!(cancellation.cancelled()); + poll_fn(|context| { + if let Poll::Ready(result) = operation.as_mut().poll(context) { + return Poll::Ready(result.map_err(Into::into)); + } + cancelled + .as_mut() + .poll(context) + .map(|()| Err(crate::Cancelled.into())) + }) + .await +} + +#[cfg(test)] +mod tests; diff --git a/crates/executor/src/scheduler/operation/tests.rs b/crates/executor/src/scheduler/operation/tests.rs new file mode 100644 index 0000000..caeab69 --- /dev/null +++ b/crates/executor/src/scheduler/operation/tests.rs @@ -0,0 +1,40 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use super::*; +use crate::OperationError; + +#[test] +fn cancellation_before_poll_prevents_execution_and_ready_progress_wins_a_race() { + futures_lite::future::block_on(async { + let cancellation = Cancellation::new(); + cancellation.cancel(); + assert!(matches!( + cancellable::<()>(&cancellation, async { + panic!("pre-cancelled operation polled") + }) + .await, + Err(OperationError::Cancelled(_)) + )); + let cancellation = Cancellation::new(); + let result = cancellable( + &cancellation, + poll_fn(|_| { + cancellation.cancel(); + Poll::Ready(Ok(42)) + }), + ) + .await; + assert_eq!(result.unwrap(), 42); + let cancellation = Cancellation::new(); + let result = cancellable::<()>( + &cancellation, + poll_fn(|_| { + cancellation.cancel(); + Poll::Pending + }), + ) + .await; + assert!(matches!(result, Err(OperationError::Cancelled(_)))); + }); +} diff --git a/crates/executor/src/scheduler/positioned_file.rs b/crates/executor/src/scheduler/positioned_file.rs new file mode 100644 index 0000000..c8c27e0 --- /dev/null +++ b/crates/executor/src/scheduler/positioned_file.rs @@ -0,0 +1,62 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +//! Blocking positioned file operations shared by selector adapters. +use std::fs::File; +use std::io; + +pub(crate) fn read_at(file: &File, buffer: &mut [u8], offset: u64) -> io::Result { + if offset > i64::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "file offset exceeds i64::MAX", + )); + } + let operation = || { + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + file.read_at(buffer, offset) + } + #[cfg(windows)] + { + use std::os::windows::fs::FileExt; + file.seek_read(buffer, offset) + } + }; + retry_interrupted(operation) +} + +pub(crate) fn write_at(file: &File, buffer: &[u8], offset: u64) -> io::Result { + if offset > i64::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "file offset exceeds i64::MAX", + )); + } + let operation = || { + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + file.write_at(buffer, offset) + } + #[cfg(windows)] + { + use std::os::windows::fs::FileExt; + file.seek_write(buffer, offset) + } + }; + retry_interrupted(operation) +} + +fn retry_interrupted(mut operation: impl FnMut() -> io::Result) -> io::Result { + loop { + match operation() { + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + result => return result, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/executor/src/scheduler/file/tests.rs b/crates/executor/src/scheduler/positioned_file/tests.rs similarity index 100% rename from crates/executor/src/scheduler/file/tests.rs rename to crates/executor/src/scheduler/positioned_file/tests.rs diff --git a/crates/executor/src/scheduler/selector/io_uring.rs b/crates/executor/src/scheduler/selector/io_uring.rs index 58c2065..2e9f2b5 100644 --- a/crates/executor/src/scheduler/selector/io_uring.rs +++ b/crates/executor/src/scheduler/selector/io_uring.rs @@ -15,13 +15,14 @@ //! channel per operation. It does not yet pool operation records or register //! buffers with the kernel. use super::readiness::{self, Listener, Socket}; -use crate::scheduler::{BufferResult, Clock, FileIo, Interest, Network}; +use crate::scheduler::{BufferResult, Clock, File, Interest, Socket as SocketOperations}; +use crate::{Cancellation, FileOptions, IoBuf, IoBufMut, OperationResult, SocketRegistration}; use event_listener::{Event as CompletionEvent, Listener as _}; use futures_channel::oneshot; use io_uring::{IoUring, opcode, squeue, types}; use polling::{Event, Events, Poller}; use std::collections::{HashMap, VecDeque}; -use std::fs::File; +use std::fs::File as StdFile; use std::io::{self, Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::os::fd::AsRawFd; @@ -30,6 +31,12 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, mpsc}; use std::thread; use std::time::Duration; +use std::{ + future::{Future, poll_fn}, + path::Path, + pin::pin, + task::Poll, +}; const CANCEL_TAG: u64 = 1 << 63; const COMMAND_EVENT: usize = 0; @@ -77,18 +84,27 @@ fn validate_operation_support(supported: impl Fn(u8) -> bool) -> io::Result<()> Ok(()) } -async fn retry_operation( - mut buffer: Vec, +type RawBufferResult> = (io::Result, B); + +async fn retry_operation( + mut buffer: B, mut operation: O, mut wait: W, -) -> BufferResult + cancellation: &Cancellation, +) -> RawBufferResult where - O: FnMut(Vec) -> F, - F: std::future::Future, + O: FnMut(B) -> F, + F: std::future::Future>, W: FnMut() -> G, G: std::future::Future>, { loop { + if cancellation.is_cancelled() { + return ( + Err(io::Error::new(io::ErrorKind::Interrupted, crate::Cancelled)), + buffer, + ); + } let (result, returned) = operation(buffer).await; buffer = returned; match result { @@ -105,22 +121,24 @@ where enum Resource { Socket(Socket), - File(Arc, u64), + File(Arc, u64), } -struct Request { +struct Request> { identifier: u64, resource: Resource, - buffer: Vec, + buffer: Option, + pointer: fn(&mut B) -> *mut u8, write: bool, - reply: Option>, + reply: Option>>, cancelling: bool, } -impl Request { +impl Request { fn entry(&mut self) -> squeue::Entry { - let length = self.buffer.len().min(i32::MAX as usize) as u32; - let pointer = self.buffer.as_mut_ptr(); + let buffer = self.buffer.as_mut().expect("request retains its buffer"); + let length = buffer.as_ref().len().min(i32::MAX as usize) as u32; + let pointer = (self.pointer)(buffer); let entry = match &self.resource { Resource::Socket(socket) => { let descriptor = types::Fd(socket.0.get_ref().as_raw_fd()); @@ -153,16 +171,14 @@ impl Request { .reply .take() .expect("unfinished requests retain their completion sender"); - let _ = reply.send((result, std::mem::take(&mut self.buffer))); - } - - fn into_buffer(mut self) -> Vec { - self.reply.take(); - std::mem::take(&mut self.buffer) + let _ = reply.send(( + result, + self.buffer.take().expect("request returns its buffer once"), + )); } } -impl Drop for Request { +impl Drop for Request { fn drop(&mut self) { // A submission can race Shutdown and remain in the incoming queue as // the selector exits. Such requests have never reached the kernel and @@ -174,23 +190,56 @@ impl Drop for Request { io::ErrorKind::BrokenPipe, "selector closed before submission", )), - std::mem::take(&mut self.buffer), + self.buffer.take().expect("request returns its buffer once"), )); } } } +// One erased request allocation lets a selector queue heterogeneous owned +// buffer types. Its typed completion returns the exact original owner. +trait PendingRequest: Send { + fn identifier(&self) -> u64; + fn entry(&mut self) -> squeue::Entry; + fn finish(self: Box, result: io::Result); + fn cancelling(&self) -> bool; + fn set_cancelling(&mut self); + fn take_reply(&mut self) -> Option>; +} +impl PendingRequest for Request { + fn identifier(&self) -> u64 { + self.identifier + } + fn entry(&mut self) -> squeue::Entry { + Request::entry(self) + } + fn finish(self: Box, result: io::Result) { + Request::finish(*self, result); + } + fn cancelling(&self) -> bool { + self.cancelling + } + fn set_cancelling(&mut self) { + self.cancelling = true; + } + fn take_reply(&mut self) -> Option> { + self.reply + .take() + .map(|reply| Box::new(reply) as Box) + } +} + /// Only terminal original completions remove requests from this map. /// On an unexpected selector failure, leak kernel-accessible resources rather /// than freeing memory whose completion cannot be established. #[derive(Default)] -struct InFlight(HashMap); +struct InFlight(HashMap>); impl Drop for InFlight { fn drop(&mut self) { let mut replies = Vec::new(); for (_, mut request) in self.0.drain() { - replies.extend(request.reply.take()); + replies.extend(request.take_reply()); std::mem::forget(request); } // Wake abandoned callers only after every in-flight buffer is retained. @@ -199,7 +248,7 @@ impl Drop for InFlight { } enum Command { - Submit(Request), + Submit(Box), Cancel(u64), Shutdown, } @@ -256,17 +305,23 @@ struct CancelOnDrop { identifier: u64, armed: bool, } -impl Drop for CancelOnDrop { - fn drop(&mut self) { +impl CancelOnDrop { + fn request(&mut self) { if self.armed { let _ = self .controller .commands .send(Command::Cancel(self.identifier)); self.controller.notify(); + self.armed = false; } } } +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.request(); + } +} /// A clonable reference to one io_uring selector thread. #[derive(Clone)] @@ -359,7 +414,20 @@ impl Selector { } } - async fn operation(&self, resource: Resource, buffer: Vec, write: bool) -> BufferResult { + async fn operation( + &self, + resource: Resource, + buffer: B, + write: bool, + pointer: fn(&mut B) -> *mut u8, + cancellation: &Cancellation, + ) -> RawBufferResult { + if cancellation.is_cancelled() { + return ( + Err(io::Error::new(io::ErrorKind::Interrupted, crate::Cancelled)), + buffer, + ); + } // Some kernels retry EAGAIN internally. Tests can deterministically // exercise the public retry contract before submitting real I/O. #[cfg(test)] @@ -369,7 +437,7 @@ impl Selector { if let Err(error) = self.check_open() { return (Err(error), buffer); } - if buffer.is_empty() { + if buffer.as_ref().is_empty() { return (Ok(0), buffer); } if matches!(&resource, Resource::File(_, offset) if *offset > i64::MAX as u64) { @@ -398,23 +466,24 @@ impl Selector { let request = Request { identifier, resource, - buffer, + buffer: Some(buffer), + pointer, write, reply: Some(reply), cancelling: false, }; - if let Err(mpsc::SendError(Command::Submit(request))) = - self.controller.commands.send(Command::Submit(request)) + if let Err(mpsc::SendError(Command::Submit(request))) = self + .controller + .commands + .send(Command::Submit(Box::new(request))) { - return ( - Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "io_uring selector has exited", - )), - request.into_buffer(), - ); + request.finish(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "io_uring selector has exited", + ))); } - let mut cancellation = CancelOnDrop { + + let mut cancel_request = CancelOnDrop { controller: Arc::clone(&self.controller), identifier, armed: true, @@ -422,18 +491,39 @@ impl Selector { self.controller.notify(); // A disconnected reply is an unexpected selector failure. Its original // buffer is deliberately retained; it cannot safely be returned. - let result = result - .await - .expect("io_uring selector failed with an operation in flight"); - cancellation.armed = false; + let mut result = pin!(result); + let mut cancelled = pin!(cancellation.cancelled()); + let result = poll_fn(|context| { + // Original completion wins the race. A cancellation request never + // releases its buffer; continue waiting for the original CQE. + if let Poll::Ready(result) = result.as_mut().poll(context) { + return Poll::Ready(result); + } + if cancel_request.armed && cancelled.as_mut().poll(context).is_ready() { + cancel_request.request(); + } + Poll::Pending + }) + .await + .expect("io_uring selector failed with an operation in flight"); + cancel_request.armed = false; result } } -impl Network for Selector { - type Socket = Socket; - type Listener = Listener; - +fn operation_result(result: io::Result, cancellation: &Cancellation) -> OperationResult { + match result { + Err(error) + if cancellation.is_cancelled() + && (error.kind() == io::ErrorKind::Interrupted + || error.raw_os_error() == Some(libc::ECANCELED)) => + { + Err(crate::Cancelled.into()) + } + result => result.map_err(Into::into), + } +} +impl SocketRegistration for Selector { fn register_socket(&self, socket: TcpStream) -> io::Result { self.check_open()?; readiness::Selector.register_socket(socket) @@ -442,44 +532,164 @@ impl Network for Selector { self.check_open()?; readiness::Selector.register_listener(listener) } - async fn connect(&self, address: SocketAddr) -> io::Result { +} +impl SocketOperations for Selector { + type Socket = Socket; + type Listener = Listener; + async fn socket_listen( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + self.check_open()?; + readiness::Selector + .socket_listen(address, cancellation) + .await + } + fn listener_addr(&self, listener: &Listener) -> io::Result { + self.check_open()?; + listener.local_addr() + } + fn socket_local_addr(&self, socket: &Socket) -> io::Result { + self.check_open()?; + socket.local_addr() + } + fn socket_peer_addr(&self, socket: &Socket) -> io::Result { self.check_open()?; - readiness::Selector.connect(address).await + socket.peer_addr() } - async fn accept(&self, listener: &Listener) -> io::Result<(Socket, SocketAddr)> { + async fn connect( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { self.check_open()?; - readiness::Selector.accept(listener).await + readiness::Selector.connect(address, cancellation).await } - async fn io_read(&self, socket: &Socket, buffer: Vec) -> BufferResult { - retry_operation( + async fn accept( + &self, + listener: &Listener, + cancellation: &Cancellation, + ) -> OperationResult<(Socket, SocketAddr)> { + self.check_open()?; + readiness::Selector.accept(listener, cancellation).await + } + async fn io_read( + &self, + socket: &Socket, + buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { + let (result, buffer) = retry_operation( buffer, - |buffer| self.operation(Resource::Socket(socket.clone()), buffer, false), - || readiness::Selector.io_wait(socket, Interest::Readable), + |buffer| { + self.operation( + Resource::Socket(socket.clone()), + buffer, + false, + |buffer| buffer.as_mut().as_mut_ptr(), + cancellation, + ) + }, + || async { + readiness::Selector + .io_wait(socket, Interest::Readable, cancellation) + .await + .map_err(Into::into) + }, + cancellation, ) - .await - } - async fn io_write(&self, socket: &Socket, buffer: Vec) -> BufferResult { - retry_operation( + .await; + (operation_result(result, cancellation), buffer) + } + async fn io_write( + &self, + socket: &Socket, + buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { + let (result, buffer) = retry_operation( buffer, - |buffer| self.operation(Resource::Socket(socket.clone()), buffer, true), - || readiness::Selector.io_wait(socket, Interest::Writable), + |buffer| { + self.operation( + Resource::Socket(socket.clone()), + buffer, + true, + |buffer| buffer.as_ref().as_ptr().cast_mut(), + cancellation, + ) + }, + || async { + readiness::Selector + .io_wait(socket, Interest::Writable, cancellation) + .await + .map_err(Into::into) + }, + cancellation, ) - .await - } - async fn io_wait(&self, socket: &Socket, interest: Interest) -> io::Result<()> { + .await; + (operation_result(result, cancellation), buffer) + } + async fn io_wait( + &self, + socket: &Socket, + interest: Interest, + cancellation: &Cancellation, + ) -> OperationResult<()> { self.check_open()?; - readiness::Selector.io_wait(socket, interest).await + readiness::Selector + .io_wait(socket, interest, cancellation) + .await } } - -impl FileIo for Selector { - async fn file_read_at(&self, file: Arc, buffer: Vec, offset: u64) -> BufferResult { - self.operation(Resource::File(file, offset), buffer, false) +impl File for Selector { + type File = Arc; + async fn file_open( + &self, + path: &Path, + options: FileOptions, + cancellation: &Cancellation, + ) -> OperationResult { + self.check_open()?; + readiness::Selector + .file_open(path, options, cancellation) .await } - async fn file_write_at(&self, file: Arc, buffer: Vec, offset: u64) -> BufferResult { - self.operation(Resource::File(file, offset), buffer, true) - .await + async fn file_read_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + let (result, buffer) = self + .operation( + Resource::File(Arc::clone(file), offset), + buffer, + false, + |buffer| buffer.as_mut().as_mut_ptr(), + cancellation, + ) + .await; + (operation_result(result, cancellation), buffer) + } + async fn file_write_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + let (result, buffer) = self + .operation( + Resource::File(Arc::clone(file), offset), + buffer, + true, + |buffer| buffer.as_ref().as_ptr().cast_mut(), + cancellation, + ) + .await; + (operation_result(result, cancellation), buffer) } } impl Clock for Selector { @@ -494,7 +704,7 @@ struct Reactor { ring: IoUring, notification: UnixStream, incoming: mpsc::Receiver, - pending: VecDeque, + pending: VecDeque>, in_flight: InFlight, cancellations: VecDeque, closing: bool, @@ -537,7 +747,7 @@ impl Reactor { if let Some(position) = self .pending .iter() - .position(|request| request.identifier == identifier) + .position(|request| request.identifier() == identifier) { let request = self .pending @@ -545,9 +755,9 @@ impl Reactor { .expect("pending queue is unchanged since finding this position"); request.finish(Err(io::ErrorKind::Interrupted.into())); } else if let Some(request) = self.in_flight.0.get_mut(&identifier) - && !request.cancelling + && !request.cancelling() { - request.cancelling = true; + request.set_cancelling(); self.cancellations.push_back(identifier); } } @@ -572,9 +782,9 @@ impl Reactor { request.finish(Err(io::ErrorKind::Interrupted.into())); } for request in self.in_flight.0.values_mut() { - if !request.cancelling { - request.cancelling = true; - self.cancellations.push_back(request.identifier); + if !request.cancelling() { + request.set_cancelling(); + self.cancellations.push_back(request.identifier()); } } } @@ -622,7 +832,7 @@ impl Reactor { let Some(request) = self.pending.pop_front() else { break; }; - let identifier = request.identifier; + let identifier = request.identifier(); let request = self.in_flight.0.entry(identifier).or_insert(request); let entry = request.entry(); // SAFETY: InFlight owns the buffer allocation and descriptor diff --git a/crates/executor/src/scheduler/selector/io_uring/tests.rs b/crates/executor/src/scheduler/selector/io_uring/tests.rs index fe3f2ec..8bb3ed5 100644 --- a/crates/executor/src/scheduler/selector/io_uring/tests.rs +++ b/crates/executor/src/scheduler/selector/io_uring/tests.rs @@ -1,6 +1,8 @@ // Released under the MIT License. // Copyright, 2026, by Samuel Williams. +static NEVER: crate::Cancellation = crate::Cancellation::never(); + use super::*; #[test] @@ -11,10 +13,11 @@ fn queued_request_returns_its_buffer_if_the_selector_exits() { let request = Request { identifier: 0, resource: Resource::File( - Arc::new(File::open(std::env::current_exe().unwrap()).unwrap()), + Arc::new(StdFile::open(std::env::current_exe().unwrap()).unwrap()), 0, ), - buffer, + buffer: Some(buffer), + pointer: |buffer| buffer.as_mut_ptr(), write: false, reply: Some(reply), cancelling: false, @@ -65,13 +68,14 @@ impl Drop for Failures { } } -fn request(identifier: u64) -> (Request, oneshot::Receiver) { +fn request(identifier: u64) -> (Request, oneshot::Receiver) { let (reply, result) = oneshot::channel(); ( Request { identifier, resource: Resource::File(Arc::new(tempfile::tempfile().unwrap()), 0), - buffer: vec![1], + buffer: Some(vec![1]), + pointer: |buffer| buffer.as_mut_ptr(), write: false, reply: Some(reply), cancelling: false, @@ -163,12 +167,14 @@ fn rejected_operations_preserve_buffer_allocations() { let (selector, _peer) = disconnected_selector(); let file = Arc::new(tempfile::tempfile().unwrap()); let (_, buffer) = selector - .file_read_at(Arc::clone(&file), Vec::new(), 0) + .file_read_at(&Arc::clone(&file), Vec::new(), 0, &NEVER) .await; assert!(buffer.is_empty()); let buffer = vec![7; 8]; let allocation = buffer.as_ptr(); - let (result, returned) = selector.file_read_at(Arc::clone(&file), buffer, 0).await; + let (result, returned) = selector + .file_read_at(&Arc::clone(&file), buffer, 0, &NEVER) + .await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); assert_eq!(returned.as_ptr(), allocation); selector @@ -177,7 +183,7 @@ fn rejected_operations_preserve_buffer_allocations() { .store(CANCEL_TAG - 1, Ordering::Relaxed); let buffer = vec![9; 8]; let allocation = buffer.as_ptr(); - let (result, returned) = selector.file_write_at(file, buffer, 0).await; + let (result, returned) = selector.file_write_at(&file, buffer, 0, &NEVER).await; assert!( result .unwrap_err() @@ -196,7 +202,7 @@ fn failure_retains_in_flight_resources_before_notifying_callers() { }; let retained = Arc::downgrade(file); let mut in_flight = InFlight::default(); - in_flight.0.insert(0, request); + in_flight.0.insert(0, Box::new(request)); drop(in_flight); assert!(receiver.try_recv().is_err()); assert_eq!(retained.strong_count(), 1); @@ -234,6 +240,7 @@ fn socket_retries_keep_the_owned_buffer_and_propagate_wait_errors() { waits.set(waits.get() + 1); std::future::ready(Ok(())) }, + &NEVER, ) .await; assert_eq!(result.unwrap(), 4); @@ -243,6 +250,7 @@ fn socket_retries_keep_the_owned_buffer_and_propagate_wait_errors() { buffer, |buffer| std::future::ready((Err(io::ErrorKind::WouldBlock.into()), buffer)), || std::future::ready(Err(io::ErrorKind::BrokenPipe.into())), + &NEVER, ) .await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); @@ -266,9 +274,27 @@ 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.connect(address).await.is_err()); - assert!(selector.accept(&listener).await.is_err()); - assert!(selector.io_wait(&socket, Interest::Readable).await.is_err()); + assert!(selector.socket_listen(address, &NEVER).await.is_err()); + assert!(selector.listener_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(); + let path = directory.path().join("closed"); + assert!( + selector + .file_open(&path, FileOptions::CREATE, &NEVER) + .await + .is_err() + ); + assert!(!path.exists()); + assert!(selector.connect(address, &NEVER).await.is_err()); + assert!(selector.accept(&listener, &NEVER).await.is_err()); + assert!( + selector + .io_wait(&socket, Interest::Readable, &NEVER) + .await + .is_err() + ); }); } @@ -315,7 +341,7 @@ fn reactor_drains_notifications_and_retries_interrupted_waits() { fn pending_cancellation_returns_buffers_and_late_cancellation_is_harmless() { let (mut reactor, _commands, _notification) = new_reactor(); let (request, mut result) = request(7); - reactor.pending.push_back(request); + reactor.pending.push_back(Box::new(request)); reactor.cancel(7); let (result, buffer) = result.try_recv().unwrap().unwrap(); assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Interrupted); @@ -328,9 +354,9 @@ fn closing_reactor_rejects_new_requests_after_draining_pending_requests() { let (mut reactor, commands, _notification) = new_reactor(); let (pending, mut pending_result) = request(1); let (late, mut late_result) = request(2); - commands.send(Command::Submit(pending)).unwrap(); + commands.send(Command::Submit(Box::new(pending))).unwrap(); commands.send(Command::Shutdown).unwrap(); - commands.send(Command::Submit(late)).unwrap(); + commands.send(Command::Submit(Box::new(late))).unwrap(); reactor.run().unwrap(); for result in [&mut pending_result, &mut late_result] { assert_eq!( @@ -346,7 +372,7 @@ fn command_batches_do_not_starve_shutdown_or_completions() { let mut results = Vec::new(); for identifier in 0..257 { let (request, result) = request(identifier); - commands.send(Command::Submit(request)).unwrap(); + commands.send(Command::Submit(Box::new(request))).unwrap(); results.push(result); } commands.send(Command::Shutdown).unwrap(); @@ -364,7 +390,7 @@ fn repeated_cancellation_does_not_duplicate_submission_and_full_queues_defer_it( let mut results = Vec::new(); for identifier in 0..3 { let (request, result) = request(identifier); - reactor.in_flight.0.insert(identifier, request); + reactor.in_flight.0.insert(identifier, Box::new(request)); let entry = reactor.in_flight.0.get_mut(&identifier).unwrap().entry(); // SAFETY: the map owns the stable allocation before publication and // run() retains it until the original completion is received. @@ -397,7 +423,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).await; + let (result, buffer) = selector.io_read(&socket, buffer, &NEVER).await; let count = result.unwrap(); assert!(count > 0); assert_eq!(&buffer[..count], &b"ready"[..count]); @@ -407,7 +433,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).await; + let (result, buffer) = selector.io_write(&socket, buffer, &NEVER).await; let count = result.unwrap(); assert!(count > 0); assert_eq!(buffer.as_ptr(), allocation); @@ -418,3 +444,121 @@ fn socket_readiness_retries_preserve_allocations() { selector.wait_closed(); }); } + +#[test] +fn cooperative_cancellation_waits_for_original_completion_and_preserves_progress() { + use std::sync::atomic::AtomicUsize; + struct CountedBuffer { + bytes: Vec, + dropped: Arc, + } + impl AsRef<[u8]> for CountedBuffer { + fn as_ref(&self) -> &[u8] { + &self.bytes + } + } + impl AsMut<[u8]> for CountedBuffer { + fn as_mut(&mut self) -> &mut [u8] { + &mut self.bytes + } + } + // SAFETY: The Vec retains its initialized heap storage through owner moves. + unsafe impl IoBuf for CountedBuffer {} + unsafe impl IoBufMut for CountedBuffer {} + impl Drop for CountedBuffer { + fn drop(&mut self) { + self.dropped.fetch_add(1, Ordering::SeqCst); + } + } + + async_io::block_on(async { + for successful in [false, true] { + let (commands, incoming) = mpsc::channel(); + let (notification, _peer) = UnixStream::pair().unwrap(); + let selector = Selector { + controller: Arc::new(Controller { + commands, + notification, + next_identifier: AtomicU64::new(0), + closed: AtomicBool::new(false), + shutdown: Arc::new(Shutdown { + finished: AtomicBool::new(false), + event: CompletionEvent::new(), + }), + }), + }; + let file = Arc::new(tempfile::tempfile().unwrap()); + let cancelled = Cancellation::new(); + let dropped = Arc::new(AtomicUsize::new(0)); + let buffer = CountedBuffer { + bytes: vec![7; 4], + dropped: dropped.clone(), + }; + let pointer = buffer.bytes.as_ptr(); + let mut read = Box::pin(selector.file_read_at(&file, buffer, 0, &cancelled)); + assert!(futures_lite::future::poll_once(&mut read).await.is_none()); + let Command::Submit(request) = incoming.try_recv().unwrap() else { + panic!("missing original submission") + }; + let identifier = request.identifier(); + cancelled.cancel(); + assert!(futures_lite::future::poll_once(&mut read).await.is_none()); + assert!( + matches!(incoming.try_recv().unwrap(), Command::Cancel(value) if value == identifier) + ); + assert!(futures_lite::future::poll_once(&mut read).await.is_none()); + assert!( + incoming.try_recv().is_err(), + "cancellation is submitted only once" + ); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + // No SQE was published in this controlled selector. Simulate its + // terminal original CQE only after confirming cancellation retained + // the typed buffer, regardless of the cancellation request result. + request.finish(if successful { + Ok(3) + } else { + Err(io::Error::from_raw_os_error(libc::ECANCELED)) + }); + let (result, buffer) = read.await; + if successful { + assert_eq!(result.unwrap(), 3); + } else { + assert!(matches!(result, Err(crate::OperationError::Cancelled(_)))); + } + assert_eq!(buffer.bytes.as_ptr(), pointer); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + drop(buffer); + assert_eq!(dropped.load(Ordering::SeqCst), 1); + } + }); +} + +#[test] +fn cancellation_before_submission_and_retry_returns_owned_buffers() { + async_io::block_on(async { + let cancellation = Cancellation::new(); + cancellation.cancel(); + let (selector, _peer) = disconnected_selector(); + let file = Arc::new(tempfile::tempfile().unwrap()); + let buffer = vec![5; 3]; + let allocation = buffer.as_ptr(); + let (result, buffer) = selector + .file_write_at(&file, buffer, 0, &cancellation) + .await; + assert!(matches!(result, Err(crate::OperationError::Cancelled(_)))); + let (result, buffer) = retry_operation( + buffer, + |_| std::future::ready((Ok(0), Vec::new())), + || std::future::ready(Ok(())), + &cancellation, + ) + .await; + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Interrupted); + assert_eq!(buffer.as_ptr(), allocation); + assert!(matches!( + operation_result::<()>(Err(io::ErrorKind::Interrupted.into()), &cancellation), + Err(crate::OperationError::Cancelled(_)) + )); + }); +} diff --git a/crates/executor/src/scheduler/selector/readiness.rs b/crates/executor/src/scheduler/selector/readiness.rs index 3484159..db5bf26 100644 --- a/crates/executor/src/scheduler/selector/readiness.rs +++ b/crates/executor/src/scheduler/selector/readiness.rs @@ -7,12 +7,15 @@ //! It remains available while registered resources are alive; Socketry task //! shutdown does not shut down that shared reactor. Futures contain no private //! coroutine stacks and may be polled on different workers. -use crate::scheduler::file::{read_at, write_at}; -use crate::scheduler::{BufferResult, Clock, FileIo, Interest, Network}; +use crate::scheduler::operation::cancellable; +use crate::scheduler::positioned_file::{read_at, write_at}; +use crate::scheduler::{BufferResult, Clock, File, Interest, Socket as SocketOperations}; +use crate::{Cancellation, FileOptions, IoBuf, IoBufMut, OperationResult, SocketRegistration}; use async_io::Async; -use std::fs::File; +use std::fs::File as StdFile; use std::io::{self, Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -64,10 +67,7 @@ fn wrap_accepted( accepted.map(|(socket, address)| (Socket(Arc::new(socket)), address)) } -impl Network for Selector { - type Socket = Socket; - type Listener = Listener; - +impl SocketRegistration for Selector { fn register_socket(&self, socket: TcpStream) -> io::Result { wrap_socket(Async::new(socket)) } @@ -75,62 +75,156 @@ impl Network for Selector { fn register_listener(&self, listener: TcpListener) -> io::Result { wrap_listener(Async::new(listener)) } +} - async fn connect(&self, address: SocketAddr) -> io::Result { - wrap_socket(Async::::connect(address).await) +impl SocketOperations for Selector { + type Socket = Socket; + type Listener = Listener; + async fn socket_listen( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + cancellation.check()?; + self.register_listener(TcpListener::bind(address)?) + .map_err(Into::into) } - - async fn accept(&self, listener: &Listener) -> io::Result<(Socket, SocketAddr)> { - wrap_accepted(listener.0.accept().await) + fn listener_addr(&self, listener: &Listener) -> io::Result { + listener.local_addr() } - - async fn io_read(&self, socket: &Socket, mut buffer: Vec) -> BufferResult { - let result = socket - .0 - .read_with(|mut socket| socket.read(&mut buffer)) - .await; + fn socket_local_addr(&self, socket: &Socket) -> io::Result { + socket.local_addr() + } + fn socket_peer_addr(&self, socket: &Socket) -> io::Result { + socket.peer_addr() + } + async fn connect( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + cancellable(cancellation, async { + wrap_socket(Async::::connect(address).await) + }) + .await + } + async fn accept( + &self, + listener: &Listener, + cancellation: &Cancellation, + ) -> OperationResult<(Socket, SocketAddr)> { + cancellable(cancellation, async { + wrap_accepted(listener.0.accept().await) + }) + .await + } + async fn io_read( + &self, + socket: &Socket, + mut buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { + let result = cancellable( + cancellation, + socket + .0 + .read_with(|mut socket| socket.read(buffer.as_mut())), + ) + .await; (result, buffer) } - - async fn io_write(&self, socket: &Socket, buffer: Vec) -> BufferResult { - let result = socket - .0 - .write_with(|mut socket| socket.write(&buffer)) - .await; + async fn io_write( + &self, + socket: &Socket, + mut buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { + let owned = &mut buffer; + let result = cancellable( + cancellation, + socket + .0 + .write_with(move |mut socket| socket.write(owned.as_ref())), + ) + .await; (result, buffer) } - - async fn io_wait(&self, socket: &Socket, interest: Interest) -> io::Result<()> { - match interest { - Interest::Readable => socket.0.readable().await, - Interest::Writable => socket.0.writable().await, - } + async fn io_wait( + &self, + socket: &Socket, + interest: Interest, + cancellation: &Cancellation, + ) -> OperationResult<()> { + cancellable(cancellation, async { + match interest { + Interest::Readable => socket.0.readable().await, + Interest::Writable => socket.0.writable().await, + } + }) + .await } } -impl FileIo for Selector { - async fn file_read_at( +impl File for Selector { + type File = Arc; + async fn file_open( &self, - file: Arc, - mut buffer: Vec, - offset: u64, - ) -> BufferResult { + path: &Path, + options: FileOptions, + cancellation: &Cancellation, + ) -> OperationResult { + cancellation.check()?; + let path = path.to_owned(); + let cancellation = cancellation.clone(); blocking::unblock(move || { - let result = read_at(&file, &mut buffer, offset); - (result, buffer) + crate::scheduler::blocking::run(&cancellation, || options.open(&path).map(Arc::new)) }) .await } - - async fn file_write_at(&self, file: Arc, buffer: Vec, offset: u64) -> BufferResult { - blocking::unblock(move || { - let result = write_at(&file, &buffer, offset); - (result, buffer) + async fn file_read_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + let file = Arc::clone(file); + file_operation(buffer, cancellation, move |buffer| { + read_at(&file, buffer.as_mut(), offset) + }) + .await + } + async fn file_write_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + let file = Arc::clone(file); + file_operation(buffer, cancellation, move |buffer| { + write_at(&file, buffer.as_ref(), offset) }) .await } } +async fn file_operation( + mut buffer: B, + cancellation: &Cancellation, + operation: impl FnOnce(&mut B) -> io::Result + Send + 'static, +) -> BufferResult { + if let Err(error) = cancellation.check() { + return (Err(error.into()), buffer); + } + let cancellation = cancellation.clone(); + blocking::unblock(move || { + let result = crate::scheduler::blocking::run(&cancellation, || operation(&mut buffer)); + (result, buffer) + }) + .await +} + impl Clock for Selector { async fn sleep(&self, duration: Duration) { async_io::Timer::after(duration).await; diff --git a/crates/executor/src/scheduler/selector/readiness/tests.rs b/crates/executor/src/scheduler/selector/readiness/tests.rs index 39c500c..42ba222 100644 --- a/crates/executor/src/scheduler/selector/readiness/tests.rs +++ b/crates/executor/src/scheduler/selector/readiness/tests.rs @@ -1,6 +1,8 @@ // Released under the MIT License. // Copyright, 2026, by Samuel Williams. +static NEVER: crate::Cancellation = crate::Cancellation::never(); + use super::{wrap_accepted, wrap_listener, wrap_socket}; use std::io; @@ -31,17 +33,27 @@ fn readiness_operations_remain_available_with_other_backends_selected() { async_io::block_on(async { let selector = Selector::new().unwrap(); let listener = selector - .register_listener(TcpListener::bind("127.0.0.1:0").unwrap()) - .unwrap(); - let socket = selector - .connect(listener.local_addr().unwrap()) + .socket_listen("127.0.0.1:0".parse().unwrap(), &NEVER) .await .unwrap(); - let (peer, _) = selector.accept(&listener).await.unwrap(); + let address = selector.listener_addr(&listener).unwrap(); + assert_eq!( + selector + .socket_listen(address, &NEVER) + .await + .err() + .unwrap() + .kind(), + io::ErrorKind::AddrInUse + ); + let socket = selector.connect(address, &NEVER).await.unwrap(); + let (peer, peer_address) = selector.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()) + .io_write(&socket, b"hello"[written..].to_vec(), &NEVER) .await; let count = result.unwrap(); assert!(count > 0); @@ -50,7 +62,9 @@ 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()]).await; + let (result, buffer) = selector + .io_read(&peer, vec![0; 5 - received.len()], &NEVER) + .await; let count = result.unwrap(); assert!(count > 0); received.extend_from_slice(&buffer[..count]); @@ -58,12 +72,41 @@ fn readiness_operations_remain_available_with_other_backends_selected() { assert_eq!(received, b"hello"); let file = Arc::new(tempfile::tempfile().unwrap()); let (result, buffer) = selector - .file_write_at(Arc::clone(&file), b"file".to_vec(), 3) + .file_write_at(&Arc::clone(&file), b"file".to_vec(), 3, &NEVER) .await; assert_eq!(result.unwrap(), 4); assert_eq!(buffer, b"file"); - let (result, buffer) = selector.file_read_at(file, vec![0; 4], 3).await; + let (result, buffer) = selector.file_read_at(&file, vec![0; 4], 3, &NEVER).await; assert_eq!(result.unwrap(), 4); assert_eq!(buffer, b"file"); }); } + +#[test] +fn direct_readiness_cancellation_returns_buffers_before_submitting_work() { + use super::*; + async_io::block_on(async { + let cancellation = Cancellation::new(); + cancellation.cancel(); + let selector = Selector; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cancelled"); + assert!(matches!( + selector + .file_open(&path, FileOptions::CREATE, &cancellation) + .await, + Err(crate::OperationError::Cancelled(_)) + )); + let file = Arc::new(tempfile::tempfile().unwrap()); + let buffer = vec![1; 4]; + let allocation = buffer.as_ptr(); + let (result, buffer) = selector + .file_write_at(&file, buffer, 0, &cancellation) + .await; + assert!(matches!(result, Err(crate::OperationError::Cancelled(_)))); + let (result, buffer) = selector.file_read_at(&file, buffer, 0, &cancellation).await; + assert!(matches!(result, Err(crate::OperationError::Cancelled(_)))); + assert_eq!(buffer.as_ptr(), allocation); + assert!(!path.exists()); + }); +} diff --git a/crates/executor/src/scheduler/signals.rs b/crates/executor/src/scheduler/signals.rs new file mode 100644 index 0000000..7a0342f --- /dev/null +++ b/crates/executor/src/scheduler/signals.rs @@ -0,0 +1,35 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::Notify; +use std::io; + +/// Explicit signal subscriptions provided by a scheduler. +/// +/// This optional capability is separate from cancellation. A destination can +/// request cancellation, enqueue the signal, or implement application policy. +/// The `signals` feature supplies Unix implementations for Socketry and the +/// Tokio adapter. Unsupported platforms do not implement this capability. +pub trait Signals: Send + Sync { + /// A backend's signal identifier; Unix implementations use `c_int`. + type Signal: Copy + Send + 'static; + + /// Owns one subscription. Dropping it stops admission of notifications; + /// dispatch already in progress may finish and must retain its destination. + /// Drop does not wait for callbacks or asynchronous backend cleanup. + type Registration: Send + 'static; + + /// Subscribe synchronously, returning a guard that must remain alive while + /// notifications are wanted. Setup failures are returned before success. + /// + /// Callbacks run outside raw signal handlers and scheduler locks. Multiple + /// subscriptions are independent. Signals may coalesce before delivery; + /// this is not an exact counter or a globally ordered stream of OS events. + fn signal_notify( + &self, + signals: &[Self::Signal], + target: Target, + ) -> io::Result + where + Target: Notify + 'static; +} diff --git a/crates/executor/src/scheduler/socket.rs b/crates/executor/src/scheduler/socket.rs new file mode 100644 index 0000000..996cbf5 --- /dev/null +++ b/crates/executor/src/scheduler/socket.rs @@ -0,0 +1,56 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::{BufferResult, Cancellation, Interest, IoBuf, IoBufMut, OperationResult}; +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. +/// +/// 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. +pub trait Socket: Send + Sync { + type Socket: Send + Sync; + type Listener: Send + Sync; + + fn socket_listen( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> impl Future> + Send; + fn listener_addr(&self, listener: &Self::Listener) -> io::Result; + fn socket_local_addr(&self, socket: &Self::Socket) -> io::Result; + fn socket_peer_addr(&self, socket: &Self::Socket) -> io::Result; + fn connect( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> impl Future> + Send; + fn accept( + &self, + listener: &Self::Listener, + cancellation: &Cancellation, + ) -> impl Future> + Send; + fn io_read( + &self, + socket: &Self::Socket, + buffer: B, + cancellation: &Cancellation, + ) -> impl Future> + Send; + fn io_write( + &self, + socket: &Self::Socket, + buffer: B, + cancellation: &Cancellation, + ) -> impl Future> + Send; + fn io_wait( + &self, + socket: &Self::Socket, + interest: Interest, + cancellation: &Cancellation, + ) -> impl Future> + Send; +} diff --git a/crates/executor/src/scheduler/socket_registration.rs b/crates/executor/src/scheduler/socket_registration.rs new file mode 100644 index 0000000..c733a41 --- /dev/null +++ b/crates/executor/src/scheduler/socket_registration.rs @@ -0,0 +1,16 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use super::Socket; +use std::{ + io, + net::{TcpListener, TcpStream}, +}; + +/// Optional interoperability with existing standard-library TCP resources. +/// 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; +} diff --git a/crates/executor/src/scheduler/socketry.rs b/crates/executor/src/scheduler/socketry.rs index e2b1998..eeff693 100644 --- a/crates/executor/src/scheduler/socketry.rs +++ b/crates/executor/src/scheduler/socketry.rs @@ -4,11 +4,12 @@ //! Socketry's work-stealing executor and scheduler context. #[cfg(feature = "native")] mod operations; +#[cfg(all(unix, feature = "signals", feature = "native"))] +mod signals; -use crate::owner::Owner; use crate::task::{Completion, Runnable, TaskFuture, TaskState, UNASSIGNED_WORKER}; use crate::worker::{self, WorkerState}; -use crate::{Barrier, Spawn, SpawnError, Task, TaskHandle}; +use crate::{Spawn, SpawnError, Task, TaskHandle}; use crossbeam_deque::{Injector, Worker}; use event_listener::{Event, Listener}; use std::cell::RefCell; @@ -40,7 +41,6 @@ pub(crate) struct Shared { #[cfg(feature = "native")] selector: std::sync::OnceLock>, registry: Mutex, - root: Arc, pub(crate) ready: Injector, pub(crate) workers: Vec, pub(crate) closed: AtomicBool, @@ -50,13 +50,10 @@ pub(crate) struct Shared { } impl Shared { - fn reserve_identifier(registry: &mut Registry, owner: &Owner) -> Result { + fn reserve_identifier(registry: &mut Registry) -> Result { if registry.closed { return Err(SpawnError::SchedulerClosed); } - if owner.closed.load(Ordering::Acquire) { - return Err(SpawnError::OwnerClosed); - } let identifier = registry.next_identifier; registry.next_identifier = identifier .checked_add(1) @@ -66,7 +63,6 @@ impl Shared { pub(crate) fn spawn( self: &Arc, - owner: &Arc, future: FutureType, ) -> Result, SpawnError> where @@ -77,11 +73,10 @@ impl Shared { .registry .lock() .unwrap_or_else(|error| error.into_inner()); - let identifier = Self::reserve_identifier(&mut registry, owner)?; + let identifier = Self::reserve_identifier(&mut registry)?; let state = Arc::new(TaskState { identifier, scheduler: Arc::downgrade(self), - owner: Arc::clone(owner), cancelled: AtomicBool::new(false), finished: AtomicBool::new(false), worker: AtomicUsize::new(UNASSIGNED_WORKER), @@ -108,7 +103,6 @@ impl Shared { waker: runnable.waker(), }, ); - owner.remaining.fetch_add(1, Ordering::Release); self.remaining.fetch_add(1, Ordering::Release); // Publish ownership before execution, and never invoke scheduling under this lock. drop(registry); @@ -128,9 +122,6 @@ impl Shared { .remove(&state.identifier); // Drop the retained waker outside the registry lock. drop(registration); - if state.owner.remaining.fetch_sub(1, Ordering::AcqRel) == 1 { - state.owner.completion.notify(usize::MAX); - } if self.remaining.fetch_sub(1, Ordering::AcqRel) == 1 { self.completion.notify(usize::MAX); if self.closed.load(Ordering::Acquire) { @@ -152,29 +143,6 @@ impl Shared { } } - pub(crate) fn close_owner(&self, owner: &Arc, cancel: bool) { - let registry = self - .registry - .lock() - .unwrap_or_else(|error| error.into_inner()); - owner.closed.store(true, Ordering::Release); - let wakers = if cancel { - registry - .tasks - .values() - .filter(|task| Arc::ptr_eq(&task.state.owner, owner)) - .filter(|task| !task.state.cancelled.swap(true, Ordering::AcqRel)) - .map(|task| task.waker.clone()) - .collect::>() - } else { - Vec::new() - }; - drop(registry); - for waker in wakers { - waker.wake(); - } - } - fn close(&self) { let mut registry = self .registry @@ -248,7 +216,7 @@ impl SchedulerHandle { Self { shared } } - /// Spawn a task owned by the scheduler. + /// Spawn a task whose returned handle owns its execution lifetime. pub fn spawn( &self, future: FutureType, @@ -260,11 +228,6 @@ impl SchedulerHandle { Spawn::spawn(self, future) } - /// Create an explicit owner for child tasks. - pub fn barrier(&self) -> Barrier { - Barrier::new(self.clone()) - } - /// Number of worker threads configured for this scheduler. pub fn worker_count(&self) -> usize { self.shared.workers.len() @@ -285,14 +248,14 @@ impl Spawn for SchedulerHandle { FutureType: Future + Send + 'static, FutureType::Output: Send + 'static, { - self.shared.spawn(&self.shared.root, future) + self.shared.spawn(future) } } /// A futures executor with worker queues, remote inboxes and idle work stealing. /// -/// Workers start at construction. Tasks run until completion or cancellation, -/// even if their join handles are dropped. There are no private task stacks. +/// Workers start at construction. Tasks run until completion or abandonment. +/// Dropping an owning task handle requests destruction after an active poll. There are no private task stacks. /// Tasks must yield through pending futures; blocking calls block their worker. /// The `native` feature supplies I/O selectors and timers. The `io-uring` /// feature selects native completion reads/writes on Linux. @@ -357,7 +320,6 @@ impl Scheduler { next_identifier: 0, tasks: HashMap::new(), }), - root: Arc::new(Owner::new()), ready: Injector::new(), workers: queues .iter() @@ -397,7 +359,7 @@ impl Scheduler { self.handle.worker_count() } - /// Spawn a task owned by this scheduler. + /// Spawn a task whose returned handle owns its execution lifetime. pub fn spawn( &self, future: FutureType, @@ -409,11 +371,6 @@ impl Scheduler { self.handle.spawn(future) } - /// Create an explicit owner for child tasks. - pub fn barrier(&self) -> Barrier { - self.handle.barrier() - } - /// Block the caller until all currently owned tasks finish. /// External submissions may extend this wait. The scheduler remains open. /// Task failures are retrieved separately through their join handles. @@ -433,8 +390,8 @@ impl Scheduler { /// Poll a root future on the calling thread while workers execute tasks. /// The root may borrow local data and need not be Send. It is not an owned - /// task: `Task::current()` is None, and child ownership must be explicit. - /// Returning does not cancel other scheduler tasks. + /// task: `Task::current()` is None. Returning drops task handles still owned + /// by the root; handles moved elsewhere retain their execution lifetimes. /// /// # Panics /// Panics on a Socketry worker. Use `.await` inside tasks. diff --git a/crates/executor/src/scheduler/socketry/operations.rs b/crates/executor/src/scheduler/socketry/operations.rs index 2d61e6a..d64ffd8 100644 --- a/crates/executor/src/scheduler/socketry/operations.rs +++ b/crates/executor/src/scheduler/socketry/operations.rs @@ -4,11 +4,11 @@ //! Native selector operations exposed by Socketry schedulers and handles. use super::{Scheduler, SchedulerHandle}; use crate::scheduler::selector::DefaultSelector; -use crate::scheduler::{BufferResult, Clock, FileIo, Interest, Network}; -use std::fs::File; +use crate::scheduler::{BufferResult, Clock, File, Interest, Socket}; +use crate::{Cancellation, FileOptions, IoBuf, IoBufMut, OperationResult, SocketRegistration}; use std::io; use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::sync::Arc; +use std::path::Path; use std::time::Duration; fn check_open(closed: bool) -> io::Result<()> { @@ -75,116 +75,234 @@ impl SchedulerHandle { } } -impl Network for SchedulerHandle { - type Socket = ::Socket; - type Listener = ::Listener; - +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 { self.selector()?.register_listener(listener) } - - async fn connect(&self, address: SocketAddr) -> io::Result { - self.selector()?.connect(address).await +} +impl Socket for SchedulerHandle { + type Socket = ::Socket; + type Listener = ::Listener; + async fn socket_listen( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + self.selector()?.socket_listen(address, cancellation).await } - - async fn accept(&self, listener: &Self::Listener) -> io::Result<(Self::Socket, SocketAddr)> { - self.selector()?.accept(listener).await + fn listener_addr(&self, listener: &Self::Listener) -> io::Result { + self.selector()?.listener_addr(listener) } - - async fn io_read(&self, socket: &Self::Socket, buffer: Vec) -> BufferResult { + fn socket_local_addr(&self, socket: &Self::Socket) -> io::Result { + self.selector()?.socket_local_addr(socket) + } + fn socket_peer_addr(&self, socket: &Self::Socket) -> io::Result { + self.selector()?.socket_peer_addr(socket) + } + async fn connect( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + self.selector()?.connect(address, cancellation).await + } + async fn accept( + &self, + listener: &Self::Listener, + cancellation: &Cancellation, + ) -> OperationResult<(Self::Socket, SocketAddr)> { + self.selector()?.accept(listener, cancellation).await + } + async fn io_read( + &self, + socket: &Self::Socket, + buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { match self.selector() { - Ok(selector) => selector.io_read(socket, buffer).await, - Err(error) => (Err(error), buffer), + Ok(selector) => selector.io_read(socket, buffer, cancellation).await, + Err(error) => (Err(error.into()), buffer), } } - - async fn io_write(&self, socket: &Self::Socket, buffer: Vec) -> BufferResult { + async fn io_write( + &self, + socket: &Self::Socket, + buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { match self.selector() { - Ok(selector) => selector.io_write(socket, buffer).await, - Err(error) => (Err(error), buffer), + Ok(selector) => selector.io_write(socket, buffer, cancellation).await, + Err(error) => (Err(error.into()), buffer), } } - - async fn io_wait(&self, socket: &Self::Socket, interest: Interest) -> io::Result<()> { - self.selector()?.io_wait(socket, interest).await + async fn io_wait( + &self, + socket: &Self::Socket, + interest: Interest, + cancellation: &Cancellation, + ) -> OperationResult<()> { + self.selector()? + .io_wait(socket, interest, cancellation) + .await } } - -impl FileIo for SchedulerHandle { - async fn file_read_at(&self, file: Arc, buffer: Vec, offset: u64) -> BufferResult { +impl File for SchedulerHandle { + type File = ::File; + async fn file_open( + &self, + path: &Path, + options: FileOptions, + cancellation: &Cancellation, + ) -> OperationResult { + self.selector()? + .file_open(path, options, cancellation) + .await + } + async fn file_read_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { match self.selector() { - Ok(selector) => selector.file_read_at(file, buffer, offset).await, - Err(error) => (Err(error), buffer), + Ok(selector) => { + selector + .file_read_at(file, buffer, offset, cancellation) + .await + } + Err(error) => (Err(error.into()), buffer), } } - - async fn file_write_at(&self, file: Arc, buffer: Vec, offset: u64) -> BufferResult { + async fn file_write_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { match self.selector() { - Ok(selector) => selector.file_write_at(file, buffer, offset).await, - Err(error) => (Err(error), buffer), + Ok(selector) => { + selector + .file_write_at(file, buffer, offset, cancellation) + .await + } + Err(error) => (Err(error.into()), buffer), } } } - impl Clock for SchedulerHandle { async fn sleep(&self, duration: Duration) { // Timer progress is independent of selector initialization and ring support. async_io::Timer::after(duration).await; } } - -impl Network for Scheduler { - type Socket = ::Socket; - type Listener = ::Listener; - +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 { self.handle.register_listener(listener) } - - async fn connect(&self, address: SocketAddr) -> io::Result { - self.handle.connect(address).await +} +impl Socket for Scheduler { + type Socket = ::Socket; + type Listener = ::Listener; + async fn socket_listen( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + self.handle.socket_listen(address, cancellation).await } - - async fn accept(&self, listener: &Self::Listener) -> io::Result<(Self::Socket, SocketAddr)> { - self.handle.accept(listener).await + fn listener_addr(&self, listener: &Self::Listener) -> io::Result { + self.handle.listener_addr(listener) } - - async fn io_read(&self, socket: &Self::Socket, buffer: Vec) -> BufferResult { - self.handle.io_read(socket, buffer).await + fn socket_local_addr(&self, socket: &Self::Socket) -> io::Result { + self.handle.socket_local_addr(socket) } - - async fn io_write(&self, socket: &Self::Socket, buffer: Vec) -> BufferResult { - self.handle.io_write(socket, buffer).await + fn socket_peer_addr(&self, socket: &Self::Socket) -> io::Result { + self.handle.socket_peer_addr(socket) } - - async fn io_wait(&self, socket: &Self::Socket, interest: Interest) -> io::Result<()> { - self.handle.io_wait(socket, interest).await + async fn connect( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + self.handle.connect(address, cancellation).await + } + async fn accept( + &self, + listener: &Self::Listener, + cancellation: &Cancellation, + ) -> OperationResult<(Self::Socket, SocketAddr)> { + self.handle.accept(listener, cancellation).await + } + async fn io_read( + &self, + socket: &Self::Socket, + buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { + self.handle.io_read(socket, buffer, cancellation).await + } + async fn io_write( + &self, + socket: &Self::Socket, + buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { + self.handle.io_write(socket, buffer, cancellation).await + } + async fn io_wait( + &self, + socket: &Self::Socket, + interest: Interest, + cancellation: &Cancellation, + ) -> OperationResult<()> { + self.handle.io_wait(socket, interest, cancellation).await } } - -impl FileIo for Scheduler { - async fn file_read_at(&self, file: Arc, buffer: Vec, offset: u64) -> BufferResult { - self.handle.file_read_at(file, buffer, offset).await +impl File for Scheduler { + type File = ::File; + async fn file_open( + &self, + path: &Path, + options: FileOptions, + cancellation: &Cancellation, + ) -> OperationResult { + self.handle.file_open(path, options, cancellation).await } - - async fn file_write_at(&self, file: Arc, buffer: Vec, offset: u64) -> BufferResult { - self.handle.file_write_at(file, buffer, offset).await + async fn file_read_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + self.handle + .file_read_at(file, buffer, offset, cancellation) + .await + } + async fn file_write_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + self.handle + .file_write_at(file, buffer, offset, cancellation) + .await } } - impl Clock for Scheduler { async fn sleep(&self, duration: Duration) { self.handle.sleep(duration).await; } } - #[cfg(test)] mod tests; diff --git a/crates/executor/src/scheduler/socketry/operations/tests.rs b/crates/executor/src/scheduler/socketry/operations/tests.rs index 28af597..0d5ae2b 100644 --- a/crates/executor/src/scheduler/socketry/operations/tests.rs +++ b/crates/executor/src/scheduler/socketry/operations/tests.rs @@ -1,8 +1,11 @@ // Released under the MIT License. // Copyright, 2026, by Samuel Williams. +static NEVER: crate::Cancellation = crate::Cancellation::never(); + use super::{DefaultSelector, Scheduler, check_initialized_open, check_open}; -use crate::scheduler::{Clock, FileIo, Interest, Network}; +use crate::SocketRegistration; +use crate::scheduler::{Clock, File, Interest, Socket}; use std::fs::OpenOptions; use std::io; use std::net::{SocketAddr, TcpListener, TcpStream}; @@ -113,8 +116,10 @@ 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))); - let (server, _) = scheduler.block_on(scheduler.accept(&listener)).unwrap(); + let connect = scope.spawn(move || scheduler.block_on(handle.connect(address, &NEVER))); + let (server, _) = scheduler + .block_on(scheduler.accept(&listener, &NEVER)) + .unwrap(); let client = connect.join().unwrap().unwrap(); (client, server) }); @@ -123,8 +128,10 @@ 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))); - let (server, _) = scheduler.block_on(handle.accept(&listener)).unwrap(); + let connect = scope.spawn(move || scheduler.block_on(scheduler.connect(address, &NEVER))); + let (server, _) = scheduler + .block_on(handle.accept(&listener, &NEVER)) + .unwrap(); let client = connect.join().unwrap().unwrap(); (client, server) }); @@ -150,41 +157,46 @@ fn scheduler_and_handle_forward_network_file_and_clock_operations() { ); scheduler.block_on(async { - let (result, buffer) = handle.io_write(&client, vec![42]).await; + let (result, buffer) = handle.io_write(&client, vec![42], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); - handle.io_wait(&server, Interest::Readable).await.unwrap(); - let (result, buffer) = scheduler.io_read(&server, vec![0]).await; + handle + .io_wait(&server, Interest::Readable, &NEVER) + .await + .unwrap(); + let (result, buffer) = scheduler.io_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]).await; + let (result, buffer) = scheduler.io_write(&client, vec![43], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![43]); scheduler - .io_wait(&server, Interest::Readable) + .io_wait(&server, Interest::Readable, &NEVER) .await .unwrap(); - let (result, buffer) = handle.io_read(&server, vec![0]).await; + let (result, buffer) = handle.io_read(&server, vec![0], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![43]); let (result, buffer) = scheduler - .file_write_at(Arc::clone(&file), b"data".to_vec(), 0) + .file_write_at(&Arc::clone(&file), b"data".to_vec(), 0, &NEVER) .await; assert_eq!(result.unwrap(), 4); assert_eq!(buffer, b"data".to_vec()); - let (result, buffer) = handle.file_read_at(Arc::clone(&file), vec![0; 4], 0).await; + let (result, buffer) = handle + .file_read_at(&Arc::clone(&file), vec![0; 4], 0, &NEVER) + .await; assert_eq!(result.unwrap(), 4); assert_eq!(buffer, b"data".to_vec()); let (result, buffer) = handle - .file_write_at(Arc::clone(&file), b"test".to_vec(), 4) + .file_write_at(&Arc::clone(&file), b"test".to_vec(), 4, &NEVER) .await; assert_eq!(result.unwrap(), 4); assert_eq!(buffer, b"test".to_vec()); let (result, buffer) = scheduler - .file_read_at(Arc::clone(&file), vec![0; 4], 4) + .file_read_at(&Arc::clone(&file), vec![0; 4], 4, &NEVER) .await; assert_eq!(result.unwrap(), 4); assert_eq!(buffer, b"test".to_vec()); diff --git a/crates/executor/src/scheduler/socketry/signals.rs b/crates/executor/src/scheduler/socketry/signals.rs new file mode 100644 index 0000000..9b85f42 --- /dev/null +++ b/crates/executor/src/scheduler/socketry/signals.rs @@ -0,0 +1,55 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use super::{Scheduler, SchedulerHandle}; +use crate::signal_registration::SignalReader; +use crate::{Notify, SignalRegistration, Signals}; +use std::ffi::c_int; +use std::io; +use std::io::Read; +use std::os::unix::net::UnixStream; +use std::sync::atomic::Ordering; + +impl Signals for SchedulerHandle { + type Signal = c_int; + type Registration = SignalRegistration; + + fn signal_notify( + &self, + signals: &[c_int], + target: Target, + ) -> io::Result + where + Target: Notify + 'static, + { + if self.shared.closed.load(Ordering::Acquire) { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "scheduler is closed", + )); + } + SignalRegistration::new(self, signals, target, async_io::Async::new) + } +} + +impl Signals for Scheduler { + type Signal = c_int; + type Registration = SignalRegistration; + + fn signal_notify( + &self, + signals: &[c_int], + target: Target, + ) -> io::Result + where + Target: Notify + 'static, + { + self.handle().signal_notify(signals, target) + } +} + +impl SignalReader for async_io::Async { + async fn read_signal(&mut self) -> io::Result { + self.read_with(|mut stream| stream.read(&mut [0])).await + } +} diff --git a/crates/executor/src/scheduler/socketry/tests.rs b/crates/executor/src/scheduler/socketry/tests.rs index 2bac996..71d63fe 100644 --- a/crates/executor/src/scheduler/socketry/tests.rs +++ b/crates/executor/src/scheduler/socketry/tests.rs @@ -78,8 +78,6 @@ fn scheduler_remains_usable_after_its_registry_mutex_is_poisoned() { Err(TaskError::Cancelled) )); - let barrier = scheduler.barrier(); - barrier.close(); drop(scheduler); assert!(shared.closed.load(std::sync::atomic::Ordering::Acquire)); } diff --git a/crates/executor/src/scheduler/tokio.rs b/crates/executor/src/scheduler/tokio.rs index 7f1d128..799f8c3 100644 --- a/crates/executor/src/scheduler/tokio.rs +++ b/crates/executor/src/scheduler/tokio.rs @@ -7,37 +7,44 @@ //! runtime. Dropping it closes admission and requests cancellation. Await //! shutdown to join task destruction. Socketry's Task::current and //! Scheduler::current describe Socketry execution, not Tokio tasks. -use super::{BufferResult, Clock, FileIo, Interest, Network}; -use crate::owner::Owner; -use crate::{Spawn, SpawnError, TaskError}; +#[cfg(all(unix, feature = "signals"))] +mod signals; + +use super::operation::cancellable; +use super::{BufferResult, Clock, File, Interest, Socket as SocketOperations}; +use crate::{ + Cancellation, FileOptions, IoBuf, IoBufMut, OperationResult, SocketRegistration, Spawn, + SpawnError, TaskError, +}; use ::tokio::runtime::Handle; use ::tokio::task::{AbortHandle, JoinHandle}; +use event_listener::Event; use pin_project_lite::pin_project; use std::cell::RefCell; use std::collections::HashMap; -use std::fs::File; +use std::fs::File as StdFile; use std::future::Future; use std::io; use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::Path; use std::pin::Pin; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, Weak}; use std::task::{Context, Poll}; use std::time::Duration; thread_local! { - static CURRENT_OWNER: RefCell>> = const { RefCell::new(None) }; + static CURRENT_SCHEDULER: RefCell> = const { RefCell::new(Weak::new()) }; } -struct OwnerScope(Option>); -impl Drop for OwnerScope { +struct SchedulerScope(Weak); +impl Drop for SchedulerScope { fn drop(&mut self) { - CURRENT_OWNER.with(|current| current.replace(self.0.take())); + CURRENT_SCHEDULER.with(|current| current.replace(std::mem::take(&mut self.0))); } } struct TaskState { - owner: Arc, cancelled: AtomicBool, } @@ -56,25 +63,24 @@ struct Shared { runtime: Handle, closed: AtomicBool, registry: Mutex, - root: Arc, + remaining: AtomicUsize, + completion: Event, } impl Shared { fn spawn( self: &Arc, - owner: &Arc, future: FutureType, ) -> Result, SpawnError> where FutureType: Future + Send + 'static, FutureType::Output: Send + 'static, { - self.spawn_with_registration_hook(owner, future, || {}) + self.spawn_with_registration_hook(future, || {}) } fn spawn_with_registration_hook( self: &Arc, - owner: &Arc, future: FutureType, after_registration: impl FnOnce(), ) -> Result, SpawnError> @@ -89,15 +95,11 @@ impl Shared { if registry.closed { return Err(SpawnError::SchedulerClosed); } - if owner.closed.load(Ordering::Acquire) { - return Err(SpawnError::OwnerClosed); - } let identifier = registry.next_identifier; registry.next_identifier = identifier .checked_add(1) .ok_or(SpawnError::IdentifiersExhausted)?; let state = Arc::new(TaskState { - owner: Arc::clone(owner), cancelled: AtomicBool::new(false), }); registry.tasks.insert( @@ -107,7 +109,7 @@ impl Shared { abort: None, }, ); - owner.remaining.fetch_add(1, Ordering::Release); + self.remaining.fetch_add(1, Ordering::Release); drop(registry); after_registration(); // Publish ownership before spawn, but do not hold the registry lock: @@ -135,36 +137,37 @@ impl Shared { Ok(TaskHandle { inner }) } - fn close(&self, owner: Option<&Arc>, cancel: bool) { + fn close(&self) { let mut registry = self .registry .lock() .unwrap_or_else(|error| error.into_inner()); - if let Some(owner) = owner { - owner.closed.store(true, Ordering::Release); - } else { - registry.closed = true; - self.closed.store(true, Ordering::Release); - } - let aborts: Vec<_> = if cancel { - registry - .tasks - .values() - .filter(|task| owner.is_none_or(|owner| Arc::ptr_eq(owner, &task.state.owner))) - .filter_map(|task| { - task.state.cancelled.store(true, Ordering::Release); - task.abort.clone() - }) - .collect() - } else { - Vec::new() - }; + registry.closed = true; + self.closed.store(true, Ordering::Release); + let aborts: Vec<_> = registry + .tasks + .values() + .filter_map(|task| { + task.state.cancelled.store(true, Ordering::Release); + task.abort.clone() + }) + .collect(); drop(registry); for abort in aborts { abort.abort(); } } + async fn wait(&self) { + loop { + let listener = self.completion.listen(); + if self.remaining.load(Ordering::Acquire) == 0 { + return; + } + listener.await; + } + } + fn check_open(&self) -> io::Result<()> { if self.closed.load(Ordering::Acquire) { Err(io::Error::new( @@ -254,9 +257,9 @@ impl Drop for Completion { .tasks .remove(&self.identifier); drop(registration); - } - if self.state.owner.remaining.fetch_sub(1, Ordering::AcqRel) == 1 { - self.state.owner.completion.notify(usize::MAX); + if shared.remaining.fetch_sub(1, Ordering::AcqRel) == 1 { + shared.completion.notify(usize::MAX); + } } } } @@ -277,9 +280,8 @@ impl Future for TrackedFuture { if this.completion.state.cancelled.load(Ordering::Acquire) { return Poll::Ready(Err(TaskError::Cancelled)); } - let _scope = OwnerScope( - CURRENT_OWNER - .with(|current| current.replace(Some(Arc::clone(&this.completion.state.owner)))), + let _scope = SchedulerScope( + CURRENT_SCHEDULER.with(|current| current.replace(this.completion.scheduler.clone())), ); this.future.poll(context).map(Ok) } @@ -337,31 +339,31 @@ where } } -fn file_operation_result( - result: Result, ::tokio::task::JoinError>, -) -> io::Result { +fn file_operation_result>( + result: Result, ::tokio::task::JoinError>, +) -> Result { match result { Ok(result) => result, Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()), - Err(error) => Err(io::Error::new(io::ErrorKind::Interrupted, error)), + Err(error) => Err(io::Error::new(io::ErrorKind::Interrupted, error).into()), } } -fn with_file_buffer( - buffer: &Mutex>, - operation: impl FnOnce(&mut Vec) -> io::Result, -) -> io::Result { +fn with_file_buffer( + buffer: &Mutex, + operation: impl FnOnce(&mut B) -> Output, +) -> Output { let mut buffer = buffer.lock().unwrap_or_else(|error| error.into_inner()); operation(&mut buffer) } -fn take_file_buffer(buffer: &Mutex>) -> Vec { +fn take_file_buffer(buffer: &Mutex) -> B { std::mem::take(&mut *buffer.lock().unwrap_or_else(|error| error.into_inner())) } -/// An awaitable Tokio task result. Drop abandons the result without cancelling -/// the task; its scheduler or barrier remains the owner. -#[must_use = "await the task to observe its result, or explicitly drop the handle"] +/// An owning Tokio task handle. Drop requests destruction after the current +/// poll returns. Move the handle to transfer ownership; await it for completion. +#[must_use = "await or retain the task; dropping its handle abandons execution"] pub struct TaskHandle { inner: JoinHandle>, } @@ -377,6 +379,12 @@ impl TaskHandle { } } +impl Drop for TaskHandle { + fn drop(&mut self) { + self.inner.abort(); + } +} + impl Future for TaskHandle { type Output = Result; @@ -409,7 +417,8 @@ impl Scheduler { next_identifier: 0, tasks: HashMap::new(), }), - root: Arc::new(Owner::new()), + remaining: AtomicUsize::new(0), + completion: Event::new(), }), }, } @@ -430,39 +439,18 @@ impl Scheduler { self.handle.spawn(future) } - pub fn barrier(&self) -> Barrier { - self.handle.barrier() - } - - /// Close admission, cancel all owned tasks (including barrier children), + /// Close admission, cancel all owned tasks, /// and wait for their futures to be destroyed. pub async fn shutdown(self) { assert_outside_scheduler(&self.handle.shared); - self.handle.shared.close(None, true); - loop { - let owners: Vec<_> = self - .handle - .shared - .registry - .lock() - .unwrap_or_else(|error| error.into_inner()) - .tasks - .values() - .map(|task| Arc::clone(&task.state.owner)) - .collect(); - if owners.is_empty() { - break; - } - for owner in owners { - owner.wait().await; - } - } + self.handle.shared.close(); + self.handle.shared.wait().await; } } impl Drop for Scheduler { fn drop(&mut self) { - self.handle.shared.close(None, true); + self.handle.shared.close(); } } @@ -481,14 +469,7 @@ impl SchedulerHandle { FutureType: Future + Send + 'static, FutureType::Output: Send + 'static, { - self.shared.spawn(&self.shared.root, future) - } - - pub fn barrier(&self) -> Barrier { - Barrier { - scheduler: self.clone(), - owner: Arc::new(Owner::new()), - } + self.shared.spawn(future) } fn check_runtime(&self, runtime: ::tokio::runtime::Id) -> io::Result<()> { @@ -503,79 +484,13 @@ impl SchedulerHandle { } } -/// Explicit ownership of a group of Tokio tasks. -pub struct Barrier { - scheduler: SchedulerHandle, - owner: Arc, -} - -impl Barrier { - pub fn spawn( - &self, - future: FutureType, - ) -> Result, SpawnError> - where - FutureType: Future + Send + 'static, - FutureType::Output: Send + 'static, - { - self.scheduler.shared.spawn(&self.owner, future) - } - - pub fn close(&self) { - self.scheduler.shared.close(Some(&self.owner), false); - } - - pub fn is_empty(&self) -> bool { - self.owner.is_empty() - } - - /// Wait for direct children. Panics if called by one of those children. - pub async fn wait(&self) { - assert_outside_owner(&self.owner); - self.owner.wait().await; - } - - /// Cancel direct children and join their destruction. - pub async fn stop(&self) { - assert_outside_owner(&self.owner); - self.scheduler.shared.close(Some(&self.owner), true); - self.owner.wait().await; - } -} - -impl Drop for Barrier { - fn drop(&mut self) { - self.scheduler.shared.close(Some(&self.owner), true); - } -} - -fn assert_outside_owner(owner: &Arc) { +fn assert_outside_scheduler(shared: &Arc) { assert!( - CURRENT_OWNER.with(|current| current - .borrow() - .as_ref() - .is_none_or(|current| !Arc::ptr_eq(current, owner))), - "a task cannot wait for its own owner" + CURRENT_SCHEDULER.with(|current| !Weak::ptr_eq(¤t.borrow(), &Arc::downgrade(shared))), + "a task cannot shut down its own scheduler" ); } -fn assert_outside_scheduler(shared: &Shared) { - let current = CURRENT_OWNER.with(|current| current.borrow().clone()); - if let Some(current) = current { - let registry = shared - .registry - .lock() - .unwrap_or_else(|error| error.into_inner()); - assert!( - !registry - .tasks - .values() - .any(|task| Arc::ptr_eq(&task.state.owner, ¤t)), - "a task cannot shut down its own scheduler" - ); - } -} - /// A Tokio socket registered with the adapter's runtime. #[derive(Clone)] pub struct Socket { @@ -619,10 +534,7 @@ impl Future for InRuntime { } } -impl Network for SchedulerHandle { - type Socket = Socket; - type Listener = Listener; - +impl SocketRegistration for SchedulerHandle { fn register_socket(&self, socket: TcpStream) -> io::Result { self.shared.check_open()?; register_socket_with( @@ -642,110 +554,208 @@ impl Network for SchedulerHandle { ::tokio::net::TcpListener::from_std, ) } - - async fn connect(&self, address: SocketAddr) -> io::Result { +} +impl SocketOperations for SchedulerHandle { + type Socket = Socket; + type Listener = Listener; + async fn socket_listen( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + cancellation.check()?; + self.shared.check_open()?; + self.register_listener(TcpListener::bind(address)?) + .map_err(Into::into) + } + fn listener_addr(&self, listener: &Listener) -> io::Result { + self.check_runtime(listener.runtime)?; + listener.local_addr() + } + fn socket_local_addr(&self, socket: &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)?; + socket.peer_addr() + } + async fn connect( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { self.shared.check_open()?; - connect_with( - self.shared.runtime.clone(), - self.shared.runtime.id(), - ::tokio::net::TcpStream::connect(address), + cancellable( + cancellation, + connect_with( + self.shared.runtime.clone(), + self.shared.runtime.id(), + ::tokio::net::TcpStream::connect(address), + ), ) .await } - - async fn accept(&self, listener: &Listener) -> io::Result<(Socket, SocketAddr)> { + async fn accept( + &self, + listener: &Listener, + cancellation: &Cancellation, + ) -> OperationResult<(Socket, SocketAddr)> { self.check_runtime(listener.runtime)?; - // Accept registers a new stream with Tokio's current runtime. Enter - // our runtime for each poll, even when another executor polls us. - accept_with( - self.shared.runtime.clone(), - listener.runtime, - listener.inner.accept(), + cancellable( + cancellation, + accept_with( + self.shared.runtime.clone(), + listener.runtime, + listener.inner.accept(), + ), ) .await } - - async fn io_read(&self, socket: &Socket, mut buffer: Vec) -> BufferResult { + async fn io_read( + &self, + socket: &Socket, + mut buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { if let Err(error) = self.check_runtime(socket.runtime) { - return (Err(error), buffer); - } - if buffer.is_empty() { - return (Ok(0), buffer); + return (Err(error.into()), buffer); } - let result = read_with( - &mut buffer, - |buffer| socket.inner.try_read(buffer), - || socket.inner.readable(), - ) + let result = cancellable(cancellation, async { + if buffer.as_ref().is_empty() { + return Ok(0); + } + read_with( + buffer.as_mut(), + |buffer| socket.inner.try_read(buffer), + || socket.inner.readable(), + ) + .await + }) .await; (result, buffer) } - - async fn io_write(&self, socket: &Socket, buffer: Vec) -> BufferResult { + async fn io_write( + &self, + socket: &Socket, + buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { if let Err(error) = self.check_runtime(socket.runtime) { - return (Err(error), buffer); + return (Err(error.into()), buffer); } - if buffer.is_empty() { - return (Ok(0), buffer); - } - let result = write_with( - &buffer, - |buffer| socket.inner.try_write(buffer), - || socket.inner.writable(), - ) + let bytes = buffer.as_ref(); + let result = cancellable(cancellation, async { + if bytes.is_empty() { + return Ok(0); + } + write_with( + bytes, + |buffer| socket.inner.try_write(buffer), + || socket.inner.writable(), + ) + .await + }) .await; (result, buffer) } - - async fn io_wait(&self, socket: &Socket, interest: Interest) -> io::Result<()> { + async fn io_wait( + &self, + socket: &Socket, + interest: Interest, + cancellation: &Cancellation, + ) -> OperationResult<()> { self.check_runtime(socket.runtime)?; - match interest { - Interest::Readable => socket.inner.readable().await, - Interest::Writable => socket.inner.writable().await, - } + cancellable(cancellation, async { + match interest { + Interest::Readable => socket.inner.readable().await, + Interest::Writable => socket.inner.writable().await, + } + }) + .await } } -impl FileIo for SchedulerHandle { - async fn file_read_at(&self, file: Arc, buffer: Vec, offset: u64) -> BufferResult { - self.file_operation(file, buffer, offset, false).await +impl File for SchedulerHandle { + type File = Arc; + async fn file_open( + &self, + path: &Path, + options: FileOptions, + cancellation: &Cancellation, + ) -> OperationResult { + cancellation.check()?; + self.shared.check_open()?; + let path = path.to_owned(); + let cancellation = cancellation.clone(); + file_operation_result( + self.shared + .runtime + .spawn_blocking(move || { + super::blocking::run(&cancellation, || options.open(&path).map(Arc::new)) + }) + .await, + ) } - - async fn file_write_at(&self, file: Arc, buffer: Vec, offset: u64) -> BufferResult { - self.file_operation(file, buffer, offset, true).await + async fn file_read_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + let file = Arc::clone(file); + self.file_operation(buffer, cancellation, move |buffer| { + super::positioned_file::read_at(&file, buffer.as_mut(), offset) + }) + .await + } + async fn file_write_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + let file = Arc::clone(file); + self.file_operation(buffer, cancellation, move |buffer| { + super::positioned_file::write_at(&file, buffer.as_ref(), offset) + }) + .await } } impl SchedulerHandle { - async fn file_operation( + async fn file_operation( &self, - file: Arc, - buffer: Vec, - offset: u64, - write: bool, - ) -> BufferResult { + buffer: B, + cancellation: &Cancellation, + operation: impl FnOnce(&mut B) -> io::Result + Send + 'static, + ) -> BufferResult { + if let Err(error) = cancellation.check() { + return (Err(error.into()), buffer); + } if let Err(error) = self.shared.check_open() { - return (Err(error), buffer); + return (Err(error.into()), buffer); } - // Retain a second owner so runtime shutdown before the blocking job - // starts can still return the caller's buffer. No byte copy is made. - let buffer = Arc::new(Mutex::new(buffer)); + // A queued job may be destroyed by the external runtime. The waiter + // retains the buffer too, and only takes it after the job has ended. + let buffer = Arc::new(Mutex::new(Some(buffer))); let operation_buffer = Arc::clone(&buffer); + let cancellation = cancellation.clone(); let result = self .shared .runtime .spawn_blocking(move || { with_file_buffer(&operation_buffer, |buffer| { - if write { - super::file::write_at(&file, buffer, offset) - } else { - super::file::read_at(&file, buffer, offset) - } + super::blocking::run(&cancellation, || { + operation(buffer.as_mut().expect("job retains its buffer")) + }) }) }) .await; let result = file_operation_result(result); - let buffer = take_file_buffer(&buffer); + let buffer = take_file_buffer(&buffer).expect("completed job returns its buffer"); (result, buffer) } } @@ -790,58 +800,107 @@ impl Spawn for SchedulerHandle { FutureType: Future + Send + 'static, FutureType::Output: Send + 'static, { - self.shared.spawn(&self.shared.root, future) + self.shared.spawn(future) } } -impl Spawn for Barrier { - type Handle - = TaskHandle - where - Output: Send + 'static; - fn spawn( - &self, - future: FutureType, - ) -> Result, SpawnError> - where - FutureType: Future + Send + 'static, - FutureType::Output: Send + 'static, - { - self.scheduler.shared.spawn(&self.owner, future) - } -} - -impl Network for Scheduler { - type Socket = Socket; - type Listener = Listener; - fn register_socket(&self, socket: TcpStream) -> io::Result { +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) } - async fn connect(&self, address: SocketAddr) -> io::Result { - self.handle.connect(address).await +} +impl SocketOperations for Scheduler { + type Socket = ::Socket; + type Listener = ::Listener; + async fn socket_listen( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + self.handle.socket_listen(address, cancellation).await + } + fn listener_addr(&self, listener: &Self::Listener) -> io::Result { + self.handle.listener_addr(listener) + } + fn socket_local_addr(&self, socket: &Self::Socket) -> io::Result { + self.handle.socket_local_addr(socket) } - async fn accept(&self, listener: &Listener) -> io::Result<(Socket, SocketAddr)> { - self.handle.accept(listener).await + fn socket_peer_addr(&self, socket: &Self::Socket) -> io::Result { + self.handle.socket_peer_addr(socket) + } + async fn connect( + &self, + address: SocketAddr, + cancellation: &Cancellation, + ) -> OperationResult { + self.handle.connect(address, cancellation).await + } + async fn accept( + &self, + listener: &Self::Listener, + cancellation: &Cancellation, + ) -> OperationResult<(Self::Socket, SocketAddr)> { + self.handle.accept(listener, cancellation).await } - async fn io_read(&self, socket: &Socket, buffer: Vec) -> BufferResult { - self.handle.io_read(socket, buffer).await + async fn io_read( + &self, + socket: &Self::Socket, + buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { + self.handle.io_read(socket, buffer, cancellation).await } - async fn io_write(&self, socket: &Socket, buffer: Vec) -> BufferResult { - self.handle.io_write(socket, buffer).await + async fn io_write( + &self, + socket: &Self::Socket, + buffer: B, + cancellation: &Cancellation, + ) -> BufferResult { + self.handle.io_write(socket, buffer, cancellation).await } - async fn io_wait(&self, socket: &Socket, interest: Interest) -> io::Result<()> { - self.handle.io_wait(socket, interest).await + async fn io_wait( + &self, + socket: &Self::Socket, + interest: Interest, + cancellation: &Cancellation, + ) -> OperationResult<()> { + self.handle.io_wait(socket, interest, cancellation).await } } -impl FileIo for Scheduler { - async fn file_read_at(&self, file: Arc, buffer: Vec, offset: u64) -> BufferResult { - self.handle.file_read_at(file, buffer, offset).await +impl File for Scheduler { + type File = ::File; + async fn file_open( + &self, + path: &Path, + options: FileOptions, + cancellation: &Cancellation, + ) -> OperationResult { + self.handle.file_open(path, options, cancellation).await } - async fn file_write_at(&self, file: Arc, buffer: Vec, offset: u64) -> BufferResult { - self.handle.file_write_at(file, buffer, offset).await + async fn file_read_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + self.handle + .file_read_at(file, buffer, offset, cancellation) + .await + } + async fn file_write_at( + &self, + file: &Self::File, + buffer: B, + offset: u64, + cancellation: &Cancellation, + ) -> BufferResult { + self.handle + .file_write_at(file, buffer, offset, cancellation) + .await } } impl Clock for Scheduler { diff --git a/crates/executor/src/scheduler/tokio/signals.rs b/crates/executor/src/scheduler/tokio/signals.rs new file mode 100644 index 0000000..70be5c1 --- /dev/null +++ b/crates/executor/src/scheduler/tokio/signals.rs @@ -0,0 +1,65 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use super::{Scheduler, SchedulerHandle}; +use crate::signal_registration::SignalReader; +use crate::{Notify, SignalRegistration, Signals}; +use ::tokio::io::AsyncReadExt; +use std::ffi::c_int; +use std::io; + +impl Signals for SchedulerHandle { + type Signal = c_int; + type Registration = SignalRegistration; + + fn signal_notify( + &self, + signals: &[c_int], + target: Target, + ) -> io::Result + where + Target: Notify + 'static, + { + self.shared.check_open()?; + SignalRegistration::new(self, signals, target, |stream| { + register_read(&self.shared.runtime, stream, |stream| { + stream.set_nonblocking(true) + }) + }) + } +} + +fn register_read( + runtime: &::tokio::runtime::Handle, + stream: std::os::unix::net::UnixStream, + set_nonblocking: impl FnOnce(&std::os::unix::net::UnixStream) -> io::Result<()>, +) -> io::Result<::tokio::net::UnixStream> { + set_nonblocking(&stream)?; + let _scope = runtime.enter(); + ::tokio::net::UnixStream::from_std(stream) +} + +#[cfg(test)] +mod tests; + +impl Signals for Scheduler { + type Signal = c_int; + type Registration = SignalRegistration; + + fn signal_notify( + &self, + signals: &[c_int], + target: Target, + ) -> io::Result + where + Target: Notify + 'static, + { + self.handle.signal_notify(signals, target) + } +} + +impl SignalReader for ::tokio::net::UnixStream { + async fn read_signal(&mut self) -> io::Result { + self.read(&mut [0]).await + } +} diff --git a/crates/executor/src/scheduler/tokio/signals/tests.rs b/crates/executor/src/scheduler/tokio/signals/tests.rs new file mode 100644 index 0000000..536d729 --- /dev/null +++ b/crates/executor/src/scheduler/tokio/signals/tests.rs @@ -0,0 +1,21 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use super::*; +use std::os::unix::net::UnixStream; + +#[test] +fn nonblocking_setup_failure_is_returned_before_runtime_registration() { + let runtime = ::tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let (read, _write) = UnixStream::pair().unwrap(); + let error = register_read(runtime.handle(), read, |_| { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "setup denied", + )) + }) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); +} diff --git a/crates/executor/src/scheduler/tokio/tests.rs b/crates/executor/src/scheduler/tokio/tests.rs index 1cd4a8a..d11b383 100644 --- a/crates/executor/src/scheduler/tokio/tests.rs +++ b/crates/executor/src/scheduler/tokio/tests.rs @@ -1,6 +1,8 @@ // Released under the MIT License. // Copyright, 2026, by Samuel Williams. +static NEVER: crate::Cancellation = crate::Cancellation::never(); + use super::*; use std::future::ready; use std::io::Read; @@ -39,24 +41,23 @@ fn shared(runtime: &::tokio::runtime::Runtime) -> Arc { next_identifier: 0, tasks: HashMap::new(), }), - root: Arc::new(Owner::new()), + remaining: AtomicUsize::new(0), + completion: Event::new(), }) } -fn task_state(owner: Arc, cancelled: bool) -> Arc { +fn task_state(cancelled: bool) -> Arc { Arc::new(TaskState { - owner, cancelled: AtomicBool::new(cancelled), }) } #[test] -fn completion_removes_registration_and_notifies_owner() { +fn completion_removes_registration_and_notifies_scheduler() { let runtime = runtime(); let shared = shared(&runtime); - let owner = Arc::new(Owner::new()); - owner.remaining.store(1, Ordering::Release); - let state = task_state(Arc::clone(&owner), false); + shared.remaining.store(1, Ordering::Release); + let state = task_state(false); shared.registry.lock().unwrap().tasks.insert( 7, Registration { @@ -72,39 +73,37 @@ fn completion_removes_registration_and_notifies_owner() { }); assert!(shared.registry.lock().unwrap().tasks.is_empty()); - assert!(owner.is_empty()); + assert_eq!(shared.remaining.load(Ordering::Acquire), 0); } #[test] -fn completion_notifies_owner_after_scheduler_is_gone() { +fn completion_is_safe_after_scheduler_is_gone() { let runtime = runtime(); let shared = shared(&runtime); - let owner = Arc::new(Owner::new()); - owner.remaining.store(1, Ordering::Release); + shared.remaining.store(1, Ordering::Release); let completion = Completion { scheduler: Arc::downgrade(&shared), identifier: 7, - state: task_state(Arc::clone(&owner), false), + state: task_state(false), }; drop(shared); drop(completion); - - assert!(owner.is_empty()); } #[test] fn tracked_future_does_not_poll_after_cancellation() { - let owner = Arc::new(Owner::new()); - owner.remaining.store(1, Ordering::Release); - let state = task_state(Arc::clone(&owner), true); + let runtime = runtime(); + let shared = shared(&runtime); + shared.remaining.store(1, Ordering::Release); + let state = task_state(true); let mut context = Context::from_waker(Waker::noop()); { let mut future = Box::pin(TrackedFuture { future: ready(42), completion: Completion { - scheduler: Weak::new(), + scheduler: Arc::downgrade(&shared), identifier: 0, state, }, @@ -114,7 +113,7 @@ fn tracked_future_does_not_poll_after_cancellation() { Poll::Ready(Err(TaskError::Cancelled)) )); } - assert!(owner.is_empty()); + assert_eq!(shared.remaining.load(Ordering::Acquire), 0); } #[test] @@ -127,18 +126,6 @@ fn delayed_abort_respects_an_earlier_cancellation_request() { assert!(runtime.block_on(task).is_err()); } -#[test] -fn close_without_cancellation_only_closes_admission() { - let runtime = runtime(); - let shared = shared(&runtime); - let owner = Arc::new(Owner::new()); - - shared.close(Some(&owner), false); - - assert!(owner.closed.load(Ordering::Acquire)); - assert!(!shared.closed.load(Ordering::Acquire)); -} - #[test] fn poisoned_registry_locks_do_not_break_spawn_or_shutdown() { let runtime = runtime(); @@ -200,15 +187,13 @@ fn shutdown_cancels_a_task_registered_before_tokio_spawn() { let runtime = runtime(); let scheduler = Scheduler::new(runtime.handle().clone()); let shared = Arc::clone(&scheduler.handle.shared); - let owner = Arc::new(Owner::new()); let (registered, wait_for_registration) = mpsc::sync_channel(0); let (resume, wait_to_resume) = mpsc::sync_channel(0); let spawning = { let shared = Arc::clone(&shared); - let owner = Arc::clone(&owner); thread::spawn(move || { - shared.spawn_with_registration_hook(&owner, std::future::pending::<()>(), || { + shared.spawn_with_registration_hook(std::future::pending::<()>(), || { registered.send(()).unwrap(); wait_to_resume.recv().unwrap(); }) @@ -220,10 +205,12 @@ fn shutdown_cancels_a_task_registered_before_tokio_spawn() { let registry = shared.registry.lock().unwrap(); let registration = registry.tasks.values().next().unwrap(); assert!(registration.abort.is_none()); - assert!(!owner.is_empty()); + assert_eq!(shared.remaining.load(Ordering::Acquire), 1); } - shared.close(None, true); + let mut shutdown = Box::pin(scheduler.shutdown()); + let mut context = Context::from_waker(Waker::noop()); + assert!(shutdown.as_mut().poll(&mut context).is_pending()); assert!(shared.closed.load(Ordering::Acquire)); assert!( @@ -246,8 +233,9 @@ fn shutdown_cancels_a_task_registered_before_tokio_spawn() { Err(error) => panic!("task registration failed: {error}"), }; + runtime.block_on(shutdown); assert!(matches!(runtime.block_on(task), Err(TaskError::Cancelled))); - assert!(owner.is_empty()); + assert_eq!(shared.remaining.load(Ordering::Acquire), 0); } #[test] @@ -324,7 +312,7 @@ fn poisoned_file_buffers_are_recovered_for_operations_and_return() { assert_eq!( with_file_buffer(&buffer, |buffer| { buffer.push(2); - Ok(buffer.len()) + Ok::<_, io::Error>(buffer.len()) }) .unwrap(), 2 @@ -466,7 +454,7 @@ fn socket_write_waits_until_the_send_buffer_is_writable() { total }); - let mut write = Box::pin(handle.io_write(&socket, vec![42])); + let mut write = Box::pin(handle.io_write(&socket, vec![42], &NEVER)); runtime.block_on(async { std::future::poll_fn(|context| { assert!(write.as_mut().poll(context).is_pending()); @@ -509,9 +497,12 @@ fn write_returns_readiness_errors_and_non_retryable_errors() { #[test] fn file_operation_results_preserve_errors_and_resume_panics() { let runtime = runtime(); - assert_eq!(file_operation_result(Ok(Ok(42))).unwrap(), 42); assert_eq!( - file_operation_result::(Ok(Err(io::Error::other("file failed")))) + file_operation_result::<_, io::Error>(Ok(Ok(42))).unwrap(), + 42 + ); + assert_eq!( + file_operation_result::(Ok(Err(io::Error::other("file failed")))) .unwrap_err() .to_string(), "file failed" @@ -521,7 +512,7 @@ fn file_operation_results_preserve_errors_and_resume_panics() { cancelled.abort(); let cancelled = runtime.block_on(cancelled).unwrap_err(); assert_eq!( - file_operation_result::(Err(cancelled)) + file_operation_result::(Err(cancelled)) .unwrap_err() .kind(), io::ErrorKind::Interrupted @@ -531,8 +522,128 @@ fn file_operation_results_preserve_errors_and_resume_panics() { let panicked = runtime.block_on(panicked).unwrap_err(); assert!( std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - file_operation_result::(Err(panicked)) + file_operation_result::(Err(panicked)) })) .is_err() ); } + +#[test] +fn polling_restores_the_previous_scheduler_context() { + let runtime = runtime(); + let previous = shared(&runtime); + let current = shared(&runtime); + current.remaining.store(1, Ordering::Release); + let _scope = + SchedulerScope(CURRENT_SCHEDULER.with(|value| value.replace(Arc::downgrade(&previous)))); + let mut future = Box::pin(TrackedFuture { + future: std::future::poll_fn(|_| { + assert!( + CURRENT_SCHEDULER + .with(|value| Weak::ptr_eq(&value.borrow(), &Arc::downgrade(¤t))) + ); + Poll::Ready(42) + }), + completion: Completion { + scheduler: Arc::downgrade(¤t), + identifier: 0, + state: task_state(false), + }, + }); + let mut context = Context::from_waker(Waker::noop()); + assert!(matches!( + future.as_mut().poll(&mut context), + Poll::Ready(Ok(42)) + )); + assert!( + CURRENT_SCHEDULER.with(|value| Weak::ptr_eq(&value.borrow(), &Arc::downgrade(&previous))) + ); +} + +#[test] +fn shutdown_waits_for_all_task_destructors_even_without_result_handles() { + struct CountDrop(Arc); + impl Drop for CountDrop { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::Release); + } + } + let runtime = runtime(); + let scheduler = Scheduler::new(runtime.handle().clone()); + let dropped = Arc::new(AtomicUsize::new(0)); + for _ in 0..100 { + let guard = CountDrop(Arc::clone(&dropped)); + drop( + scheduler + .spawn(async move { + let _guard = guard; + std::future::pending::<()>().await; + }) + .unwrap(), + ); + } + runtime.block_on(scheduler.shutdown()); + assert_eq!(dropped.load(Ordering::Acquire), 100); +} + +#[test] +fn a_task_can_shut_down_another_scheduler_on_the_same_runtime() { + let runtime = runtime(); + let first = Scheduler::new(runtime.handle().clone()); + let second = Scheduler::new(runtime.handle().clone()); + let task = first.spawn(async move { second.shutdown().await }).unwrap(); + runtime.block_on(task).unwrap(); + runtime.block_on(first.shutdown()); +} + +#[test] +fn cancelling_queued_blocking_work_preserves_buffers_and_prevents_creation() { + let runtime = ::tokio::runtime::Builder::new_current_thread() + .enable_all() + .max_blocking_threads(1) + .build() + .unwrap(); + let scheduler = Scheduler::new(runtime.handle().clone()); + let handle = scheduler.handle(); + let (started, waiting) = std::sync::mpsc::channel(); + let (release, blocked) = std::sync::mpsc::channel(); + let blocker = runtime.spawn_blocking(move || { + started.send(()).unwrap(); + blocked + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + }); + waiting + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cancelled-open"); + let file = Arc::new(tempfile::tempfile().unwrap()); + let cancellation = Cancellation::new(); + runtime.block_on(async { + let buffer = vec![42; 4]; + let allocation = buffer.as_ptr(); + let mut write = Box::pin(handle.file_write_at(&file, buffer, 0, &cancellation)); + let mut open = Box::pin(handle.file_open(&path, FileOptions::CREATE, &cancellation)); + std::future::poll_fn(|context| { + assert!(write.as_mut().poll(context).is_pending()); + assert!(open.as_mut().poll(context).is_pending()); + Poll::Ready(()) + }) + .await; + cancellation.cancel(); + release.send(()).unwrap(); + let (result, buffer) = write.await; + assert!(matches!(result, Err(crate::OperationError::Cancelled(_)))); + assert_eq!(buffer.as_ptr(), allocation); + assert_eq!(buffer, vec![42; 4]); + assert!(matches!( + open.await, + Err(crate::OperationError::Cancelled(_)) + )); + blocker.await.unwrap(); + }); + assert!(!path.exists()); + assert_eq!(file.metadata().unwrap().len(), 0); + runtime.block_on(scheduler.shutdown()); +} diff --git a/crates/executor/src/signal_registration.rs b/crates/executor/src/signal_registration.rs new file mode 100644 index 0000000..690bffe --- /dev/null +++ b/crates/executor/src/signal_registration.rs @@ -0,0 +1,153 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::{Cancellation, Notify, Spawn}; +use signal_hook::iterator::{backend::SignalDelivery, exfiltrator::SignalOnly}; +use std::ffi::c_int; +use std::future::{Future, poll_fn}; +use std::io; +use std::os::fd::AsRawFd; +use std::os::unix::net::UnixStream; +use std::pin::pin; +use std::task::Poll; + +/// Owns a Unix signal subscription created by `Signals::signal_notify`. +/// +/// Dropping this guard disables further dispatch and wakes the subscription's +/// owned scheduler task to be destroyed after any active poll. An already-admitted +/// notification may finish; the task retains its destination. Drop +/// does not join the task or wait for user code. +/// +/// Keep the guard, scheduler, and I/O services alive during graceful draining. +/// Scheduler shutdown also ends delivery. A panicking destination unwinds its +/// own task using the scheduler's usual panic handling. +/// +/// Removing a subscription does not restore the process's original signal +/// disposition, including after partial setup failure. Coordinate ownership +/// with other libraries; existing handlers may still run. No escalation or +/// default termination policy is installed. +#[must_use = "dropping the registration removes the signal subscription"] +pub struct SignalRegistration { + stopped: Cancellation, + // One owning handle per subscription, erased only at the guard boundary. + _task: Box, +} + +impl std::fmt::Debug for SignalRegistration { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SignalRegistration") + .finish_non_exhaustive() + } +} + +/// The backend's asynchronous self-pipe reader. Never block a polling worker. +pub(crate) trait SignalReader: AsRawFd + Send + Sync + 'static { + fn read_signal(&mut self) -> impl Future> + Send; +} + +impl SignalRegistration { + pub(crate) fn new( + scheduler: &impl Spawn, + signals: &[c_int], + target: impl Notify + 'static, + register: impl FnOnce(UnixStream) -> io::Result, + ) -> io::Result { + validate(signals)?; + let delivery = prepare(signals, UnixStream::pair(), register)?; + Self::start(scheduler, delivery, target) + } + + fn start( + scheduler: &impl Spawn, + delivery: SignalDelivery, + target: impl Notify + 'static, + ) -> io::Result { + let stopped = Cancellation::new(); + let task = scheduler + .spawn(dispatch(delivery, target, stopped.clone())) + .map_err(io::Error::other)?; + let registration = Self { + stopped, + _task: Box::new(task), + }; + Ok(registration) + } +} + +fn prepare( + signals: &[c_int], + pipe: io::Result<(UnixStream, UnixStream)>, + register: impl FnOnce(UnixStream) -> io::Result, +) -> io::Result> { + let (read, write) = pipe?; + let read = register(read)?; + SignalDelivery::with_pipe(read, write, SignalOnly, signals) +} + +async fn dispatch( + mut delivery: SignalDelivery, + target: impl Notify, + stopped: Cancellation, +) { + loop { + let result = { + let mut stop = pin!(stopped.cancelled()); + let mut read = pin!(delivery.get_read_mut().read_signal()); + poll_fn(|context| { + if stop.as_mut().poll(context).is_ready() { + return Poll::Ready(None); + } + read.as_mut().poll(context).map(Some) + }) + .await + }; + match result { + Some(Ok(1..)) => { + for signal in delivery.pending() { + // Passing this check admits one notification. Removal may + // race the callback, but cannot destroy its owned target. + if stopped.is_cancelled() { + return; + } + target.notify(signal); + } + } + Some(Err(error)) if error.kind() == io::ErrorKind::Interrupted => {} + _ => return, + } + // A continuously readable pipe must not monopolize a polling worker. + crate::yield_now().await; + } +} + +impl Drop for SignalRegistration { + fn drop(&mut self) { + self.stopped.cancel(); + } +} + +fn validate(signals: &[c_int]) -> io::Result<()> { + if signals.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "empty signal set", + )); + } + // Check the entire set for values that would panic inside signal-hook. + // The backend reports any further platform-specific registration errors. + for &signal in signals { + // signal-hook's iterator indexes a table of 128 entries. Reject its + // forbidden signals and out-of-range values instead of panicking. + if !(1..128).contains(&signal) || signal_hook::consts::FORBIDDEN.contains(&signal) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid or forbidden signal", + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/executor/src/signal_registration/tests.rs b/crates/executor/src/signal_registration/tests.rs new file mode 100644 index 0000000..c6861f0 --- /dev/null +++ b/crates/executor/src/signal_registration/tests.rs @@ -0,0 +1,130 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use super::*; +use crate::{SpawnError, TaskError}; +use std::collections::VecDeque; +use std::future::Ready; +use std::os::fd::RawFd; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, mpsc}; + +struct Reader { + stream: UnixStream, + results: VecDeque>, + polls: Arc, +} + +impl AsRawFd for Reader { + fn as_raw_fd(&self) -> RawFd { + self.stream.as_raw_fd() + } +} + +impl SignalReader for Reader { + async fn read_signal(&mut self) -> io::Result { + self.polls.fetch_add(1, Ordering::Relaxed); + self.results + .pop_front() + .expect("read polled again after termination") + } +} + +fn reader(stream: UnixStream) -> io::Result { + Ok(Reader { + stream, + results: VecDeque::from([Ok(0)]), + polls: Arc::default(), + }) +} + +struct RejectSpawn; + +impl Spawn for RejectSpawn { + type Handle + = Ready> + where + Output: Send + 'static; + + fn spawn( + &self, + _future: FutureType, + ) -> Result, SpawnError> + where + FutureType: Future + Send + 'static, + FutureType::Output: Send + 'static, + { + Err(SpawnError::SchedulerClosed) + } +} + +#[test] +fn pipe_and_reactor_failures_release_setup_resources() { + let error = prepare(&[], Err(io::Error::from_raw_os_error(libc::EMFILE)), reader) + .err() + .unwrap(); + assert_eq!(error.raw_os_error(), Some(libc::EMFILE)); + + let (sender, receiver) = mpsc::channel::(); + let error = + SignalRegistration::new::(&RejectSpawn, &[crate::signal::SIGUSR1], sender, |_| { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "registration denied", + )) + }) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert_eq!(receiver.try_recv(), Err(mpsc::TryRecvError::Disconnected)); + + // The backend can fail after validation; preserve its error and drop both + // pipe ends. An invalid OS number avoids installing real test handlers. + let error = prepare(&[0], UnixStream::pair(), reader).err().unwrap(); + assert_eq!(error.raw_os_error(), Some(libc::EINVAL)); +} + +#[test] +fn spawn_failure_drops_the_unpolled_subscription_and_target() { + let delivery = prepare(&[], UnixStream::pair(), reader).unwrap(); + let (sender, receiver) = mpsc::channel::(); + let error = SignalRegistration::start(&RejectSpawn, delivery, sender).unwrap_err(); + assert!(error.get_ref().unwrap().is::()); + assert_eq!(receiver.try_recv(), Err(mpsc::TryRecvError::Disconnected)); +} + +#[test] +fn eof_or_read_error_ends_dispatch_without_spinning() { + for result in [Ok(0), Err(io::Error::from(io::ErrorKind::BrokenPipe))] { + let mut delivery = prepare(&[], UnixStream::pair(), reader).unwrap(); + let polls = delivery.get_read().polls.clone(); + delivery.get_read_mut().results = VecDeque::from([result]); + let (sender, receiver) = mpsc::channel::(); + futures_lite::future::block_on(dispatch(delivery, sender, Cancellation::new())); + assert_eq!(polls.load(Ordering::Relaxed), 1); + assert_eq!(receiver.try_recv(), Err(mpsc::TryRecvError::Disconnected)); + } +} + +#[test] +fn interrupted_reads_are_retried_before_dispatch_ends() { + let mut delivery = prepare(&[], UnixStream::pair(), reader).unwrap(); + let polls = delivery.get_read().polls.clone(); + delivery.get_read_mut().results = + VecDeque::from([Err(io::Error::from(io::ErrorKind::Interrupted)), Ok(0)]); + let (sender, receiver) = mpsc::channel::(); + futures_lite::future::block_on(dispatch(delivery, sender, Cancellation::new())); + assert_eq!(polls.load(Ordering::Relaxed), 2); + assert_eq!(receiver.try_recv(), Err(mpsc::TryRecvError::Disconnected)); +} + +#[test] +fn removal_before_the_first_poll_releases_without_reading() { + let delivery = prepare(&[], UnixStream::pair(), reader).unwrap(); + let polls = delivery.get_read().polls.clone(); + let stopped = Cancellation::new(); + stopped.cancel(); + let (sender, receiver) = mpsc::channel::(); + futures_lite::future::block_on(dispatch(delivery, sender, stopped)); + assert_eq!(polls.load(Ordering::Relaxed), 0); + assert_eq!(receiver.try_recv(), Err(mpsc::TryRecvError::Disconnected)); +} diff --git a/crates/executor/src/spawn.rs b/crates/executor/src/spawn.rs new file mode 100644 index 0000000..d0fc24d --- /dev/null +++ b/crates/executor/src/spawn.rs @@ -0,0 +1,52 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use crate::TaskError; +use std::fmt; +use std::future::Future; + +/// The common spawning contract for schedulers and their handles. +pub trait Spawn { + /// The runtime's awaitable task handle. Runtime adapters may use their own + /// representation while preserving the ownership and result contract. + type Handle: Future> + Send + 'static + where + Output: Send + 'static; + + /// Register a task with this scheduler before making it runnable. + /// + /// The returned handle owns the execution lifetime. Dropping it requests + /// destruction after any active poll returns; moving it transfers ownership. + /// Await it explicitly for completion, or cancel cooperatively and then await. + /// If the scheduler is closed, the future is dropped without being polled. + fn spawn( + &self, + future: FutureType, + ) -> Result, SpawnError> + where + FutureType: Future + Send + 'static, + FutureType::Output: Send + 'static; +} + +/// A task could not be registered with its scheduler. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SpawnError { + /// The scheduler has begun shutting down. + SchedulerClosed, + /// All task identifiers have been used. + IdentifiersExhausted, +} + +impl fmt::Display for SpawnError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::SchedulerClosed => "the scheduler is closed", + Self::IdentifiersExhausted => "task identifiers are exhausted", + }) + } +} + +impl std::error::Error for SpawnError {} + +#[cfg(test)] +mod tests; diff --git a/crates/executor/src/owner/tests.rs b/crates/executor/src/spawn/tests.rs similarity index 81% rename from crates/executor/src/owner/tests.rs rename to crates/executor/src/spawn/tests.rs index de339d9..ece95ef 100644 --- a/crates/executor/src/owner/tests.rs +++ b/crates/executor/src/spawn/tests.rs @@ -9,10 +9,6 @@ fn spawn_errors_have_descriptive_messages() { SpawnError::SchedulerClosed.to_string(), "the scheduler is closed" ); - assert_eq!( - SpawnError::OwnerClosed.to_string(), - "the task owner is closed" - ); assert_eq!( SpawnError::IdentifiersExhausted.to_string(), "task identifiers are exhausted" diff --git a/crates/executor/src/task.rs b/crates/executor/src/task.rs index c716656..63c6de8 100644 --- a/crates/executor/src/task.rs +++ b/crates/executor/src/task.rs @@ -1,7 +1,6 @@ // Released under the MIT License. // Copyright, 2026, by Samuel Williams. -use crate::owner::Owner; use crate::scheduler::{SchedulerHandle, Shared}; use pin_project_lite::pin_project; use std::any::Any; @@ -20,7 +19,6 @@ pub(crate) type Runnable = async_task::Runnable>; pub(crate) struct TaskState { pub(crate) identifier: u64, pub(crate) scheduler: Weak, - pub(crate) owner: Arc, pub(crate) cancelled: AtomicBool, pub(crate) finished: AtomicBool, pub(crate) worker: AtomicUsize, @@ -108,9 +106,10 @@ impl std::error::Error for TaskError {} /// An awaitable result of an owned task. /// -/// Dropping this handle abandons the result; the scheduler or barrier continues -/// owning the task. Use `cancel().await` to request and wait for cancellation. -#[must_use = "await the task to observe its result, or explicitly drop the handle"] +/// The handle owns the task lifetime. Moving it transfers that ownership. +/// Dropping it requests destruction once any active poll returns; it does not +/// wait or run asynchronous cleanup. Await the handle to observe completion. +#[must_use = "await or retain the task; dropping its handle abandons execution"] pub struct TaskHandle { pub(crate) inner: Option, Arc>>, pub(crate) task: Task, @@ -162,6 +161,8 @@ impl Future for TaskHandle { impl Drop for TaskHandle { fn drop(&mut self) { if let Some(inner) = self.inner.take() { + self.task.cancel(); + // The registry retains execution until cancellation is observed. inner.detach(); } } diff --git a/crates/executor/src/task/tests.rs b/crates/executor/src/task/tests.rs index a9c1699..5501d7f 100644 --- a/crates/executor/src/task/tests.rs +++ b/crates/executor/src/task/tests.rs @@ -2,7 +2,6 @@ // Copyright, 2026, by Samuel Williams. use super::{Completion, Task, TaskError, TaskState, UNASSIGNED_WORKER}; -use crate::owner::Owner; use std::future::Future; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Weak}; @@ -12,7 +11,6 @@ fn task_state(cancelled: bool) -> Arc { Arc::new(TaskState { identifier: 1, scheduler: Weak::new(), - owner: Arc::new(Owner::new()), cancelled: AtomicBool::new(cancelled), finished: AtomicBool::new(false), worker: AtomicUsize::new(UNASSIGNED_WORKER), @@ -62,13 +60,13 @@ fn completion_marks_finished_after_its_scheduler_is_gone() { } #[test] -fn dropping_a_task_handle_detaches_without_cancelling_the_task() { +fn dropping_a_task_handle_requests_task_destruction() { let scheduler = crate::scheduler::Scheduler::with_workers(1).unwrap(); let handle = scheduler.spawn(std::future::pending::<()>()).unwrap(); let task = handle.task(); drop(handle); - assert!(task.cancel()); + assert!(!task.cancel()); scheduler.run(); diff --git a/crates/executor/src/worker/tests.rs b/crates/executor/src/worker/tests.rs index a6a7a3b..585de89 100644 --- a/crates/executor/src/worker/tests.rs +++ b/crates/executor/src/worker/tests.rs @@ -5,7 +5,6 @@ use super::{ IdleSearch, SearchResult, WorkerState, classify_search, finish_idle_search, search_result, take_idle_runnable, take_stolen, }; -use crate::owner::Owner; use crate::task::{Runnable, TaskState, UNASSIGNED_WORKER}; use crossbeam_deque::{Steal, Worker}; use std::sync::Arc; @@ -21,7 +20,6 @@ fn runnable() -> Runnable { let state = Arc::new(TaskState { identifier: 0, scheduler: Weak::new(), - owner: Arc::new(Owner::new()), cancelled: AtomicBool::new(false), finished: AtomicBool::new(false), worker: AtomicUsize::new(UNASSIGNED_WORKER), diff --git a/crates/executor/tests/buffers.rs b/crates/executor/tests/buffers.rs new file mode 100644 index 0000000..c864cef --- /dev/null +++ b/crates/executor/tests/buffers.rs @@ -0,0 +1,59 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use socketry_executor::{Cancelled, IoBuf, OperationError}; +use std::{error::Error, io, ops::Bound}; + +#[test] +fn owned_ranges_preserve_storage_and_validate_boundaries() { + let buffer = vec![1, 2, 3, 4]; + let allocation = buffer.as_ptr(); + let mut slice = buffer.slice(1..=2); + assert_eq!(slice.as_ref(), &[2, 3]); + slice.as_mut()[0] = 7; + let slice = slice.slice(..1); + assert_eq!(slice.as_ref(), &[7]); + let buffer = slice.into_inner().into_inner(); + assert_eq!(buffer.as_ptr(), allocation); + assert_eq!(buffer, vec![1, 7, 3, 4]); + assert_eq!( + buffer + .slice((Bound::Excluded(0), Bound::Unbounded)) + .as_ref(), + &[7, 3, 4] + ); + assert!(Vec::new().slice(..).as_ref().is_empty()); + assert_eq!( + vec![1].into_boxed_slice().slice(..).into_inner().as_ref(), + &[1] + ); + for range in [ + (Bound::Included(2), Bound::Excluded(1)), + (Bound::Included(0), Bound::Included(4)), + (Bound::Excluded(usize::MAX), Bound::Unbounded), + (Bound::Unbounded, Bound::Included(usize::MAX)), + ] { + assert!(std::panic::catch_unwind(|| vec![0; 4].slice(range)).is_err()); + } +} + +#[test] +fn operation_errors_keep_cancellation_and_os_errors_distinct() { + let cancelled = OperationError::from(Cancelled); + assert_eq!(cancelled.kind(), io::ErrorKind::Interrupted); + assert_eq!(cancelled.raw_os_error(), None); + assert!(cancelled.source().unwrap().is::()); + assert_eq!(cancelled.to_string(), Cancelled.to_string()); + let io_cancelled: io::Error = cancelled.into(); + assert!(io_cancelled.get_ref().unwrap().is::()); + let error = OperationError::from(io::Error::from_raw_os_error(2)); + assert_eq!(error.raw_os_error(), Some(2)); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert!(error.source().unwrap().is::()); + assert_eq!( + error.to_string(), + io::Error::from_raw_os_error(2).to_string() + ); + let error: io::Error = error.into(); + assert_eq!(error.raw_os_error(), Some(2)); +} diff --git a/crates/executor/tests/cancellation.rs b/crates/executor/tests/cancellation.rs new file mode 100644 index 0000000..4d7c13f --- /dev/null +++ b/crates/executor/tests/cancellation.rs @@ -0,0 +1,553 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +mod support; + +use socketry_executor::{Cancellation, Cancelled, Scheduler, defer_cancel, yield_now}; +use std::cell::Cell; +use std::future::{Future, pending, poll_fn}; +use std::marker::PhantomPinned; +use std::pin::{Pin, pin}; +use std::rc::Rc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier, mpsc}; +use std::task::{Context, Poll, Wake, Waker}; +use support::{CountDrop, receive}; + +#[derive(Default)] +struct WakeCounter(AtomicUsize); + +impl Wake for WakeCounter { + fn wake(self: Arc) { + self.wake_by_ref(); + } + + fn wake_by_ref(self: &Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +fn poll( + future: Pin<&mut FutureType>, + waker: &Waker, +) -> Poll { + future.poll(&mut Context::from_waker(waker)) +} + +#[test] +fn clones_share_persistent_cancellation_and_a_descriptive_error() { + fn thread_safe() {} + fn send(_: Value) {} + thread_safe::(); + thread_safe::(); + + let token = Cancellation::default(); + send(token.cancelled()); + let clone = token.clone(); + assert_eq!(clone.check(), Ok(())); + assert!(token.cancel()); + assert!(clone.is_cancelled()); + assert!(!clone.cancel()); + assert_eq!(clone.check(), Err(Cancelled)); + assert_eq!(Cancelled.to_string(), "operation cancelled"); + let error: &dyn std::error::Error = &Cancelled; + assert!(error.source().is_none()); + + let mut notification = pin!(clone.cancelled()); + assert!(poll(notification.as_mut(), Waker::noop()).is_ready()); +} + +#[test] +fn independent_and_child_signals_have_separate_cancellation_boundaries() { + let root = Cancellation::new(); + let child = root.child(); + let grandchild = child.child(); + let sibling = root.child(); + let independent = Cancellation::new(); + assert!(child.cancel()); + assert!(grandchild.is_cancelled()); + assert!(!root.is_cancelled()); + assert!(!sibling.is_cancelled()); + assert!(root.cancel()); + assert!(sibling.is_cancelled()); + assert!(!independent.is_cancelled()); + assert!(!child.cancel()); + assert!(!grandchild.cancel()); + assert!(child.child().child().is_cancelled()); +} + +#[test] +fn ancestor_cancellation_reaches_children_after_intermediate_handles_are_dropped() { + let root = Cancellation::new(); + let intermediate = root.child(); + let child = intermediate.child(); + drop(intermediate); + assert!(!child.is_cancelled()); + root.cancel(); + assert!(child.is_cancelled()); +} + +#[test] +fn dropping_all_parent_handles_does_not_cancel_surviving_children() { + let root = Cancellation::new(); + let child = root.child(); + drop(root.clone()); + drop(root); + assert!(!child.is_cancelled()); + assert!(child.cancel()); +} + +#[test] +fn never_cancelled_tokens_remain_pending_and_can_create_independent_children() { + let token = Cancellation::never(); + let counter = Arc::new(WakeCounter::default()); + let waker = Waker::from(Arc::clone(&counter)); + let mut notification = pin!(token.cancelled()); + assert!(poll(notification.as_mut(), &waker).is_pending()); + assert!(!token.cancel()); + assert!(!token.clone().cancel()); + assert!(!token.is_cancelled()); + assert_eq!(token.check(), Ok(())); + assert_eq!(counter.0.load(Ordering::SeqCst), 0); + let child = token.child(); + assert!(child.cancel()); + assert!(!token.is_cancelled()); + assert!(poll(notification.as_mut(), &waker).is_pending()); +} + +#[test] +fn cancellation_wakes_every_registered_waiter_including_descendants() { + let root = Cancellation::new(); + let tokens = [root.clone(), root.clone(), root.child()]; + let counters: Vec<_> = tokens + .iter() + .map(|_| Arc::new(WakeCounter::default())) + .collect(); + let wakers: Vec<_> = counters + .iter() + .map(|counter| Waker::from(Arc::clone(counter))) + .collect(); + let mut notifications: Vec<_> = tokens + .iter() + .map(|token| Box::pin(token.cancelled())) + .collect(); + for (notification, waker) in notifications.iter_mut().zip(&wakers) { + assert!(poll(notification.as_mut(), waker).is_pending()); + } + assert!(root.cancel()); + for ((notification, waker), counter) in notifications.iter_mut().zip(&wakers).zip(&counters) { + assert_eq!(counter.0.load(Ordering::SeqCst), 1); + assert!(poll(notification.as_mut(), waker).is_ready()); + } + assert!(!root.cancel()); + assert!( + counters + .iter() + .all(|counter| counter.0.load(Ordering::SeqCst) == 1) + ); +} + +#[test] +fn dropping_a_wait_unregisters_it_without_affecting_other_waiters() { + let token = Cancellation::new(); + let abandoned = Arc::new(WakeCounter::default()); + let retained = Arc::new(WakeCounter::default()); + let mut first = Box::pin(token.cancelled()); + let mut second = pin!(token.cancelled()); + let retained_waker = Waker::from(Arc::clone(&retained)); + assert!(poll(first.as_mut(), &Waker::from(Arc::clone(&abandoned))).is_pending()); + assert!(poll(second.as_mut(), &retained_waker).is_pending()); + drop(first); + assert!(!token.is_cancelled()); + token.cancel(); + assert_eq!(abandoned.0.load(Ordering::SeqCst), 0); + assert_eq!(retained.0.load(Ordering::SeqCst), 1); + assert!(poll(second.as_mut(), &retained_waker).is_ready()); +} + +#[test] +fn a_wait_uses_the_waker_from_its_latest_poll() { + let token = Cancellation::new(); + let old = Arc::new(WakeCounter::default()); + let new = Arc::new(WakeCounter::default()); + let mut notification = pin!(token.cancelled()); + let new_waker = Waker::from(Arc::clone(&new)); + assert!(poll(notification.as_mut(), &Waker::from(Arc::clone(&old))).is_pending()); + assert!(poll(notification.as_mut(), &new_waker).is_pending()); + token.cancel(); + assert_eq!(old.0.load(Ordering::SeqCst), 0); + assert_eq!(new.0.load(Ordering::SeqCst), 1); + assert!(poll(notification.as_mut(), &new_waker).is_ready()); +} + +#[test] +fn cancellation_wakers_can_reenter_token_operations() { + struct ReentrantWake { + token: Cancellation, + count: AtomicUsize, + } + impl Wake for ReentrantWake { + fn wake(self: Arc) { + self.wake_by_ref(); + } + fn wake_by_ref(self: &Arc) { + assert!(!self.token.cancel()); + assert!(self.token.child().is_cancelled()); + self.count.fetch_add(1, Ordering::SeqCst); + } + } + + let token = Cancellation::new(); + let counter = Arc::new(ReentrantWake { + token: token.clone(), + count: AtomicUsize::new(0), + }); + let waker = Waker::from(Arc::clone(&counter)); + let mut notification = pin!(token.cancelled()); + assert!(poll(notification.as_mut(), &waker).is_pending()); + let (sender, receiver) = mpsc::channel(); + let cancellation = token.clone(); + let thread = std::thread::spawn(move || sender.send(cancellation.cancel()).unwrap()); + assert!(receive(&receiver)); + thread.join().unwrap(); + assert_eq!(counter.count.load(Ordering::SeqCst), 1); + assert!(poll(notification.as_mut(), &waker).is_ready()); +} + +#[test] +fn cancellation_racing_child_registration_cannot_escape_propagation() { + for _ in 0..64 { + let root = Cancellation::new(); + let start = Arc::new(Barrier::new(2)); + let parent = root.clone(); + let child_start = Arc::clone(&start); + let child = std::thread::spawn(move || { + child_start.wait(); + parent.child().child() + }); + start.wait(); + root.cancel(); + assert!(child.join().unwrap().is_cancelled()); + } +} + +#[test] +fn cancellation_racing_waiter_registration_preserves_wakeups() { + for _ in 0..64 { + let token = Cancellation::new(); + let cancellation = token.clone(); + let start = Arc::new(Barrier::new(2)); + let cancellation_start = Arc::clone(&start); + let thread = std::thread::spawn(move || { + cancellation_start.wait(); + cancellation.cancel(); + }); + let counter = Arc::new(WakeCounter::default()); + let waker = Waker::from(Arc::clone(&counter)); + let mut notification = pin!(token.cancelled()); + start.wait(); + let first = poll(notification.as_mut(), &waker); + thread.join().unwrap(); + if first.is_pending() { + assert_eq!(counter.0.load(Ordering::SeqCst), 1); + assert!(poll(notification.as_mut(), &waker).is_ready()); + } + } +} + +#[test] +fn concurrent_cancellation_requests_have_one_winner() { + let root = Cancellation::new(); + let child = root.child(); + let start = Arc::new(Barrier::new(8)); + let threads: Vec<_> = (0..8) + .map(|_| { + let token = root.clone(); + let start = Arc::clone(&start); + std::thread::spawn(move || { + start.wait(); + token.cancel() + }) + }) + .collect(); + assert_eq!( + threads + .into_iter() + .map(|thread| thread.join().unwrap()) + .filter(|won| *won) + .count(), + 1 + ); + assert!(child.is_cancelled()); +} + +#[test] +fn cancellation_traverses_a_deep_chain_without_recursion() { + let root = Cancellation::new(); + let mut leaf = root.clone(); + for _ in 0..10_000 { + leaf = leaf.child(); + } + assert!(root.cancel()); + assert!(leaf.is_cancelled()); +} + +#[test] +fn deferred_work_completes_normally_without_a_cancellation_callback() { + let scheduler = Scheduler::with_workers(1).unwrap(); + let token = Cancellation::new(); + let called = Rc::new(Cell::new(false)); + let callback_called = Rc::clone(&called); + let callback_dropped = Arc::new(AtomicUsize::new(0)); + let guard = CountDrop(Arc::clone(&callback_dropped)); + let value = scheduler.block_on(defer_cancel( + &token, + async { + yield_now().await; + 42 + }, + move || { + let _guard = guard; + callback_called.set(true); + }, + )); + assert_eq!(value, 42); + assert!(!called.get()); + assert_eq!(callback_dropped.load(Ordering::SeqCst), 1); +} + +#[test] +fn already_cancelled_tokens_notify_before_the_first_protected_poll() { + let scheduler = Scheduler::with_workers(1).unwrap(); + let token = Cancellation::new(); + token.cancel(); + let called = Cell::new(false); + let value = scheduler.block_on(defer_cancel( + &token, + async { + assert!(called.get()); + 42 + }, + || called.set(true), + )); + assert_eq!(value, 42); +} + +#[test] +fn deferred_cancellation_keeps_pending_work_alive_and_calls_a_consuming_callback_once() { + let token = Cancellation::new(); + let callback_count = Cell::new(0); + let released = Cell::new(false); + let dropped = Arc::new(AtomicUsize::new(0)); + let guard = CountDrop(Arc::clone(&dropped)); + let future = poll_fn(|_| { + let _ = &guard; + if released.get() { + Poll::Ready(42) + } else { + Poll::Pending + } + }); + let payload = String::from("shutdown"); + let counter = Arc::new(WakeCounter::default()); + let waker = Waker::from(Arc::clone(&counter)); + let mut wrapper = pin!(defer_cancel(&token, future, || { + assert_eq!(payload, "shutdown"); + drop(payload); + callback_count.set(callback_count.get() + 1); + })); + assert!(poll(wrapper.as_mut(), &waker).is_pending()); + assert!(token.cancel()); + assert_eq!(counter.0.load(Ordering::SeqCst), 1); + assert!(poll(wrapper.as_mut(), &waker).is_pending()); + assert_eq!(callback_count.get(), 1); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + assert!(!token.cancel()); + assert!(poll(wrapper.as_mut(), &waker).is_pending()); + assert_eq!(callback_count.get(), 1); + released.set(true); + assert_eq!(poll(wrapper.as_mut(), &waker), Poll::Ready(42)); +} + +#[test] +fn the_callback_can_make_protected_work_ready_in_the_same_poll() { + let token = Cancellation::new(); + let ready = Cell::new(false); + let future = poll_fn(|_| { + if ready.get() { + Poll::Ready(42) + } else { + Poll::Pending + } + }); + let mut wrapper = pin!(defer_cancel(&token, future, || ready.set(true))); + assert!(poll(wrapper.as_mut(), Waker::noop()).is_pending()); + token.cancel(); + assert_eq!(poll(wrapper.as_mut(), Waker::noop()), Poll::Ready(42)); +} + +#[test] +fn nested_wrappers_observe_their_own_tokens_and_keep_draining() { + let outer = Cancellation::new(); + let inner = Cancellation::new(); + let outer_count = Cell::new(0); + let inner_count = Cell::new(0); + let ready = Cell::new(false); + let future = poll_fn(|_| { + if ready.get() { + Poll::Ready(42) + } else { + Poll::Pending + } + }); + let mut wrapper = pin!(defer_cancel( + &outer, + defer_cancel(&inner, future, || inner_count.set(1)), + || outer_count.set(1) + )); + assert!(poll(wrapper.as_mut(), Waker::noop()).is_pending()); + outer.cancel(); + assert!(poll(wrapper.as_mut(), Waker::noop()).is_pending()); + assert_eq!(outer_count.get(), 1); + assert_eq!(inner_count.get(), 0); + inner.cancel(); + assert!(poll(wrapper.as_mut(), Waker::noop()).is_pending()); + assert_eq!(inner_count.get(), 1); + ready.set(true); + assert_eq!(poll(wrapper.as_mut(), Waker::noop()), Poll::Ready(42)); +} + +#[test] +fn dropping_a_wrapper_drops_its_work_and_does_not_call_on_cancel() { + let token = Cancellation::new(); + let count = Cell::new(0); + let dropped = Arc::new(AtomicUsize::new(0)); + let guard = CountDrop(Arc::clone(&dropped)); + let future = async move { + let _guard = guard; + pending::<()>().await; + }; + let mut wrapper = Box::pin(defer_cancel(&token, future, || count.set(1))); + assert!(poll(wrapper.as_mut(), Waker::noop()).is_pending()); + drop(wrapper); + token.cancel(); + assert_eq!(count.get(), 0); + assert_eq!(dropped.load(Ordering::SeqCst), 1); +} + +#[test] +fn callback_panics_propagate_and_the_wrapper_can_be_destroyed() { + let token = Cancellation::new(); + token.cancel(); + let dropped = Arc::new(AtomicUsize::new(0)); + let guard = CountDrop(Arc::clone(&dropped)); + let mut wrapper = Box::pin(defer_cancel( + &token, + async move { + let _guard = guard; + pending::<()>().await; + }, + || panic!("shutdown callback failed"), + )); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + poll(wrapper.as_mut(), Waker::noop()) + })); + assert!(panic.is_err()); + drop(wrapper); + assert_eq!(dropped.load(Ordering::SeqCst), 1); +} + +#[test] +fn protected_futures_need_not_be_unpin() { + struct PinnedFuture { + address: Cell, + _pin: PhantomPinned, + } + impl Future for PinnedFuture { + type Output = usize; + fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll { + let this = self.as_ref().get_ref(); + let address = this as *const Self as usize; + if this.address.get() == 0 { + this.address.set(address); + context.waker().wake_by_ref(); + Poll::Pending + } else { + assert_eq!(this.address.get(), address); + Poll::Ready(address) + } + } + } + let scheduler = Scheduler::with_workers(1).unwrap(); + let address = scheduler.block_on(defer_cancel( + &Cancellation::never(), + PinnedFuture { + address: Cell::new(0), + _pin: PhantomPinned, + }, + || panic!("never-cancelled token invoked callback"), + )); + assert_ne!(address, 0); +} + +#[test] +fn socketry_tasks_can_finish_async_cleanup_after_token_cancellation() { + let scheduler = Scheduler::with_workers(2).unwrap(); + let shutdown = Cancellation::new(); + let token = shutdown.clone(); + let (started, waiting) = mpsc::channel(); + let task = scheduler + .spawn(async move { + let drain = Cancellation::new(); + let callback_drain = drain.clone(); + defer_cancel( + &token, + async { + started.send(()).unwrap(); + drain.cancelled().await; + yield_now().await; + 42 + }, + move || { + callback_drain.cancel(); + }, + ) + .await + }) + .unwrap(); + receive(&waiting); + shutdown.cancel(); + assert_eq!(scheduler.block_on(task).unwrap(), 42); +} + +#[cfg(feature = "tokio")] +#[test] +fn tokio_can_drive_the_same_token_and_deferred_cleanup() { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let shutdown = Cancellation::new(); + let token = shutdown.clone(); + let task = runtime.spawn(async move { + let drain = Cancellation::new(); + let callback_drain = drain.clone(); + defer_cancel( + &token, + async { + drain.cancelled().await; + tokio::task::yield_now().await; + 42 + }, + move || { + callback_drain.cancel(); + }, + ) + .await + }); + let value = runtime.block_on(async { + tokio::task::yield_now().await; + shutdown.cancel(); + task.await.unwrap() + }); + assert_eq!(value, 42); +} diff --git a/crates/executor/tests/contracts.rs b/crates/executor/tests/contracts.rs new file mode 100644 index 0000000..1e42183 --- /dev/null +++ b/crates/executor/tests/contracts.rs @@ -0,0 +1,250 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use socketry_executor::{ + Cancellation, File, FileOptions, Interest, IoBuf, OperationError, OperationResult, Socket, + Spawn, +}; +use std::{ + future::{Future, poll_fn}, + io, + path::Path, + pin::pin, + task::Poll, +}; + +#[path = "../examples/interface_probe/simulated.rs"] +mod simulated; + +async fn resources(scheduler: &S, root: &Path) { + let never = Cancellation::never(); + let cancelled = Cancellation::new(); + cancelled.cancel(); + let path = root.join("contracts"); + let options = FileOptions { + read: true, + write: true, + create_new: true, + ..Default::default() + }; + assert!(matches!( + scheduler.file_open(&path, options, &cancelled).await, + Err(OperationError::Cancelled(_)) + )); + let file = scheduler.file_open(&path, options, &never).await.unwrap(); + assert_eq!( + scheduler + .file_open(&path, options, &never) + .await + .err() + .unwrap() + .kind(), + io::ErrorKind::AlreadyExists + ); + let source = b"--data--".to_vec(); + let allocation = source.as_ptr(); + let (result, source) = scheduler + .file_write_at(&file, source.slice(2..6), 3, &never) + .await; + assert_eq!(result.unwrap(), 4); + let source = source.into_inner(); + assert_eq!(source.as_ptr(), allocation); + let destination = vec![255; 8].into_boxed_slice(); + let allocation = destination.as_ptr(); + let (result, destination) = scheduler + .file_read_at(&file, destination.slice(2..6), 3, &never) + .await; + assert_eq!(result.unwrap(), 4); + let destination = destination.into_inner(); + assert_eq!(destination.as_ptr(), allocation); + assert_eq!(&*destination, b"\xff\xffdata\xff\xff"); + for write in [false, true] { + let buffer = vec![17; 4]; + let allocation = buffer.as_ptr(); + let (result, buffer) = if write { + scheduler.file_write_at(&file, buffer, 0, &cancelled).await + } else { + scheduler.file_read_at(&file, buffer, 0, &cancelled).await + }; + assert!(matches!(result, Err(OperationError::Cancelled(_)))); + assert_eq!(buffer, vec![17; 4]); + assert_eq!(buffer.as_ptr(), allocation); + } + let missing = root.join("missing").join("file"); + assert!( + scheduler + .file_open(&missing, FileOptions::READ, &never) + .await + .is_err() + ); + drop(file); + + let address = "127.0.0.1:0".parse().unwrap(); + assert!(matches!( + scheduler.socket_listen(address, &cancelled).await, + Err(OperationError::Cancelled(_)) + )); + let listener = scheduler.socket_listen(address, &never).await.unwrap(); + let address = scheduler.listener_addr(&listener).unwrap(); + assert_ne!(address.port(), 0); + assert_eq!( + scheduler + .socket_listen(address, &never) + .await + .err() + .unwrap() + .kind(), + io::ErrorKind::AddrInUse + ); + assert!(matches!( + scheduler.connect(address, &cancelled).await, + Err(OperationError::Cancelled(_)) + )); + let stopping = Cancellation::new(); + let mut accept = pin!(scheduler.accept(&listener, &stopping)); + poll_fn(|context| { + assert!(accept.as_mut().poll(context).is_pending()); + Poll::Ready(()) + }) + .await; + 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(); + 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) + .await + .unwrap(); + assert!(matches!( + scheduler + .io_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)); + poll_fn(|context| { + assert!(read.as_mut().poll(context).is_pending()); + Poll::Ready(()) + }) + .await; + stopping.cancel(); + let (result, buffer) = read.await; + 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; + assert!(matches!(result, Err(OperationError::Cancelled(_)))); + let (result, _) = scheduler + .io_write(&client, b"-ok-".to_vec().slice(1..3), &never) + .await; + assert_eq!(result.unwrap(), 2); + scheduler + .io_wait(&server, Interest::Readable, &never) + .await + .unwrap(); + let (result, buffer) = scheduler.io_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]); +} + +struct ReportDrop(Option>); +impl Drop for ReportDrop { + fn drop(&mut self) { + let _ = self.0.take().unwrap().send(()); + } +} + +async fn ownership(scheduler: S) { + let (dropped, destroyed) = futures_channel::oneshot::channel(); + let guard = ReportDrop(Some(dropped)); + let child = scheduler + .spawn(async move { + let _guard = guard; + std::future::pending::<()>().await; + }) + .unwrap(); + let (retained, ready) = futures_channel::oneshot::channel(); + let owner = scheduler + .spawn(async move { + let _child = child; + retained.send(()).unwrap(); + std::future::pending::<()>().await; + }) + .unwrap(); + ready.await.unwrap(); + drop(owner); + destroyed.await.unwrap(); + + let finish = Cancellation::new(); + let release = finish.clone(); + let child = scheduler + .spawn(async move { + release.cancelled().await; + 42 + }) + .unwrap(); + let (transfer, transferred) = futures_channel::oneshot::channel(); + let original = scheduler + .spawn(async move { + assert!(transfer.send(child).is_ok()); + std::future::pending::<()>().await; + }) + .unwrap(); + let child = transferred.await.unwrap(); + drop(original); + let waiter = scheduler.spawn(child).unwrap(); + finish.cancel(); + assert_eq!(waiter.await.unwrap().unwrap(), 42); +} + +#[test] +fn public_contracts_work_without_native_resources() { + let scheduler = simulated::Scheduler::new(1024, 1024); + futures_lite::future::block_on(resources(&scheduler, Path::new("fixture"))); + assert_eq!(scheduler.open_files(), 0); + scheduler.insert(Path::new("fixture/imported"), b"fixture"); + assert_eq!( + scheduler.contents(Path::new("fixture/imported")), + b"fixture" + ); +} + +#[cfg(feature = "native")] +#[test] +fn native_resources() { + let scheduler = socketry_executor::Scheduler::with_workers(2).unwrap(); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + scheduler.block_on(resources(&scheduler, first.path())); + scheduler.block_on(resources(&scheduler.handle(), second.path())); +} + +#[test] +fn native_transferable_task_ownership() { + let scheduler = socketry_executor::Scheduler::with_workers(2).unwrap(); + scheduler.block_on(ownership(scheduler.handle())); +} + +#[cfg(feature = "tokio")] +#[test] +fn tokio_resources_and_transferable_task_ownership() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let scheduler = socketry_executor::scheduler::tokio::Scheduler::new(runtime.handle().clone()); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + runtime.block_on(resources(&scheduler, first.path())); + runtime.block_on(resources(&scheduler.handle(), second.path())); + runtime.block_on(ownership(scheduler.handle())); + runtime.block_on(scheduler.shutdown()); +} diff --git a/crates/executor/tests/io.rs b/crates/executor/tests/io.rs index 2c0575f..eb07d78 100644 --- a/crates/executor/tests/io.rs +++ b/crates/executor/tests/io.rs @@ -5,8 +5,11 @@ mod support; -use socketry_executor::{Clock, FileIo, Interest, Network, Spawn}; -use std::fs::{File, OpenOptions}; +static NEVER: socketry_executor::Cancellation = socketry_executor::Cancellation::never(); + +use socketry_executor::SocketRegistration; +use socketry_executor::{Clock, File, Interest, Socket, Spawn}; +use std::fs::File as StdFile; use std::future::{Future, poll_fn}; use std::io; use std::net::{SocketAddr, TcpListener, TcpStream}; @@ -48,32 +51,34 @@ fn socket_pair() -> (TcpStream, TcpStream) { } #[cfg(feature = "tokio")] -fn error_kind(result: io::Result) -> io::ErrorKind { +fn error_kind>( + result: Result, +) -> io::ErrorKind { match result { Ok(_) => panic!("operation unexpectedly succeeded"), - Err(error) => error.kind(), + Err(error) => error.into().kind(), } } async fn round_trip(scheduler: SchedulerType) where - SchedulerType: Network + Spawn + Clock + Clone + 'static, + SchedulerType: Socket + SocketRegistration + Spawn + Clock + Clone + 'static, { let (listener, address) = listening_socket(); let listener = scheduler.register_listener(listener).unwrap(); let server = scheduler.clone(); let server = scheduler .spawn(async move { - let (socket, _) = server.accept(&listener).await.unwrap(); + let (socket, _) = server.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).await; + let (result, returned) = server.io_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).await; + let (result, returned) = server.io_write(&socket, buffer, &NEVER).await; buffer = returned; assert_eq!(result.unwrap(), 1); assert_eq!(buffer.as_ptr() as usize, allocation); @@ -81,37 +86,37 @@ where }) .unwrap(); - let socket = scheduler.connect(address).await.unwrap(); + let socket = scheduler.connect(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).await; + let (result, returned) = scheduler.io_write(&socket, buffer, &NEVER).await; buffer = returned; assert_eq!(result.unwrap(), 1); scheduler - .io_wait(&socket, Interest::Readable) + .io_wait(&socket, Interest::Readable, &NEVER) .await .unwrap(); - let (result, returned) = scheduler.io_read(&socket, buffer).await; + let (result, returned) = scheduler.io_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]).await; + let (result, _) = scheduler.io_read(&socket, vec![0; 1], &NEVER).await; assert_eq!(result.unwrap(), 0, "peer closure must report EOF"); } async fn cancel_pending_read(scheduler: &SchedulerType) where - SchedulerType: Network + Clock, + SchedulerType: Socket + SocketRegistration + Clock, { let (client, server) = socket_pair(); let socket = scheduler.register_socket(client).unwrap(); { - let mut read = pin!(scheduler.io_read(&socket, vec![0; 1024])); + let mut read = pin!(scheduler.io_read(&socket, vec![0; 1024], &NEVER)); poll_fn(|context| { assert!(read.as_mut().poll(context).is_pending()); Poll::Ready(()) @@ -121,14 +126,14 @@ 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]).await; + let (result, buffer) = scheduler.io_read(&socket, vec![0; 1], &NEVER).await; assert_eq!(result.unwrap(), 0); assert_eq!(buffer.len(), 1); } async fn concurrent_reads(scheduler: SchedulerType) where - SchedulerType: Network + Spawn + Clone + 'static, + SchedulerType: Socket + SocketRegistration + Spawn + Clone + 'static, { let (client, mut server) = socket_pair(); let socket = Arc::new(scheduler.register_socket(client).unwrap()); @@ -139,7 +144,7 @@ where let handle = scheduler .clone() .spawn(async move { - let (result, buffer) = scheduler.io_read(&socket, vec![0; 1]).await; + let (result, buffer) = scheduler.io_read(&socket, vec![0; 1], &NEVER).await; assert_eq!(result.unwrap(), 1); buffer[0] }) @@ -167,7 +172,7 @@ impl Drop for TemporaryFile { } } -async fn positioned_files(scheduler: &SchedulerType) { +async fn positioned_files(scheduler: &SchedulerType) { static NEXT_FILE: AtomicU64 = AtomicU64::new(0); let path = std::env::temp_dir().join(format!( "socketry-io-{}-{}", @@ -175,32 +180,41 @@ async fn positioned_files(scheduler: &SchedulerType) { NEXT_FILE.fetch_add(1, Ordering::Relaxed) )); let _cleanup = TemporaryFile(path.clone()); - let file = Arc::new( - OpenOptions::new() - .create_new(true) - .read(true) - .write(true) - .open(&path) - .unwrap(), - ); + let cancellation = socketry_executor::Cancellation::never(); + let file = scheduler + .file_open( + &path, + socketry_executor::FileOptions { + read: true, + write: true, + create_new: true, + ..Default::default() + }, + &cancellation, + ) + .await + .unwrap(); let buffer = b"hello".to_vec(); let allocation = buffer.as_ptr(); - let (result, buffer) = scheduler.file_write_at(Arc::clone(&file), buffer, 7).await; + let (result, buffer) = scheduler.file_write_at(&file, buffer, 7, &NEVER).await; assert_eq!(result.unwrap(), 5); assert_eq!(buffer.as_ptr(), allocation); - let (result, buffer) = scheduler.file_read_at(Arc::clone(&file), buffer, 7).await; + let (result, buffer) = scheduler.file_read_at(&file, buffer, 7, &NEVER).await; assert_eq!(result.unwrap(), 5); assert_eq!(&buffer, b"hello"); assert_eq!(buffer.as_ptr(), allocation); - let (result, buffer) = scheduler.file_read_at(Arc::clone(&file), buffer, 100).await; + let (result, buffer) = scheduler.file_read_at(&file, buffer, 100, &NEVER).await; assert_eq!(result.unwrap(), 0); let (result, buffer) = scheduler - .file_read_at(Arc::clone(&file), buffer, u64::MAX) + .file_read_at(&file, buffer, u64::MAX, &NEVER) .await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput); assert_eq!(buffer.as_ptr(), allocation); - let read_only = Arc::new(File::open(&path).unwrap()); - let (result, returned) = scheduler.file_write_at(read_only, buffer, 0).await; + let read_only = scheduler + .file_open(&path, socketry_executor::FileOptions::READ, &cancellation) + .await + .unwrap(); + let (result, returned) = scheduler.file_write_at(&read_only, buffer, 0, &NEVER).await; assert!(result.is_err()); assert_eq!(returned.as_ptr(), allocation); } @@ -261,7 +275,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])); + let mut read = pin!(handle.io_read(&socket, vec![0; 1024], &NEVER)); let mut started = Some(started); poll_fn(|context| -> Poll<()> { assert!(read.as_mut().poll(context).is_pending()); @@ -298,9 +312,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))); + let connect = scope.spawn(|| scheduler.block_on(scheduler.connect(address, &NEVER))); let accepted = scheduler - .block_on(scheduler.accept(&listener)) + .block_on(scheduler.accept(&listener, &NEVER)) .expect("accept connected socket"); let client = connect.join().unwrap().expect("connect to listener"); (accepted, client) @@ -311,18 +325,18 @@ mod native { assert_eq!(server.peer_addr().unwrap(), peer_address); scheduler.block_on(async { - let (result, buffer) = scheduler.io_write(&client, vec![42]).await; + let (result, buffer) = scheduler.io_write(&client, vec![42], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); scheduler - .io_wait(&server, Interest::Readable) + .io_wait(&server, Interest::Readable, &NEVER) .await .unwrap(); - let (result, buffer) = scheduler.io_read(&server, vec![0]).await; + let (result, buffer) = scheduler.io_read(&server, vec![0], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); scheduler - .io_wait(&client, Interest::Writable) + .io_wait(&client, Interest::Writable, &NEVER) .await .unwrap(); scheduler.sleep(std::time::Duration::ZERO).await; @@ -346,7 +360,7 @@ 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 = Arc::new(File::open(std::env::current_exe().unwrap()).unwrap()); + let file = Arc::new(StdFile::open(std::env::current_exe().unwrap()).unwrap()); scheduler.shutdown(); let (client, _peer) = socket_pair(); @@ -360,38 +374,79 @@ mod native { Err(error) if error.kind() == io::ErrorKind::BrokenPipe )); + assert_eq!( + handle + .listener_addr(®istered_listener) + .unwrap_err() + .kind(), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + handle.socket_local_addr(&socket).unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + handle.socket_peer_addr(&socket).unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); let executor = Scheduler::with_workers(1).unwrap(); executor.block_on(async { - let error = match handle.connect(address).await { + assert_eq!( + handle + .socket_listen(address, &NEVER) + .await + .err() + .unwrap() + .kind(), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + handle + .file_open( + std::path::Path::new("closed"), + socketry_executor::FileOptions::READ, + &NEVER + ) + .await + .err() + .unwrap() + .kind(), + io::ErrorKind::BrokenPipe + ); + let error = match handle.connect(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).await { + let error = match handle.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) + .io_wait(&socket, Interest::Readable, &NEVER) .await .unwrap_err() .kind(), io::ErrorKind::BrokenPipe ); - let (result, buffer) = handle.io_read(&socket, vec![1, 2]).await; + let (result, buffer) = handle.io_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]).await; + let (result, buffer) = handle.io_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(Arc::clone(&file), vec![5], 0).await; + let (result, buffer) = handle + .file_read_at(&Arc::clone(&file), vec![5], 0, &NEVER) + .await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); assert_eq!(buffer, vec![5]); - let (result, buffer) = handle.file_write_at(Arc::clone(&file), vec![6], 0).await; + let (result, buffer) = handle + .file_write_at(&Arc::clone(&file), vec![6], 0, &NEVER) + .await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); assert_eq!(buffer, vec![6]); }); @@ -433,8 +488,8 @@ mod tokio_adapter { let server = executor .spawn(async move { assert!(tokio::runtime::Handle::try_current().is_err()); - let (socket, _) = server_scheduler.accept(&listener).await.unwrap(); - let (result, _) = server_scheduler.io_write(&socket, vec![42]).await; + let (socket, _) = server_scheduler.accept(&listener, &NEVER).await.unwrap(); + let (result, _) = server_scheduler.io_write(&socket, vec![42], &NEVER).await; assert_eq!(result.unwrap(), 1); assert!(tokio::runtime::Handle::try_current().is_err()); }) @@ -444,12 +499,12 @@ mod tokio_adapter { let client = executor .spawn(async move { assert!(tokio::runtime::Handle::try_current().is_err()); - let socket = client_scheduler.connect(address).await.unwrap(); + let socket = client_scheduler.connect(address, &NEVER).await.unwrap(); client_scheduler - .io_wait(&socket, Interest::Readable) + .io_wait(&socket, Interest::Readable, &NEVER) .await .unwrap(); - let (result, buffer) = client_scheduler.io_read(&socket, vec![0; 1]).await; + let (result, buffer) = client_scheduler.io_read(&socket, vec![0; 1], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); client_scheduler.sleep(std::time::Duration::ZERO).await; @@ -495,17 +550,6 @@ mod tokio_adapter { let runtime = runtime(); let scheduler = Scheduler::new(runtime.handle().clone()); runtime.block_on(async { - let barrier = scheduler.barrier(); - let completed = barrier.spawn(async { 42 }).unwrap(); - assert_eq!(completed.await.unwrap(), 42); - let cancelled = barrier.spawn(std::future::pending::<()>()).unwrap(); - barrier.stop().await; - assert!(matches!(cancelled.await, Err(TaskError::Cancelled))); - assert!(barrier.is_empty()); - assert!(matches!( - barrier.spawn(async {}), - Err(SpawnError::OwnerClosed) - )); let panicked = scheduler.spawn(async { panic!("task panic") }).unwrap(); assert!(matches!(panicked.await, Err(TaskError::Panicked(_)))); @@ -553,56 +597,46 @@ mod tokio_adapter { let server_scheduler = Arc::clone(&scheduler); let server = scheduler .spawn(async move { - let (socket, _) = server_scheduler.accept(&listener).await.unwrap(); + let (socket, _) = server_scheduler.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]).await; + let (result, buffer) = server_scheduler.io_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]).await; + let (result, _) = server_scheduler.io_write(&socket, vec![24], &NEVER).await; assert_eq!(result.unwrap(), 1); }) .unwrap(); runtime.block_on(async { - let client = scheduler.connect(address).await.unwrap(); + let client = scheduler.connect(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]).await; + let (result, buffer) = scheduler.io_write(&client, vec![42], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![42]); scheduler - .io_wait(&client, Interest::Readable) + .io_wait(&client, Interest::Readable, &NEVER) .await .unwrap(); - let (result, buffer) = scheduler.io_read(&client, vec![0]).await; + let (result, buffer) = scheduler.io_read(&client, vec![0], &NEVER).await; assert_eq!(result.unwrap(), 1); assert_eq!(buffer, vec![24]); scheduler - .io_wait(&client, Interest::Writable) + .io_wait(&client, Interest::Writable, &NEVER) .await .unwrap(); scheduler.sleep(std::time::Duration::ZERO).await; - let (result, buffer) = scheduler.io_read(&client, Vec::new()).await; + let (result, buffer) = scheduler.io_read(&client, Vec::new(), &NEVER).await; assert_eq!(result.unwrap(), 0); assert!(buffer.is_empty()); - let (result, buffer) = scheduler.io_write(&client, Vec::new()).await; + let (result, buffer) = scheduler.io_write(&client, Vec::new(), &NEVER).await; assert_eq!(result.unwrap(), 0); assert!(buffer.is_empty()); let ordinary_task = Spawn::spawn(&*scheduler, async { 9 }).unwrap(); assert_eq!(ordinary_task.await.unwrap(), 9); - - let barrier = scheduler.barrier(); - let child = Spawn::spawn(&barrier, async { 7 }).unwrap(); - barrier.close(); - assert!(matches!( - barrier.spawn(async {}), - Err(SpawnError::OwnerClosed) - )); - assert_eq!(child.await.unwrap(), 7); - barrier.wait().await; }); runtime.block_on(server).unwrap(); @@ -637,7 +671,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 = Arc::new(File::open(std::env::current_exe().unwrap()).unwrap()); + let file = Arc::new(StdFile::open(std::env::current_exe().unwrap()).unwrap()); runtime.block_on(scheduler.shutdown()); let (new_socket, _) = socket_pair(); @@ -651,69 +685,91 @@ mod tokio_adapter { io::ErrorKind::BrokenPipe ); + assert_eq!( + handle.listener_addr(&listener).unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + handle.socket_local_addr(&socket).unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + handle.socket_peer_addr(&socket).unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); runtime.block_on(async { assert_eq!( - error_kind(handle.connect(address).await), + handle + .socket_listen(address, &NEVER) + .await + .err() + .unwrap() + .kind(), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + handle + .file_open( + std::path::Path::new("closed"), + socketry_executor::FileOptions::READ, + &NEVER + ) + .await + .err() + .unwrap() + .kind(), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + error_kind(handle.connect(address, &NEVER).await), io::ErrorKind::BrokenPipe ); assert_eq!( - error_kind(handle.accept(&listener).await), + error_kind(handle.accept(&listener, &NEVER).await), io::ErrorKind::BrokenPipe ); assert_eq!( handle - .io_wait(&socket, Interest::Readable) + .io_wait(&socket, Interest::Readable, &NEVER) .await .unwrap_err() .kind(), io::ErrorKind::BrokenPipe ); - let (result, buffer) = handle.io_read(&socket, vec![1]).await; + let (result, buffer) = handle.io_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]).await; + let (result, buffer) = handle.io_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(Arc::clone(&file), vec![3], 0).await; + let (result, buffer) = handle + .file_read_at(&Arc::clone(&file), vec![3], 0, &NEVER) + .await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); assert_eq!(buffer, vec![3]); - let (result, buffer) = handle.file_write_at(Arc::clone(&file), vec![4], 0).await; + let (result, buffer) = handle + .file_write_at(&Arc::clone(&file), vec![4], 0, &NEVER) + .await; assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); assert_eq!(buffer, vec![4]); }); } #[test] - fn dropping_result_does_not_cancel_owner_task() { - let runtime = runtime(); - let scheduler = Scheduler::new(runtime.handle().clone()); - let barrier = scheduler.barrier(); - let completed = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let task_completed = Arc::clone(&completed); - drop( - barrier - .spawn(async move { - task_completed.store(true, Ordering::Release); - }) - .unwrap(), - ); - runtime.block_on(barrier.wait()); - assert!(completed.load(Ordering::Acquire)); - } - - #[test] - fn barrier_rejects_a_child_joining_itself() { + fn dropping_handle_abandons_tokio_task() { let runtime = runtime(); let scheduler = Scheduler::new(runtime.handle().clone()); - let barrier = Arc::new(scheduler.barrier()); - let child_barrier = Arc::clone(&barrier); - let child = barrier - .spawn(async move { child_barrier.wait().await }) + let dropped = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let guard = support::CountDrop(dropped.clone()); + let task = scheduler + .spawn(async move { + let _guard = guard; + std::future::pending::<()>().await; + }) .unwrap(); - assert!(matches!( - runtime.block_on(child), - Err(TaskError::Panicked(_)) - )); + drop(task); + runtime.block_on(scheduler.shutdown()); + assert_eq!(dropped.load(Ordering::SeqCst), 1); } #[test] @@ -738,7 +794,7 @@ 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])); + let (result, buffer) = second_runtime.block_on(second.io_read(&socket, vec![0; 7], &NEVER)); assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput); assert_eq!(buffer.len(), 7); } @@ -761,7 +817,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]))) + .map(|_| Box::pin(selector.io_read(&socket, vec![0; 16], &NEVER))) .collect(); poll_fn(|context| { for read in &mut reads { @@ -792,7 +848,8 @@ fn ring_shutdown_racing_submissions_returns_owned_buffers() { tasks.push( executor .spawn(async move { - let (result, buffer) = selector.io_read(&socket, vec![42; 32]).await; + let (result, buffer) = + selector.io_read(&socket, vec![42; 32], &NEVER).await; assert!(result.is_err()); assert_eq!(buffer, vec![42; 32]); }) diff --git a/crates/executor/tests/notify.rs b/crates/executor/tests/notify.rs new file mode 100644 index 0000000..f46a6c5 --- /dev/null +++ b/crates/executor/tests/notify.rs @@ -0,0 +1,41 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use socketry_executor::{Cancellation, Notify}; +use std::sync::{Arc, mpsc}; + +#[test] +fn cancellation_ignores_values_and_propagates_to_children() { + let cancellation = Cancellation::new(); + let child = cancellation.child(); + cancellation.notify("stop"); + cancellation.notify(42); + assert!(cancellation.is_cancelled()); + assert!(child.is_cancelled()); + let never = Cancellation::never(); + never.notify(()); + assert!(!never.is_cancelled()); +} + +#[test] +fn shared_targets_and_standard_channels_retain_values_in_order() { + let (sender, receiver) = mpsc::channel(); + let target: Arc> = Arc::new(sender); + target.notify(1); + target.clone().notify(2); + assert_eq!(receiver.recv().unwrap(), 1); + assert_eq!(receiver.recv().unwrap(), 2); + drop(receiver); + target.notify(3); +} + +#[test] +fn asynchronous_channels_retain_values_and_ignore_closed_receivers() { + let (sender, mut receiver) = futures_channel::mpsc::unbounded(); + sender.notify(1); + sender.notify(2); + assert_eq!(receiver.try_recv().unwrap(), 1); + assert_eq!(receiver.try_recv().unwrap(), 2); + drop(receiver); + sender.notify(3); +} diff --git a/crates/executor/tests/ownership.rs b/crates/executor/tests/ownership.rs index 70913b2..06af311 100644 --- a/crates/executor/tests/ownership.rs +++ b/crates/executor/tests/ownership.rs @@ -3,7 +3,7 @@ mod support; -use socketry_executor::{Scheduler, Spawn, SpawnError, TaskError, yield_now}; +use socketry_executor::{Scheduler, Spawn, TaskError, yield_now}; use std::future::{Future, pending, poll_fn}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, mpsc}; @@ -11,9 +11,9 @@ use std::task::Poll; use support::{CountDrop, receive}; #[test] -fn barriers_and_schedulers_share_the_spawn_contract() { - fn submit(owner: &impl Spawn) -> impl Future> + Send { - owner +fn schedulers_and_handles_share_the_spawn_contract() { + fn submit(scheduler: &impl Spawn) -> impl Future> + Send { + scheduler .spawn(async { yield_now().await; 42 @@ -21,27 +21,19 @@ fn barriers_and_schedulers_share_the_spawn_contract() { .unwrap() } let scheduler = Scheduler::with_workers(2).unwrap(); - let barrier = scheduler.barrier(); assert_eq!(scheduler.block_on(submit(&scheduler)).unwrap(), 42); - assert_eq!(scheduler.block_on(submit(&barrier)).unwrap(), 42); - barrier.close(); - scheduler.block_on(barrier.wait()); - assert!(barrier.is_empty()); - assert!(matches!( - barrier.spawn(async {}), - Err(SpawnError::OwnerClosed) - )); + assert_eq!(scheduler.block_on(submit(&scheduler.handle())).unwrap(), 42); } #[test] -fn barrier_waits_after_join_handles_are_dropped() { +fn scheduler_can_observe_completion_while_handles_remain_owned() { let scheduler = Scheduler::with_workers(4).unwrap(); - let barrier = scheduler.barrier(); let completed = Arc::new(AtomicUsize::new(0)); + let mut tasks = Vec::new(); for _ in 0..500 { let completed = Arc::clone(&completed); - drop( - barrier + tasks.push( + scheduler .spawn(async move { yield_now().await; completed.fetch_add(1, Ordering::SeqCst); @@ -49,71 +41,21 @@ fn barrier_waits_after_join_handles_are_dropped() { .unwrap(), ); } - barrier.close(); - scheduler.block_on(barrier.wait()); + scheduler.run(); assert_eq!(completed.load(Ordering::SeqCst), 500); } -#[test] -fn stop_waits_for_child_destructors() { - let scheduler = Scheduler::with_workers(2).unwrap(); - let barrier = scheduler.barrier(); - let dropped = Arc::new(AtomicUsize::new(0)); - let mut handles = Vec::new(); - for _ in 0..100 { - let guard = CountDrop(Arc::clone(&dropped)); - handles.push( - barrier - .spawn(async move { - let _guard = guard; - pending::<()>().await; - }) - .unwrap(), - ); - } - scheduler.block_on(barrier.stop()); - assert_eq!(dropped.load(Ordering::SeqCst), 100); - for handle in handles { - assert!(matches!( - scheduler.block_on(handle), - Err(TaskError::Cancelled) - )); - } -} - -#[test] -fn dropping_barrier_requests_cancellation() { - let scheduler = Scheduler::with_workers(2).unwrap(); - let barrier = scheduler.barrier(); - let dropped = Arc::new(AtomicUsize::new(0)); - let guard = CountDrop(Arc::clone(&dropped)); - let handle = barrier - .spawn(async move { - let _guard = guard; - pending::<()>().await; - }) - .unwrap(); - drop(barrier); - assert!(matches!( - scheduler.block_on(handle), - Err(TaskError::Cancelled) - )); - assert_eq!(dropped.load(Ordering::SeqCst), 1); -} - #[test] fn cancellation_before_first_poll_drops_captured_values() { let scheduler = Scheduler::with_workers(1).unwrap(); let (started_sender, started_receiver) = mpsc::channel(); let (release_sender, release_receiver) = mpsc::channel(); - drop( - scheduler - .spawn(async move { - started_sender.send(()).unwrap(); - receive(&release_receiver); - }) - .unwrap(), - ); + let blocker = scheduler + .spawn(async move { + started_sender.send(()).unwrap(); + receive(&release_receiver); + }) + .unwrap(); receive(&started_receiver); let polled = Arc::new(AtomicBool::new(false)); let dropped = Arc::new(AtomicUsize::new(0)); @@ -133,6 +75,7 @@ fn cancellation_before_first_poll_drops_captured_values() { )); assert!(!polled.load(Ordering::SeqCst)); assert_eq!(dropped.load(Ordering::SeqCst), 1); + scheduler.block_on(blocker).unwrap(); } #[test] @@ -161,6 +104,32 @@ fn cancellation_does_not_destroy_an_in_progress_poll() { assert_eq!(dropped.load(Ordering::SeqCst), 1); } +#[test] +fn dropping_a_handle_waits_for_an_active_poll_before_destroying_the_future() { + let scheduler = Scheduler::with_workers(2).unwrap(); + let (started_sender, started_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let dropped = Arc::new(AtomicUsize::new(0)); + let guard = CountDrop(Arc::clone(&dropped)); + let handle = scheduler + .spawn(poll_fn(move |_| { + let _ = &guard; + started_sender.send(()).unwrap(); + receive(&release_receiver); + Poll::<()>::Pending + })) + .unwrap(); + receive(&started_receiver); + let task = handle.task(); + drop(handle); + assert!(!task.is_finished()); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + release_sender.send(()).unwrap(); + scheduler.run(); + assert!(task.is_finished()); + assert_eq!(dropped.load(Ordering::SeqCst), 1); +} + #[test] fn cancellation_method_awaits_cleanup() { let scheduler = Scheduler::with_workers(1).unwrap(); @@ -202,81 +171,6 @@ fn cancelled_tasks_report_finished_state_and_reject_late_cancellation() { assert!(!task.cancel()); } -#[test] -fn child_cannot_wait_for_its_own_barrier() { - let scheduler = Scheduler::with_workers(1).unwrap(); - let barrier = Arc::new(scheduler.barrier()); - let child_barrier = Arc::clone(&barrier); - let handle = barrier - .spawn(async move { - child_barrier.wait().await; - }) - .unwrap(); - assert!(matches!( - scheduler.block_on(handle), - Err(TaskError::Panicked(_)) - )); -} - -#[test] -fn closing_owner_races_safely_with_submission() { - let scheduler = Scheduler::with_workers(4).unwrap(); - let barrier = Arc::new(scheduler.barrier()); - std::thread::scope(|scope| { - for _ in 0..4 { - let barrier = Arc::clone(&barrier); - scope.spawn(move || { - for _ in 0..100 { - if let Ok(handle) = barrier.spawn(async { - pending::<()>().await; - }) { - drop(handle); - } - } - }); - } - scheduler.block_on(barrier.stop()); - }); - assert!(barrier.is_empty()); - assert!(matches!( - barrier.spawn(async {}), - Err(SpawnError::OwnerClosed) - )); -} - -#[test] -fn cancelling_parent_drops_its_barrier_and_cancels_children() { - let scheduler = Scheduler::with_workers(2).unwrap(); - let dropped = Arc::new(AtomicUsize::new(0)); - let guard = CountDrop(Arc::clone(&dropped)); - let (started_sender, started_receiver) = mpsc::channel(); - let parent = scheduler - .spawn(async move { - let children = Scheduler::current().unwrap().barrier(); - drop( - children - .spawn(async move { - let _guard = guard; - pending::<()>().await; - }) - .unwrap(), - ); - started_sender.send(()).unwrap(); - pending::<()>().await; - drop(children); - }) - .unwrap(); - receive(&started_receiver); - assert!(matches!( - scheduler.block_on(parent.cancel()), - Err(TaskError::Cancelled) - )); - // Parent cancellation requests child cancellation. The scheduler's drain - // supplies the explicit wait for descendant destruction in this test. - scheduler.run(); - assert_eq!(dropped.load(Ordering::SeqCst), 1); -} - #[test] fn future_destructor_can_submit_work_without_holding_the_registry_lock() { struct SubmitOnDrop(mpsc::Sender); @@ -306,3 +200,29 @@ fn future_destructor_can_submit_work_without_holding_the_registry_lock() { )); scheduler.run(); } + +#[test] +fn cancelling_a_parent_does_not_cancel_independently_spawned_work() { + let scheduler = Scheduler::with_workers(2).unwrap(); + let (spawned, receiver) = mpsc::channel(); + let parent = scheduler + .spawn(async move { + let child = Scheduler::current() + .unwrap() + .spawn(pending::<()>()) + .unwrap(); + spawned.send(child).unwrap(); + pending::<()>().await; + }) + .unwrap(); + let child = receive(&receiver); + assert!(matches!( + scheduler.block_on(parent.cancel()), + Err(TaskError::Cancelled) + )); + assert!(!child.task().is_finished()); + assert!(matches!( + scheduler.block_on(child.cancel()), + Err(TaskError::Cancelled) + )); +} diff --git a/crates/executor/tests/scheduler.rs b/crates/executor/tests/scheduler.rs index c12d8e2..587a7f2 100644 --- a/crates/executor/tests/scheduler.rs +++ b/crates/executor/tests/scheduler.rs @@ -131,22 +131,19 @@ fn wake_during_poll_is_retained_without_concurrent_polling() { } #[test] -fn dropped_join_handle_leaves_task_owned_by_scheduler() { +fn dropping_an_unfinished_handle_releases_its_captures() { let scheduler = Scheduler::with_workers(1).unwrap(); - let completed = Arc::new(AtomicUsize::new(0)); - for _ in 0..100 { - let completed = Arc::clone(&completed); - drop( - scheduler - .spawn(async move { - yield_now().await; - completed.fetch_add(1, Ordering::SeqCst); - }) - .unwrap(), - ); - } + let dropped = Arc::new(AtomicUsize::new(0)); + let guard = CountDrop(dropped.clone()); + let task = scheduler + .spawn(async move { + let _guard = guard; + pending::<()>().await; + }) + .unwrap(); + drop(task); scheduler.run(); - assert_eq!(completed.load(Ordering::SeqCst), 100); + assert_eq!(dropped.load(Ordering::SeqCst), 1); } #[test] @@ -167,10 +164,11 @@ fn shutdown_drops_pending_futures_and_closes_surviving_handles() { let scheduler = Scheduler::with_workers(2).unwrap(); let handle = scheduler.handle(); let dropped = Arc::new(AtomicUsize::new(0)); + let mut tasks = Vec::new(); for _ in 0..100 { let guard = CountDrop(Arc::clone(&dropped)); let retained_handle = handle.clone(); - drop( + tasks.push( scheduler .spawn(async move { let _guard = guard; @@ -190,14 +188,12 @@ fn scheduler_can_be_dropped_by_its_own_task() { let scheduler = Scheduler::with_workers(2).unwrap(); let handle = scheduler.handle(); let (sender, receiver) = mpsc::channel(); - drop( - handle - .spawn(async move { - drop(scheduler); - sender.send(()).unwrap(); - }) - .unwrap(), - ); + let _task = handle + .spawn(async move { + drop(scheduler); + sender.send(()).unwrap(); + }) + .unwrap(); receive(&receiver); assert!(handle.spawn(async {}).is_err()); } @@ -207,13 +203,11 @@ fn repeated_external_submission_wakes_parked_workers() { let scheduler = Scheduler::with_workers(4).unwrap(); for value in 0..500 { let (sender, receiver) = mpsc::channel(); - drop( - scheduler - .spawn(async move { - sender.send(value).unwrap(); - }) - .unwrap(), - ); + let _task = scheduler + .spawn(async move { + sender.send(value).unwrap(); + }) + .unwrap(); assert_eq!(receive(&receiver), value); } scheduler.run(); diff --git a/crates/executor/tests/signals.rs b/crates/executor/tests/signals.rs new file mode 100644 index 0000000..642b405 --- /dev/null +++ b/crates/executor/tests/signals.rs @@ -0,0 +1,300 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +#![cfg(all(unix, feature = "signals", any(feature = "native", feature = "tokio")))] + +mod support; + +use futures_lite::StreamExt; +use socketry_executor::{Cancellation, Notify, SignalRegistration, Signals, signal::*}; +use std::ffi::c_int; +use std::future::Future; +use std::io; +use std::pin::pin; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex, mpsc}; +use std::task::{Context, Poll, Wake, Waker}; +use std::thread; +use std::time::{Duration, Instant}; +use support::{TIMEOUT, receive}; + +// Signal dispositions are process-global. Exercise them only in a child +// process, and impose a deadline so a delivery or teardown bug cannot hang CI. +#[test] +fn signal_subscriptions_in_an_isolated_process() { + const CHILD: &str = "SOCKETRY_SIGNAL_TEST_CHILD"; + if std::env::var_os(CHILD).is_some() { + exercise_backends(); + return; + } + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "signal_subscriptions_in_an_isolated_process", + "--nocapture", + ]) + .env(CHILD, "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + TIMEOUT * 6; + loop { + if child.try_wait().unwrap().is_some() { + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + break; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!( + "signal tests timed out: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + thread::sleep(Duration::from_millis(10)); + } +} + +fn emit(signal: c_int) { + signal_hook::low_level::raise(signal).unwrap(); +} + +struct WakeChannel(mpsc::Sender<()>); + +impl Wake for WakeChannel { + fn wake(self: Arc) { + let _ = self.0.send(()); + } +} + +fn awaited_signal>(scheduler: &S) { + let cancellation = Cancellation::new(); + let registration = scheduler + .signal_notify(&[SIGUSR1, SIGUSR2], cancellation.clone()) + .unwrap(); + let (wake, woke) = mpsc::channel(); + let waker = Waker::from(Arc::new(WakeChannel(wake))); + let mut context = Context::from_waker(&waker); + let mut future = pin!(cancellation.cancelled()); + assert!(future.as_mut().poll(&mut context).is_pending()); + emit(SIGUSR1); + receive(&woke); + assert!(future.as_mut().poll(&mut context).is_ready()); + drop(registration); + + let (sender, mut receiver) = futures_channel::mpsc::unbounded(); + let registration = scheduler.signal_notify(&[SIGUSR2], sender).unwrap(); + for before_wait in [true, false] { + if before_wait { + emit(SIGUSR2); + } + let mut next = pin!(receiver.next()); + if !before_wait { + assert!(next.as_mut().poll(&mut context).is_pending()); + emit(SIGUSR2); + } + loop { + if let Poll::Ready(value) = next.as_mut().poll(&mut context) { + assert_eq!(value, Some(SIGUSR2)); + break; + } + receive(&woke); + } + } + drop(registration); +} + +struct Callback { + entered: mpsc::Sender, + resume: Mutex>, + dropped: mpsc::Sender<()>, + panic: bool, +} + +impl Notify for Callback { + fn notify(&self, signal: c_int) { + self.entered.send(signal).unwrap(); + receive(&self.resume.lock().unwrap()); + assert!(!self.panic, "intentional notification panic"); + } +} + +impl Drop for Callback { + fn drop(&mut self) { + self.dropped.send(()).unwrap(); + } +} + +fn removal_and_panic>(scheduler: &S, panic: bool) { + let (observer, observed) = mpsc::channel(); + let observer_guard = scheduler + .signal_notify(&[SIGUSR1, SIGUSR2], observer) + .unwrap(); + let (entered, entering) = mpsc::channel(); + let (resume, resuming) = mpsc::channel(); + let (dropped, dropping) = mpsc::channel(); + let guard = scheduler + .signal_notify( + &[SIGUSR1, SIGUSR2], + Callback { + entered, + resume: Mutex::new(resuming), + dropped, + panic, + }, + ) + .unwrap(); + emit(SIGUSR1); + assert_eq!(receive(&entering), SIGUSR1); + // Queue another signal while user code is still executing. + emit(SIGUSR2); + if !panic { + drop(guard); + assert!(dropping.try_recv().is_err()); + resume.send(()).unwrap(); + receive(&dropping); + } else { + resume.send(()).unwrap(); + receive(&dropping); + drop(guard); + } + assert!(entering.recv_timeout(TIMEOUT).is_err()); + let mut values = [receive(&observed), receive(&observed)]; + values.sort(); + let mut expected = [SIGUSR1, SIGUSR2]; + expected.sort(); + assert_eq!(values, expected); + emit(SIGUSR1); + assert_eq!(receive(&observed), SIGUSR1); + drop(observer_guard); +} + +fn validation>(scheduler: &S) { + for signals in [ + vec![], + vec![0], + vec![-1], + vec![i32::MAX], + vec![SIGUSR1, SIGKILL], + vec![SIGSTOP], + vec![SIGSEGV], + vec![128], + ] { + let (sender, receiver) = mpsc::channel::(); + assert!(scheduler.signal_notify(&signals, sender).is_err()); + assert!(matches!( + receiver.recv_timeout(TIMEOUT), + Err(mpsc::RecvTimeoutError::Disconnected) + )); + } + let (sender, receiver) = mpsc::channel(); + let guard = scheduler + .signal_notify(&[SIGUSR1, SIGUSR1], sender) + .unwrap(); + emit(SIGUSR1); + assert_eq!(receive(&receiver), SIGUSR1); + drop(guard); + assert!(matches!( + receiver.recv_timeout(TIMEOUT), + Err(mpsc::RecvTimeoutError::Disconnected) + )); +} + +fn subscriptions>(scheduler: &S) { + awaited_signal(scheduler); + removal_and_panic(scheduler, false); + removal_and_panic(scheduler, true); + validation(scheduler); +} + +fn closed>(scheduler: &S) { + let error = scheduler + .signal_notify(&[SIGUSR1], Cancellation::new()) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::BrokenPipe); +} + +struct ExecutionContext(mpsc::Sender<(bool, bool)>); + +impl Notify for ExecutionContext { + fn notify(&self, _: c_int) { + #[cfg(feature = "tokio")] + let tokio = tokio::runtime::Handle::try_current().is_ok(); + #[cfg(not(feature = "tokio"))] + let tokio = false; + self.0 + .send((socketry_executor::Task::current().is_some(), tokio)) + .unwrap(); + } +} + +fn execution_context>(scheduler: &S, native: bool) { + let (sender, receiver) = mpsc::channel(); + let guard = scheduler + .signal_notify(&[SIGUSR1], ExecutionContext(sender)) + .unwrap(); + emit(SIGUSR1); + assert_eq!(receive(&receiver), (native, !native)); + drop(guard); +} + +fn exercise_backends() { + #[cfg(feature = "native")] + { + let scheduler = socketry_executor::Scheduler::with_workers(1).unwrap(); + subscriptions(&scheduler); + subscriptions(&scheduler.handle()); + execution_context(&scheduler, true); + let (sender, receiver) = mpsc::channel(); + let guard = scheduler.signal_notify(&[SIGUSR1], sender).unwrap(); + assert!(format!("{guard:?}").contains("SignalRegistration")); + let handle = scheduler.handle(); + drop(scheduler); + closed(&handle); + // Scheduler shutdown releases the subscription even with a live guard. + assert!(matches!( + receiver.recv_timeout(TIMEOUT), + Err(mpsc::RecvTimeoutError::Disconnected) + )); + drop(guard); + } + + #[cfg(feature = "tokio")] + { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let scheduler = + socketry_executor::scheduler::tokio::Scheduler::new(runtime.handle().clone()); + let stop = Cancellation::new(); + let stopping = stop.clone(); + let driver = thread::spawn(move || { + runtime.block_on(stopping.cancelled()); + runtime + }); + subscriptions(&scheduler); + subscriptions(&scheduler.handle()); + execution_context(&scheduler, false); + let (sender, receiver) = mpsc::channel(); + let guard = scheduler.signal_notify(&[SIGUSR1], sender).unwrap(); + let handle = scheduler.handle(); + drop(scheduler); + closed(&handle); + assert!(matches!( + receiver.recv_timeout(TIMEOUT), + Err(mpsc::RecvTimeoutError::Disconnected) + )); + drop(guard); + stop.cancel(); + drop(driver.join().unwrap()); + } +} diff --git a/crates/executor/tests/work_stealing.rs b/crates/executor/tests/work_stealing.rs index 3d14f47..16ae9a7 100644 --- a/crates/executor/tests/work_stealing.rs +++ b/crates/executor/tests/work_stealing.rs @@ -22,18 +22,17 @@ fn idle_worker_steals_work_spawned_on_a_busy_worker() { .spawn(async { let parent_thread = thread::current().id(); let (sender, receiver) = mpsc::channel(); - drop( - Scheduler::current() - .unwrap() - .spawn(async move { - sender.send(thread::current().id()).unwrap(); - }) - .unwrap(), - ); + let child = Scheduler::current() + .unwrap() + .spawn(async move { + sender.send(thread::current().id()).unwrap(); + }) + .unwrap(); // Deliberately occupy the owner: the child must be stolen from its // local queue by the other worker, rather than run on its parent. let child_thread = receive(&receiver); assert_ne!(parent_thread, child_thread); + child.await.unwrap(); }) .unwrap(); scheduler.block_on(parent).unwrap(); @@ -46,6 +45,7 @@ struct MigrationFuture { blocker_started: mpsc::Sender, blocker_release: RefCell>>, release_sender: mpsc::Sender<()>, + blocker: RefCell>>, _pinned: PhantomPinned, } @@ -64,15 +64,14 @@ impl Future for MigrationFuture { this.original.set(Some(thread::current().id())); let started = this.blocker_started.clone(); let release = this.blocker_release.borrow_mut().take().unwrap(); - drop( - Scheduler::current() - .unwrap() - .spawn(async move { - started.send(thread::current().id()).unwrap(); - receive(&release); - }) - .unwrap(), - ); + let blocker = Scheduler::current() + .unwrap() + .spawn(async move { + started.send(thread::current().id()).unwrap(); + receive(&release); + }) + .unwrap(); + *this.blocker.borrow_mut() = Some(blocker); this.waker_sender.send(context.waker().clone()).unwrap(); Poll::Pending } @@ -102,6 +101,7 @@ fn pending_future_migrates_without_moving_its_pinned_storage() { blocker_started, blocker_release: RefCell::new(Some(release_receiver)), release_sender, + blocker: RefCell::new(None), _pinned: PhantomPinned, }) .unwrap(); diff --git a/readme.md b/readme.md index 36128d6..c7ca7c9 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ Foundational concurrency APIs for Rust projects in the Socketry ecosystem. -The `socketry` package re-exports `socketry-executor`: owned asynchronous tasks, explicit child barriers, and a futures executor with multiple workers and work stealing. Tasks use ordinary future polling and have no private coroutine stacks. +The `socketry` package re-exports `socketry-executor`: owned asynchronous tasks and a futures executor with multiple workers and work stealing. Tasks use ordinary future polling and have no private coroutine stacks. ## Usage @@ -26,9 +26,9 @@ fn main() -> Result<(), Box> { } ``` -Workers start when the scheduler is constructed. `spawn` accepts `Send + 'static` futures and returns an awaitable result handle. Dropping that handle leaves the task owned by its scheduler or barrier. Dropping the scheduler requests cancellation and joins workers when called outside a worker. +Workers start when the scheduler is constructed. `spawn` accepts `Send + 'static` futures and returns an owning task handle. Moving it transfers ownership; dropping it requests task destruction after any active poll returns. Dropping the scheduler requests cancellation and joins workers when called outside a worker. -Use `scheduler.barrier()` to own children explicitly. Both schedulers and barriers implement `Spawn`. `barrier.wait().await` waits for completion; `barrier.stop().await` closes the barrier, cancels children, and waits for their synchronous destructors. Dropping a barrier requests cancellation without waiting. Observe individual results and panics through task handles. +Schedulers and their handles implement the primitive `Spawn` contract. Await individual task handles to observe completion, results, and panics. Task groups can be built above dispatch; the executor does not provide a group abstraction. `Scheduler::current()` and `Task::current()` provide contextual lookup while tasks run. Use `.await` to suspend; synchronous blocking calls occupy a worker. @@ -38,9 +38,59 @@ See the [executor package](crates/executor/readme.md) for scheduling, ownership, cargo run --package socketry-executor --example work_stealing ``` +## Cooperative cancellation + +`Cancellation` signals a shutdown request without destroying futures. Clones share the request; `child()` creates a boundary that receives parent cancellation without cancelling its parent or siblings. Use one root for application shutdown and children for independently stoppable services. Dropping a signal does not cancel it. + +Work can await `signal.cancelled()` or use `signal.check()` at explicit cancellation points, returning `Cancelled`. Waiting uses wakers and does not busy-wait. `Cancellation::never()` supplies an allocation-free input for work that cannot be cancelled through its signal. + +`defer_cancel(&signal, future, on_cancel)` calls the synchronous callback once when cancellation is observed and continues awaiting the future's normal output. The callback requests graceful shutdown; the future performs asynchronous draining. These primitives work with standard futures on Socketry or Tokio, without a runtime dependency. + +```rust +use socketry::{Cancellation, Scheduler, defer_cancel, yield_now}; + +fn main() -> Result<(), Box> { + let scheduler = Scheduler::with_workers(2)?; + let shutdown = Cancellation::new(); + let server_shutdown = shutdown.child(); + let server = scheduler.spawn(async move { + let drain = Cancellation::new(); + defer_cancel(&server_shutdown, async { + drain.cancelled().await; + // Finish accepted work while the scheduler remains available. + yield_now().await; + 42 + }, || { drain.cancel(); }).await + })?; + + shutdown.cancel(); // Request shutdown after receiving a process signal. + assert_eq!(scheduler.block_on(server)?, 42); + scheduler.shutdown(); + Ok(()) +} +``` + +Cancellation expresses intent; task handles confirm completion. Keep the scheduler and I/O services alive while draining. Existing task cancellation and scheduler shutdown still destroy futures; `defer_cancel` cannot protect a future from destruction. File and socket operations take explicit cancellation inputs; clock cancellation remains proposed. There is no signal escalation policy: an external supervisor can enforce SIGTERM followed by SIGKILL. + +The optional `signals` feature adds Unix `Signals::signal_notify(&signals, target)` to both scheduler implementations. Targets implement `Notify`: pass a `Cancellation` for shutdown or an unbounded channel sender for queued events such as reload requests. Keep the returned `SignalRegistration` alive and drop it to unsubscribe; already-admitted callbacks may finish. Callbacks run outside raw signal handlers. See the executor's [signal notification contract](crates/executor/readme.md#signal-notifications) for examples, lifetime rules, and process-wide signal disposition behavior. + ## Portable I/O and runtime selection -Generic code can accept `Network`, `FileIo`, `Clock`, and `Spawn` capabilities. Socketry and the optional Tokio adapter implement these contracts with concrete future and resource types. Import the traits to call their methods. +Generic code can accept `Socket`, `File`, `Clock`, and `Spawn` capabilities. Socketry and the optional Tokio adapter implement these contracts with concrete future and resource types. Import the traits to call their methods. + +`File` is the scheduler capability trait; `File::File` is its associated resource type. Generic code can create and use files without importing OS resource types: + +```rust +use socketry::{Cancellation, File, FileOptions, OperationResult}; +use std::path::Path; + +async fn read_prefix(scheduler: &S, path: &Path, cancellation: &Cancellation) -> OperationResult> { + let file = scheduler.file_open(path, FileOptions::READ, cancellation).await?; + let (result, mut buffer) = scheduler.file_read_at(&file, vec![0; 4096], 0, cancellation).await; + buffer.truncate(result?); + Ok(buffer) +} +``` | Cargo configuration | Implementation | | --- | --- | @@ -50,7 +100,7 @@ Generic code can accept `Network`, `FileIo`, `Clock`, and `Spawn` capabilities. | `default-features = false, features = ["tokio"]` | Tokio adapter without Socketry's native I/O dependencies. | | `default-features = false` | Task executor and portable contracts, without I/O implementations. | -Socket registrations persist across operations and worker migration. Reads and writes take a reusable owned `Vec` and return `(io::Result, Vec)`. Reads fill the buffer's existing length; allocate it with `vec![0; capacity]`. Operations may transfer fewer bytes than requested. Dropping a future can abandon an operation that has already consumed or transmitted bytes. +Socket registrations persist across operations and worker migration. Reads and writes accept owned `IoBuf`/`IoBufMut` storage and return `(OperationResult, B)`, preserving buffers on cancellation and errors. `Vec`, `Box<[u8]>`, and owned slice views are supported. Reads fill the buffer's existing length; allocate it with `vec![0; capacity]`. Operations may transfer fewer bytes than requested. Dropping a future can abandon an operation that has already consumed or transmitted bytes. Run the same TCP exchange with either runtime: @@ -61,7 +111,7 @@ cargo run -p socketry-executor --example portable_io --no-default-features --fea cargo run -p socketry-executor --example portable_io --features io-uring ``` -The Tokio adapter needs a live runtime with I/O and time enabled. It preserves explicit task/barrier ownership, and its asynchronous `shutdown().await` joins task destruction. Passing its handle explicitly selects Tokio; Socketry's `Scheduler::current()` continues to identify Socketry execution. +The Tokio adapter needs a live runtime with I/O and time enabled. Its task handles preserve transferable execution ownership, and its asynchronous `shutdown().await` joins task destruction. Passing its handle explicitly selects Tokio; Socketry's `Scheduler::current()` continues to identify Socketry execution. ## Current scope diff --git a/releases.md b/releases.md index 07b1928..66de0a0 100644 --- a/releases.md +++ b/releases.md @@ -2,10 +2,26 @@ ## Unreleased +- Make task handles own execution lifetimes: moving a handle transfers ownership, while dropping it requests destruction after any active poll. Await explicitly for completion. Signal guards retain their subscription task handles. + +- Add associated file resources, `file_open`, portable `FileOptions`, TCP listener creation and address inspection. Move OS socket importing to `SocketRegistration`. Add `IoBuf`, `IoBufMut`, owned `BufferSlice` views and explicit file/socket cancellation with `OperationError`; return buffers only after backend access ends. The generic example now uses the public traits across native, Tokio, and simulated I/O. + - Document the complete timer lifecycle comparison with Tokio across cancellation rates in the timer crate readme. +- Add an executable scheduler-interface experiment with generic file-copy and echo-server functions running on native, Tokio, and in-memory backends. Record resource creation, buffer-range, blocking execution, and backend cleanup requirements in the executor design. + +- Add `Notify` and the `Signals` scheduler capability with associated signal and registration types. The optional Unix `signals` feature supports cancellation and queued notifications on Socketry and Tokio; dropping the returned guard removes its subscription without waiting for callbacks already in progress. + - Add the independent `socketry-timers` crate and expose it as `socketry::timers`. Include reusable batched queues, optional worker assistance and lazy sleep futures, plus the experimental inbox driver and Tokio lifecycle benchmarks. +- Remove `Barrier`, scheduler barrier factories, and `SpawnError::OwnerClosed` from `socketry-executor` and the facade. Use primitive `spawn(future)` dispatch and await task handles; higher-level task groups are deferred. + +- Rename the portable socket operations trait from `Network` to `Socket` in `socketry`, `socketry-executor`, and their public `scheduler` module. Update imports and trait bounds. + +- Rename the portable file operations trait from `FileIo` to `File` in `socketry`, `socketry-executor`, and their public `scheduler` module. Update imports and trait bounds; alias `std::fs::File` when both names are used. + +- Add runtime-independent `Cancellation`, `Cancelled`, and `defer_cancel` for cooperative service shutdown and asynchronous draining. Export them through `socketry-executor` and `socketry`. + - Let Cargo select the dependency version in the installation example. ## v0.2.0 diff --git a/src/lib.rs b/src/lib.rs index 611291c..332caf7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,10 +6,16 @@ pub use socketry_executor as executor; pub use socketry_executor::{ - Barrier, BufferResult, Clock, FileIo, Interest, Network, Scheduler, SchedulerHandle, Spawn, - SpawnError, Task, TaskError, TaskHandle, yield_now, + BufferResult, BufferSlice, Cancellation, Cancelled, Clock, File, FileOptions, Interest, IoBuf, + IoBufMut, Notify, OperationError, OperationResult, Scheduler, SchedulerHandle, Signals, Socket, + SocketRegistration, Spawn, SpawnError, Task, TaskError, TaskHandle, defer_cancel, yield_now, }; +#[cfg(all(unix, feature = "signals", any(feature = "native", feature = "tokio")))] +pub use socketry_executor::SignalRegistration; +#[cfg(all(unix, feature = "signals"))] +pub use socketry_executor::signal; + pub use socketry_timers as timers; pub use socketry_executor::scheduler;