From 2eee4c88a13c1862b239daf7b9827a9675706462 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 14 Sep 2026 19:42:23 +0200 Subject: [PATCH 01/15] Use NT timer packets after Windows rejects high-resolution APC timers --- spikes/iocp/WINDOWS_RESULTS.md | 80 ++++++++++++++++++++++++++++++++ spikes/iocp/backend_draft/mod.rs | 17 +++++-- 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 spikes/iocp/WINDOWS_RESULTS.md diff --git a/spikes/iocp/WINDOWS_RESULTS.md b/spikes/iocp/WINDOWS_RESULTS.md new file mode 100644 index 0000000..83bdf8d --- /dev/null +++ b/spikes/iocp/WINDOWS_RESULTS.md @@ -0,0 +1,80 @@ +# Windows runtime results + +Issue #4, branch `windows/iocp-backend`, 2026-09-14. + +## Environment + +- Windows 11 Pro, version 10.0.26200, build 26200. +- GMKtec NucBox M6 Ultra; AMD Ryzen 5 7640HS with Radeon 760M. +- Physical-machine SMBIOS identification; Windows reports a hypervisor present + (Hyper-V/VBS). This is not evidence of a guest VM; precise hypervisor/VBS + configuration has not been independently established. +- Native x86_64 MSVC build and execution, from the interactive desktop session. +- `rustc +nightly-2026-08-20 --version`: 1.100.0-nightly + (f7d782a3b 2026-08-19). MSVC linker and Windows SDK are installed and linked tests. +- `rustc +stable --version`: 1.96.1 (31fca3adb 2026-06-26). +- Node v24.21.0 initially installed; HTTP/2 interop is not yet run. +- Commands explicitly select the pinned nightly: implicit `cargo`/`rustc` + attempts to install every cross target from `rust-toolchain.toml`, but the + existing wasip1-threads component has a rustup installation conflict. + +## Phase 1 baseline + +Each row ran natively with +`cargo +nightly-2026-08-20 test --manifest-path spikes/iocp/Cargo.toml --test NAME -- --nocapture --test-threads=1`. + +| Binary | Initial result | Tests | +| --- | --- | --- | +| port | PASS | 1/1 | +| timer | FAIL | 2/3; APC arm returns Win32 error 87 | +| tcp | PASS | 1/1; 1,024 echoed bytes, cancellation drained | +| pipe | PASS | 2/2, both connection orderings | +| stdio | PASS | 1/1, bytes and EOF | +| integration | PASS | 2/2, GUI wait and full-queue shutdown | +| process | PASS | 3/3, including live child and grandchild termination | +| console | PASS | 1/1, real events in isolated child console | +| draft | FAIL | 1/3; both direct turns fail at APC timer arm | +| handles | PASS | 1/1, 128 port lifetimes | + +`cargo +nightly-2026-08-20 test --manifest-path spikes/iocp/Cargo.toml --all-targets -- --nocapture --test-threads=1` +also ran, but stopped at the draft failures. The separate binary runs above +ensure later binaries were actually executed. + +PASS: + +- `cargo +nightly-2026-08-20 clippy --manifest-path spikes/iocp/Cargo.toml --all-targets -- -D warnings -D clippy::undocumented_unsafe_blocks` +- `cargo +stable check --manifest-path spikes/iocp/Cargo.toml --all-targets` + +## Timer decision (DESIGN.md section 15 question 3) + +Choose a high-resolution waitable timer associated with IOCP through dynamically +resolved `NtAssociateWaitCompletionPacket`, and use nonalertable port waits. + +| Route | Requested delay | Samples | p50 lateness | p95 lateness | Maximum lateness | +| --- | --- | --- | --- | --- | --- | +| High-resolution timer + APC | 250 us | 0 | UNMEASURED | UNMEASURED | UNMEASURED | +| High-resolution timer + NT packet | 250 us | 100 | 275.2 us | 285.8 us | 380.9 us | + +The APC probe fails in `SetWaitableTimer` with `ERROR_INVALID_PARAMETER` (87). +An independent native API check confirmed the combination: ordinary timers accept +APC callbacks; high-resolution timers accept a null callback but reject a non-null +callback, for both auto-reset and manual-reset creation. Downgrading to an ordinary +timer would not validate the required high-resolution APC route. The original +precision assertion remains unchanged and its result remains FAIL. + +The NT probe additionally passed 100 cancel/rearm cycles. Its measured precision +passes the existing bounds without changing the system timer period. Nonalertable +waits also preserve the rule that turns do not dispatch arbitrary host APCs. +Microsoft documents the packet APIs but does not specify a minimum OS version; +missing exports must produce an explicit capability error. This single host does +not establish the minimum-version or VM precision matrix. + +## Phase 2 + +After replacing the draft's APC deadline with the measured NT packet route, +`cargo +nightly-2026-08-20 test --manifest-path spikes/iocp/Cargo.toml --test draft -- --nocapture --test-threads=1` +passes all three tests, including zero allocations across 256 measured transfers +and cancellation before Closed. Formatting and unsafe-aware Clippy pass again. + +Production backend integration and shared contracts are in progress. No production +Windows runtime coverage or green Windows CI is claimed by this initial record. diff --git a/spikes/iocp/backend_draft/mod.rs b/spikes/iocp/backend_draft/mod.rs index 55643e4..24305e2 100644 --- a/spikes/iocp/backend_draft/mod.rs +++ b/spikes/iocp/backend_draft/mod.rs @@ -2,9 +2,9 @@ //! See README.md: these types are internal backend events, not a replacement Loop. use crate::{ integration::EventIntegration, - port::{Entry, Port, WAKE, Wait, bool_result}, + port::{Entry, Port, TIMER, WAKE, Wait, bool_result}, tcp::Winsock, - timer::ApcTimer, + timer::PacketTimer, }; use std::{ cell::UnsafeCell, @@ -155,7 +155,7 @@ pub struct IocpBackend { handles: Box<[HandleSlot]>, ready: VecDeque, port: Arc, - timer: ApcTimer, + timer: PacketTimer, wake: Arc, event: Option, pending: usize, @@ -205,8 +205,8 @@ impl IocpBackend { kernel, handles, ready: VecDeque::with_capacity(operation_capacity), + timer: PacketTimer::new(Arc::clone(&port))?, port, - timer: ApcTimer::new()?, wake, event: None, pending: 0, @@ -516,7 +516,9 @@ impl IocpBackend { return Err(error); } info.waits = 1; - let result = self.port.wait(timeout, true, &mut entries); + // High-resolution waitable timers reject APC callbacks on Windows + // 11 build 26200. NT packets also avoid executing host APCs here. + let result = self.port.wait(timeout, false, &mut entries); let notified = self.wake.state.swap(RUNNING, Ordering::AcqRel) == NOTIFIED; info.notified |= notified; self.timer.cancel()?; @@ -531,6 +533,11 @@ impl IocpBackend { info.notified = true; continue; } + if entry.key == TIMER { + // SAFETY: this backend's timer packet was dequeued above. + unsafe { self.timer.dequeued() }; + continue; + } let Some(i) = self .kernel .iter() From 6082f2776a4159767a70f0f33bd723a8eb0e9656 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 14 Sep 2026 20:14:53 +0200 Subject: [PATCH 02/15] Let NT timer packets provide the sole deadline for IOCP waits --- spikes/iocp/backend_draft/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/spikes/iocp/backend_draft/mod.rs b/spikes/iocp/backend_draft/mod.rs index 24305e2..da9b07e 100644 --- a/spikes/iocp/backend_draft/mod.rs +++ b/spikes/iocp/backend_draft/mod.rs @@ -518,7 +518,12 @@ impl IocpBackend { info.waits = 1; // High-resolution waitable timers reject APC callbacks on Windows // 11 build 26200. NT packets also avoid executing host APCs here. - let result = self.port.wait(timeout, false, &mut entries); + let wait_timeout = if timeout.is_some_and(|d| !d.is_zero()) { + None + } else { + timeout + }; + let result = self.port.wait(wait_timeout, false, &mut entries); let notified = self.wake.state.swap(RUNNING, Ordering::AcqRel) == NOTIFIED; info.notified |= notified; self.timer.cancel()?; From a8191c2c0a2e04ede1667b82840f5a91cf355a90 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 14 Sep 2026 20:26:12 +0200 Subject: [PATCH 03/15] Implement the Windows IOCP backend and native contracts --- Cargo.lock | 2 + DESIGN.md | 2 +- README.md | 2 +- crates/turnloop-contract/Cargo.toml | 5 +- .../turnloop-contract/src/bin/native_child.rs | 12 +- crates/turnloop-contract/src/lib.rs | 18 +- crates/turnloop-contract/tests/allocations.rs | 20 +- crates/turnloop-contract/tests/executor.rs | 3 +- crates/turnloop-contract/tests/windows.rs | 167 ++ .../tests/windows_console.rs | 198 +++ .../tests/windows_lifetimes.rs | 198 +++ crates/turnloop/Cargo.toml | 9 + crates/turnloop/README.md | 2 +- crates/turnloop/src/backend/iocp/README.md | 56 + crates/turnloop/src/backend/iocp/bridge.rs | 156 ++ .../turnloop/src/backend/iocp/integration.rs | 160 ++ crates/turnloop/src/backend/iocp/ipc.rs | 119 ++ crates/turnloop/src/backend/iocp/mod.rs | 1421 +++++++++++++++++ crates/turnloop/src/backend/iocp/pipes.rs | 56 + crates/turnloop/src/backend/iocp/port.rs | 144 ++ crates/turnloop/src/backend/iocp/process.rs | 483 ++++++ crates/turnloop/src/backend/iocp/services.rs | 167 ++ crates/turnloop/src/backend/iocp/signals.rs | 102 ++ crates/turnloop/src/backend/iocp/socket.rs | 301 ++++ crates/turnloop/src/backend/iocp/sync_io.rs | 401 +++++ crates/turnloop/src/backend/iocp/timer.rs | 197 +++ crates/turnloop/src/backend/mod.rs | 5 + crates/turnloop/src/buffer.rs | 6 +- crates/turnloop/src/lib.rs | 8 +- spikes/iocp/WINDOWS_RESULTS.md | 51 +- 30 files changed, 4443 insertions(+), 28 deletions(-) create mode 100644 crates/turnloop-contract/tests/windows.rs create mode 100644 crates/turnloop-contract/tests/windows_console.rs create mode 100644 crates/turnloop-contract/tests/windows_lifetimes.rs create mode 100644 crates/turnloop/src/backend/iocp/README.md create mode 100644 crates/turnloop/src/backend/iocp/bridge.rs create mode 100644 crates/turnloop/src/backend/iocp/integration.rs create mode 100644 crates/turnloop/src/backend/iocp/ipc.rs create mode 100644 crates/turnloop/src/backend/iocp/mod.rs create mode 100644 crates/turnloop/src/backend/iocp/pipes.rs create mode 100644 crates/turnloop/src/backend/iocp/port.rs create mode 100644 crates/turnloop/src/backend/iocp/process.rs create mode 100644 crates/turnloop/src/backend/iocp/services.rs create mode 100644 crates/turnloop/src/backend/iocp/signals.rs create mode 100644 crates/turnloop/src/backend/iocp/socket.rs create mode 100644 crates/turnloop/src/backend/iocp/sync_io.rs create mode 100644 crates/turnloop/src/backend/iocp/timer.rs diff --git a/Cargo.lock b/Cargo.lock index 6ace372..57dcf41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1741,6 +1741,7 @@ dependencies = [ "futures-io", "libc", "loom", + "windows-sys 0.61.2", ] [[package]] @@ -1760,6 +1761,7 @@ dependencies = [ "futures-io", "libc", "turnloop", + "windows-sys 0.61.2", ] [[package]] diff --git a/DESIGN.md b/DESIGN.md index f4b22ae..ec2a554 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -565,7 +565,7 @@ P5–P7 are independent of each other once P1 and P4 have landed, and can run as 1. **Name.** Settled: `turnloop`. 2. **`sys` layer.** Own thin backends vs building on compio-driver (IOCP and io_uring already done) vs `polling`/mio for Unix. Decided in M1 with instruction numbers. -3. **Windows timer mechanism.** Alertable GQCSEx + waitable timer APC, or timer-to-port association. +3. **Windows timer mechanism.** Use a high-resolution waitable timer with dynamically resolved `NtAssociateWaitCompletionPacket` and nonalertable GQCSEx. Windows 11 build 26200 rejected high-resolution APC callbacks with error 87; 100 NT-packet samples at 250 us measured p50/p95/max lateness of 275.2/285.8/380.9 us. See [the Windows results](spikes/iocp/WINDOWS_RESULTS.md); minimum-version and VM precision validation remain separate work. 4. **Pooled buffer sizing and lease lifetime.** Until the next `turn`, or explicit release only? 5. **io_uring timing.** When, and whether as the default where available. 6. **Mobile CI.** iOS (kqueue) and Android (epoll) come almost free from the Unix backends. Which simulators/emulators run in CI. diff --git a/README.md b/README.md index 7304360..d6b2306 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ adapters are still being integrated. Perry is the first intended consumer. | macOS arm64 | kqueue driver, sockets, timers, posts and pool | Native contracts and allocation tests | | Linux x86_64 / arm64 | epoll, nanosecond waits, timerfd fallback | Cross-checked locally; native CI required | | FreeBSD / Apple mobile / Android | Unix backend paths | Best effort; runtime validation pending | -| Windows x86_64 | Standalone IOCP spike | Cross-checked; production adapter and native contracts pending | +| Windows x86_64 | IOCP, TCP/UDP/named pipes, stdio, processes, console signals, timers and shared services | Native Windows 11 contracts and allocation/executor tests; see `spikes/iocp/WINDOWS_RESULTS.md` | | WASI 0.2 / 0.3 | Standalone polling / component async spikes | Production adapters and shared contracts pending | | Web | Standalone host-callback spike | Production adapter and browser contracts pending | diff --git a/crates/turnloop-contract/Cargo.toml b/crates/turnloop-contract/Cargo.toml index 033590c..386247a 100644 --- a/crates/turnloop-contract/Cargo.toml +++ b/crates/turnloop-contract/Cargo.toml @@ -20,10 +20,11 @@ workspace = true [target.'cfg(unix)'.dependencies] libc.workspace = true +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Threading", "Win32_System_Console", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_IO", "Win32_System_Pipes"] } + [package.metadata.turnloop-ci] role = "contract" -# Remove when the production IOCP provider instantiates the shared contracts. -windows-contracts-pending = "WINDOWS_HANDOFF.md" [[bin]] name = "native_child" diff --git a/crates/turnloop-contract/src/bin/native_child.rs b/crates/turnloop-contract/src/bin/native_child.rs index 2f44597..454538a 100644 --- a/crates/turnloop-contract/src/bin/native_child.rs +++ b/crates/turnloop-contract/src/bin/native_child.rs @@ -42,7 +42,8 @@ fn main() { target_vendor = "apple", target_os = "linux", target_os = "android", - target_os = "freebsd" + target_os = "freebsd", + target_os = "windows" ))] fn native_stdio() { use turnloop::*; @@ -90,7 +91,8 @@ fn native_stdio() { target_vendor = "apple", target_os = "linux", target_os = "android", - target_os = "freebsd" + target_os = "freebsd", + target_os = "windows" )))] fn native_stdio() { panic!("instantiate stdio fixture on production native backend"); @@ -99,7 +101,8 @@ fn native_stdio() { target_vendor = "apple", target_os = "linux", target_os = "android", - target_os = "freebsd" + target_os = "freebsd", + target_os = "windows" ))] fn native_handle(path: &std::ffi::OsStr) { use turnloop::*; @@ -146,7 +149,8 @@ fn native_handle(path: &std::ffi::OsStr) { target_vendor = "apple", target_os = "linux", target_os = "android", - target_os = "freebsd" + target_os = "freebsd", + target_os = "windows" )))] fn native_handle(_: &std::ffi::OsStr) { panic!("instantiate handle fixture on production native backend"); diff --git a/crates/turnloop-contract/src/lib.rs b/crates/turnloop-contract/src/lib.rs index ca8d2d9..9533b08 100644 --- a/crates/turnloop-contract/src/lib.rs +++ b/crates/turnloop-contract/src/lib.rs @@ -357,13 +357,17 @@ pub fn refused_connect_once() { l.turn(Timeout::Until(until), &mut out).expect("turn"); for c in out.drain() { assert_eq!(c.token, Token(7)); - assert!(matches!( - c.result, - OpResult::Err(Error { - kind: ErrorKind::ConnectionRefused, - .. - }) - )); + assert!( + matches!( + c.result, + OpResult::Err(Error { + kind: ErrorKind::ConnectionRefused, + .. + }) + ), + "unexpected refused-connect result: {:?}", + c.result + ); assert!(c.terminal); count += 1; } diff --git a/crates/turnloop-contract/tests/allocations.rs b/crates/turnloop-contract/tests/allocations.rs index 4fa5b13..2349a84 100644 --- a/crates/turnloop-contract/tests/allocations.rs +++ b/crates/turnloop-contract/tests/allocations.rs @@ -5,7 +5,8 @@ target_vendor = "apple", target_os = "linux", target_os = "android", - target_os = "freebsd" + target_os = "freebsd", + target_os = "windows" ) ))] use std::{ @@ -285,7 +286,10 @@ fn cancellation_reserves_survive_a_full_event_backlog() { #[test] fn ipc_handle_transfer_and_external_waits_allocate_nothing_after_setup() { let mut l = Loop::new(Config::default()).expect("loop"); + #[cfg(unix)] let path = std::env::temp_dir().join(format!("tl-alloc-ipc-{}.sock", std::process::id())); + #[cfg(windows)] + let path = std::path::PathBuf::from(format!(r"\\.\pipe\tl-alloc-ipc-{}", std::process::id())); let (_, a, b) = turnloop_contract::native_surface::pipe_pair(&mut l, &PipeName(path.clone())); let (_, source, _peer) = turnloop_contract::pair(&mut l); let condition = WaitCondition::new(0).expect("wait condition"); @@ -352,15 +356,13 @@ fn ipc_handle_transfer_and_external_waits_allocate_nothing_after_setup() { "IPC and external waits steady allocations" ); assert_eq!((transferred, waits), (200, 200)); + #[cfg(unix)] std::fs::remove_file(path).expect("remove socket path"); } #[test] fn regular_file_jobs_reuse_pool_storage() { - use std::{ - io::{Seek, SeekFrom, Write}, - os::fd::OwnedFd, - }; + use std::io::{Seek, SeekFrom, Write}; let path = std::env::temp_dir().join(format!("tl-alloc-file-{}", std::process::id())); let mut file = std::fs::OpenOptions::new() .read(true) @@ -370,9 +372,12 @@ fn regular_file_jobs_reuse_pool_storage() { .expect("file"); file.write_all(&[9; 64]).expect("file bytes"); let mut l = Loop::new(Config::default()).expect("loop"); - let fd: OwnedFd = file.try_clone().expect("clone file").into(); + #[cfg(unix)] + let transport = Detached::from_fd(file.try_clone().expect("clone file").into()); + #[cfg(windows)] + let transport = Detached::from_handle(file.try_clone().expect("clone file").into()); let h = l - .attach(Detached::from_fd(fd).expect("file transport"), Token(1)) + .attach(transport.expect("file transport"), Token(1)) .expect("file attach"); let mut out = Completions::default(); let mut count = 0; @@ -498,6 +503,7 @@ fn executor_steady_io_poll_and_sleep_allocate_nothing() { assert_eq!(count, 1001); } +#[cfg(unix)] #[test] fn signal_exit_and_external_notification_delivery_allocate_nothing() { let mut l = Loop::new(Config::default()).expect("loop"); diff --git a/crates/turnloop-contract/tests/executor.rs b/crates/turnloop-contract/tests/executor.rs index 3b63210..f519798 100644 --- a/crates/turnloop-contract/tests/executor.rs +++ b/crates/turnloop-contract/tests/executor.rs @@ -5,7 +5,8 @@ target_vendor = "apple", target_os = "linux", target_os = "android", - target_os = "freebsd" + target_os = "freebsd", + target_os = "windows" ) ))] #[test] diff --git a/crates/turnloop-contract/tests/windows.rs b/crates/turnloop-contract/tests/windows.rs new file mode 100644 index 0000000..7fa44de --- /dev/null +++ b/crates/turnloop-contract/tests/windows.rs @@ -0,0 +1,167 @@ +#![cfg(all(windows, not(loom)))] +#![deny(unsafe_op_in_unsafe_fn)] +use turnloop::{backend::Platform, *}; + +macro_rules! contract { + ($($name:ident),+ $(,)?) => { $(#[test] fn $name() { turnloop_contract::$name::(); })+ }; +} +contract!( + bounded_turn, + notify_parked, + notify_running, + cancel_close_ordering, + refused_connect_once, + ref_unref, + no_spin, + timer_precision, + udp_round_trip, + detach_inflight, + pool_and_dns, + handoff_distribution, + writev_and_shutdown, + capacity_and_stale_ids, + ready_timer_liveness, + pooled_lease_backpressure, + io_and_posts_progress_with_repeating_timers +); +#[test] +fn echo_one() { + turnloop_contract::tcp_echo::(1); +} +#[test] +fn local_pipe_and_socket_passing() { + let name = PipeName(format!(r"\\.\pipe\turnloop-contract-{}", std::process::id()).into()); + turnloop_contract::native_surface::ipc::(&name); +} +#[test] +fn echo_64() { + turnloop_contract::tcp_echo::(64); +} +#[test] +fn children_256_exit_once() { + turnloop_contract::native_surface::children::(std::ffi::OsStr::new(env!( + "CARGO_BIN_EXE_native_child" + ))); +} +#[test] +fn child_stdio_runs_the_driver() { + turnloop_contract::native_surface::child_stdio::(std::ffi::OsStr::new(env!( + "CARGO_BIN_EXE_native_child" + ))); +} +#[test] +fn job_kills_child_and_grandchild() { + turnloop_contract::native_surface::process_group::(std::ffi::OsStr::new(env!( + "CARGO_BIN_EXE_native_child" + ))); +} +#[test] +fn external_waits_route_and_cancel() { + turnloop_contract::native_surface::external_waits::(); +} +#[test] +fn services_do_not_spin() { + turnloop_contract::native_surface::services_no_spin::( + std::ffi::OsStr::new(env!("CARGO_BIN_EXE_native_child")), + Signal::Break, + ); +} +#[test] +fn sockets_pass_to_child_and_back() { + let name = PipeName(format!(r"\\.\pipe\turnloop-process-ipc-{}", std::process::id()).into()); + turnloop_contract::native_surface::ipc_process::( + std::ffi::OsStr::new(env!("CARGO_BIN_EXE_native_child")), + &name, + ); +} +#[test] +fn four_loops_cross_post() { + turnloop_contract::cross_post::(4, 1000); +} +#[test] +fn reuse_port_is_explicitly_unsupported() { + let mut driver = Loop::new(Config::default()).expect("loop"); + let result = driver.tcp_listen( + ([127, 0, 0, 1], 0).into(), + &ListenOpts { + reuse_port: true, + ..ListenOpts::default() + }, + ); + assert!(matches!( + result, + Err(Error { + kind: ErrorKind::Unsupported, + .. + }) + )); + assert!(!driver.alive()); +} +#[test] +fn gui_event_receives_cross_thread_posts() { + use windows_sys::Win32::{ + Foundation::WAIT_OBJECT_0, UI::WindowsAndMessaging::MsgWaitForMultipleObjectsEx, + }; + let mut driver = Loop::new(Config::default()).expect("loop"); + let Integration::Event(event) = driver.integration().expect("integration") else { + panic!("event"); + }; + let poster = driver.poster(); + let thread = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(10)); + poster.post(Token(42), Payload::U64(99)).expect("post"); + }); + let event = event as *mut std::ffi::c_void; + // SAFETY: borrowed event remains owned by the driver for the entire GUI wait. + assert_eq!( + // SAFETY: borrowed event remains owned by the driver for the entire GUI wait. + unsafe { MsgWaitForMultipleObjectsEx(1, &event, 2000, 0, 0) }, + WAIT_OBJECT_0 + ); + let mut out = Completions::default(); + driver.turn(Timeout::Now, &mut out).expect("turn"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].token, Token(42)); + assert!(matches!(out[0].result, OpResult::Posted(Payload::U64(99)))); + thread.join().expect("producer"); +} + +#[test] +fn gui_event_tracks_timer_reset_and_partial_output() { + use std::time::Duration; + use windows_sys::Win32::{ + Foundation::WAIT_OBJECT_0, UI::WindowsAndMessaging::MsgWaitForMultipleObjectsEx, + }; + let mut driver = Loop::new(Config::default()).expect("loop"); + let Integration::Event(event) = driver.integration().expect("integration") else { + panic!("event"); + }; + let event = event as *mut std::ffi::c_void; + let mut out = Completions::with_capacity(1); + for _ in 0..32 { + let timer = driver + .timer(driver.now() + Duration::from_secs(30), None, Token(1)) + .expect("timer"); + let deadline = driver.now() + Duration::from_millis(2); + assert!(driver.timer_reset(timer, deadline)); + let watchdog = driver.now() + Duration::from_secs(2); + loop { + assert!(driver.now() < watchdog); + assert_eq!( + // SAFETY: borrowed event stays owned by driver throughout this GUI wait. + unsafe { MsgWaitForMultipleObjectsEx(1, &event, 2000, 0, 0) }, + WAIT_OBJECT_0 + ); + driver.turn(Timeout::Now, &mut out).expect("GUI turn"); + if !out.is_empty() { + assert!(driver.now() >= deadline); + assert_eq!(out.len(), 1); + assert!(matches!(out[0].result, OpResult::Timer)); + break; + } + } + driver.close(timer, Token(2)).expect("close timer"); + driver.turn(Timeout::Now, &mut out).expect("close turn"); + assert!(matches!(out[0].result, OpResult::Closed)); + } +} diff --git a/crates/turnloop-contract/tests/windows_console.rs b/crates/turnloop-contract/tests/windows_console.rs new file mode 100644 index 0000000..1764edd --- /dev/null +++ b/crates/turnloop-contract/tests/windows_console.rs @@ -0,0 +1,198 @@ +#![cfg(all(windows, not(loom)))] +#![deny(unsafe_op_in_unsafe_fn)] +use std::{ + os::windows::{ + io::{AsRawHandle, FromRawHandle, OwnedHandle}, + process::CommandExt, + }, + ptr, + time::{Duration, Instant}, +}; +use turnloop::*; +use windows_sys::Win32::{ + Foundation::*, + Storage::FileSystem::*, + System::{Console::*, Threading::CREATE_NEW_CONSOLE}, +}; + +fn isolated(name: &str, body: impl FnOnce()) { + if std::env::var("TURNLOOP_CONSOLE_TEST").as_deref() == Ok(name) { + body(); + return; + } + let mut child = std::process::Command::new(std::env::current_exe().expect("test executable")) + .args(["--exact", name, "--nocapture", "--test-threads=1"]) + .env("TURNLOOP_CONSOLE_TEST", name) + .creation_flags(CREATE_NEW_CONSOLE) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("isolated console child"); + let deadline = Instant::now() + Duration::from_secs(20); + while child.try_wait().expect("child status").is_none() { + if Instant::now() >= deadline { + child.kill().expect("watchdog kill"); + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().expect("child output"); + assert!( + output.status.success(), + "console child failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); +} +fn console(input: bool) -> OwnedHandle { + let name: Vec = if input { "CONIN$\0" } else { "CONOUT$\0" } + .encode_utf16() + .collect(); + // SAFETY: console device path; newly created, independently owned handle. + let handle = unsafe { + CreateFileW( + name.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + ptr::null(), + OPEN_EXISTING, + 0, + ptr::null_mut(), + ) + }; + assert_ne!(handle, INVALID_HANDLE_VALUE); + // SAFETY: CreateFileW successfully transferred exclusive ownership. + unsafe { OwnedHandle::from_raw_handle(handle) } +} +fn mode(handle: &OwnedHandle) -> u32 { + let mut mode = 0; + // SAFETY: owned console handle and writable mode output. + assert_ne!( + // SAFETY: owned console handle and writable mode output. + unsafe { GetConsoleMode(handle.as_raw_handle(), &mut mode) }, + 0 + ); + mode +} +#[test] +fn real_console_signals_fan_out_to_four_loops() { + isolated("real_console_signals_fan_out_to_four_loops", || { + turnloop_contract::native_surface::signal_fanout::( + Signal::Break, + || { + // SAFETY: test owns its isolated console; all four subscriptions are installed. + assert_ne!(unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, 0) }, 0); + }, + ); + // SAFETY: isolated child only; enable Ctrl-C delivery for the next real event. + assert_ne!(unsafe { SetConsoleCtrlHandler(None, 0) }, 0); + turnloop_contract::native_surface::signal_fanout::(Signal::Int, || { + // SAFETY: isolated console and live subscriptions; no host console is affected. + assert_ne!(unsafe { GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) }, 0); + }); + }); +} +#[test] +fn console_input_resize_and_modes_restore() { + isolated("console_input_resize_and_modes_restore", || { + let input = console(true); + let output = console(false); + let original = [mode(&input), mode(&output)]; + for close in [true, false] { + let mut driver = Loop::new(Config::default()).expect("loop"); + let h = driver + .attach( + Detached::from_handle(input.try_clone().expect("input duplicate")) + .expect("input"), + Token(1), + ) + .expect("attach input"); + let screen = driver + .attach( + Detached::from_handle(output.try_clone().expect("output duplicate")) + .expect("output"), + Token(2), + ) + .expect("attach output"); + driver.tty_set_mode(h, TtyMode::Raw).expect("raw"); + assert_eq!(mode(&input) & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT), 0); + assert_ne!(mode(&input) & ENABLE_PROCESSED_INPUT, 0); + driver.tty_set_mode(h, TtyMode::Io).expect("io"); + assert_eq!(mode(&input) & ENABLE_PROCESSED_INPUT, 0); + driver + .tty_set_mode(screen, TtyMode::Raw) + .expect("VT output"); + assert_ne!(mode(&output) & ENABLE_VIRTUAL_TERMINAL_PROCESSING, 0); + let size = driver.tty_window_size(h).expect("input window query"); + assert!(size.columns > 0 && size.rows > 0); + let resize = driver.tty_resize_start(h, Token(3)).expect("resize"); + driver + .read(h, ReadBuf::Pooled, Token(4)) + .expect("console read"); + let mut out = Completions::default(); + driver.turn(Timeout::Now, &mut out).expect("start reader"); + assert!(out.is_empty()); + // SAFETY: initialized native input records; fill the selected union arms. + let mut records: [INPUT_RECORD; 2] = unsafe { std::mem::zeroed() }; + records[0].EventType = WINDOW_BUFFER_SIZE_EVENT as u16; + records[0].Event.WindowBufferSizeEvent.dwSize = COORD { X: 80, Y: 25 }; + records[1].EventType = KEY_EVENT as u16; + records[1].Event.KeyEvent = KEY_EVENT_RECORD { + bKeyDown: 1, + wRepeatCount: 1, + wVirtualKeyCode: 0, + wVirtualScanCode: 0, + uChar: KEY_EVENT_RECORD_0 { + UnicodeChar: b'Z' as u16, + }, + dwControlKeyState: 0, + }; + let mut written = 0; + // SAFETY: test's isolated console input and two complete initialized records. + assert_ne!( + // SAFETY: isolated input handle and two fully initialized records. + unsafe { + WriteConsoleInputW(input.as_raw_handle(), records.as_ptr(), 2, &mut written) + }, + 0 + ); + assert_eq!(written, 2); + let (mut read, mut resized) = (false, false); + let deadline = driver.now() + Duration::from_secs(3); + while !read || !resized { + assert!(driver.now() < deadline); + driver + .turn(Timeout::Until(deadline), &mut out) + .expect("input turn"); + for c in out.drain() { + match c.result { + OpResult::Read { + n, + lease: Some(bytes), + } => { + assert_eq!(n, 1); + assert_eq!(bytes.as_slice(), b"Z"); + assert!(!read); + read = true; + } + OpResult::Signal(Signal::WinCh) => { + resized = true; + } + other => panic!("unexpected console result: {other:?}"), + } + } + } + driver.signal_stop(resize, Token(5)).expect("stop resize"); + if close { + driver.close(h, Token(6)).expect("close input"); + driver.close(screen, Token(7)).expect("close output"); + while driver.alive() { + driver.turn(Timeout::Now, &mut out).expect("close turn"); + } + } + drop(driver); + assert_eq!([mode(&input), mode(&output)], original); + } + }); +} diff --git a/crates/turnloop-contract/tests/windows_lifetimes.rs b/crates/turnloop-contract/tests/windows_lifetimes.rs new file mode 100644 index 0000000..fa35008 --- /dev/null +++ b/crates/turnloop-contract/tests/windows_lifetimes.rs @@ -0,0 +1,198 @@ +#![cfg(all(windows, not(loom)))] +#![deny(unsafe_op_in_unsafe_fn)] +use std::{ + os::windows::io::{FromRawHandle, OwnedHandle}, + ptr, + time::Duration, +}; +use turnloop::*; +use windows_sys::Win32::System::{ + Pipes::CreatePipe, + Threading::{GetCurrentProcess, GetProcessHandleCount}, +}; + +#[test] +fn imported_overlapped_pipe_routes_away_from_its_existing_port() { + use std::os::windows::{fs::OpenOptionsExt, io::AsRawHandle}; + use windows_sys::Win32::{ + Foundation::INVALID_HANDLE_VALUE, Storage::FileSystem::FILE_FLAG_OVERLAPPED, + System::IO::CreateIoCompletionPort, + }; + let mut driver = Loop::new(Config::default()).expect("loop"); + let name = format!(r"\\.\pipe\turnloop-import-{}", std::process::id()); + let listener = driver + .pipe_listen(&PipeName(name.clone().into()), &ListenOpts::default()) + .expect("listen"); + driver.accept(listener, Token(1)).expect("accept"); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .custom_flags(FILE_FLAG_OVERLAPPED) + .open(name) + .expect("overlapped client"); + // SAFETY: creates a new, unassociated completion port with no pointer retention. + let port = unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, ptr::null_mut(), 0, 1) }; + assert!(!port.is_null()); + // SAFETY: successful port creation transferred sole ownership. + let port = unsafe { OwnedHandle::from_raw_handle(port) }; + // SAFETY: owned overlapped pipe and live port; this initial association is permanent. + let associated = + unsafe { CreateIoCompletionPort(file.as_raw_handle(), port.as_raw_handle(), 9, 0) }; + assert_eq!(associated, port.as_raw_handle()); + let client = driver + .attach( + Detached::from_handle(file.into()).expect("classify pipe"), + Token(2), + ) + .expect("attach migrated pipe"); + let deadline = driver.now() + Duration::from_secs(3); + let mut out = Completions::default(); + let server = loop { + assert!(driver.now() < deadline, "accept timeout"); + driver + .turn(Timeout::Until(deadline), &mut out) + .expect("turn"); + if let Some(c) = out.drain().next() { + if let OpResult::PipeAccepted { conn } = c.result { + break conn; + } + panic!("unexpected {c:?}"); + } + }; + turnloop_contract::native_surface::transfer(&mut driver, server, client, b"routed read"); + turnloop_contract::native_surface::transfer(&mut driver, client, server, b"routed write"); +} + +#[test] +fn overlapped_regular_files_are_rejected_before_worker_submission() { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OVERLAPPED; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OVERLAPPED) + .open(std::env::current_exe().expect("test executable")) + .expect("overlapped file"); + assert!(matches!( + Detached::from_handle(file.into()), + Err(Error { + kind: ErrorKind::Unsupported, + .. + }) + )); +} +fn handles() -> u32 { + let mut count = 0; + // SAFETY: current-process pseudo handle and writable count output. + assert_ne!( + // SAFETY: current-process pseudo handle and writable count output. + unsafe { GetProcessHandleCount(GetCurrentProcess(), &mut count) }, + 0 + ); + count +} +#[test] +fn cancelled_synchronous_reads_release_buffers_and_threads() { + let (mut read, mut write) = (ptr::null_mut(), ptr::null_mut()); + // SAFETY: two output slots; noninherited anonymous synchronous pipe. + assert_ne!( + // SAFETY: two output slots; noninherited anonymous synchronous pipe. + unsafe { CreatePipe(&mut read, &mut write, ptr::null(), 4096) }, + 0 + ); + // SAFETY: successful CreatePipe transferred two independent handle owners. + let (read, _write) = unsafe { + ( + OwnedHandle::from_raw_handle(read), + OwnedHandle::from_raw_handle(write), + ) + }; + for round in 0..128 { + let mut memory = [0xa5; 64]; // outlives backend on every unwind/drop path + let mut driver = Loop::new(Config { + max_handles: 4, + max_operations: 4, + pooled_buffers: 2, + ..Config::default() + }) + .expect("loop"); + let h = driver + .attach( + Detached::from_handle(read.try_clone().expect("dup read")).expect("stdio"), + Token(1), + ) + .expect("attach"); + // SAFETY: buffer stays fixed/exclusive until cancellation or completed driver destruction. + let buf = unsafe { IoBufMut::from_raw_parts(memory.as_mut_ptr(), memory.len()) }; + driver + .read(h, ReadBuf::Provided(buf), Token(2)) + .expect("read"); + let mut out = Completions::with_capacity(1); + driver + .turn(Timeout::Now, &mut out) + .expect("start synchronous read"); + assert!(out.is_empty()); + if round % 3 == 0 { + std::thread::yield_now(); + } + if round % 2 == 0 { + driver.close(h, Token(3)).expect("close blocked stdio"); + let deadline = driver.now() + Duration::from_secs(3); + let mut cancelled = false; + while driver.alive() { + assert!(driver.now() < deadline, "synchronous cancellation race"); + driver + .turn(Timeout::Until(deadline), &mut out) + .expect("cancel turn"); + for c in out.drain() { + match c.result { + OpResult::Cancelled => { + assert!(!cancelled); + cancelled = true; + } + OpResult::Closed => { + assert!(cancelled, "buffer acknowledgement before close") + } + other => panic!("unexpected {other:?}"), + } + } + } + assert!(cancelled); + } + drop(driver); + assert_eq!(memory, [0xa5; 64]); + } +} +#[test] +fn loop_drop_and_stale_wakers_release_windows_handles() { + let cycle = || { + let mut driver = Loop::new(Config::default()).expect("loop"); + let (_, _, receiver) = turnloop_contract::pair(&mut driver); + driver + .read(receiver, ReadBuf::Pooled, Token(5)) + .expect("read"); + driver + .turn(Timeout::Now, &mut Completions::default()) + .expect("arm"); + let notifier = driver.notifier(); + let poster = driver.poster(); + drop(driver); + assert!(matches!( + notifier.notify(), + Err(Error { + kind: ErrorKind::NotFound, + .. + }) + )); + let error = poster + .post(Token(6), Payload::U64(7)) + .expect_err("closed poster"); + assert_eq!(error.error.kind, ErrorKind::NotFound); + assert!(matches!(error.payload, Some(Payload::U64(7)))); + }; + cycle(); // process-lifetime Winsock/runtime setup precedes the baseline + let baseline = handles(); + for _ in 0..32 { + cycle(); + assert_eq!(handles(), baseline, "native handle leak"); + } +} diff --git a/crates/turnloop/Cargo.toml b/crates/turnloop/Cargo.toml index ef38d40..55a0777 100644 --- a/crates/turnloop/Cargo.toml +++ b/crates/turnloop/Cargo.toml @@ -24,6 +24,15 @@ futures-io = { workspace = true, optional = true } [target.'cfg(unix)'.dependencies] libc.workspace = true +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = [ + "Wdk_Storage_FileSystem", + "Win32_Foundation", "Win32_Networking_WinSock", "Win32_Security", + "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", + "Win32_System_JobObjects", "Win32_System_LibraryLoader", "Win32_System_Pipes", + "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", +] } + [lints] workspace = true diff --git a/crates/turnloop/README.md b/crates/turnloop/README.md index eccab3d..3bffea5 100644 --- a/crates/turnloop/README.md +++ b/crates/turnloop/README.md @@ -15,7 +15,7 @@ drives futures-io streams, sleep and timeout from host turns. Perry is the first | macOS arm64 | kqueue, TCP/UDP/local IPC, stdio, processes, signals, TTY, timers and shared services | Native contracts, six allocation gates and executor tests | | Linux x86_64 / arm64 | epoll, pidfd/SIGCHLD, native services, nanosecond waits and timerfd fallback | Cross-checked locally; native CI required | | FreeBSD / Apple mobile / Android | Unix backend paths | Best effort; runtime validation pending | -| Windows x86_64 | Standalone IOCP spike | Cross-checked; production adapter and native contracts pending | +| Windows x86_64 | IOCP, TCP/UDP/named pipes, stdio, processes, console signals, timers and shared services | Native Windows 11 contracts and allocation/executor tests; see `spikes/iocp/WINDOWS_RESULTS.md` | | WASI 0.2 / 0.3 | Standalone polling / component async spikes | Production adapters and shared contracts pending | | Web | Standalone host-callback spike | Production adapter and browser contracts pending | diff --git a/crates/turnloop/src/backend/iocp/README.md b/crates/turnloop/src/backend/iocp/README.md new file mode 100644 index 0000000..d345005 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/README.md @@ -0,0 +1,56 @@ +# Windows IOCP backend + +One completion port belongs to each loop. Normal turns have no driver thread. +Requests use core generational IDs, per-direction FIFO queues, fixed operation +storage and a separate pinned kernel slab. Cancellation retains buffers, accepted +sockets and OVERLAPPED storage until acknowledgement; destruction drains pending +I/O before releasing caller memory. + +- TCP uses provider-specific AcceptEx/ConnectEx and overlapped receive/send. + Pooled idle reads arm zero-byte receives, then acquire a lease at readiness. + Supported IFS handles skip success packets; other providers keep normal IOCP + notifications. UDP preserves datagram boundaries and source addresses. +- Timed waits use high-resolution waitable timers and dynamically resolved NT + completion packets. Missing NT exports return an error. GQCSEx is nonalertable + and has no competing coarse timeout when an exact timer is armed. No global + timer-period change is made. See the measured decision in + [WINDOWS_RESULTS.md](../../../../../spikes/iocp/WINDOWS_RESULTS.md). +- Named pipes use overlapped, byte-mode, local-only instances. Socket passing is + a private cooperative control protocol: WSADuplicateSocket targets the actual + peer PID queried from the OS, and send acceptance retains independent socket + ownership. Do not mix payload reads with receive-handle operations on a control + stream. This is not a general-purpose untrusted wire protocol. +- IOCP association survives duplication. Quiescent transferred sockets and pipe + endpoints suppress their old port's notifications with the OVERLAPPED event's + low bit; one-shot registered event waits forward completion to the new port. + Callbacks are joined before slot reuse. Named-pipe listeners cannot be detached; + socket listeners and connected pipe endpoints can. Reuse-port is unsupported. +- Child creation uses an explicit inherited handle list, correctly quoted argv, + Unicode environment, suspended creation and optional Job Object assignment + before resume. Parent stdio ends are overlapped; child ends are synchronous. + One-shot process waits publish to the owning notifier. Close terminates live + owned children, then acknowledges exit before Closed. uid/gid and non-Kill + process signals return Unsupported; console signal subscriptions are separate. +- Standard streams are duplicated, preserving the host originals. Synchronous + files/pipes use one lazy worker per handle; operations on a handle are FIFO. + Imported handles are classified by native file mode before submission; + overlapped pipes use routed I/O, and other overlapped files are unsupported. + One process-wide cancellation helper handles the race before a worker enters + ReadFile, without blocking turns or polling idle handles. Console input workers + translate key records to UTF-8 and dispatch resize records to WinCh subscribers. + Resize delivery requires the console input reader to be active. Captured console + modes are restored on close/drop; input and output VT modes are distinguished. +- Console handlers fan out Int/Break/Hup to independent loop subscriptions. + Unsupported Unix signals fail explicitly. Hup from CTRL_CLOSE is best-effort: + Windows can terminate the process before the host next turns the loop. +- Integration::Event opts into a sole-consumer helper with a bounded queue and + an auto-reset event. Timer resets carry generations so old forwarded packets + cannot complete a later deadline. Hosts call turn(Now) after their GUI wait. +- Core provides timer semantics, liveness, pooled buffers, the parking handshake, + shared blocking jobs, external waits and executor behavior. + +The tests in `turnloop-contract/tests/windows*.rs` instantiate the same shared +scenarios as Unix, plus native console, GUI, cancellation and handle-count checks. +The shared allocation and executor suites also execute on Windows. Run contracts +serially because console state, process resources and allocation counters are +part of the subjects being measured. diff --git a/crates/turnloop/src/backend/iocp/bridge.rs b/crates/turnloop/src/backend/iocp/bridge.rs new file mode 100644 index 0000000..8e30ec8 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/bridge.rs @@ -0,0 +1,156 @@ +//! A migrated file object cannot change its IOCP association. The low event bit +//! suppresses old-port packets; a registered event wait forwards to the new port. +use super::{ + bool_result, os_error, + port::{Port, owned}, +}; +use crate::Result; +use std::{ + ffi::c_void, + os::windows::io::{AsRawHandle, OwnedHandle}, + ptr, + sync::{ + Arc, + atomic::{AtomicI32, AtomicU32, Ordering}, + }, +}; +use windows_sys::Win32::{ + Foundation::*, + System::{ + IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED, PostQueuedCompletionStatus}, + Threading::*, + }, +}; + +pub(super) const KEY: usize = 2; +struct Context { + port: Arc, + handle: usize, + overlapped: usize, + error: AtomicI32, + bytes: AtomicU32, +} +pub(super) struct Bridge { + context: Box, + event: Option, + wait: HANDLE, +} +unsafe extern "system" fn complete(context: *mut c_void, _: bool) { + // SAFETY: stable context remains owned until UnregisterWaitEx joins this callback. + let context = unsafe { &*context.cast::() }; + let mut bytes = 0; + // SAFETY: signaled event proves native I/O is complete; handle/OVERLAPPED are retained. + let ok = unsafe { + GetOverlappedResult( + context.handle as HANDLE, + context.overlapped as *const OVERLAPPED, + &mut bytes, + 0, + ) + }; + let error = if ok == 0 { + std::io::Error::last_os_error() + .raw_os_error() + .unwrap_or(ERROR_GEN_FAILURE as i32) + } else { + 0 + }; + context.bytes.store(bytes, Ordering::Relaxed); + context.error.store(error, Ordering::Release); + // SAFETY: port and operation are retained; pointer is only an opaque routing identity. + if unsafe { + PostQueuedCompletionStatus( + context.port.raw(), + bytes, + KEY, + context.overlapped as *const OVERLAPPED, + ) + } == 0 + { + // A lost completion could let caller buffers be freed while teardown waits forever. + std::process::abort(); + } +} +impl Bridge { + pub(super) fn new(port: Arc, overlapped: *mut OVERLAPPED) -> Result { + Ok(Self { + context: Box::new(Context { + port, + handle: 0, + overlapped: overlapped as usize, + error: AtomicI32::new(0), + bytes: AtomicU32::new(0), + }), + event: None, + wait: ptr::null_mut(), + }) + } + pub(super) fn prepare(&mut self, handle: HANDLE) -> Result { + assert!(self.wait.is_null()); + self.context.handle = handle as usize; + self.context.error.store(0, Ordering::Relaxed); + if self.event.is_none() { + // SAFETY: lazily create a noninherited, owned manual-reset event. + self.event = Some(unsafe { owned(CreateEventW(ptr::null(), 1, 0, ptr::null())) }?); + } + let event = self.event.as_ref().expect("event").as_raw_handle(); + // SAFETY: live owned event, no outstanding operation or callback. + bool_result(unsafe { ResetEvent(event) })?; + Ok(event) + } + pub(super) fn start(&mut self) -> Result<()> { + // SAFETY: context is stable, event is owned, callback executes at most once; + // finish/drop joins it before either context or kernel storage is reused. + if unsafe { + RegisterWaitForSingleObject( + &mut self.wait, + self.event.as_ref().expect("event").as_raw_handle(), + Some(complete), + ptr::from_mut(&mut *self.context).cast(), + INFINITE, + WT_EXECUTEONLYONCE, + ) + } == 0 + { + let error = os_error(); + let mut bytes = 0; + // SAFETY: setup failed after native submission; cancel and synchronously + // quiesce before returning an error or releasing caller memory. + unsafe { + CancelIoEx( + self.context.handle as HANDLE, + self.context.overlapped as *const OVERLAPPED, + ); + GetOverlappedResult( + self.context.handle as HANDLE, + self.context.overlapped as *const OVERLAPPED, + &mut bytes, + 1, + ); + } + self.wait = ptr::null_mut(); + return Err(error); + } + Ok(()) + } + pub(super) fn finish(&mut self) -> Result> { + if !self.wait.is_null() { + // SAFETY: called by the owning loop, never from its callback; joins it. + bool_result(unsafe { UnregisterWaitEx(self.wait, INVALID_HANDLE_VALUE) })?; + self.wait = ptr::null_mut(); + } + let error = self.context.error.load(Ordering::Acquire); + Ok(if error == 0 { + Ok(self.context.bytes.load(Ordering::Relaxed)) + } else { + Err(super::socket::error(error)) + }) + } +} +impl Drop for Bridge { + fn drop(&mut self) { + if !self.wait.is_null() && self.finish().is_err() { + std::process::abort(); + } + } +} diff --git a/crates/turnloop/src/backend/iocp/integration.rs b/crates/turnloop/src/backend/iocp/integration.rs new file mode 100644 index 0000000..45ecfa1 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/integration.rs @@ -0,0 +1,160 @@ +//! Opt-in, sole IOCP consumer. The host drains a bounded preallocated queue. +use super::port::{Entry, Port, STOP, Wait, bool_result, owned}; +use std::{ + collections::VecDeque, + io, + os::windows::io::{AsRawHandle, OwnedHandle}, + ptr, + sync::{Arc, Condvar, Mutex}, + thread::JoinHandle, +}; +use windows_sys::Win32::{ + Foundation::HANDLE, + System::Threading::{CreateEventW, SetEvent}, +}; + +struct Queue { + entries: VecDeque, + high_water: usize, + stopping: bool, + error: Option, +} +struct Shared { + queue: Mutex, + room: Condvar, + event: OwnedHandle, +} + +pub struct EventIntegration { + shared: Arc, + port: Arc, + worker: Option>, +} + +impl EventIntegration { + /// The caller must stop calling Port::wait while this helper is active. + pub fn new(port: Arc) -> io::Result { + // SAFETY: auto-reset event, initially nonsignaled, owned until helper joined. + let event = unsafe { owned(CreateEventW(ptr::null(), 0, 0, ptr::null())) }?; + let shared = Arc::new(Shared { + queue: Mutex::new(Queue { + entries: VecDeque::with_capacity(128), + high_water: 0, + stopping: false, + error: None, + }), + room: Condvar::new(), + event, + }); + let worker_shared = Arc::clone(&shared); + let worker_port = Arc::clone(&port); + let worker = std::thread::Builder::new() + .name("turnloop-iocp-event".into()) + .spawn(move || { + if let Err(error) = pump(&worker_shared, &worker_port) { + let mut queue = worker_shared + .queue + .lock() + .unwrap_or_else(|e| e.into_inner()); + queue.error = Some(error); + // SAFETY: event owned by worker_shared; report pump failure to host. + unsafe { + SetEvent(worker_shared.event.as_raw_handle()); + } + } + })?; + Ok(Self { + shared, + port, + worker: Some(worker), + }) + } + pub fn event(&self) -> HANDLE { + self.shared.event.as_raw_handle() + } + pub fn drain(&mut self, out: &mut [Entry]) -> io::Result { + let mut queue = self + .shared + .queue + .lock() + .map_err(|_| io::Error::other("helper queue poisoned"))?; + if let Some(error) = queue.error.take() { + return Err(error); + } + let mut n = 0; + for slot in out { + let Some(entry) = queue.entries.pop_front() else { + break; + }; + *slot = entry; + n += 1; + } + if !queue.entries.is_empty() { + // SAFETY: live auto-reset event; partial drains must re-signal while + // holding the queue lock, or the external waiter can miss remaining work. + // https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-setevent + bool_result(unsafe { SetEvent(self.event()) })?; + } + self.shared.room.notify_one(); + Ok(n) + } + pub fn shutdown(&mut self) -> io::Result<()> { + if self.worker.is_none() { + return Ok(()); + } + { + let mut queue = self.shared.queue.lock().unwrap_or_else(|e| e.into_inner()); + queue.stopping = true; + self.shared.room.notify_all(); + } + self.port.post(STOP, 0)?; + if let Some(worker) = self.worker.take() { + worker + .join() + .map_err(|_| io::Error::other("helper panicked"))?; + } + Ok(()) + } +} +impl Drop for EventIntegration { + fn drop(&mut self) { + let _ = self.shutdown(); + } +} + +fn pump(shared: &Shared, port: &Port) -> io::Result<()> { + let mut batch = [Entry::default(); 64]; + loop { + { + let queue = shared + .queue + .lock() + .map_err(|_| io::Error::other("helper queue poisoned"))?; + if queue.stopping { + return Ok(()); + } + } + let Wait::Entries(n) = port.wait(None, false, &mut batch)? else { + continue; + }; + for entry in &batch[..n] { + let mut queue = shared + .queue + .lock() + .map_err(|_| io::Error::other("helper queue poisoned"))?; + while queue.entries.len() == 128 && !queue.stopping { + queue = shared + .room + .wait(queue) + .map_err(|_| io::Error::other("helper queue poisoned"))?; + } + if queue.stopping || entry.key == STOP { + return Ok(()); + } + queue.entries.push_back(*entry); // capacity checked: never grows + queue.high_water = queue.high_water.max(queue.entries.len()); + // SAFETY: shared owns live event; queue published before wake. + bool_result(unsafe { SetEvent(shared.event.as_raw_handle()) })?; + } + } +} diff --git a/crates/turnloop/src/backend/iocp/ipc.rs b/crates/turnloop/src/backend/iocp/ipc.rs new file mode 100644 index 0000000..f2c9b23 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/ipc.rs @@ -0,0 +1,119 @@ +//! Private cooperative named-pipe control protocol. Only sockets are transferred; +//! WSADuplicateSocket targets the pipe's OS-reported peer PID, never a supplied PID. +use super::*; +use windows_sys::Win32::System::Pipes::{ + GetNamedPipeClientProcessId, GetNamedPipeInfo, GetNamedPipeServerProcessId, PIPE_SERVER_END, +}; +const MAGIC: u32 = 0x314c_5457; // WTL1 +const LENGTH: usize = 4 + size_of::(); + +impl Iocp { + pub(super) fn ipc_step( + &mut self, + i: usize, + p: &mut Pending, + ) -> Result, bool)>> { + let raw = self.get(p.request.handle)?.transport.native.raw(); + let send = matches!(p.request.operation, Operation::SendHandle(_)); + if let Some(result) = p.completion.take() { + let n = result? as usize; + if n == 0 || n > LENGTH - p.offset { + return Err(Error::new(ErrorKind::BrokenPipe)); + } + p.offset += n; + if p.offset == LENGTH { + if send { + return Ok(Some((Outcome::HandleSent, true))); + } + // SAFETY: completed read initialized the complete frame; the word + // array ensures alignment and initialized padding in protocol data. + let k = unsafe { &*self.kernel[i].get() }; + if k.wire[0] != MAGIC { + return Err(invalid()); + } + // SAFETY: full validated-size frame; WSAPROTOCOL_INFOW contains no + // pointers and is only accepted through cooperative local IPC. + let info = unsafe { &*k.wire.as_ptr().add(1).cast::() }; + let socket = socket::reconstruct(info)?; + let transport = Detached::from_socket(socket)?; + return Ok(Some((Outcome::HandleReceived(transport), true))); + } + } + let k = if p.stage == Stage::Start { + let k = self.prepare(i, p)?; + if send { + let mut flags = 0; + let mut pid = 0; + // SAFETY: live named pipe, valid output; OS identifies the actual peer. + bool_result(unsafe { + GetNamedPipeInfo( + raw, + &mut flags, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ) + })?; + // SAFETY: connected named pipe and writable PID output. + bool_result(unsafe { + if flags & PIPE_SERVER_END != 0 { + GetNamedPipeClientProcessId(raw, &mut pid) + } else { + GetNamedPipeServerProcessId(raw, &mut pid) + } + })?; + let socket = p.passed.as_ref().expect("independent send ownership"); + // SAFETY: quiescent aligned word storage, large enough for the frame; + // duplicate source remains owned through terminal acknowledgement. + unsafe { + (*k).wire[0] = MAGIC; + socket::check(WSADuplicateSocketW( + socket.as_raw_socket() as usize, + pid, + ptr::addr_of_mut!((*k).wire).cast::().add(1).cast(), + ))?; + } + } + k + } else { + let k = self.kernel[i].get(); + // SAFETY: previous I/O and bridge callback completed. Preserve the wire + // frame while resetting only OVERLAPPED for a partial transfer. + let event = unsafe { (*k).overlapped.hEvent }; + // SAFETY: quiescent OVERLAPPED can be reset without touching the payload. + unsafe { + ptr::write(ptr::addr_of_mut!((*k).overlapped), std::mem::zeroed()); + (*k).overlapped.hEvent = event; + } + if let Some(bridge) = &mut self.bridges[i] { + bridge.prepare(raw)?; + } + k + }; + let mut bytes = 0; + // SAFETY: frame stays in pinned kernel storage until completion, and offset + // bounds restrict every partial read/write to the remaining frame bytes. + let ok = unsafe { + let buffer = ptr::addr_of_mut!((*k).wire).cast::().add(p.offset); + if send { + WriteFile( + raw, + buffer, + (LENGTH - p.offset) as u32, + &mut bytes, + k.cast(), + ) + } else { + ReadFile( + raw, + buffer, + (LENGTH - p.offset) as u32, + &mut bytes, + k.cast(), + ) + } + } != 0; + self.submitted(i, p, ok, bytes, os_error(), Stage::Io)?; + Ok(None) + } +} diff --git a/crates/turnloop/src/backend/iocp/mod.rs b/crates/turnloop/src/backend/iocp/mod.rs new file mode 100644 index 0000000..bb357b1 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/mod.rs @@ -0,0 +1,1421 @@ +//! Native IOCP completion backend. Kernel storage is pinned separately from Rust +//! operation metadata and survives cancellation until the OS acknowledgement. +mod bridge; +mod integration; +mod ipc; +mod pipes; +mod port; +mod process; +mod services; +mod signals; +mod socket; +mod sync_io; +mod timer; + +use crate::{ + backend::{Backend, Event, Operation, Outcome, PollInfo, Request}, + *, +}; +use integration::EventIntegration; +use port::{Entry, Port, TIMER, WAKE, Wait}; +use std::{ + cell::UnsafeCell, + collections::VecDeque, + mem::size_of, + net::SocketAddr, + os::windows::io::{AsRawHandle, AsRawSocket, OwnedHandle, OwnedSocket}, + ptr, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; +use windows_sys::Win32::{ + Foundation::*, + Networking::WinSock::*, + Storage::FileSystem::{ReadFile, SetFileCompletionNotificationModes, WriteFile}, + System::{ + IO::{CancelIoEx, OVERLAPPED}, + Pipes::ConnectNamedPipe, + }, +}; + +fn invalid() -> Error { + Error::new(ErrorKind::InvalidInput) +} +fn unsupported() -> Error { + Error::new(ErrorKind::Unsupported) +} +fn os_error() -> Error { + std::io::Error::last_os_error().into() +} +fn bool_result(value: i32) -> Result<()> { + if value == 0 { Err(os_error()) } else { Ok(()) } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Tcp, + Listener, + Udp, + Pipe, + PipeListener, + Sync, +} +#[derive(Debug)] +enum Native { + Socket(OwnedSocket), + Handle(OwnedHandle), +} +impl Native { + fn raw(&self) -> HANDLE { + match self { + Self::Socket(s) => s.as_raw_socket() as HANDLE, + Self::Handle(h) => h.as_raw_handle(), + } + } +} +/// Owning, quiescent Windows transport. Transfer preserves independent ownership; +/// a prior IOCP association is routed through overlapped events on the next loop. +#[derive(Debug)] +pub struct Detached { + native: Native, + kind: Kind, + routed: bool, + mode: Option, + console_input: bool, +} +impl Detached { + fn new(native: Native, kind: Kind, routed: bool) -> Self { + Self { + native, + kind, + routed, + mode: None, + console_input: false, + } + } +} +impl Drop for Detached { + fn drop(&mut self) { + if let Some(mode) = self.mode { + // SAFETY: owned console handle is still live; mode was captured on adoption. + unsafe { + windows_sys::Win32::System::Console::SetConsoleMode(self.native.raw(), mode); + } + } + } +} +struct Resource { + handle: Handle, + transport: Detached, + connect: Option, + name: Option>, + skip: bool, + heads: [Option; 2], + tails: [Option; 2], +} +#[repr(C)] +struct Kernel { + overlapped: OVERLAPPED, + addr: SOCKADDR_STORAGE, + addr_len: i32, + flags: u32, + accept_addr: [u8; 288], + wire: [u32; 160], +} +#[derive(Clone, Copy, PartialEq, Eq)] +enum Stage { + Start, + Io, + IdleRead, +} +struct Pending { + request: Request, + next: Option, + stage: Stage, + waiting: bool, + queued: bool, + pool_wait: bool, + cancelled: bool, + completion: Option>, + offset: usize, + lease: Option, + accepted: Option, + passed: Option, + direction: usize, +} +fn direction(operation: &Operation) -> usize { + usize::from(!matches!( + operation, + Operation::Read { .. } + | Operation::RecvFrom(_) + | Operation::Accept { .. } + | Operation::RecvHandle + )) +} +/// Lifetime-safe, parking-aware IOCP wake endpoint. +pub struct IocpWake { + port: Arc, + calls: AtomicU64, +} +impl crate::backend::Wake for IocpWake { + fn wake(&self) -> Result<()> { + self.calls.fetch_add(1, Ordering::Relaxed); + self.port.post(WAKE, 0).map_err(Into::into) + } + fn syscall_count(&self) -> u64 { + self.calls.load(Ordering::Relaxed) + } +} +/// One IOCP per host-driven loop, with fixed operation storage and an opt-in GUI helper. +pub struct Iocp { + resources: Vec>, + ops: Vec>, + kernel: Box<[UnsafeCell]>, + bridges: Vec>, + ready: VecDeque, + pool_waiting: VecDeque, + pool: BufferPool, + port: Arc, + wake: Arc, + timer: timer::PacketTimer, + event: Option, + notifier: Option, + services: services::Services, + workers: Vec>, + deadline: Option, + failure: Option, +} +impl Iocp { + fn get(&self, h: Handle) -> Result<&Resource> { + self.resources + .get(h.index()) + .and_then(Option::as_ref) + .filter(|r| r.handle == h) + .ok_or(Error::new(ErrorKind::NotFound)) + } + fn install( + &mut self, + h: Handle, + transport: Detached, + connect: Option, + name: Option>, + ) -> Result<()> { + if self.resources.get(h.index()).is_none_or(Option::is_some) { + return Err(invalid()); + } + let raw = transport.native.raw(); + let mut skip = false; + if let Native::Socket(_) = transport.native { + socket::nonblocking(raw as usize)?; + } + if transport.kind != Kind::Sync && !transport.routed { + // SAFETY: freshly created, exclusively owned overlapped resource. + unsafe { self.port.associate(raw, 1) }.map_err(Error::from)?; + let ifs = if matches!(transport.native, Native::Socket(_)) { + socket::ifs(raw as usize)? + } else { + true + }; + if ifs { + // SAFETY: live overlapped IFS handle. Unsupported skip mode retains + // normal completion packets, including synchronous successes. + skip = unsafe { SetFileCompletionNotificationModes(raw, 1) } != 0; + } + } + self.resources[h.index()] = Some(Resource { + handle: h, + transport, + connect, + name, + skip, + heads: [None; 2], + tails: [None; 2], + }); + Ok(()) + } + fn schedule(&mut self, i: usize) { + if let Some(p) = &mut self.ops[i] + && !p.queued + && !p.waiting + { + p.queued = true; + self.ready.push_back(i); + } + } + fn unlink(&mut self, i: usize, p: &Pending) { + let h = p.request.handle; + let d = p.direction; + let r = self.resources[h.index()].as_mut().expect("live resource"); + if r.heads[d] == Some(i) { + r.heads[d] = p.next; + if r.tails[d] == Some(i) { + r.tails[d] = None; + } + } else { + let mut at = r.heads[d]; + while let Some(j) = at { + let previous = self.ops[j].as_mut().expect("queued operation"); + if previous.next == Some(i) { + previous.next = p.next; + if r.tails[d] == Some(i) { + r.tails[d] = Some(j); + } + break; + } + at = previous.next; + } + } + if let Some(next) = r.heads[d] { + self.schedule(next); + } + } + fn run_ready(&mut self, events: &mut Vec>) { + if self.pool.available() { + while let Some(op) = self.pool_waiting.pop_front() { + self.ops[op.index()] + .as_mut() + .expect("pool waiter") + .pool_wait = false; + self.schedule(op.index()); + } + } + let budget = self.ready.len(); + for _ in 0..budget { + if events.len() == events.capacity() { + break; + } + let i = self.ready.pop_front().expect("ready operation"); + let Some(mut p) = self.ops[i].take() else { + continue; + }; + p.queued = false; + let result = if p.cancelled { + Ok(Some((Outcome::Cancelled, true))) + } else { + self.execute(i, &mut p) + }; + let result = match result { + Ok(value) => value, + Err(error) => { + self.unlink(i, &p); + events.push(Event { + op: p.request.op, + terminal: true, + result: Err(error), + }); + continue; + } + }; + if let Some((result, terminal)) = result { + if terminal { + self.unlink(i, &p); + } + events.push(Event { + op: p.request.op, + terminal, + result: Ok(result), + }); + if terminal { + continue; + } + p.stage = Stage::Start; + p.completion = None; + } + let runnable = !p.waiting && !p.pool_wait; + if p.pool_wait { + self.pool_waiting.push_back(p.request.op); + } + self.ops[i] = Some(p); + if runnable { + self.schedule(i); + } + } + } + fn kernel_ptr(&self, i: usize) -> *mut OVERLAPPED { + self.kernel[i].get().cast() + } + fn io_raw(&self, p: &Pending) -> HANDLE { + if matches!(p.request.operation, Operation::Accept { .. }) + && let Some(accepted) = &p.accepted + && accepted.kind == Kind::Pipe + { + return accepted.native.raw(); + } + self.resources[p.request.handle.index()] + .as_ref() + .expect("resource") + .transport + .native + .raw() + } + fn prepare(&mut self, i: usize, p: &Pending) -> Result<*mut Kernel> { + let raw = self.kernel[i].get(); + // SAFETY: caller only prepares after the previous native completion and + // registered-wait callback have both quiesced. Slab storage never moves. + unsafe { + ptr::write(raw, std::mem::zeroed()); + } + let resource = self.get(p.request.handle)?; + if resource.transport.routed { + if self.bridges[i].is_none() { + self.bridges[i] = Some(bridge::Bridge::new( + Arc::clone(&self.port), + self.kernel_ptr(i), + )?); + } + let handle = self.io_raw(p); + let event = self.bridges[i].as_mut().expect("bridge").prepare(handle)?; + // SAFETY: inactive kernel slot. Low event bit suppresses the old IOCP + // association, including when socket duplication retained that association. + unsafe { + (*raw).overlapped.hEvent = (event as usize | 1) as HANDLE; + } + } + Ok(raw) + } + fn submitted( + &mut self, + i: usize, + p: &mut Pending, + success: bool, + bytes: u32, + error: Error, + stage: Stage, + ) -> Result<()> { + let r = self.get(p.request.handle)?; + p.stage = stage; + if success && (r.skip || r.transport.routed) { + p.completion = Some(Ok(bytes)); + } else if success || error.os == Some(ERROR_IO_PENDING as i32) { + p.waiting = true; + if r.transport.routed { + // A failed wait registration cannot release an in-flight request. + // Bridge::start cancels and synchronously drains on setup failure. + if let Err(error) = self.bridges[i].as_mut().expect("bridge").start() { + p.waiting = false; + return Err(error); + } + } + } else { + p.completion = Some(Err(error)); + } + Ok(()) + } + fn execute(&mut self, i: usize, p: &mut Pending) -> Result, bool)>> { + if matches!( + p.request.operation, + Operation::SendHandle(_) | Operation::RecvHandle + ) { + return self.ipc_step(i, p); + } + let r = self.get(p.request.handle)?; + let raw = r.transport.native.raw(); + let kind = r.transport.kind; + if let Some(result) = p.completion.take() { + let bytes = match result { + Ok(bytes) => bytes as usize, + Err(error) + if matches!(p.request.operation, Operation::Read { .. }) + && matches!(error.os, Some(code) if code == ERROR_BROKEN_PIPE as i32 || code == ERROR_HANDLE_EOF as i32) => + { + return Ok(Some((Outcome::Eof, true))); + } + Err(error) => return Err(error), + }; + if p.stage == Stage::IdleRead { + return self.read_ready(p, raw as usize); + } + match &p.request.operation { + Operation::Connect => { + if kind == Kind::Tcp { + // SAFETY: successful ConnectEx requires context update before use. + socket::check(unsafe { + setsockopt( + raw as usize, + SOL_SOCKET, + SO_UPDATE_CONNECT_CONTEXT, + ptr::null(), + 0, + ) + })?; + } + return Ok(Some((Outcome::Connected, true))); + } + Operation::Accept { multishot } => { + let transport = p.accepted.take().expect("accept transport"); + let outcome = if kind == Kind::Listener { + let listener = raw as usize; + // SAFETY: AcceptEx completed; option consumes the live listener value. + socket::check(unsafe { + setsockopt( + transport.native.raw() as usize, + SOL_SOCKET, + SO_UPDATE_ACCEPT_CONTEXT, + ptr::from_ref(&listener).cast(), + size_of::() as i32, + ) + })?; + let peer = socket::address(transport.native.raw() as usize, true)?; + Outcome::Accepted { transport, peer } + } else { + Outcome::PipeAccepted(transport) + }; + return Ok(Some((outcome, !multishot))); + } + Operation::Read { multishot, .. } => { + if bytes == 0 { + return Ok(Some((Outcome::Eof, true))); + } + let mut lease = p.lease.take(); + if let Some(lease) = &mut lease { + lease.set_len(bytes); + } + return Ok(Some((Outcome::Read { n: bytes, lease }, !multishot))); + } + Operation::RecvFrom(_) => { + // SAFETY: native receive has completed, so the address output is quiescent. + let k = unsafe { &*self.kernel[i].get() }; + let from = socket::decode(&k.addr, k.addr_len)?; + let mut lease = p.lease.take(); + if let Some(lease) = &mut lease { + lease.set_len(bytes); + } + return Ok(Some(( + Outcome::RecvFrom { + n: bytes, + from, + lease, + }, + true, + ))); + } + Operation::SendTo { .. } => return Ok(Some((Outcome::Wrote(bytes), true))), + Operation::Write(_) | Operation::Writev(_) => { + if bytes == 0 && write_length(&p.request.operation) != p.offset { + return Err(Error::new(ErrorKind::BrokenPipe)); + } + p.offset += bytes; + if p.offset == write_length(&p.request.operation) { + return Ok(Some((Outcome::Wrote(p.offset), true))); + } + p.stage = Stage::Start; + } + _ => return Err(unsupported()), + } + } + let r = self.get(p.request.handle)?; + if kind == Kind::Sync { + let write = matches!( + p.request.operation, + Operation::Write(_) | Operation::Writev(_) + ); + let (buffer, len) = if write { + let mut buffers = [WSABUF { + len: 0, + buf: ptr::null_mut(), + }; MAX_IOV]; + if write_buffers(&p.request.operation, p.offset, &mut buffers) == 0 { + return Ok(Some((Outcome::Wrote(p.offset), true))); + } + (buffers[0].buf, buffers[0].len) + } else { + match read_buffer(p, &self.pool) { + Some(buf) => buf, + None => { + p.pool_wait = true; + return Ok(None); + } + } + }; + let index = p.request.handle.index(); + if self.workers[index].is_none() { + self.workers[index] = Some(sync_io::Worker::new(raw, Arc::clone(&self.port))?); + } + self.workers[index].as_ref().expect("stdio worker").start( + buffer, + len, + write, + self.kernel_ptr(i), + ); + p.stage = Stage::Io; + p.waiting = true; + return Ok(None); + } + match &p.request.operation { + Operation::Shutdown => { + if matches!(kind, Kind::Tcp) { + // SAFETY: live stream; sends ahead of shutdown have completed in FIFO order. + socket::check(unsafe { shutdown(raw as usize, SD_SEND) })?; + } else { + return Err(unsupported()); + } + return Ok(Some((Outcome::Shutdown, true))); + } + Operation::Connect if kind == Kind::Pipe => { + return Ok(Some((Outcome::Connected, true))); + } + Operation::Read { + buf: ReadBuf::Pooled, + .. + } if kind == Kind::Tcp => { + let k = self.prepare(i, p)?; + let buf = WSABUF { + len: 0, + buf: ptr::null_mut(), + }; + let mut bytes = 0; + // SAFETY: pinned OVERLAPPED/flags, descriptor captured synchronously; + // zero-byte receive retains no payload or pool lease while idle. + let ok = unsafe { + WSARecv( + raw as usize, + &buf, + 1, + &mut bytes, + ptr::addr_of_mut!((*k).flags), + k.cast(), + None, + ) + } == 0; + self.submitted(i, p, ok, bytes, socket::last_error(), Stage::IdleRead)?; + } + Operation::Read { .. } | Operation::RecvFrom(_) => { + let (buf, len) = match read_buffer(p, &self.pool) { + Some(buf) => buf, + None => { + p.pool_wait = true; + return Ok(None); + } + }; + let k = self.prepare(i, p)?; + let mut bytes = 0; + let (ok, error) = if matches!(kind, Kind::Tcp | Kind::Udp) { + let buf = WSABUF { len, buf }; + // SAFETY: stable provided/leased storage; kernel output lives in + // the pinned slab through acknowledgement. WSABUF is captured. + let code = unsafe { + if kind == Kind::Udp { + (*k).addr_len = size_of::() as i32; + WSARecvFrom( + raw as usize, + &buf, + 1, + &mut bytes, + ptr::addr_of_mut!((*k).flags), + ptr::addr_of_mut!((*k).addr).cast(), + ptr::addr_of_mut!((*k).addr_len), + k.cast(), + None, + ) + } else { + WSARecv( + raw as usize, + &buf, + 1, + &mut bytes, + ptr::addr_of_mut!((*k).flags), + k.cast(), + None, + ) + } + }; + (code == 0, socket::last_error()) + } else { + // SAFETY: live overlapped pipe and stable exclusive buffer until completion. + let ok = unsafe { ReadFile(raw, buf, len, &mut bytes, k.cast()) } != 0; + (ok, os_error()) + }; + self.submitted(i, p, ok, bytes, error, Stage::Io)?; + } + Operation::Write(_) | Operation::Writev(_) | Operation::SendTo { .. } => { + let k = self.prepare(i, p)?; + let mut buffers = [WSABUF { + len: 0, + buf: ptr::null_mut(), + }; MAX_IOV]; + let n = write_buffers(&p.request.operation, p.offset, &mut buffers); + if n == 0 && !matches!(p.request.operation, Operation::SendTo { .. }) { + return Ok(Some((Outcome::Wrote(p.offset), true))); + } + let mut bytes = 0; + let (ok, error) = if matches!(kind, Kind::Tcp | Kind::Udp) { + // SAFETY: request retains immutable payload until completion; + // Winsock captures stack descriptors and destination synchronously. + let code = unsafe { + if let Operation::SendTo { to, .. } = p.request.operation { + let addr = socket::Addr::new(to); + WSASendTo( + raw as usize, + buffers.as_ptr(), + n.max(1) as u32, + &mut bytes, + 0, + addr.as_ptr(), + addr.len, + k.cast(), + None, + ) + } else { + WSASend( + raw as usize, + buffers.as_ptr(), + n as u32, + &mut bytes, + 0, + k.cast(), + None, + ) + } + }; + (code == 0, socket::last_error()) + } else { + // SAFETY: overlapped pipe; first remaining segment retained until completion. + let ok = unsafe { + WriteFile(raw, buffers[0].buf, buffers[0].len, &mut bytes, k.cast()) + } != 0; + (ok, os_error()) + }; + self.submitted(i, p, ok, bytes, error, Stage::Io)?; + } + Operation::Connect => { + let addr = socket::Addr::new(r.connect.ok_or_else(invalid)?); + // SAFETY: nullable ConnectEx pointer matches the provider GUID. + let connect = + unsafe { socket::extension::(raw as usize, &WSAID_CONNECTEX) }? + .ok_or_else(unsupported)?; + let k = self.prepare(i, p)?; + let mut bytes = 0; + // SAFETY: explicitly bound socket; address captured; pinned OVERLAPPED. + let ok = unsafe { + connect( + raw as usize, + addr.as_ptr(), + addr.len, + ptr::null(), + 0, + &mut bytes, + k.cast(), + ) + } != 0; + self.submitted(i, p, ok, bytes, socket::last_error(), Stage::Io)?; + } + Operation::Accept { .. } if kind == Kind::Listener => { + let v6 = socket::address(raw as usize, false)?.is_ipv6(); + p.accepted = Some(Detached::new( + Native::Socket(socket::create(v6, false)?), + Kind::Tcp, + false, + )); + // SAFETY: nullable AcceptEx pointer matches the provider GUID. + let accept = + unsafe { socket::extension::(raw as usize, &WSAID_ACCEPTEX) }? + .ok_or_else(unsupported)?; + let k = self.prepare(i, p)?; + let accepted = p.accepted.as_ref().expect("socket").native.raw() as usize; + let mut bytes = 0; + // SAFETY: listener and secondary socket stay owned until completion; + // address buffer is pinned and has two sockaddr_storage + 16 regions. + let ok = unsafe { + accept( + raw as usize, + accepted, + ptr::addr_of_mut!((*k).accept_addr).cast(), + 0, + 144, + 144, + &mut bytes, + k.cast(), + ) + } != 0; + self.submitted(i, p, ok, bytes, socket::last_error(), Stage::Io)?; + } + Operation::Accept { .. } if kind == Kind::PipeListener => { + let next = pipes::instance(r.name.as_ref().expect("pipe name"), false)?; + // SAFETY: newly created overlapped pipe is unassociated. + unsafe { self.port.associate(next.native.raw(), 1) }.map_err(Error::from)?; + // SAFETY: fresh pipe supports skip-success. + bool_result(unsafe { SetFileCompletionNotificationModes(next.native.raw(), 1) })?; + let r = self.resources[p.request.handle.index()] + .as_mut() + .expect("listener"); + let mut accepted = std::mem::replace(&mut r.transport, next); + accepted.kind = Kind::Pipe; + accepted.routed = true; + p.accepted = Some(accepted); + let k = self.prepare(i, p)?; + let accept_raw = self.io_raw(p); + // SAFETY: owned overlapped instance and pinned operation. + let ok = unsafe { ConnectNamedPipe(accept_raw, k.cast()) } != 0; + let error = os_error(); + if error.os == Some(ERROR_PIPE_CONNECTED as i32) && !ok { + p.stage = Stage::Io; + p.completion = Some(Ok(0)); + } else { + self.submitted(i, p, ok, 0, error, Stage::Io)?; + } + } + _ => return Err(unsupported()), + } + Ok(None) + } + fn read_ready( + &mut self, + p: &mut Pending, + raw: usize, + ) -> Result, bool)>> { + let Some(mut lease) = self.pool.acquire() else { + p.pool_wait = true; + p.completion = Some(Ok(0)); + return Ok(None); + }; + let buf = lease.writable(); + // SAFETY: nonblocking socket and exclusive lease; synchronous recv retains no pointer. + let n = unsafe { + recv( + raw, + buf.as_mut_ptr(), + buf.len().min(i32::MAX as usize) as i32, + 0, + ) + }; + if n == SOCKET_ERROR { + let error = socket::last_error(); + if error.kind == ErrorKind::WouldBlock { + p.stage = Stage::Start; + return Ok(None); + } + return Err(error); + } + if n == 0 { + return Ok(Some((Outcome::Eof, true))); + } + lease.set_len(n as usize); + let multishot = matches!( + p.request.operation, + Operation::Read { + multishot: true, + .. + } + ); + Ok(Some(( + Outcome::Read { + n: n as usize, + lease: Some(lease), + }, + !multishot, + ))) + } + fn entries(&mut self, entries: &[Entry]) -> Result<()> { + for entry in entries { + if entry.key == WAKE { + continue; + } + if entry.key == TIMER { + // SAFETY: this port has exactly one associated deadline timer. + unsafe { + self.timer.dequeued(entry.overlapped); + } + if self.event.is_some() + && self + .deadline + .is_some_and(|deadline| deadline > Instant::now()) + { + self.arm_event_deadline()?; + } + continue; + } + let base = self.kernel.as_ptr() as usize; + let stride = size_of::>(); + let offset = entry.overlapped.checked_sub(base).ok_or_else(invalid)?; + if offset % stride != 0 || offset / stride >= self.ops.len() { + return Err(invalid()); + } + let i = offset / stride; + let bridge_result = if entry.key == sync_io::KEY { + let p = self.ops[i].as_ref().ok_or_else(invalid)?; + Some( + self.workers[p.request.handle.index()] + .as_ref() + .ok_or_else(invalid)? + .finish(), + ) + } else if entry.key == bridge::KEY { + Some(self.bridges[i].as_mut().ok_or_else(invalid)?.finish()?) + } else { + None + }; + let p = self.ops[i].as_mut().ok_or_else(invalid)?; + if !p.waiting { + return Err(invalid()); + } + p.waiting = false; + p.completion = Some(bridge_result.unwrap_or_else(|| { + if entry.status < 0 { + // SAFETY: pure NTSTATUS conversion, no pointers. + Err(socket::error( + unsafe { RtlNtStatusToDosError(entry.status) } as i32, + )) + } else { + Ok(entry.bytes) + } + })); + self.schedule(i); + } + Ok(()) + } +} +// SAFETY: accepted requests and pinned kernel storage stay owned until terminal +// acknowledgement. Cancel never frees pending storage. Drop cancels and drains +// native I/O and joins routed completion callbacks before releasing any buffers. +unsafe impl Backend for Iocp { + type Wake = IocpWake; + type Detached = Detached; + fn new(config: &Config, pool: BufferPool) -> Result { + socket::startup()?; + let port = Arc::new(Port::new()?); + let kernel: Box<[UnsafeCell]> = (0..config.max_operations) + .map(|_| { + // SAFETY: inactive all-zero kernel I/O storage is valid. + UnsafeCell::new(unsafe { std::mem::zeroed() }) + }) + .collect(); + let bridges = kernel + .iter() + .map(|slot| bridge::Bridge::new(Arc::clone(&port), slot.get().cast()).map(Some)) + .collect::>>()?; + Ok(Self { + resources: (0..config.max_handles).map(|_| None).collect(), + ops: (0..config.max_operations).map(|_| None).collect(), + kernel, + bridges, + ready: VecDeque::with_capacity(config.max_operations), + pool_waiting: VecDeque::with_capacity(config.max_operations), + pool, + wake: Arc::new(IocpWake { + port: Arc::clone(&port), + calls: AtomicU64::new(0), + }), + timer: timer::PacketTimer::new(Arc::clone(&port))?, + port, + event: None, + notifier: None, + services: services::Services::new(config.max_handles), + workers: (0..config.max_handles).map(|_| None).collect(), + deadline: None, + failure: None, + }) + } + fn set_notifier(&mut self, notifier: Notifier) { + self.notifier = Some(notifier); + } + fn spawn( + &mut self, + handle: Handle, + pipes: [Option; 3], + spec: &ProcessSpec, + ) -> Result { + let mut existing = [None; 3]; + for (i, option) in spec.stdio.iter().enumerate() { + if let ProcessStdio::Handle(h) = option { + existing[i] = Some(self.get(*h)?.transport.native.raw()); + } + } + let (child, parents) = + process::spawn(spec, existing, self.notifier.clone().ok_or_else(invalid)?)?; + let pid = child.pid; + let result = (|| { + for (h, parent) in pipes.into_iter().zip(parents) { + if let (Some(h), Some(parent)) = (h, parent) { + self.install(h, parent, None, None)?; + } + } + child.resume() + })(); + if let Err(error) = result { + for h in pipes.into_iter().flatten() { + self.release(h); + } + return Err(error); + } + self.services.child(handle, child); + Ok(pid) + } + fn prepare_close(&mut self, handle: Handle) -> Result<()> { + self.services.close(handle) + } + fn kill(&mut self, handle: Handle, signal: Signal, group: bool) -> Result<()> { + self.services.kill(handle, signal, group) + } + fn signal(&mut self, handle: Handle, signal: Signal) -> Result<()> { + self.services + .signal(handle, signal, self.notifier.clone().ok_or_else(invalid)?) + } + fn tty_set_mode(&mut self, handle: Handle, mode: TtyMode) -> Result<()> { + use windows_sys::Win32::System::Console::*; + let r = self.get(handle)?; + let original = r.transport.mode.ok_or_else(unsupported)?; + let raw = r.transport.native.raw(); + let flags = match mode { + TtyMode::Normal => original, + TtyMode::Raw | TtyMode::Io if r.transport.console_input => { + let mut flags = (original + | ENABLE_WINDOW_INPUT + | ENABLE_VIRTUAL_TERMINAL_INPUT + | ENABLE_EXTENDED_FLAGS) + & !(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_QUICK_EDIT_MODE); + if mode == TtyMode::Io { + flags &= !ENABLE_PROCESSED_INPUT; + } + flags + } + TtyMode::Raw | TtyMode::Io => { + original | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING + } + }; + // SAFETY: mode was captured from this live console handle. + bool_result(unsafe { SetConsoleMode(raw, flags) }) + } + fn tty_window_size(&self, handle: Handle) -> Result { + use windows_sys::Win32::System::Console::*; + let r = self.get(handle)?; + if r.transport.mode.is_none() { + return Err(unsupported()); + } + // SAFETY: plain writable console screen-buffer information. + let mut info = unsafe { std::mem::zeroed() }; + // SAFETY: live owned console screen-buffer handle and correct output. + let output = if r.transport.console_input { + use windows_sys::Win32::Storage::FileSystem::*; + let name = [67u16, 79, 78, 79, 85, 84, 36, 0]; // CONOUT$ + // SAFETY: explicit console device, exclusively owned temporary query handle. + Some(unsafe { + port::owned(CreateFileW( + name.as_ptr(), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + ptr::null(), + OPEN_EXISTING, + 0, + ptr::null_mut(), + )) + }?) + } else { + None + }; + let raw = output + .as_ref() + .map_or(r.transport.native.raw(), AsRawHandle::as_raw_handle); + // SAFETY: owned console screen-buffer handle and correct writable output. + bool_result(unsafe { GetConsoleScreenBufferInfo(raw, &mut info) })?; + Ok(WindowSize { + columns: (info.srWindow.Right - info.srWindow.Left + 1) as u16, + rows: (info.srWindow.Bottom - info.srWindow.Top + 1) as u16, + }) + } + fn now(&self) -> Instant { + Instant::now() + } + fn validate_timeout(&self, timeout: Timeout) -> Result<()> { + if self.event.is_some() && !matches!(timeout, Timeout::Now) { + return Err(invalid()); + } + Ok(()) + } + fn deadline_changed(&mut self, deadline: Option) { + self.deadline = deadline; + if self.event.is_some() + && let Err(error) = self.arm_event_deadline() + { + self.failure = Some(error); + } + } + fn waker(&self) -> Arc { + Arc::clone(&self.wake) + } + fn open(&mut self, h: Handle, spec: Open) -> Result<()> { + let (transport, connect, name) = match spec { + Open::Tcp { addr, opts } => { + let socket = socket::create(addr.is_ipv6(), false)?; + let raw = socket.as_raw_socket() as usize; + socket::bind_to( + raw, + if addr.is_ipv6() { + ([0u16; 8], 0).into() + } else { + ([0u8; 4], 0).into() + }, + )?; + if addr.ip().is_loopback() { + socket::loopback_connect(raw)?; + } + if opts.nodelay { + let value = 1i32; + // SAFETY: TCP_NODELAY takes an initialized integer value. + socket::check(unsafe { + setsockopt( + raw, + IPPROTO_TCP, + TCP_NODELAY, + ptr::from_ref(&value).cast(), + 4, + ) + })?; + } + ( + Detached::new(Native::Socket(socket), Kind::Tcp, false), + Some(addr), + None, + ) + } + Open::Listener { addr, opts } => { + if opts.reuse_port { + return Err(unsupported()); + } + let socket = socket::create(addr.is_ipv6(), false)?; + let raw = socket.as_raw_socket() as usize; + socket::option(raw, SO_EXCLUSIVEADDRUSE, 1)?; + socket::bind_to(raw, addr)?; + // SAFETY: bound socket; backlog is bounded to the signed API range. + socket::check(unsafe { listen(raw, opts.backlog.min(i32::MAX as u32) as i32) })?; + ( + Detached::new(Native::Socket(socket), Kind::Listener, false), + None, + None, + ) + } + Open::Udp { addr, opts } => { + if opts.reuse_port { + return Err(unsupported()); + } + let socket = socket::create(addr.is_ipv6(), true)?; + socket::bind_to(socket.as_raw_socket() as usize, addr)?; + ( + Detached::new(Native::Socket(socket), Kind::Udp, false), + None, + None, + ) + } + Open::PipeListener { name, opts } => { + if opts.reuse_port { + return Err(unsupported()); + } + let name = pipes::name(&name)?; + (pipes::instance(&name, true)?, None, Some(name)) + } + Open::Pipe(name) => (pipes::connect(&pipes::name(&name)?)?, None, None), + Open::Stdio(which) => { + let i = match which { + Stdio::Stdin => 0, + Stdio::Stdout => 1, + Stdio::Stderr => 2, + }; + ( + Detached::from_handle(process::stdio(i, false)?)?, + None, + None, + ) + } + }; + self.install(h, transport, connect, name) + } + fn local_addr(&self, h: Handle) -> Result { + let r = self.get(h)?; + if !matches!(r.transport.native, Native::Socket(_)) { + return Err(unsupported()); + } + socket::address(r.transport.native.raw() as usize, false) + } + fn submit(&mut self, request: Request) -> Result<()> { + if self.services.contains(request.handle) { + return self.services.submit(request); + } + let r = self.get(request.handle)?; + let kind = r.transport.kind; + let valid = match &request.operation { + Operation::Connect => matches!(kind, Kind::Tcp | Kind::Pipe), + Operation::Accept { .. } => matches!(kind, Kind::Listener | Kind::PipeListener), + Operation::Read { .. } | Operation::Write(_) | Operation::Writev(_) => { + matches!(kind, Kind::Tcp | Kind::Pipe | Kind::Sync) + } + Operation::SendTo { .. } | Operation::RecvFrom(_) => kind == Kind::Udp, + Operation::Shutdown => kind == Kind::Tcp, + Operation::SendHandle(_) | Operation::RecvHandle => kind == Kind::Pipe, + _ => false, + }; + if !valid { + return Err(unsupported()); + } + let passed = if let Operation::SendHandle(source) = request.operation { + let Native::Socket(socket) = &self.get(source)?.transport.native else { + return Err(unsupported()); + }; + Some(socket::duplicate(socket)?) + } else { + None + }; + let i = request.op.index(); + if self.ops.get(i).is_none_or(Option::is_some) { + return Err(invalid()); + } + let d = if kind == Kind::Sync { + 0 + } else { + direction(&request.operation) + }; + let r = self.resources[request.handle.index()] + .as_mut() + .expect("resource"); + let previous = r.tails[d]; + r.tails[d] = Some(i); + if previous.is_none() { + r.heads[d] = Some(i); + } + if let Some(previous) = previous { + self.ops[previous].as_mut().expect("tail").next = Some(i); + } + self.ops[i] = Some(Pending { + request, + next: None, + stage: Stage::Start, + waiting: false, + queued: false, + pool_wait: false, + cancelled: false, + completion: None, + offset: 0, + lease: None, + accepted: None, + passed, + direction: d, + }); + if previous.is_none() { + self.schedule(i); + } + Ok(()) + } + fn cancel(&mut self, op: OpId) -> Result<()> { + if self.services.cancel(op) { + return Ok(()); + } + let Some(p) = self + .ops + .get(op.index()) + .and_then(Option::as_ref) + .filter(|p| p.request.op == op) + else { + return Ok(()); + }; + if p.waiting { + if let Some(worker) = &self.workers[p.request.handle.index()] { + worker.cancel(); + } else { + // SAFETY: exact live native request; acknowledgement is still required. + if unsafe { CancelIoEx(self.io_raw(p), self.kernel_ptr(op.index())) } == 0 { + let error = os_error(); + if error.os != Some(ERROR_NOT_FOUND as i32) { + return Err(error); + } + } + } + } + self.ops[op.index()].as_mut().expect("op").cancelled = true; + if self.ops[op.index()].as_ref().expect("op").pool_wait { + self.pool_waiting.retain(|queued| *queued != op); + self.ops[op.index()].as_mut().expect("op").pool_wait = false; + } + self.schedule(op.index()); + Ok(()) + } + fn has_work(&self) -> bool { + self.failure.is_some() + || !self.ready.is_empty() + || self.services.has_work() + || (self.pool.available() && !self.pool_waiting.is_empty()) + } + fn poll( + &mut self, + timeout: Option, + events: &mut Vec>, + ) -> Result { + if let Some(error) = self.failure.take() { + return Err(error); + } + let before = events.len(); + self.services.collect(events)?; + self.run_ready(events); + if events.len() != before || events.len() == events.capacity() || !self.ready.is_empty() { + return Ok(PollInfo::default()); + } + let mut entries = [Entry::default(); 64]; + let (n, info) = if let Some(event) = &mut self.event { + (event.drain(&mut entries)?, PollInfo::default()) + } else { + let armed = timeout.is_some_and(|d| !d.is_zero()); + if let Some(delay) = timeout.filter(|d| !d.is_zero()) { + self.timer.arm(delay)?; + } + // GQCSEx's millisecond timeout may expire at an earlier system tick. + // Once armed, the exact NT timer is the only deadline wake source. + let result = self + .port + .wait(if armed { None } else { timeout }, false, &mut entries); + if armed { + self.timer.cancel()?; + } + match result? { + Wait::Entries(n) => ( + n, + PollInfo { + waits: 1, + zero_event_waits: 0, + }, + ), + Wait::Timeout | Wait::Apc => ( + 0, + PollInfo { + waits: 1, + zero_event_waits: 1, + }, + ), + } + }; + self.entries(&entries[..n])?; + self.services.collect(events)?; + self.run_ready(events); + Ok(info) + } + fn release(&mut self, h: Handle) { + self.services.release(h); + if self.get(h).is_ok() { + self.workers[h.index()] = None; + self.resources[h.index()] = None; + } + } + fn detach(&mut self, h: Handle) -> Result { + let r = self.get(h)?; + if r.transport.kind == Kind::PipeListener { + return Err(unsupported()); + } + if r.heads.iter().any(Option::is_some) { + return Err(Error::new(ErrorKind::WouldBlock)); + } + let mut r = self.resources[h.index()].take().expect("resource"); + self.workers[h.index()] = None; + r.transport.routed = true; + Ok(r.transport) + } + fn attach(&mut self, h: Handle, transport: Detached) -> Result<()> { + self.install(h, transport, None, None) + } + fn integration(&mut self) -> Result { + if self.event.is_none() { + self.event = Some(EventIntegration::new(Arc::clone(&self.port))?); + self.arm_event_deadline()?; + } + if self.has_work() { + self.port.post(WAKE, 0)?; + } + Ok(Integration::Event( + self.event.as_ref().expect("event").event() as usize, + )) + } +} +impl Iocp { + fn arm_event_deadline(&mut self) -> Result<()> { + if self.timer.cancel()? == 0x103 { + return Ok(()); + } // drain the in-flight packet before rearming + if let Some(deadline) = self.deadline { + let now = Instant::now(); + if deadline <= now { + self.port.post(WAKE, 0)?; + } else { + self.timer.arm(deadline.saturating_duration_since(now))?; + } + } + Ok(()) + } +} +impl Drop for Iocp { + fn drop(&mut self) { + for i in 0..self.ops.len() { + if let Some(p) = &self.ops[i] { + let op = p.request.op; + if self.cancel(op).is_err() { + std::process::abort(); + } + } + } + let mut events = Vec::with_capacity(64); + while self.ops.iter().any(Option::is_some) { + if self.poll(None, &mut events).is_err() { + std::process::abort(); + } + events.clear(); + if self.event.is_some() { + std::thread::yield_now(); + } + } + if let Some(event) = &mut self.event + && event.shutdown().is_err() + { + std::process::abort(); + } + } +} +fn read_buffer(p: &mut Pending, pool: &BufferPool) -> Option<(*mut u8, u32)> { + let buf = match &p.request.operation { + Operation::Read { buf, .. } | Operation::RecvFrom(buf) => buf, + _ => unreachable!(), + }; + match buf { + ReadBuf::Provided(buf) => Some((buf.as_mut_ptr(), buf.len().min(u32::MAX as usize) as u32)), + ReadBuf::Pooled => { + if p.lease.is_none() { + p.lease = Some(pool.acquire()?); + } + let bytes = p.lease.as_mut().expect("lease").writable(); + Some(( + bytes.as_mut_ptr(), + bytes.len().min(u32::MAX as usize) as u32, + )) + } + } +} +fn write_length(op: &Operation) -> usize { + match op { + Operation::Write(buf) | Operation::SendTo { buf, .. } => buf.as_slice().len(), + Operation::Writev(bufs) => bufs.bufs.iter().flatten().map(|b| b.as_slice().len()).sum(), + _ => 0, + } +} +fn write_buffers(op: &Operation, mut offset: usize, out: &mut [WSABUF; MAX_IOV]) -> usize { + let mut n = 0; + let mut add = |buf: &WriteBuf| { + let bytes = buf.as_slice(); + if offset >= bytes.len() { + offset -= bytes.len(); + return; + } + let bytes = &bytes[offset..]; + offset = 0; + out[n] = WSABUF { + len: bytes.len().min(u32::MAX as usize) as u32, + buf: bytes.as_ptr().cast_mut(), + }; + n += 1; + }; + match op { + Operation::Write(buf) | Operation::SendTo { buf, .. } => add(buf), + Operation::Writev(bufs) => { + for buf in bufs.bufs.iter().flatten() { + add(buf); + } + } + _ => {} + } + n +} diff --git a/crates/turnloop/src/backend/iocp/pipes.rs b/crates/turnloop/src/backend/iocp/pipes.rs new file mode 100644 index 0000000..c298136 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/pipes.rs @@ -0,0 +1,56 @@ +use super::{Detached, Kind, Native, invalid, port::owned}; +use crate::{PipeName, Result}; +use std::{os::windows::ffi::OsStrExt, ptr}; +use windows_sys::Win32::{Foundation::*, Storage::FileSystem::*, System::Pipes::*}; + +pub(super) fn name(name: &PipeName) -> Result> { + let mut value: Vec = name.0.as_os_str().encode_wide().collect(); + let prefix: Vec = r"\\.\pipe\".encode_utf16().collect(); + if !value.starts_with(&prefix) || value.len() == prefix.len() || value.contains(&0) { + return Err(invalid()); + } + value.push(0); + Ok(value) +} +pub(super) fn instance(name: &[u16], first: bool) -> Result { + // SAFETY: validated terminated local name; exclusively owned overlapped byte + // pipe. FIRST_PIPE_INSTANCE rejects accidental binding to another server. + let handle = unsafe { + owned(CreateNamedPipeW( + name.as_ptr(), + PIPE_ACCESS_DUPLEX + | FILE_FLAG_OVERLAPPED + | if first { + FILE_FLAG_FIRST_PIPE_INSTANCE + } else { + 0 + }, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + PIPE_UNLIMITED_INSTANCES, + 65536, + 65536, + 0, + ptr::null(), + )) + }?; + Ok(Detached::new( + Native::Handle(handle), + Kind::PipeListener, + false, + )) +} +pub(super) fn connect(name: &[u16]) -> Result { + // SAFETY: validated terminated name; noninherited, overlapped client endpoint. + let handle = unsafe { + owned(CreateFileW( + name.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + 0, + ptr::null(), + OPEN_EXISTING, + FILE_FLAG_OVERLAPPED | SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, + ptr::null_mut(), + )) + }?; + Ok(Detached::new(Native::Handle(handle), Kind::Pipe, false)) +} diff --git a/crates/turnloop/src/backend/iocp/port.rs b/crates/turnloop/src/backend/iocp/port.rs new file mode 100644 index 0000000..78e5f98 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/port.rs @@ -0,0 +1,144 @@ +use std::{ + io, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + ptr, + time::Duration, +}; +use windows_sys::Win32::{ + Foundation::{HANDLE, INVALID_HANDLE_VALUE, WAIT_IO_COMPLETION, WAIT_TIMEOUT}, + System::IO::{ + CreateIoCompletionPort, GetQueuedCompletionStatusEx, OVERLAPPED_ENTRY, + PostQueuedCompletionStatus, + }, +}; + +pub const WAKE: usize = usize::MAX; +pub const TIMER: usize = usize::MAX - 1; +pub const STOP: usize = usize::MAX - 2; + +pub struct Port(OwnedHandle); + +#[derive(Clone, Copy, Debug, Default)] +pub struct Entry { + pub key: usize, + /// Opaque identity only; never dereferenced by the port or helper thread. + pub overlapped: usize, + pub bytes: u32, + /// NTSTATUS for kernel packets, not GetLastError(). + pub status: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Wait { + Entries(usize), + Timeout, + Apc, +} + +/// # Safety +/// The handle must be newly created and uniquely owned, or a failure sentinel. +pub unsafe fn owned(handle: HANDLE) -> io::Result { + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: callers pass a newly created, uniquely owned kernel handle. + Ok(unsafe { OwnedHandle::from_raw_handle(handle) }) +} + +pub fn bool_result(value: i32) -> io::Result<()> { + if value == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +impl Port { + pub fn new() -> io::Result { + // SAFETY: INVALID_HANDLE_VALUE + null creates an unassociated port. + // https://learn.microsoft.com/en-us/windows/win32/api/ioapiset/nf-ioapiset-createiocompletionport + unsafe { + owned(CreateIoCompletionPort( + INVALID_HANDLE_VALUE, + ptr::null_mut(), + 0, + 1, + )) + } + .map(Self) + } + + pub fn raw(&self) -> HANDLE { + self.0.as_raw_handle() + } + + /// # Safety + /// `handle` must be live, overlapped-capable, and not associated with another port. + pub unsafe fn associate(&self, handle: HANDLE, key: usize) -> io::Result<()> { + if key >= STOP { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "reserved key")); + } + // SAFETY: caller guarantees a live overlapped handle; self owns the port. + if unsafe { CreateIoCompletionPort(handle, self.raw(), key, 0) }.is_null() { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + pub fn post(&self, key: usize, bytes: u32) -> io::Result<()> { + // SAFETY: self keeps port live. Synthetic packets have no OVERLAPPED. + // https://learn.microsoft.com/en-us/windows/win32/api/ioapiset/nf-ioapiset-postqueuedcompletionstatus + bool_result(unsafe { PostQueuedCompletionStatus(self.raw(), bytes, key, ptr::null()) }) + } + + /// Exactly one OS wait. APCs return to the host; they do not restart the wait. + pub fn wait( + &self, + timeout: Option, + alertable: bool, + out: &mut [Entry], + ) -> io::Result { + if out.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty batch")); + } + // SAFETY: all-zero OVERLAPPED_ENTRY is valid writable output storage. + let mut raw: [OVERLAPPED_ENTRY; 64] = unsafe { std::mem::zeroed() }; + let mut n = 0; + let ms = timeout.map_or(u32::MAX, |d| { + d.as_millis() + .saturating_add(u128::from(d.subsec_nanos() % 1_000_000 != 0)) + .min(u128::from(u32::MAX - 1)) as u32 + }); + // SAFETY: both output pointers are valid for their specified lengths. + // A successful batch can contain failed I/O; preserve each entry's NTSTATUS. + // https://learn.microsoft.com/en-us/windows/win32/api/ioapiset/nf-ioapiset-getqueuedcompletionstatusex + let ok = unsafe { + GetQueuedCompletionStatusEx( + self.raw(), + raw.as_mut_ptr(), + out.len().min(raw.len()) as u32, + &mut n, + ms, + i32::from(alertable), + ) + }; + if ok == 0 { + let e = io::Error::last_os_error(); + return match e.raw_os_error().map(|e| e as u32) { + Some(WAIT_TIMEOUT) => Ok(Wait::Timeout), + Some(WAIT_IO_COMPLETION) => Ok(Wait::Apc), + _ => Err(e), + }; + } + for (dst, src) in out.iter_mut().zip(raw.iter()).take(n as usize) { + *dst = Entry { + key: src.lpCompletionKey, + overlapped: src.lpOverlapped as usize, + bytes: src.dwNumberOfBytesTransferred, + status: src.Internal as i32, + }; + } + Ok(Wait::Entries(n as usize)) + } +} diff --git a/crates/turnloop/src/backend/iocp/process.rs b/crates/turnloop/src/backend/iocp/process.rs new file mode 100644 index 0000000..0b2f96e --- /dev/null +++ b/crates/turnloop/src/backend/iocp/process.rs @@ -0,0 +1,483 @@ +use super::{Detached, Kind, Native, bool_result, invalid, os_error, port::owned, unsupported}; +use crate::{ExitStatus, Notifier, ProcessSpec, ProcessStdio, Result, Signal}; +use std::{ + collections::BTreeMap, + ffi::{OsStr, OsString, c_void}, + mem::size_of, + os::windows::{ + ffi::OsStrExt, + io::{AsRawHandle, OwnedHandle}, + }, + path::PathBuf, + ptr, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, +}; +use windows_sys::Win32::{ + Foundation::*, + Storage::FileSystem::*, + System::{Console::*, JobObjects::*, Pipes::*, Threading::*}, +}; + +fn wide(value: &OsStr) -> Result> { + let mut value: Vec = value.encode_wide().collect(); + if value.contains(&0) { + return Err(invalid()); + } + value.push(0); + Ok(value) +} +fn quote(value: &OsStr, out: &mut Vec) -> Result<()> { + out.push(b'"' as u16); + let mut slashes = 0; + for unit in value.encode_wide() { + if unit == 0 { + return Err(invalid()); + } + if unit == b'\\' as u16 { + slashes += 1; + continue; + } + if unit == b'"' as u16 { + out.extend(std::iter::repeat_n(b'\\' as u16, slashes * 2 + 1)); + } else { + out.extend(std::iter::repeat_n(b'\\' as u16, slashes)); + } + slashes = 0; + out.push(unit); + } + out.extend(std::iter::repeat_n(b'\\' as u16, slashes * 2)); + out.push(b'"' as u16); + Ok(()) +} +fn program(spec: &ProcessSpec) -> Result { + let path = PathBuf::from(&spec.program); + if path.is_absolute() || path.components().count() > 1 { + return Ok(path); + } + let paths = spec + .env + .iter() + .rev() + .find(|(key, _)| key.eq_ignore_ascii_case("PATH")) + .map(|(_, value)| value.clone()) + .or_else(|| { + if spec.env_clear { + None + } else { + std::env::var_os("PATH") + } + }); + if let Some(paths) = paths { + for directory in std::env::split_paths(&paths) { + let candidate = directory.join(&path); + if candidate.is_file() { + return Ok(candidate); + } + if candidate.extension().is_none() { + let executable = candidate.with_extension("exe"); + if executable.is_file() { + return Ok(executable); + } + } + } + } + Ok(path) +} +pub(super) fn duplicate(handle: HANDLE, inherit: bool) -> Result { + let mut out = ptr::null_mut(); + // SAFETY: source belongs to this process; returned handle has independent ownership. + bool_result(unsafe { + DuplicateHandle( + GetCurrentProcess(), + handle, + GetCurrentProcess(), + &mut out, + 0, + i32::from(inherit), + DUPLICATE_SAME_ACCESS, + ) + })?; + // SAFETY: successful DuplicateHandle transferred unique ownership. + unsafe { owned(out) }.map_err(Into::into) +} +pub(super) fn null(input: bool) -> Result { + let name: Vec = "NUL\0".encode_utf16().collect(); + // SAFETY: valid device path; exclusive ownership of newly created handle. + unsafe { + owned(CreateFileW( + name.as_ptr(), + if input { GENERIC_READ } else { GENERIC_WRITE }, + FILE_SHARE_READ | FILE_SHARE_WRITE, + ptr::null(), + OPEN_EXISTING, + 0, + ptr::null_mut(), + )) + } + .map_err(Into::into) +} +pub(super) fn stdio(index: usize, inherit: bool) -> Result { + // SAFETY: query borrows the host's original standard handle, never closes it. + let handle = + unsafe { GetStdHandle([STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE][index]) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + let handle = null(index == 0)?; + return duplicate(handle.as_raw_handle(), inherit); + } + duplicate(handle, inherit) +} +fn pipe(index: usize) -> Result<(Detached, OwnedHandle)> { + static NEXT: AtomicU64 = AtomicU64::new(0); + let name = format!( + r"\\.\pipe\turnloop-stdio-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + ); + let name = wide(OsStr::new(&name))?; + // SAFETY: unique local-only pipe, parent uses overlapped I/O. + let parent = unsafe { + owned(CreateNamedPipeW( + name.as_ptr(), + FILE_FLAG_OVERLAPPED + | FILE_FLAG_FIRST_PIPE_INSTANCE + | if index == 0 { + PIPE_ACCESS_OUTBOUND + } else { + PIPE_ACCESS_INBOUND + }, + PIPE_TYPE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, + 65536, + 65536, + 0, + ptr::null(), + )) + }?; + // SAFETY: child end is synchronous so ordinary child stdio APIs work. + let child = unsafe { + owned(CreateFileW( + name.as_ptr(), + if index == 0 { + GENERIC_READ + } else { + GENERIC_WRITE + }, + 0, + ptr::null(), + OPEN_EXISTING, + 0, + ptr::null_mut(), + )) + }?; + // SAFETY: initialized inactive operation; the successful client open has already + // connected this instance, so ERROR_PIPE_CONNECTED has no outstanding I/O. + let mut overlapped = unsafe { std::mem::zeroed() }; + // SAFETY: live parent and stack output retained for this already-connected call. + let ok = unsafe { ConnectNamedPipe(parent.as_raw_handle(), &mut overlapped) }; + if ok == 0 && os_error().os != Some(ERROR_PIPE_CONNECTED as i32) { + return Err(os_error()); + } + Ok(( + Detached::new(Native::Handle(parent), Kind::Pipe, false), + child, + )) +} +struct Attributes(Vec); +impl Attributes { + fn new(handles: &[HANDLE]) -> Result { + let mut bytes = 0; + // SAFETY: documented sizing query, no output list yet. + unsafe { + InitializeProcThreadAttributeList(ptr::null_mut(), 1, 0, &mut bytes); + } + if bytes == 0 { + return Err(os_error()); + } + let mut storage = vec![0usize; bytes.div_ceil(size_of::())]; + // SAFETY: correctly sized and aligned opaque list storage. + bool_result(unsafe { + InitializeProcThreadAttributeList(storage.as_mut_ptr().cast(), 1, 0, &mut bytes) + })?; + let mut list = Self(storage); + // SAFETY: inherited handles remain live and slice remains fixed through spawn. + bool_result(unsafe { + UpdateProcThreadAttribute( + list.0.as_mut_ptr().cast(), + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + handles.as_ptr().cast(), + std::mem::size_of_val(handles), + ptr::null_mut(), + ptr::null(), + ) + })?; + Ok(list) + } +} +impl Drop for Attributes { + fn drop(&mut self) { + // SAFETY: successful initialization, uniquely owned allocation still live. + unsafe { + DeleteProcThreadAttributeList(self.0.as_mut_ptr().cast()); + } + } +} +struct Context { + ready: AtomicBool, + notifier: Notifier, +} +unsafe extern "system" fn exited(context: *mut c_void, _: bool) { + // SAFETY: Arc allocation remains owned until the registered wait is joined. + let context = unsafe { &*context.cast::() }; + context.ready.store(true, Ordering::Release); + let _ = context.notifier.notify(); // NotFound is permitted during loop destruction. +} +pub(super) struct Child { + process: OwnedHandle, + thread: OwnedHandle, + job: Option, + context: Arc, + wait: HANDLE, + status: Option, + pub pid: u32, +} +impl Child { + pub(super) fn ready(&self) -> bool { + self.context.ready.load(Ordering::Acquire) || self.status.is_some() + } + pub(super) fn status(&mut self) -> Result> { + if !self.ready() { + return Ok(None); + } + if let Some(status) = self.status { + return Ok(Some(status)); + } + let mut code = 0; + // SAFETY: registered wait reported actual process termination. + bool_result(unsafe { GetExitCodeProcess(self.process.as_raw_handle(), &mut code) })?; + self.join()?; + let status = ExitStatus { + code: Some(code as i32), + signal: None, + }; + self.status = Some(status); + Ok(Some(status)) + } + fn join(&mut self) -> Result<()> { + if !self.wait.is_null() { + // SAFETY: called outside callback, joining before freeing callback state. + bool_result(unsafe { UnregisterWaitEx(self.wait, INVALID_HANDLE_VALUE) })?; + self.wait = ptr::null_mut(); + } + Ok(()) + } + pub(super) fn resume(&self) -> Result<()> { + // SAFETY: owned suspended main thread; all resources/job/wait already installed. + if unsafe { ResumeThread(self.thread.as_raw_handle()) } == u32::MAX { + Err(os_error()) + } else { + Ok(()) + } + } + pub(super) fn kill(&mut self, signal: Signal, group: bool) -> Result<()> { + if self.status.is_some() { + return Err(crate::Error::new(crate::ErrorKind::NotFound)); + } + if signal != Signal::Kill { + return Err(unsupported()); + } + if group { + let job = self.job.as_ref().ok_or_else(invalid)?; + // SAFETY: exclusively owned process tree; no PID/handle reuse is possible. + bool_result(unsafe { TerminateJobObject(job.as_raw_handle(), 1) }) + } else { + // SAFETY: process handle pins the identity even if its PID is later reused. + bool_result(unsafe { TerminateProcess(self.process.as_raw_handle(), 1) }) + } + } + pub(super) fn close(&mut self) -> Result<()> { + if self.status.is_some() { + return Ok(()); + } + if self.ready() { + self.status()?; + return Ok(()); + } + self.kill(Signal::Kill, self.job.is_some()) + } +} +impl Drop for Child { + fn drop(&mut self) { + if self.status.is_none() { + // SAFETY: teardown owns the process and optional job; termination then + // blocking wait prevents a live child or outstanding callback escaping. + unsafe { + if let Some(job) = &self.job { + TerminateJobObject(job.as_raw_handle(), 1); + } + TerminateProcess(self.process.as_raw_handle(), 1); + WaitForSingleObject(self.process.as_raw_handle(), INFINITE); + } + } + if self.join().is_err() { + std::process::abort(); + } + } +} +pub(super) fn spawn( + spec: &ProcessSpec, + existing: [Option; 3], + notifier: Notifier, +) -> Result<(Child, [Option; 3])> { + if spec.uid.is_some() || spec.gid.is_some() || spec.program.is_empty() { + return Err(unsupported()); + } + let mut parents = [None, None, None]; + let mut child_ends = Vec::with_capacity(3); + for (i, option) in spec.stdio.iter().enumerate() { + let handle = match option { + ProcessStdio::Inherit => stdio(i, true)?, + ProcessStdio::Null => { + let handle = null(i == 0)?; + duplicate(handle.as_raw_handle(), true)? + } + ProcessStdio::Handle(_) => duplicate(existing[i].ok_or_else(invalid)?, true)?, + ProcessStdio::Pipe => { + let (parent, child) = pipe(i)?; + parents[i] = Some(parent); + duplicate(child.as_raw_handle(), true)? + } + }; + child_ends.push(handle); + } + let handles = std::array::from_fn::<_, 3, _>(|i| child_ends[i].as_raw_handle()); + let mut attributes = Attributes::new(&handles)?; + let application = wide(program(spec)?.as_os_str())?; + let mut command = Vec::new(); + quote(&spec.program, &mut command)?; + for arg in &spec.args { + command.push(b' ' as u16); + quote(arg, &mut command)?; + } + command.push(0); + if command.len() > 32767 { + return Err(invalid()); + } + let mut vars: BTreeMap = BTreeMap::new(); + if !spec.env_clear { + for (key, value) in std::env::vars_os() { + vars.insert(key.to_ascii_uppercase(), (key, value)); + } + } + for (key, value) in &spec.env { + if key.is_empty() + || key + .encode_wide() + .any(|unit| unit == 0 || unit == b'=' as u16) + { + return Err(invalid()); + } + vars.insert(key.to_ascii_uppercase(), (key.clone(), value.clone())); + } + let mut environment = Vec::new(); + for (_, (key, value)) in vars { + let mut entry = key; + entry.push("="); + entry.push(value); + environment.extend(wide(&entry)?); + } + if environment.is_empty() { + environment.push(0); + } + environment.push(0); + let cwd = spec + .cwd + .as_ref() + .map(|path| wide(path.as_os_str())) + .transpose()?; + // SAFETY: initialized C startup and result structures with correct cb. + let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() }; + startup.StartupInfo.cb = size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = handles[0]; + startup.StartupInfo.hStdOutput = handles[1]; + startup.StartupInfo.hStdError = handles[2]; + startup.lpAttributeList = attributes.0.as_mut_ptr().cast(); + let job = if spec.new_process_group { + // SAFETY: newly created unnamed job, unique ownership. + let job = unsafe { owned(CreateJobObjectW(ptr::null(), ptr::null())) }?; + // SAFETY: initialized C structure for exactly the requested information class. + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: valid job, information pointer and exact byte size. + bool_result(unsafe { + SetInformationJobObject( + job.as_raw_handle(), + JobObjectExtendedLimitInformation, + ptr::from_ref(&limits).cast(), + size_of::() as u32, + ) + })?; + Some(job) + } else { + None + }; + // SAFETY: plain writable process output structure. + let mut info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: explicit application and quoted writable command line; environment, + // directory, attribute list and exactly the listed inherited handles stay live. + bool_result(unsafe { + CreateProcessW( + application.as_ptr(), + command.as_mut_ptr(), + ptr::null(), + ptr::null(), + 1, + CREATE_SUSPENDED + | CREATE_UNICODE_ENVIRONMENT + | EXTENDED_STARTUPINFO_PRESENT + | CREATE_NO_WINDOW, + environment.as_ptr().cast(), + cwd.as_ref().map_or(ptr::null(), |v| v.as_ptr()), + &startup.StartupInfo, + &mut info, + ) + })?; + // SAFETY: successful creation transfers these two independent handle owners. + let mut child = unsafe { + Child { + process: owned(info.hProcess)?, + thread: owned(info.hThread)?, + job, + context: Arc::new(Context { + ready: AtomicBool::new(false), + notifier, + }), + wait: ptr::null_mut(), + status: None, + pid: info.dwProcessId, + } + }; + if let Some(job) = &child.job { + // SAFETY: child still suspended, so no descendant can escape job assignment. + bool_result(unsafe { + AssignProcessToJobObject(job.as_raw_handle(), child.process.as_raw_handle()) + })?; + } + // SAFETY: initialized callback context and owned child precede registration; + // immediate exits are safe and Drop joins the one-shot callback. + bool_result(unsafe { + RegisterWaitForSingleObject( + &mut child.wait, + child.process.as_raw_handle(), + Some(exited), + Arc::as_ptr(&child.context).cast(), + INFINITE, + WT_EXECUTEONLYONCE, + ) + })?; + Ok((child, parents)) +} diff --git a/crates/turnloop/src/backend/iocp/services.rs b/crates/turnloop/src/backend/iocp/services.rs new file mode 100644 index 0000000..1c02e6a --- /dev/null +++ b/crates/turnloop/src/backend/iocp/services.rs @@ -0,0 +1,167 @@ +use super::{Detached, invalid, process::Child, signals::Subscription, unsupported}; +use crate::{ + backend::{Event, Operation, Outcome, Request}, + *, +}; +enum Service { + Child(Child), + Signal(Signal, Subscription), +} +struct Entry { + handle: Handle, + service: Service, + op: Option, + cancelled: bool, +} +pub(super) struct Services { + entries: Vec>, + pending: usize, +} +impl Services { + pub(super) fn new(capacity: usize) -> Self { + Self { + entries: (0..capacity).map(|_| None).collect(), + pending: 0, + } + } + pub(super) fn contains(&self, handle: Handle) -> bool { + self.entries + .get(handle.index()) + .and_then(Option::as_ref) + .is_some_and(|e| e.handle == handle) + } + pub(super) fn child(&mut self, handle: Handle, child: Child) { + self.entries[handle.index()] = Some(Entry { + handle, + service: Service::Child(child), + op: None, + cancelled: false, + }); + } + pub(super) fn signal( + &mut self, + handle: Handle, + signal: Signal, + notifier: Notifier, + ) -> Result<()> { + let subscription = Subscription::new(signal, notifier)?; + self.entries[handle.index()] = Some(Entry { + handle, + service: Service::Signal(signal, subscription), + op: None, + cancelled: false, + }); + Ok(()) + } + pub(super) fn submit(&mut self, request: Request) -> Result<()> { + let entry = self.entries[request.handle.index()] + .as_mut() + .ok_or_else(invalid)?; + if entry.handle != request.handle || entry.op.is_some() { + return Err(invalid()); + } + if !matches!( + (&entry.service, request.operation), + (Service::Child(_), Operation::ProcessExit) + | (Service::Signal(..), Operation::WatchSignal) + ) { + return Err(unsupported()); + } + entry.op = Some(request.op); + self.pending += 1; + entry.cancelled = false; + Ok(()) + } + pub(super) fn cancel(&mut self, op: OpId) -> bool { + if let Some(entry) = self.entries.iter_mut().flatten().find(|e| e.op == Some(op)) { + entry.cancelled = true; + true + } else { + false + } + } + pub(super) fn close(&mut self, handle: Handle) -> Result<()> { + if let Some(entry) = self + .entries + .get_mut(handle.index()) + .and_then(Option::as_mut) + && entry.handle == handle + && let Service::Child(child) = &mut entry.service + { + child.close()?; + } + Ok(()) + } + pub(super) fn kill(&mut self, handle: Handle, signal: Signal, group: bool) -> Result<()> { + let entry = self + .entries + .get_mut(handle.index()) + .and_then(Option::as_mut) + .filter(|e| e.handle == handle) + .ok_or(Error::new(ErrorKind::NotFound))?; + match &mut entry.service { + Service::Child(child) => child.kill(signal, group), + _ => Err(unsupported()), + } + } + pub(super) fn release(&mut self, handle: Handle) { + if self.contains(handle) { + self.entries[handle.index()] = None; + } + } + pub(super) fn has_work(&self) -> bool { + if self.pending == 0 { + return false; + } + self.entries.iter().flatten().any(|e| { + e.op.is_some() + && match &e.service { + Service::Child(child) => child.ready(), + Service::Signal(_, signal) => e.cancelled || signal.ready(), + } + }) + } + pub(super) fn collect(&mut self, out: &mut Vec>) -> Result<()> { + if self.pending == 0 { + return Ok(()); + } + for entry in self.entries.iter_mut().flatten() { + if out.len() == out.capacity() { + break; + } + let Some(op) = entry.op else { + continue; + }; + let result = match &mut entry.service { + Service::Child(child) => child + .status()? + .map(|status| (Outcome::Exited(status), true)), + Service::Signal(signal, ticket) => { + if entry.cancelled { + Some((Outcome::Cancelled, true)) + } else if ticket.take() { + Some((Outcome::Signal(*signal), false)) + } else { + None + } + } + }; + if let Some((outcome, terminal)) = result { + if terminal { + entry.op = None; + self.pending -= 1; + } + out.push(Event { + op, + terminal, + result: Ok(if entry.cancelled { + Outcome::Cancelled + } else { + outcome + }), + }); + } + } + Ok(()) + } +} diff --git a/crates/turnloop/src/backend/iocp/signals.rs b/crates/turnloop/src/backend/iocp/signals.rs new file mode 100644 index 0000000..5b26a44 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/signals.rs @@ -0,0 +1,102 @@ +use super::{bool_result, unsupported}; +use crate::{Error, ErrorKind, Notifier, Result, Signal}; +use std::{ + ptr, + sync::{ + Mutex, + atomic::{AtomicBool, AtomicPtr, AtomicUsize, Ordering}, + }, +}; +use windows_sys::Win32::System::Console::*; + +static SLOTS: [AtomicPtr; 1024] = [const { AtomicPtr::new(ptr::null_mut()) }; 1024]; +static ACTIVE: AtomicUsize = AtomicUsize::new(0); +static REGISTRATION: Mutex = Mutex::new(0); +struct Ticket { + signal: Signal, + pending: AtomicBool, + notifier: Notifier, +} +pub(super) struct Subscription { + ticket: Box, + index: usize, +} + +pub(super) fn dispatch(signal: Signal) -> bool { + ACTIVE.fetch_add(1, Ordering::SeqCst); + let mut handled = false; + for slot in &SLOTS { + let pointer = slot.load(Ordering::SeqCst); + if pointer.is_null() { + continue; + } + // SAFETY: removal nulls the slot before waiting for every active dispatcher; + // the Box and its notifier remain live through this entire access. + let ticket = unsafe { &*pointer }; + if ticket.signal == signal { + handled = true; + ticket.pending.store(true, Ordering::Release); + let _ = ticket.notifier.notify(); + } + } + ACTIVE.fetch_sub(1, Ordering::SeqCst); + handled +} +unsafe extern "system" fn handler(control: u32) -> i32 { + let signal = match control { + CTRL_C_EVENT => Signal::Int, + CTRL_BREAK_EVENT => Signal::Break, + CTRL_CLOSE_EVENT => Signal::Hup, + _ => return 0, + }; + i32::from(dispatch(signal)) +} +impl Subscription { + pub(super) fn new(signal: Signal, notifier: Notifier) -> Result { + if !matches!( + signal, + Signal::Int | Signal::Break | Signal::Hup | Signal::WinCh + ) { + return Err(unsupported()); + } + let mut count = REGISTRATION.lock().unwrap_or_else(|e| e.into_inner()); + let index = SLOTS + .iter() + .position(|s| s.load(Ordering::SeqCst).is_null()) + .ok_or(Error::new(ErrorKind::ResourceLimit))?; + if *count == 0 { + // SAFETY: process-lifetime function pointer; handler has no locks or allocations. + bool_result(unsafe { SetConsoleCtrlHandler(Some(handler), 1) })?; + } + let mut ticket = Box::new(Ticket { + signal, + pending: AtomicBool::new(false), + notifier, + }); + SLOTS[index].store(ptr::from_mut(&mut *ticket), Ordering::SeqCst); + *count += 1; + Ok(Self { ticket, index }) + } + pub(super) fn ready(&self) -> bool { + self.ticket.pending.load(Ordering::Acquire) + } + pub(super) fn take(&self) -> bool { + self.ticket.pending.swap(false, Ordering::AcqRel) + } +} +impl Drop for Subscription { + fn drop(&mut self) { + let mut count = REGISTRATION.lock().unwrap_or_else(|e| e.into_inner()); + SLOTS[self.index].store(ptr::null_mut(), Ordering::SeqCst); + *count -= 1; + if *count == 0 { + // SAFETY: remove only our handler, preserving every host handler. + unsafe { + SetConsoleCtrlHandler(Some(handler), 0); + } + } + while ACTIVE.load(Ordering::SeqCst) != 0 { + std::thread::yield_now(); + } + } +} diff --git a/crates/turnloop/src/backend/iocp/socket.rs b/crates/turnloop/src/backend/iocp/socket.rs new file mode 100644 index 0000000..e300262 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/socket.rs @@ -0,0 +1,301 @@ +use super::{Detached, Kind, Native, invalid, unsupported}; +use crate::{Error, Result}; +use std::{ + mem::size_of, + net::{Ipv4Addr, Ipv6Addr, SocketAddr}, + os::windows::io::{AsRawSocket, FromRawSocket, OwnedSocket}, + ptr, + sync::OnceLock, +}; +use windows_sys::{Win32::Networking::WinSock::*, core::GUID}; + +pub(super) fn startup() -> Result<()> { + static STARTED: OnceLock> = OnceLock::new(); + *STARTED.get_or_init(|| { + // SAFETY: WSADATA is writable C output; retain Winsock for process lifetime, + // including Detached sockets that outlive every loop. + let mut data = unsafe { std::mem::zeroed() }; + // SAFETY: valid output and supported version. WSAStartup returns its error. + let code = unsafe { WSAStartup(0x202, &mut data) }; + if code == 0 { Ok(()) } else { Err(error(code)) } + }) +} +pub(super) fn error(code: i32) -> Error { + use crate::ErrorKind; + use windows_sys::Win32::Foundation::*; + let mut error: Error = std::io::Error::from_raw_os_error(code).into(); + error.kind = match code as u32 { + ERROR_CONNECTION_REFUSED => ErrorKind::ConnectionRefused, + ERROR_NETNAME_DELETED | ERROR_CONNECTION_ABORTED => ErrorKind::ConnectionReset, + ERROR_BROKEN_PIPE | ERROR_NO_DATA => ErrorKind::BrokenPipe, + ERROR_SEM_TIMEOUT | ERROR_TIMEOUT => ErrorKind::TimedOut, + ERROR_OPERATION_ABORTED => ErrorKind::Cancelled, + ERROR_NOT_ENOUGH_MEMORY | ERROR_NO_SYSTEM_RESOURCES => ErrorKind::ResourceLimit, + _ => error.kind, + }; + error +} +pub(super) fn last_error() -> Error { + // SAFETY: thread-local error query. + error(unsafe { WSAGetLastError() }) +} +pub(super) fn check(code: i32) -> Result<()> { + if code == SOCKET_ERROR { + Err(last_error()) + } else { + Ok(()) + } +} +pub(super) fn create(v6: bool, udp: bool) -> Result { + startup()?; + // SAFETY: initialized Winsock; no provider pointer; exclusively owned socket. + let raw = unsafe { + WSASocketW( + if v6 { AF_INET6 } else { AF_INET } as i32, + if udp { SOCK_DGRAM } else { SOCK_STREAM }, + if udp { IPPROTO_UDP } else { IPPROTO_TCP }, + ptr::null(), + 0, + WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT, + ) + }; + if raw == INVALID_SOCKET { + return Err(last_error()); + } + // SAFETY: successful creation transferred exclusive ownership. + Ok(unsafe { OwnedSocket::from_raw_socket(raw as _) }) +} + +pub(super) struct Addr { + pub storage: SOCKADDR_STORAGE, + pub len: i32, +} +impl Addr { + pub fn new(addr: SocketAddr) -> Self { + // SAFETY: plain C address storage; each address below fits with correct alignment. + let mut storage: SOCKADDR_STORAGE = unsafe { std::mem::zeroed() }; + let len = match addr { + SocketAddr::V4(addr) => { + // SAFETY: SOCKADDR_STORAGE is large and aligned for SOCKADDR_IN. + let out = unsafe { &mut *ptr::from_mut(&mut storage).cast::() }; + out.sin_family = AF_INET; + out.sin_port = addr.port().to_be(); + out.sin_addr.S_un.S_addr = u32::from_ne_bytes(addr.ip().octets()); + size_of::() + } + SocketAddr::V6(addr) => { + // SAFETY: SOCKADDR_STORAGE is large and aligned for SOCKADDR_IN6. + let out = unsafe { &mut *ptr::from_mut(&mut storage).cast::() }; + out.sin6_family = AF_INET6; + out.sin6_port = addr.port().to_be(); + out.sin6_flowinfo = addr.flowinfo(); + out.Anonymous.sin6_scope_id = addr.scope_id(); + out.sin6_addr.u.Byte = addr.ip().octets(); + size_of::() + } + } as i32; + Self { storage, len } + } + pub fn as_ptr(&self) -> *const SOCKADDR { + ptr::from_ref(&self.storage).cast() + } +} +pub(super) fn decode(storage: &SOCKADDR_STORAGE, len: i32) -> Result { + match storage.ss_family { + AF_INET if len >= size_of::() as i32 => { + // SAFETY: validated family and size; storage has sufficient alignment. + let addr = unsafe { &*ptr::from_ref(storage).cast::() }; + // SAFETY: IPv4 address union was initialized by Winsock. + let ip = unsafe { addr.sin_addr.S_un.S_addr }.to_ne_bytes(); + Ok((Ipv4Addr::from(ip), u16::from_be(addr.sin_port)).into()) + } + AF_INET6 if len >= size_of::() as i32 => { + // SAFETY: validated family and size; initialized Winsock output unions. + let addr = unsafe { &*ptr::from_ref(storage).cast::() }; + // SAFETY: initialized IPv6 union members. + let (ip, scope) = unsafe { (addr.sin6_addr.u.Byte, addr.Anonymous.sin6_scope_id) }; + Ok(std::net::SocketAddrV6::new( + Ipv6Addr::from(ip), + u16::from_be(addr.sin6_port), + addr.sin6_flowinfo, + scope, + ) + .into()) + } + _ => Err(invalid()), + } +} +pub(super) fn address(socket: usize, peer: bool) -> Result { + // SAFETY: plain C writable address storage. + let mut addr: SOCKADDR_STORAGE = unsafe { std::mem::zeroed() }; + let mut len = size_of::() as i32; + // SAFETY: live socket and bounded output pointers. + check(unsafe { + if peer { + getpeername(socket, ptr::from_mut(&mut addr).cast(), &mut len) + } else { + getsockname(socket, ptr::from_mut(&mut addr).cast(), &mut len) + } + })?; + decode(&addr, len) +} +pub(super) fn bind_to(socket: usize, addr: SocketAddr) -> Result<()> { + let addr = Addr::new(addr); + // SAFETY: live socket; address pointer matches its byte length. + check(unsafe { bind(socket, addr.as_ptr(), addr.len) }) +} +pub(super) fn option(socket: usize, name: i32, value: i32) -> Result<()> { + // SAFETY: live socket, option takes a four-byte integer. + check(unsafe { + setsockopt( + socket, + SOL_SOCKET, + name, + ptr::from_ref(&value).cast(), + size_of::() as i32, + ) + }) +} +pub(super) fn nonblocking(socket: usize) -> Result<()> { + let mut value = 1; + // SAFETY: live socket and valid FIONBIO input. + check(unsafe { ioctlsocket(socket, FIONBIO, &mut value) }) +} +pub(super) fn loopback_connect(socket: usize) -> Result<()> { + // Loopback delivery does not lose SYN packets. Avoid Windows retrying a + // refused local connection for ~2 seconds before reporting ECONNREFUSED. + // https://learn.microsoft.com/en-us/windows/win32/api/mstcpip/ns-mstcpip-tcp_initial_rto_parameters + let options = TCP_INITIAL_RTO_PARAMETERS { + Rtt: u16::MAX, // TCP_INITIAL_RTO_UNSPECIFIED_RTT (absent from windows-sys) + MaxSynRetransmissions: TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS as u8, + }; + let mut bytes = 0; + // SAFETY: live TCP socket and documented synchronous IOCTL input layout. + check(unsafe { + WSAIoctl( + socket, + SIO_TCP_INITIAL_RTO, + ptr::from_ref(&options).cast(), + size_of::() as u32, + ptr::null_mut(), + 0, + &mut bytes, + ptr::null_mut(), + None, + ) + }) +} +pub(super) fn ifs(socket: usize) -> Result { + let mut info = WSAPROTOCOL_INFOW::default(); + let mut len = size_of::() as i32; + // SAFETY: option output has its documented layout and exact length. + check(unsafe { + getsockopt( + socket, + SOL_SOCKET, + SO_PROTOCOL_INFOW, + ptr::from_mut(&mut info).cast(), + &mut len, + ) + })?; + Ok(info.dwServiceFlags1 & XP1_IFS_HANDLES != 0) +} +/// Resolve per-provider extension pointers rather than assuming one Winsock provider. +pub(super) unsafe fn extension(socket: usize, guid: &GUID) -> Result { + let mut out = std::mem::MaybeUninit::::uninit(); + let mut bytes = 0; + // SAFETY: caller supplies the nullable function-pointer type matching this GUID; + // synchronous WSAIoctl writes exactly the provided output storage. + check(unsafe { + WSAIoctl( + socket, + SIO_GET_EXTENSION_FUNCTION_POINTER, + ptr::from_ref(guid).cast(), + size_of::() as u32, + out.as_mut_ptr().cast(), + size_of::() as u32, + &mut bytes, + ptr::null_mut(), + None, + ) + })?; + if bytes as usize != size_of::() { + return Err(unsupported()); + } + // SAFETY: successful ioctl initialized the entire documented pointer type. + Ok(unsafe { out.assume_init() }) +} +pub(super) fn duplicate(socket: &OwnedSocket) -> Result { + let mut info = WSAPROTOCOL_INFOW::default(); + // SAFETY: source is owned; target is this process; output layout is correct. + check(unsafe { + WSADuplicateSocketW( + socket.as_raw_socket() as usize, + std::process::id(), + &mut info, + ) + })?; + reconstruct(&info) +} +pub(super) fn reconstruct(info: &WSAPROTOCOL_INFOW) -> Result { + startup()?; + // SAFETY: protocol information obtained from cooperative WSADuplicateSocketW. + let raw = unsafe { + WSASocketW( + FROM_PROTOCOL_INFO, + FROM_PROTOCOL_INFO, + FROM_PROTOCOL_INFO, + info, + 0, + WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT, + ) + }; + if raw == INVALID_SOCKET { + return Err(last_error()); + } + // SAFETY: successful reconstruction owns one independent socket reference. + Ok(unsafe { OwnedSocket::from_raw_socket(raw as _) }) +} +impl Detached { + /// Adopt an owned, quiescent socket. Existing IOCP associations are tolerated + /// by routing overlapped-event completions to the receiving loop. + pub fn from_socket(socket: OwnedSocket) -> Result { + startup()?; + let raw = socket.as_raw_socket() as usize; + let mut typ = 0i32; + let mut len = size_of::() as i32; + // SAFETY: correct writable integer option buffer. + check(unsafe { + getsockopt( + raw, + SOL_SOCKET, + SO_TYPE, + ptr::from_mut(&mut typ).cast(), + &mut len, + ) + })?; + let mut accepting = 0i32; + // SAFETY: same valid output layout for SO_ACCEPTCONN. + check(unsafe { + getsockopt( + raw, + SOL_SOCKET, + SO_ACCEPTCONN, + ptr::from_mut(&mut accepting).cast(), + &mut len, + ) + })?; + let kind = if typ == SOCK_DGRAM { + Kind::Udp + } else if typ == SOCK_STREAM { + if accepting != 0 { + Kind::Listener + } else { + Kind::Tcp + } + } else { + return Err(unsupported()); + }; + Ok(Self::new(Native::Socket(socket), kind, true)) + } +} diff --git a/crates/turnloop/src/backend/iocp/sync_io.rs b/crates/turnloop/src/backend/iocp/sync_io.rs new file mode 100644 index 0000000..dd664c2 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/sync_io.rs @@ -0,0 +1,401 @@ +//! One on-demand worker per synchronous stdio handle. Requests retain their caller +//! buffers until the worker's completion packet; cancellation joins buffer access. +use super::{ + Detached, Kind, Native, bool_result, os_error, port::Port, process, signals, unsupported, +}; +use crate::{Result, Signal}; +use std::{ + os::windows::io::{AsRawHandle, OwnedHandle}, + ptr, + sync::{Arc, Condvar, Mutex, OnceLock, mpsc}, + thread::JoinHandle, +}; +use windows_sys::Win32::{ + Foundation::*, + Storage::FileSystem::{FILE_TYPE_PIPE, GetFileType, ReadFile, WriteFile}, + System::{ + Console::*, + IO::{CancelSynchronousIo, IO_STATUS_BLOCK, OVERLAPPED, PostQueuedCompletionStatus}, + }, +}; +pub(super) const KEY: usize = 3; +#[derive(Clone, Copy)] +struct Job { + pointer: usize, + len: u32, + write: bool, + overlapped: usize, +} +struct State { + job: Option, + active: bool, + stop: bool, + cancelled: bool, + result: Option>, +} +struct Shared { + state: Mutex, + changed: Condvar, + port: Arc, + handle: OwnedHandle, + thread_handle: Mutex>, +} +pub(super) struct Worker { + shared: Arc, + thread: Option>, + canceller: mpsc::Sender>, +} + +// CancelSynchronousIo only cancels I/O that has entered the kernel. A cancellation +// can race between the worker's active flag and ReadFile. One process-wide helper +// closes that interval without blocking a turn or creating a per-request thread. +// It sleeps on its queue while idle, then retries only until a cancelled worker +// has entered I/O or finished; no periodic timer or ordinary-I/O polling is used. +fn canceller() -> Result>> { + static CANCELLER: OnceLock>>> = OnceLock::new(); + CANCELLER + .get_or_init(|| { + let (sender, receiver) = mpsc::channel::>(); + std::thread::Builder::new() + .name("turnloop-stdio-cancel".into()) + .spawn(move || { + while let Ok(shared) = receiver.recv() { + loop { + let mut state = shared.state.lock().unwrap_or_else(|e| e.into_inner()); + if !state.active || !state.cancelled { + break; + } + let thread = shared + .thread_handle + .lock() + .unwrap_or_else(|e| e.into_inner()); + let Some(thread) = thread.as_ref() else { + break; + }; + // SAFETY: duplicated thread handle pins its identity. The state + // lock prevents this cancellation reaching a subsequent request. + let ok = unsafe { CancelSynchronousIo(thread.as_raw_handle()) }; + if ok != 0 { + while state.active && state.cancelled { + state = shared + .changed + .wait(state) + .unwrap_or_else(|e| e.into_inner()); + } + break; + } + if os_error().os != Some(ERROR_NOT_FOUND as i32) { + std::process::abort(); + } + drop(state); + std::thread::yield_now(); + } + } + })?; + Ok(sender) + }) + .clone() +} +impl Worker { + pub(super) fn new(handle: HANDLE, port: Arc) -> Result { + let canceller = canceller()?; + let shared = Arc::new(Shared { + state: Mutex::new(State { + job: None, + active: false, + stop: false, + cancelled: false, + result: None, + }), + changed: Condvar::new(), + port, + handle: process::duplicate(handle, false)?, + thread_handle: Mutex::new(None), + }); + let run = Arc::clone(&shared); + let thread = std::thread::Builder::new() + .name("turnloop-stdio".into()) + .spawn(move || pump(run))?; + let worker = Self { + shared, + thread: Some(thread), + canceller, + }; + let duplicate = process::duplicate( + worker.thread.as_ref().expect("worker").as_raw_handle(), + false, + )?; + *worker + .shared + .thread_handle + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(duplicate); + Ok(worker) + } + pub(super) fn start( + &self, + pointer: *mut u8, + len: u32, + write: bool, + overlapped: *mut OVERLAPPED, + ) { + let mut state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); + assert!(state.job.is_none() && !state.active && state.result.is_none()); + state.cancelled = false; + state.job = Some(Job { + pointer: pointer as usize, + len, + write, + overlapped: overlapped as usize, + }); + self.shared.changed.notify_one(); + } + pub(super) fn cancel(&self) { + let mut state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); + state.cancelled = true; + if state.active { + // SAFETY: owned thread is live until Drop joins. Cancellation does not + // acknowledge caller buffers; only finish after its packet does that. + unsafe { + CancelSynchronousIo(self.thread.as_ref().expect("worker").as_raw_handle()); + } + if self.canceller.send(Arc::clone(&self.shared)).is_err() { + std::process::abort(); + } + } + self.shared.changed.notify_one(); + } + pub(super) fn finish(&self) -> Result { + self.shared + .state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .result + .take() + .expect("worker completion") + } +} +fn pump(shared: Arc) { + let mut console = ConsoleBytes::default(); + loop { + let (job, cancelled) = { + let mut state = shared.state.lock().unwrap_or_else(|e| e.into_inner()); + while state.job.is_none() && !state.stop { + state = shared + .changed + .wait(state) + .unwrap_or_else(|e| e.into_inner()); + } + if state.stop { + return; + } + let job = state.job.take().expect("job"); + state.active = true; + (job, state.cancelled) + }; + let result = if cancelled { + Err(super::socket::error(ERROR_OPERATION_ABORTED as i32)) + } else { + let mut mode = 0; + // SAFETY: handle stays owned throughout the worker; failure means non-console. + let terminal = unsafe { GetConsoleMode(shared.handle.as_raw_handle(), &mut mode) } != 0; + if terminal && !job.write { + console.read( + shared.handle.as_raw_handle(), + job.pointer as *mut u8, + job.len, + ) + } else { + let mut n = 0; + // SAFETY: backend retains exclusive read/immutable write memory until + // the packet below is consumed. Handle is synchronous and owned. + let ok = unsafe { + if job.write { + WriteFile( + shared.handle.as_raw_handle(), + job.pointer as *const u8, + job.len, + &mut n, + ptr::null_mut(), + ) + } else { + ReadFile( + shared.handle.as_raw_handle(), + job.pointer as *mut u8, + job.len, + &mut n, + ptr::null_mut(), + ) + } + }; + if ok == 0 { Err(os_error()) } else { Ok(n) } + } + }; + let mut state = shared.state.lock().unwrap_or_else(|e| e.into_inner()); + state.active = false; + state.result = Some(result); + // SAFETY: retained port and opaque stable kernel slot. Publication happens + // under the same lock used by finish, after all native buffer access ends. + if unsafe { + PostQueuedCompletionStatus( + shared.port.raw(), + 0, + KEY, + job.overlapped as *const OVERLAPPED, + ) + } == 0 + { + std::process::abort(); + } + shared.changed.notify_all(); + } +} +impl Drop for Worker { + fn drop(&mut self) { + // Backend Drop first waits for completion acknowledgements. There is no + // active request here, including after normal close and quiescent detach. + { + let mut state = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); + assert!(!state.active && state.job.is_none()); + state.stop = true; + self.shared.changed.notify_one(); + } + if let Some(thread) = self.thread.take() + && thread.join().is_err() + { + std::process::abort(); + } + } +} +#[derive(Default)] +struct ConsoleBytes { + bytes: [u8; 4], + len: usize, + offset: usize, + repeats: u16, + high: Option, +} +impl ConsoleBytes { + fn read(&mut self, handle: HANDLE, pointer: *mut u8, len: u32) -> Result { + if len == 0 { + return Ok(0); + } + loop { + if self.repeats > 0 { + let n = (self.len - self.offset).min(len as usize); + // SAFETY: worker retains exclusive caller buffer; source range initialized. + unsafe { + ptr::copy_nonoverlapping(self.bytes.as_ptr().add(self.offset), pointer, n); + } + self.offset += n; + if self.offset == self.len { + self.offset = 0; + self.repeats -= 1; + } + return Ok(n as u32); + } + // SAFETY: plain C console input output record. + let mut record: INPUT_RECORD = unsafe { std::mem::zeroed() }; + let mut read = 0; + // SAFETY: owned console and one writable input record; synchronous worker only. + bool_result(unsafe { ReadConsoleInputW(handle, &mut record, 1, &mut read) })?; + if read == 0 { + continue; + } + if record.EventType == WINDOW_BUFFER_SIZE_EVENT as u16 { + signals::dispatch(Signal::WinCh); + continue; + } + if record.EventType != KEY_EVENT as u16 { + continue; + } + // SAFETY: EventType selected the initialized key-event union arm. + let key = unsafe { record.Event.KeyEvent }; + if key.bKeyDown == 0 { + continue; + } + // SAFETY: ReadConsoleInputW initializes the Unicode member. + let unit = unsafe { key.uChar.UnicodeChar }; + if unit == 0 { + continue; + } + if (0xd800..=0xdbff).contains(&unit) { + self.high = Some(unit); + continue; + } + let code = if let Some(high) = self.high.take() { + if (0xdc00..=0xdfff).contains(&unit) { + 0x10000 + ((high as u32 - 0xd800) << 10) + (unit as u32 - 0xdc00) + } else { + unit as u32 + } + } else { + unit as u32 + }; + let ch = char::from_u32(code).unwrap_or(char::REPLACEMENT_CHARACTER); + self.len = ch.encode_utf8(&mut self.bytes).len(); + self.offset = 0; + self.repeats = key.wRepeatCount.max(1); + } + } +} +impl Detached { + /// Adopt a quiescent file, pipe or console handle. Synchronous handles use a + /// worker; overlapped pipes use event-routed I/O even with a prior IOCP + /// association. Other overlapped handles return Unsupported. Captured console + /// modes are restored on close or drop. + pub fn from_handle(handle: OwnedHandle) -> Result { + let mut mode = 0; + // SAFETY: owned handle, writable mode output. + let console = unsafe { GetConsoleMode(handle.as_raw_handle(), &mut mode) } != 0; + // SAFETY: valid zeroed C output storage for screen-buffer classification. + let mut info = unsafe { std::mem::zeroed() }; + // SAFETY: owned handle; failure distinguishes input from screen-buffer handles. + let console_input = console + && unsafe { GetConsoleScreenBufferInfo(handle.as_raw_handle(), &mut info) } == 0; + let kind = if console { + Kind::Sync + } else { + handle_kind(handle.as_raw_handle())? + }; + let mut transport = Self::new(Native::Handle(handle), kind, kind == Kind::Pipe); + transport.mode = console.then_some(mode); + transport.console_input = console_input; + Ok(transport) + } +} + +fn handle_kind(handle: HANDLE) -> Result { + use windows_sys::Wdk::Storage::FileSystem::{ + FILE_MODE_INFORMATION, FILE_SYNCHRONOUS_IO_ALERT, FILE_SYNCHRONOUS_IO_NONALERT, + FileModeInformation, NtQueryInformationFile, + }; + // SAFETY: valid zeroed C output storage; FileModeInformation completes inline + // and does not initiate data I/O or retain either output pointer. + let mut status: IO_STATUS_BLOCK = unsafe { std::mem::zeroed() }; + let mut info = FILE_MODE_INFORMATION { Mode: 0 }; + // SAFETY: live owned handle and correctly sized writable mode information. + // https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntqueryinformationfile + let result = unsafe { + NtQueryInformationFile( + handle, + &mut status, + ptr::from_mut(&mut info).cast(), + std::mem::size_of_val(&info) as u32, + FileModeInformation, + ) + }; + if result != 0 { + return Err(unsupported()); + } + if info.Mode & (FILE_SYNCHRONOUS_IO_ALERT | FILE_SYNCHRONOUS_IO_NONALERT) != 0 { + Ok(Kind::Sync) + // SAFETY: live owned handle; classification has no lifetime side effects. + } else if unsafe { GetFileType(handle) } == FILE_TYPE_PIPE { + Ok(Kind::Pipe) + } else { + // Never submit synchronous ReadFile with null OVERLAPPED on an unknown + // asynchronous handle: a pending result would outlive worker buffers. + Err(unsupported()) + } +} diff --git a/crates/turnloop/src/backend/iocp/timer.rs b/crates/turnloop/src/backend/iocp/timer.rs new file mode 100644 index 0000000..bdb6495 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/timer.rs @@ -0,0 +1,197 @@ +use super::port::{Port, TIMER, bool_result, owned}; +use std::{ + ffi::{CStr, c_void}, + io, + os::windows::io::{AsRawHandle, OwnedHandle}, + ptr, + sync::Arc, + time::Duration, +}; +use windows_sys::Win32::{ + Foundation::{HANDLE, HMODULE}, + System::{ + LibraryLoader::{GetModuleHandleW, GetProcAddress}, + Threading::{ + CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, CancelWaitableTimer, CreateWaitableTimerExW, + SetWaitableTimer, TIMER_ALL_ACCESS, + }, + }, +}; + +pub fn relative_due(delay: Duration) -> i64 { + -(delay.as_nanos().div_ceil(100).clamp(1, i64::MAX as u128) as i64) +} + +fn high_resolution() -> io::Result { + // SAFETY: unnamed, noninheritable timer; ownership transferred once. + // Windows 10 1803+: do not silently downgrade precision if unsupported. + // https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-createwaitabletimerexw + unsafe { + owned(CreateWaitableTimerExW( + ptr::null(), + ptr::null(), + CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, + TIMER_ALL_ACCESS, + )) + } +} + +type Create = unsafe extern "system" fn(*mut HANDLE, u32, *const c_void) -> i32; +type Associate = unsafe extern "system" fn( + HANDLE, + HANDLE, + HANDLE, + *const c_void, + *const c_void, + i32, + usize, + *mut u8, +) -> i32; +type Cancel = unsafe extern "system" fn(HANDLE, u8) -> i32; + +fn symbol(module: HMODULE, name: &CStr) -> io::Result isize> { + // SAFETY: ntdll is process-resident; name is nul-terminated. No FreeLibrary + // on a borrowed GetModuleHandle result. + // https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getprocaddress + unsafe { GetProcAddress(module, name.as_ptr().cast()) }.ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + format!("{} is unavailable", name.to_string_lossy()), + ) + }) +} + +fn nt_result(status: i32) -> io::Result<()> { + if status < 0 { + Err(io::Error::other(format!("NTSTATUS {status:#010x}"))) + } else { + Ok(()) + } +} + +pub struct PacketTimer { + packet: OwnedHandle, + timer: OwnedHandle, + port: Arc, + associate: Associate, + cancel: Cancel, + active: bool, + generation: usize, +} + +impl PacketTimer { + pub fn new(port: Arc) -> io::Result { + let name: Vec = "ntdll.dll\0".encode_utf16().collect(); + // SAFETY: valid nul-terminated name; ntdll cannot unload during the process. + // https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulehandlew + let module = unsafe { GetModuleHandleW(name.as_ptr()) }; + if module.is_null() { + return Err(io::Error::last_os_error()); + } + // SAFETY: each named export has exactly the documented system ABI below. + // No SDK headers/import library; feature-detect all exports at runtime. + // https://learn.microsoft.com/en-us/windows/win32/devnotes/ntcreatewaitcompletionpacket + let create = unsafe { + std::mem::transmute:: isize, Create>(symbol( + module, + c"NtCreateWaitCompletionPacket", + )?) + }; + // SAFETY: exact ABI from the Microsoft devnote. + // https://learn.microsoft.com/en-us/windows/win32/devnotes/ntassociatewaitcompletionpacket + let associate = unsafe { + std::mem::transmute:: isize, Associate>(symbol( + module, + c"NtAssociateWaitCompletionPacket", + )?) + }; + // SAFETY: exact ABI from the Microsoft devnote (BOOLEAN is u8). + // https://learn.microsoft.com/en-us/windows/win32/devnotes/ntcancelwaitcompletionpacket + let cancel = unsafe { + std::mem::transmute:: isize, Cancel>(symbol( + module, + c"NtCancelWaitCompletionPacket", + )?) + }; + let mut handle = ptr::null_mut(); + // SAFETY: valid output pointer; MAXIMUM_ALLOWED avoids private access-mask constants. + nt_result(unsafe { create(&mut handle, 0x02000000, ptr::null()) })?; + // SAFETY: successful NtCreate returned unique ownership. + let packet = unsafe { owned(handle) }?; + Ok(Self { + packet, + timer: high_resolution()?, + port, + associate, + cancel, + active: false, + generation: 0, + }) + } + + pub fn arm(&mut self, delay: Duration) -> io::Result<()> { + if self.active { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "dequeue previous timer packet before reuse", + )); + } + let due = relative_due(delay); + self.generation = self + .generation + .checked_add(1) + .ok_or_else(|| io::Error::other("timer generation exhausted"))?; + // SAFETY: live timer; no callback. Arm first to reset any previous signal. + bool_result(unsafe { + SetWaitableTimer(self.timer.as_raw_handle(), &due, 0, None, ptr::null(), 0) + })?; + let mut already = 0; + // SAFETY: inactive packet and three live owned handles; contexts are opaque + // integers and null. AlreadySignaled STILL queues a packet; do not synthesize one. + // https://learn.microsoft.com/en-us/windows/win32/devnotes/ntassociatewaitcompletionpacket + nt_result(unsafe { + (self.associate)( + self.packet.as_raw_handle(), + self.port.raw(), + self.timer.as_raw_handle(), + TIMER as *const c_void, + self.generation as *const c_void, + 0, + 0, + &mut already, + ) + })?; + self.active = true; + Ok(()) + } + + /// # Safety + /// Call only after dequeuing this timer's TIMER packet on its port. + pub unsafe fn dequeued(&mut self, generation: usize) { + if generation == self.generation { + self.active = false; + } + } + + /// Cancel without promising immediate packet reuse. STATUS_PENDING requires + /// draining; callers should drop this timer or wait for the original packet. + pub fn cancel(&mut self) -> io::Result { + // SAFETY: live timer, no APC. + bool_result(unsafe { CancelWaitableTimer(self.timer.as_raw_handle()) })?; + // SAFETY: live packet; remove a signaled packet if it is still queued. + let status = unsafe { (self.cancel)(self.packet.as_raw_handle(), 1) }; + if status == 0 || status == 0xc0000120u32 as i32 { + self.active = false; + } + if status == 0xc0000120u32 as i32 { + return Ok(status); + } + nt_result(status)?; + Ok(status) + } +} +impl Drop for PacketTimer { + fn drop(&mut self) { + let _ = self.cancel(); + } +} diff --git a/crates/turnloop/src/backend/mod.rs b/crates/turnloop/src/backend/mod.rs index b86374a..347f34c 100644 --- a/crates/turnloop/src/backend/mod.rs +++ b/crates/turnloop/src/backend/mod.rs @@ -305,6 +305,11 @@ pub mod unix; #[cfg(any(turnloop_backend = "kqueue", turnloop_backend = "epoll"))] pub use unix::Unix as Platform; +#[cfg(turnloop_backend = "iocp")] +pub mod iocp; +#[cfg(turnloop_backend = "iocp")] +pub use iocp::Iocp as Platform; + #[cfg(any(turnloop_backend = "kqueue", turnloop_backend = "epoll"))] mod ipc; diff --git a/crates/turnloop/src/buffer.rs b/crates/turnloop/src/buffer.rs index 75582db..2496f14 100644 --- a/crates/turnloop/src/buffer.rs +++ b/crates/turnloop/src/buffer.rs @@ -134,7 +134,11 @@ impl BufferPool { inner: Rc::new(RefCell::new((0..count).map(|_| vec![0; size]).collect())), } } - #[cfg(any(turnloop_backend = "kqueue", turnloop_backend = "epoll"))] + #[cfg(any( + turnloop_backend = "kqueue", + turnloop_backend = "epoll", + turnloop_backend = "iocp" + ))] pub(crate) fn available(&self) -> bool { !self.inner.borrow().is_empty() } diff --git a/crates/turnloop/src/lib.rs b/crates/turnloop/src/lib.rs index 8f65b26..6194f04 100644 --- a/crates/turnloop/src/lib.rs +++ b/crates/turnloop/src/lib.rs @@ -55,9 +55,15 @@ mod queue; mod sync; pub use notifier::{Notifier, PostError, Poster}; -#[cfg(any(turnloop_backend = "kqueue", turnloop_backend = "epoll"))] +#[cfg(any( + turnloop_backend = "kqueue", + turnloop_backend = "epoll", + turnloop_backend = "iocp" +))] /// The native driver selected for the compilation target; owned by one thread. pub type Loop = Driver; +#[cfg(turnloop_backend = "iocp")] +pub use backend::iocp::Detached; #[cfg(any(turnloop_backend = "kqueue", turnloop_backend = "epoll"))] pub use backend::unix::Detached; diff --git a/spikes/iocp/WINDOWS_RESULTS.md b/spikes/iocp/WINDOWS_RESULTS.md index 83bdf8d..98574d1 100644 --- a/spikes/iocp/WINDOWS_RESULTS.md +++ b/spikes/iocp/WINDOWS_RESULTS.md @@ -76,5 +76,52 @@ After replacing the draft's APC deadline with the measured NT packet route, passes all three tests, including zero allocations across 256 measured transfers and cancellation before Closed. Formatting and unsafe-aware Clippy pass again. -Production backend integration and shared contracts are in progress. No production -Windows runtime coverage or green Windows CI is claimed by this initial record. +The production adapter is integrated at `crates/turnloop/src/backend/iocp/`. +The Windows pending-contract marker is removed: shared contracts execute natively. + +PASS on this host: + +- `cargo +nightly-2026-08-20 test -p turnloop-contract --all-features -- --test-threads=1`: + 47 tests: 30 shared/backend, five allocation, six executor, two isolated-console + and four Windows lifetime/imported-handle scenarios. +- `cargo +nightly-2026-08-20 test -p turnloop-contract --test windows_console -- --nocapture --test-threads=1`: + two tests, each spawning an isolated console child; real Ctrl-C/Break fan-out, + console input/resize, VT modes and restoration on close/drop. +- `cargo +nightly-2026-08-20 test -p turnloop-contract --test windows_lifetimes --test windows -- --nocapture --test-threads=1`: + 34 tests, including GUI timer reset, 128 synchronous cancellation/drop races, + and 32 TCP loop lifetimes returning to the native handle-count baseline. +- `cargo +nightly-2026-08-20 clippy --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks`. + +Intermediate failures fixed without relaxing assertions: + +- Shared refused-connect watchdog: Windows retries refused loopback SYNs. Disable + SYN retransmission for loopback destinations only; retain administrator settings + for network destinations. RtlNtStatusToDosError returns Win32 code 1225, which + additionally needs explicit ConnectionRefused mapping (the standard I/O mapping + recognizes Winsock's different code). +- IPC allocation gate: one lazily allocated routed-callback context on first + handle transfer. Reserve callback contexts with the operation slab at setup; + all five enabled allocation gates pass, including IPC and file-worker I/O. +- Bounded 12 ms wait: GQCSEx's competing integer timeout could end before 10 ms. + Once an NT deadline is armed, use an infinite GQCSEx timeout and let the exact + packet provide the deadline. The original bounded/no-spin assertions pass. +- Initial unsafe-aware lint runs flagged comment placement around multiline + assertions. Comments now immediately precede the unsafe expressions. +- Imported handles need native file-mode classification before using a synchronous + worker. Overlapped pipes now route to the receiving loop, including handles + already associated with another port. Other overlapped files return Unsupported. + +The initial PR CI run's Windows native job failed because Microsoft's bundled +curl lacks HTTP/2 (`--http2-prior-knowledge`), before production integration was +pushed. CI now installs checksum-pinned official curl 8.22.0_1 with HTTP/2. +Interop commands explicitly set child PATH because Rust otherwise searches +System32 before the inherited PATH. The unchanged HTTP/2 assertions now pass. +The next full-workspace run exposed CRLF-converted decoder reference fixtures; +`.gitattributes` now preserves their original bytes. All 68 decoder tests pass +after restoring the fixtures to their exact committed bytes. +Latest production CI and full-workspace runtime results are recorded below when +available; this record does not claim they have passed yet. + +Unrun wider gates: ETW syscall traces, CPU-cycle A/B attribution, overnight soak, +Windows 10 minimum-version coverage, and VM/power-state precision matrices. +Console resize dispatch currently requires an active console input read. From dc4e8d50eaab85d6cd1b93d7ab9167c7a550660f Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 14 Sep 2026 20:26:24 +0200 Subject: [PATCH 04/15] Preserve Windows fixtures and use HTTP2-capable curl in CI --- .gitattributes | 4 ++ .github/workflows/ci.yml | 6 ++- protocols/turnloop-http/tests/interop.rs | 4 ++ scripts/ci/install-tools.py | 40 ++++++++++++----- scripts/ci/test_tool_archives.py | 56 ++++++++++++++++++++++++ scripts/ci/tools.json | 10 +++++ 6 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 .gitattributes create mode 100644 scripts/ci/test_tool_archives.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..087bec8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Decoder corpora are byte-for-byte reference data, including text originals. +# CRLF conversion would make the originals differ from their compressed copies. +/protocols/turnloop-zstd-decoder/decodecorpus_files/** -text +/protocols/turnloop-zstd-decoder/dict_tests/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f026ba..51708ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,7 +105,6 @@ jobs: cargo +nightly-2026-08-20 clippy --locked --workspace --all-targets --target "$TARGET" --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks rustup toolchain install stable --profile minimal --target "$TARGET" cargo +stable check --locked --workspace --all-targets --all-features --target "$TARGET" - # PENDING Windows shared contracts until the IOCP production provider lands (wave 2). test-native: strategy: fail-fast: false @@ -128,6 +127,9 @@ jobs: package-manager-cache: false - name: Install pinned Rust run: rustup toolchain install nightly-2026-08-20 --profile minimal --component rustfmt,clippy + - name: Install checksum-pinned curl with HTTP/2 on Windows + if: runner.os == 'Windows' + run: python3 scripts/ci/install-tools.py curl - name: Workspace and single-threaded contracts, default and all features run: python3 scripts/ci/run-tests.py native - name: HTTP, TLS and WebSocket Node/curl interop @@ -154,6 +156,8 @@ jobs: package-manager-cache: false - name: Install pinned Rust run: rustup toolchain install nightly-2026-08-20 --profile minimal --component rustfmt,clippy + - name: Install checksum-pinned curl with HTTP/2 + run: python3 scripts/ci/install-tools.py curl - name: Native Windows lint and contracts run: | cargo +nightly-2026-08-20 clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks diff --git a/protocols/turnloop-http/tests/interop.rs b/protocols/turnloop-http/tests/interop.rs index 40a78d5..18be909 100644 --- a/protocols/turnloop-http/tests/interop.rs +++ b/protocols/turnloop-http/tests/interop.rs @@ -165,6 +165,8 @@ fn curl_and_node_fetch_against_native_http1() { Command::new("node").args(["--input-type=module","-e","const r=await fetch(process.argv[1]);if(r.status!==200)process.exit(2);console.log(await r.text());",&url]).output().unwrap() } else { Command::new("curl") + // Explicit child PATH takes precedence over System32 on Windows. + .env("PATH", std::env::var_os("PATH").expect("PATH")) .args([ "--silent", "--show-error", @@ -301,6 +303,8 @@ fn curl_and_node_h2_hundred_streams_against_native_server() { Command::new("node").args(["--input-type=module","-e",r#"import h2 from 'node:http2'; setTimeout(()=>{console.error('client timeout');process.exit(70);},10000).unref(); const c=h2.connect(process.argv[1]); await Promise.all(Array.from({length:100},()=>new Promise((resolve,reject)=>{const s=c.request({':path':'/interop'});let b='';s.on('response',h=>{if(h[':status']!==200)reject(Error('status'));});s.on('data',x=>b+=x);s.on('end',()=>b==='native-h2'?resolve():reject(Error(b)));s.on('error',reject);s.on('aborted',()=>reject(Error('stream aborted')));s.end();})));c.close();console.log('100 verified');"#,&url]).output().unwrap() } else { Command::new("curl") + // Explicit child PATH takes precedence over System32 on Windows. + .env("PATH", std::env::var_os("PATH").expect("PATH")) .args([ "--http2-prior-knowledge", "--silent", diff --git a/scripts/ci/install-tools.py b/scripts/ci/install-tools.py index 3468c73..5aa446f 100644 --- a/scripts/ci/install-tools.py +++ b/scripts/ci/install-tools.py @@ -9,6 +9,7 @@ import tarfile import tempfile import urllib.request +import zipfile from common import ROOT, entrypoint, fail @@ -28,21 +29,36 @@ def install(name, destination): with tempfile.TemporaryFile() as handle: handle.write(archive) handle.seek(0) - with tarfile.open(fileobj=handle, mode='r:*') as bundle: - for executable in pin['executables']: - matches = [m for m in bundle.getmembers() - if m.isfile() and Path(m.name).name == executable] - if len(matches) != 1: - fail(f'{name}: expected exactly one {executable} in archive') - contents = bundle.extractfile(matches[0]) - if contents is None: - fail(f'{name}: cannot read {executable}') - target = destination / executable - target.write_bytes(contents.read()) - target.chmod(0o755) + if pin['url'].endswith('.zip'): + with zipfile.ZipFile(handle) as bundle: + for executable in pin['executables']: + matches = [m for m in bundle.infolist() + if not m.is_dir() and (m.external_attr >> 16) & 0o170000 in (0, 0o100000) + and Path(m.filename).name == executable] + if len(matches) != 1: + fail(f'{name}: expected exactly one {executable} in archive') + target = destination / executable + target.write_bytes(bundle.read(matches[0])) + else: + install_tar(handle, pin, name, destination) print(f'PASS install {name} {pin["version"]}: sha256:{digest}') +def install_tar(handle, pin, name, destination): + with tarfile.open(fileobj=handle, mode='r:*') as bundle: + for executable in pin['executables']: + matches = [m for m in bundle.getmembers() + if m.isfile() and Path(m.name).name == executable] + if len(matches) != 1: + fail(f'{name}: expected exactly one {executable} in archive') + contents = bundle.extractfile(matches[0]) + if contents is None: + fail(f'{name}: cannot read {executable}') + target = destination / executable + target.write_bytes(contents.read()) + target.chmod(0o755) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('tools', nargs='+') diff --git a/scripts/ci/test_tool_archives.py b/scripts/ci/test_tool_archives.py new file mode 100644 index 0000000..a021158 --- /dev/null +++ b/scripts/ci/test_tool_archives.py @@ -0,0 +1,56 @@ +"""Pinned ZIP tools extract only the named regular executable.""" +import hashlib +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import zipfile +from test_gates import module + + +class ToolArchives(unittest.TestCase): + def install_zip(self, members, *, digest=None): + installer = module('install-tools') + archive = io.BytesIO() + with zipfile.ZipFile(archive, 'w') as bundle: + for name, content, symlink in members: + item = zipfile.ZipInfo(name) + item.external_attr = (0o120777 if symlink else 0o100755) << 16 + bundle.writestr(item, content) + data = archive.getvalue() + pin = {'curl': {'Windows-AMD64': { + 'url': 'https://example.invalid/pinned.zip', 'version': 'pinned', + 'sha256': digest or hashlib.sha256(data).hexdigest(), + 'executables': ['curl.exe'], + }}} + with tempfile.TemporaryDirectory() as folder: + destination = Path(folder) + with patch.object(installer.platform, 'system', return_value='Windows'), \ + patch.object(installer.platform, 'machine', return_value='AMD64'), \ + patch.object(Path, 'read_text', return_value=json.dumps(pin)), \ + patch.object(installer.urllib.request, 'urlopen', return_value=io.BytesIO(data)): + installer.install('curl', destination) + self.assertEqual([p.name for p in destination.iterdir()], ['curl.exe']) + return (destination / 'curl.exe').read_bytes() + + def test_extracts_only_the_requested_regular_file(self): + self.assertEqual(self.install_zip([ + ('release/bin/curl.exe', b'fixture', False), + ('../../outside.txt', b'do not extract', False), + ]), b'fixture') + + def test_rejects_digest_mismatch(self): + with self.assertRaisesRegex(RuntimeError, 'SHA-256 mismatch'): + self.install_zip([('curl.exe', b'fixture', False)], digest='0' * 64) + + def test_rejects_symlink_and_duplicate_basenames(self): + for members in [[('curl.exe', b'other.exe', True)], + [('a/curl.exe', b'one', False), ('b/curl.exe', b'two', False)]]: + with self.subTest(members=members), self.assertRaisesRegex(RuntimeError, 'exactly one'): + self.install_zip(members) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/ci/tools.json b/scripts/ci/tools.json index 551c71a..cf0e7e8 100644 --- a/scripts/ci/tools.json +++ b/scripts/ci/tools.json @@ -157,5 +157,15 @@ "commit": "70ac2294010887f48b18e2d64f5cccd48421fad1", "url": "https://codeload.github.com/summerwind/h2spec/tar.gz/70ac2294010887f48b18e2d64f5cccd48421fad1", "sha256": "791b995048c7e2a2895ed2c019eb9abe46015c3a7a6107cb9ea3f5e5f311da39" + }, + "curl": { + "Windows-AMD64": { + "version": "8.22.0_1", + "url": "https://curl.se/windows/dl-8.22.0_1/curl-8.22.0_1-win64-mingw.zip", + "sha256": "7f23b039f6ea4197362d4468e1a0e71428201222e1bef3b680d5ef7b2aefb714", + "executables": [ + "curl.exe" + ] + } } } From 228cc51dfa328686fe99daed18e430645217357c Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 14 Sep 2026 20:32:28 +0200 Subject: [PATCH 05/15] Validate Windows examples and read path-check inputs as UTF8 --- crates/turnloop/src/lib.rs | 4 ++-- scripts/ci/check-paths.py | 4 ++-- scripts/ci/test_paths.py | 11 ++++++++++- spikes/iocp/README.md | 5 +++-- spikes/iocp/WINDOWS_RESULTS.md | 30 +++++++++++++++++++++++++++--- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/crates/turnloop/src/lib.rs b/crates/turnloop/src/lib.rs index 6194f04..d316ddc 100644 --- a/crates/turnloop/src/lib.rs +++ b/crates/turnloop/src/lib.rs @@ -5,7 +5,7 @@ //! process-wide blocking, signal and external-wait services start only on use. //! //! ``` -//! # #[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android", target_os = "freebsd"))] +//! # #[cfg(any(windows, target_vendor = "apple", target_os = "linux", target_os = "android", target_os = "freebsd"))] //! # fn main() -> turnloop::Result<()> { //! use std::time::Duration; //! use turnloop::{Completions, Config, Loop, OpResult, Timeout, Token}; @@ -24,7 +24,7 @@ //! driver.turn(Timeout::Now, &mut completions)?; //! assert!(!driver.alive()); //! # Ok(()) } -//! # #[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android", target_os = "freebsd")))] +//! # #[cfg(not(any(windows, target_vendor = "apple", target_os = "linux", target_os = "android", target_os = "freebsd")))] //! # fn main() {} //! ``` //! diff --git a/scripts/ci/check-paths.py b/scripts/ci/check-paths.py index d769318..6cfa7a0 100644 --- a/scripts/ci/check-paths.py +++ b/scripts/ci/check-paths.py @@ -126,7 +126,7 @@ def __init__(self, root, tracked): self.roots = set() for path in sorted(self.tracked): if PurePosixPath(path).name == 'Cargo.toml': - data = tomllib.loads((self.root / path).read_text()) + data = tomllib.loads((self.root / path).read_text(encoding='utf-8')) base = posixpath.dirname(path) self.manifests[base] = data for kind in ('lib', 'bin', 'test', 'bench', 'example'): @@ -191,7 +191,7 @@ def expression(self, ts, source): raise ValueError('cannot statically resolve file reference; use a literal or literal concat!') def rust(self, path, module_dir=None): - source = (self.root / path).read_text() + source = (self.root / path).read_text(encoding='utf-8') ts = tokens(source) parent = posixpath.dirname(path) if module_dir is None: diff --git a/scripts/ci/test_paths.py b/scripts/ci/test_paths.py index 69e3e2f..90c4933 100644 --- a/scripts/ci/test_paths.py +++ b/scripts/ci/test_paths.py @@ -16,11 +16,20 @@ def check(self, files, tracked=None): for name, contents in files.items(): path = root / name path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(contents) + path.write_text(contents, encoding='utf-8') check = checker.Check(root, tracked if tracked is not None else files) errors = check.run() return errors, check.references + def test_sources_and_manifests_are_utf8_on_windows(self): + errors, count = self.check({ + 'Cargo.toml': '[package]\nname="p"\ndescription="あ"\nreadme="README.md"', + 'README.md': '', + 'src/lib.rs': '// あ\nconst DOC: &str = include_str!("../README.md");', + }) + self.assertEqual(errors, []) + self.assertEqual(count, 2) + def test_real_git_index_case_mismatch_cli(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/spikes/iocp/README.md b/spikes/iocp/README.md index df6aab7..b7addd3 100644 --- a/spikes/iocp/README.md +++ b/spikes/iocp/README.md @@ -2,8 +2,9 @@ Standalone workspace, windows-sys 0.61.2 only. No tokio or compio dependency. Requires Windows 10 1803+ for the high-resolution timer probes. NT packet probes -feature-detect ntdll exports and fail explicitly if unavailable. All Windows -runtime tests are **UNRUN** on the development macOS host. +feature-detect ntdll exports and fail explicitly if unavailable. Originally +cross-checked on macOS; native Windows results, the recorded APC failure and +the production backend validation are in [WINDOWS_RESULTS.md](WINDOWS_RESULTS.md). Each mechanism has its own Cargo integration-test binary: diff --git a/spikes/iocp/WINDOWS_RESULTS.md b/spikes/iocp/WINDOWS_RESULTS.md index 98574d1..6a1e652 100644 --- a/spikes/iocp/WINDOWS_RESULTS.md +++ b/spikes/iocp/WINDOWS_RESULTS.md @@ -13,7 +13,9 @@ Issue #4, branch `windows/iocp-backend`, 2026-09-14. - `rustc +nightly-2026-08-20 --version`: 1.100.0-nightly (f7d782a3b 2026-08-19). MSVC linker and Windows SDK are installed and linked tests. - `rustc +stable --version`: 1.96.1 (31fca3adb 2026-06-26). -- Node v24.21.0 initially installed; HTTP/2 interop is not yet run. +- Node v24.21.0 initially installed; validation uses official Node v26.5.1 from + `.tools/bin`, with the release archive verified against its published SHA-256. +- Official curl 8.22.0_1 (HTTP/2 enabled), checksum pinned in `scripts/ci/tools.json`. - Commands explicitly select the pinned nightly: implicit `cargo`/`rustc` attempts to install every cross target from `rust-toolchain.toml`, but the existing wasip1-threads component has a rustup installation conflict. @@ -119,8 +121,30 @@ System32 before the inherited PATH. The unchanged HTTP/2 assertions now pass. The next full-workspace run exposed CRLF-converted decoder reference fixtures; `.gitattributes` now preserves their original bytes. All 68 decoder tests pass after restoring the fixtures to their exact committed bytes. -Latest production CI and full-workspace runtime results are recorded below when -available; this record does not claim they have passed yet. +Further local validation: + +- `python scripts/ci/run-tests.py native`: PASS, workspace and individual member + runs with default/all features. Workspace passes were 209/224 tests; separate + contract passes were 40/47. These counts precede the merge of main's additional + HTTP capability and portable TLS tests. Fixture-dependent protocol cases retain + their existing ignored status here and run separately through the fixture runner. +- `python scripts/test-servers.py --services http run python scripts/ci/run-tests.py interop`: + PASS after merging main, 17 tests (HTTP 9, TLS 5, WebSocket 3), including real + Node 26 and curl HTTP/2 transfers. The additional upstream no-curl capability + scenario intentionally logs its curl leg UNRUN while requiring Node's 100 streams. +- `RUSTDOCFLAGS='-D warnings' cargo +nightly-2026-08-20 doc --locked --workspace --all-features --no-deps`: + PASS (set RUSTDOCFLAGS with PowerShell environment syntax on Windows). +- ZIP installer regression tests: PASS, 3 tests. UTF-8/path guard regression tests: + PASS, 9 tests. `python scripts/ci/check-paths.py`: PASS after fixing its implicit + Windows code-page decoding to read UTF-8 source and manifests. +- `cargo +1.97.1 check --locked --workspace --all-targets --all-features`: PASS, + using the declared MSRV installed alongside the existing stable toolchain. +- `cargo +nightly-2026-08-20 test --locked -p turnloop --doc --all-features`: + PASS, two tests; the public Loop example now actually executes on Windows. + +GitHub CI results and links are tracked in PR #5; local results alone do not +establish a CI pass. The initial run at `2eee4c8` failed the Windows HTTP/2 setup; +the production run starts after the current-main merge at `833ebe0`. Unrun wider gates: ETW syscall traces, CPU-cycle A/B attribution, overnight soak, Windows 10 minimum-version coverage, and VM/power-state precision matrices. From e960b0804cb6aa3772dafba3f2ba710876ddcbec Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 14 Sep 2026 20:41:12 +0200 Subject: [PATCH 06/15] Require the pinned curl executable in Windows interop --- .github/workflows/ci.yml | 8 +++-- protocols/turnloop-http/tests/interop.rs | 41 +++++++++++++++--------- spikes/iocp/WINDOWS_RESULTS.md | 13 +++++++- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dd0bec..3ef00e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,7 +126,9 @@ jobs: run: rustup toolchain install nightly-2026-08-20 --profile minimal --component rustfmt,clippy - name: Install checksum-pinned curl with HTTP/2 on Windows if: runner.os == 'Windows' - run: python3 scripts/ci/install-tools.py curl + run: | + python3 scripts/ci/install-tools.py curl + echo "TURNLOOP_TEST_CURL=$GITHUB_WORKSPACE/.tools/bin/curl.exe" >> "$GITHUB_ENV" - name: Workspace and single-threaded contracts, default and all features run: python3 scripts/ci/run-tests.py native - name: HTTP, TLS and WebSocket Node/curl interop @@ -154,7 +156,9 @@ jobs: - name: Install pinned Rust run: rustup toolchain install nightly-2026-08-20 --profile minimal --component rustfmt,clippy - name: Install checksum-pinned curl with HTTP/2 - run: python3 scripts/ci/install-tools.py curl + run: | + python3 scripts/ci/install-tools.py curl + echo "TURNLOOP_TEST_CURL=$GITHUB_WORKSPACE/.tools/bin/curl.exe" >> "$GITHUB_ENV" - name: Native Windows lint and contracts run: | cargo +nightly-2026-08-20 clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks diff --git a/protocols/turnloop-http/tests/interop.rs b/protocols/turnloop-http/tests/interop.rs index cd688b5..fc42a8e 100644 --- a/protocols/turnloop-http/tests/interop.rs +++ b/protocols/turnloop-http/tests/interop.rs @@ -13,6 +13,14 @@ use turnloop_http::{ }; #[path = "support/tls.rs"] mod tls_support; +fn curl() -> Command { + let executable = std::env::var_os("TURNLOOP_TEST_CURL").unwrap_or_else(|| "curl".into()); + let mut command = Command::new(executable); + // Explicit child PATH takes precedence over System32 on Windows. CI uses an + // absolute override because Git Bash can prepend its own curl to that PATH. + command.env("PATH", std::env::var_os("PATH").expect("PATH")); + command +} // Probe the same executable used by the tests. Windows' bundled curl commonly // has HTTP/1 support but no HTTP2, regardless of its version number. fn curl_capability(version: &str, field: &str, capability: &str) -> bool { @@ -22,12 +30,7 @@ fn curl_capability(version: &str, field: &str, capability: &str) -> bool { }) } fn curl_supports(http2: bool) -> bool { - match Command::new("curl") - // Probe the same PATH-selected executable used by the transfer tests. - .env("PATH", std::env::var_os("PATH").expect("PATH")) - .arg("-V") - .output() - { + match curl().arg("-V").output() { Ok(output) => { assert!( output.status.success(), @@ -35,10 +38,22 @@ fn curl_supports(http2: bool) -> bool { String::from_utf8_lossy(&output.stderr) ); let version = String::from_utf8(output.stdout).expect("curl -V must emit UTF-8"); - curl_capability(&version, "Protocols:", "http") - && (!http2 || curl_capability(&version, "Features:", "HTTP2")) + let supported = curl_capability(&version, "Protocols:", "http") + && (!http2 || curl_capability(&version, "Features:", "HTTP2")); + if std::env::var_os("TURNLOOP_TEST_CURL").is_some() { + assert!( + supported, + "explicit CI curl lacks required capabilities: {version}" + ); + } + supported + } + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && std::env::var_os("TURNLOOP_TEST_CURL").is_none() => + { + false } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, Err(error) => panic!("cannot probe curl: {error}"), } } @@ -214,9 +229,7 @@ fn curl_and_node_fetch_against_native_http1() { let output = if node { Command::new("node").args(["--input-type=module","-e","setTimeout(()=>process.exit(70),10000).unref();const r=await fetch(process.argv[1]);if(r.status!==200)process.exit(2);console.log(await r.text());",&url]).output().expect("Node fetch must run") } else { - Command::new("curl") - // Explicit child PATH takes precedence over System32 on Windows. - .env("PATH", std::env::var_os("PATH").expect("PATH")) + curl() .args([ "--silent", "--show-error", @@ -373,9 +386,7 @@ fn run_h2_clients(curl_http2: impl Fn() -> bool) -> usize { let output = if node { Command::new("node").args(["--input-type=module","-e",r#"import h2 from 'node:http2'; setTimeout(()=>{console.error('client timeout');process.exit(70);},10000).unref(); const c=h2.connect(process.argv[1]); await Promise.all(Array.from({length:100},()=>new Promise((resolve,reject)=>{const s=c.request({':path':'/interop'});let b='';s.on('response',h=>{if(h[':status']!==200)reject(Error('status'));});s.on('data',x=>b+=x);s.on('end',()=>b==='native-h2'?resolve():reject(Error(b)));s.on('error',reject);s.on('aborted',()=>reject(Error('stream aborted')));s.end();})));c.close();console.log('100 verified');"#,&url]).output().unwrap() } else { - Command::new("curl") - // Explicit child PATH takes precedence over System32 on Windows. - .env("PATH", std::env::var_os("PATH").expect("PATH")) + curl() .args([ "--http2-prior-knowledge", "--silent", diff --git a/spikes/iocp/WINDOWS_RESULTS.md b/spikes/iocp/WINDOWS_RESULTS.md index 6a1e652..c4a71fb 100644 --- a/spikes/iocp/WINDOWS_RESULTS.md +++ b/spikes/iocp/WINDOWS_RESULTS.md @@ -144,7 +144,18 @@ Further local validation: GitHub CI results and links are tracked in PR #5; local results alone do not establish a CI pass. The initial run at `2eee4c8` failed the Windows HTTP/2 setup; -the production run starts after the current-main merge at `833ebe0`. +the production run starts after the current-main merge at `833ebe0`. At `228cc51`, +GitHub's `windows-2025` test and lint jobs passed: workspace 215/230, contracts +40/47, fixture interop 17, and the stable/MSRV/documentation checks. The final local +native runner also passed 215/230 and 40/47 after merging main. + +Inspection of the hosted interop log found its curl HTTP/2 leg was UNRUN despite +the green job: the runner still selected a curl without HTTP/2 from PATH. CI now +sets `TURNLOOP_TEST_CURL` to the installed absolute path. Both capability probing +and transfers use that path; a missing or incapable explicit binary fails instead +of skipping. Local positive verification ran Node's 100 streams and curl's stream; +a deliberately missing explicit path failed at capability probing as required. +This final CI correction needs its own hosted result, linked from the PR. Unrun wider gates: ETW syscall traces, CPU-cycle A/B attribution, overnight soak, Windows 10 minimum-version coverage, and VM/power-state precision matrices. From c65a6226daba7c2f91e680d4870ba7461195a083 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 14 Sep 2026 20:46:40 +0200 Subject: [PATCH 07/15] Preserve LF in Git attributes --- .gitattributes | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitattributes b/.gitattributes index c8b10a9..e91f5c9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ -# turnloop is byte-sensitive: protocol fixtures, compressed corpora, HPACK/SMTP transcripts and -# CRLF-bearing test data must be checked out exactly as committed on every platform. -# Disable line-ending conversion everywhere (Windows core.autocrlf would otherwise rewrite them). -* -text +# turnloop is byte-sensitive: protocol fixtures, compressed corpora, HPACK/SMTP transcripts and +# CRLF-bearing test data must be checked out exactly as committed on every platform. +# Disable line-ending conversion everywhere (Windows core.autocrlf would otherwise rewrite them). +* -text From e4cd9c84a77dd0c7786b767ca8c74fcd993e41c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 23:21:03 +0200 Subject: [PATCH 08/15] Count IOCP deadline-timer-only wakes as zero-event waits The armed high-resolution timer completes through the port, so a quiet deadline wait dequeued one TIMER packet and reported zero_event_waits = 0. PollInfo's contract (and epoll's private timerfd) treats a private timeout event as a timed wait returning zero; notifier and I/O packets still count. Fixes quiet_deadline_waits_have_identical_accounting_without_allocations on windows-2025. --- crates/turnloop/src/backend/iocp/mod.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/turnloop/src/backend/iocp/mod.rs b/crates/turnloop/src/backend/iocp/mod.rs index 5333060..eced7b0 100644 --- a/crates/turnloop/src/backend/iocp/mod.rs +++ b/crates/turnloop/src/backend/iocp/mod.rs @@ -1268,13 +1268,19 @@ unsafe impl Backend for Iocp { self.timer.cancel()?; } match result? { - Wait::Entries(n) => ( - n, - PollInfo { - waits: 1, - zero_event_waits: 0, - }, - ), + // The private deadline timer packet has the meaning of a timed wait + // returning zero (see `PollInfo` in backend/mod.rs, and epoll's timerfd): + // it is not native work. Notifier and I/O packets still count. + Wait::Entries(n) => { + let timer_only = entries[..n].iter().all(|entry| entry.key == TIMER); + ( + n, + PollInfo { + waits: 1, + zero_event_waits: if timer_only { 1 } else { 0 }, + }, + ) + } Wait::Timeout | Wait::Apc => ( 0, PollInfo { From 286647e160c19d86ac36e822f7197dfa111ae407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 23:25:03 +0200 Subject: [PATCH 09/15] Hold IOCP to the shared pool-exhaustion deadline accounting The Windows-only zero_event_waits == 0 arm existed because IOCP reported its private deadline timer packet as native work. With that fixed, the pool exhaustion wait asserts the same single empty wait on every native backend. --- crates/turnloop-contract/tests/allocations.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/turnloop-contract/tests/allocations.rs b/crates/turnloop-contract/tests/allocations.rs index 652913c..b369aba 100644 --- a/crates/turnloop-contract/tests/allocations.rs +++ b/crates/turnloop-contract/tests/allocations.rs @@ -817,11 +817,7 @@ fn file_readiness_survives_pool_backpressure_without_allocations_or_spin() { .turn(Timeout::Until(at), &mut out) .expect("pool exhaustion wait"); assert_eq!(info.os_waits, 1); - #[cfg(unix)] assert_eq!(info.zero_event_waits, 1); - // IOCP deadlines arrive as real NT completion packets, rather than an empty wait. - #[cfg(windows)] - assert_eq!(info.zero_event_waits, 0); assert!(l.now() >= at); assert_eq!(out.len(), 1); assert!(matches!(out[0].result, OpResult::Timer)); From eeb626ee5da458d555f57d26408948f9546fc929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 23:37:32 +0200 Subject: [PATCH 10/15] Consume the server's session tickets before closing in the async TLS test AsyncIo::poll_close closes the socket, and the client closed while the server's TLS 1.3 session tickets were still unread. Closing with unread bytes sends RST; Windows then discards the payload and close_notify the server has not read yet, so the server's read failed with WSAECONNABORTED on every windows-2025 mode. The server now acknowledges the 32 KiB payload and the client reads that acknowledgement (and the tickets before it) before close. Fragmented reads, ALPN and close_notify -> EOF are still asserted. --- protocols/turnloop-tls/tests/asynchronous.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/protocols/turnloop-tls/tests/asynchronous.rs b/protocols/turnloop-tls/tests/asynchronous.rs index cdc82cd..731947f 100644 --- a/protocols/turnloop-tls/tests/asynchronous.rs +++ b/protocols/turnloop-tls/tests/asynchronous.rs @@ -57,15 +57,14 @@ fn async_tls_alpn_fragmented_plaintext_and_close_notify() { assert_eq!(tls.alpn_protocol(), Some(b"h2".as_slice())); let mut data = [0; 1]; let mut count = 0; - loop { + while count < 32768 { let n = read(&mut tls, &mut data).await.expect("TLS read"); - if n == 0 { - break; - } - assert_eq!(data[0], b'x'); + assert_eq!((n, data[0]), (1, b'x'), "early EOF or corrupt plaintext"); count += n; } - assert_eq!(count, 32768); + write_all(&mut tls, b"k").await.expect("acknowledge"); + flush(&mut tls).await.expect("acknowledge flush"); + assert_eq!(read(&mut tls, &mut data).await.expect("close notify"), 0); count }) .expect("spawn"); @@ -89,6 +88,13 @@ fn async_tls_alpn_fragmented_plaintext_and_close_notify() { write_all(&mut tls, &[b'x'; 32768]) .await .expect("TLS write"); + flush(&mut tls).await.expect("TLS flush"); + // Reading the acknowledgement also consumes the server's session tickets. + // Closing a socket with unread bytes sends RST, and Windows then discards + // data the server has not read yet (Unix delivers it before the reset). + let mut ack = [0; 1]; + assert_eq!(read(&mut tls, &mut ack).await.expect("acknowledgement"), 1); + assert_eq!(ack[0], b'k'); close(&mut tls).await.expect("close notify"); 32768 }) From 28a49c804013cf39953aab2a3f6232386054b627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 23:38:44 +0200 Subject: [PATCH 11/15] Run the async curl test with the pinned curl and report a timeout's cause Use TURNLOOP_TEST_CURL like interop.rs (on Windows it is the checksum-pinned build, not whichever curl.exe is first on PATH). windows-2025 executor mode once hit the 10 s deadline with the server task still pending even though curl has --max-time 5; on timeout the test now reports curl's exit status and stderr, so the next occurrence distinguishes a hung curl from a lost close. --- protocols/turnloop-http/tests/asynchronous.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/protocols/turnloop-http/tests/asynchronous.rs b/protocols/turnloop-http/tests/asynchronous.rs index ba9da74..580af5a 100644 --- a/protocols/turnloop-http/tests/asynchronous.rs +++ b/protocols/turnloop-http/tests/asynchronous.rs @@ -759,7 +759,8 @@ fn curl_against_async_http1_server() { received }) .expect("spawn"); - let child = Command::new("curl") + let curl = std::env::var_os("TURNLOOP_TEST_CURL").unwrap_or_else(|| "curl".into()); + let mut child = Command::new(curl) .args([ "--http1.1", "--silent", @@ -774,11 +775,20 @@ fn curl_against_async_http1_server() { &format!("http://{address}/"), ]) .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .expect("curl required for HTTP/1.1"); let end = executor.driver().now() + Duration::from_secs(10); while !task.is_finished() { - assert!(executor.driver().now() < end); + if executor.driver().now() >= end { + // Tell a hung server (curl exited, its close never delivered) from a hung curl. + let exited = child.try_wait(); + let _ = child.kill(); + panic!( + "server task pending after 10 s; curl before kill: {exited:?}; curl output: {:?}", + child.wait_with_output() + ); + } executor.turn(Timeout::Until(end)).expect("turn"); } assert_eq!(finish(&mut task), 15); From 87a44cde378222e690b8a12f73f706db954168bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 01:54:32 +0200 Subject: [PATCH 12/15] Fix the IOCP review findings: batch files, exit races, batch errors, pending timers - Reject .bat/.cmd programs (explicit or via PATH) with InvalidInput before any process setup: CreateProcessW runs them through cmd.exe, which ignores MSVCRT quoting (CVE-2024-24576/27980 class; Node rejects them without shell: true). - kill/close after the child exited but before the wait callback published readiness: ERROR_ACCESS_DENIED with a signaled process handle now means exited (kill -> NotFound, close proceeds to Cancelled/Closed). - entries() applies every dequeued completion and returns the first error afterwards; poll applies dequeued entries before propagating a timer-cancel error, so already-dequeued operations can no longer be lost. - NtCancelWaitCompletionPacket STATUS_PENDING on the wait path keeps the timer pending until its generation's TIMER packet is dequeued, as the Event path did. - A cancelled child exit watch completes with Cancelled immediately instead of waiting for the child to exit. - Loopback SIO_TCP_INITIAL_RTO is best-effort, as in libuv; signal tickets use Box::into_raw/from_raw ownership. - windows_lifetimes.rs serializes its handle-creating tests around the exact handle-count baseline, and the post-cancel buffer check now writes data so it can fail. - Docs: IOCP is the production provider; DESIGN 7.3 records the NT wait-packet decision. Eleven new Windows tests cover these; see docs/lanes/iocp-fix1.md. --- CONTRIBUTING.md | 19 +- DESIGN.md | 2 +- crates/turnloop-contract/tests/allocations.rs | 118 +++++++ .../tests/windows_lifetimes.rs | 300 +++++++++++++++++- crates/turnloop/src/backend/iocp/README.md | 2 + crates/turnloop/src/backend/iocp/mod.rs | 228 +++++++++---- crates/turnloop/src/backend/iocp/process.rs | 82 ++++- crates/turnloop/src/backend/iocp/services.rs | 55 ++-- crates/turnloop/src/backend/iocp/signals.rs | 51 ++- crates/turnloop/src/backend/iocp/socket.rs | 29 +- crates/turnloop/src/backend/iocp/timer.rs | 140 +++++++- docs/BACKEND_REVISION_2.md | 32 +- docs/iocp-fix1-commands.md | 43 +++ docs/lanes/iocp-fix1.md | 176 ++++++++++ 14 files changed, 1156 insertions(+), 121 deletions(-) create mode 100644 docs/iocp-fix1-commands.md create mode 100644 docs/lanes/iocp-fix1.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d42b7a..b5d3e31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,8 +52,8 @@ for each core/protocol/contract member, and runs native Node/curl interop with t same mode. Independent member runs retain the selected features belonging to that member or its direct dependencies; a sans-IO crate with no core dependency has no backend feature to select. The workspace run keeps the full feature selection. -The existing Windows pending-contract marker remains scoped to -that provider. Every arm is required through the matrix job's `ci-gate` result. +Every arm independently requires positive contract counts, including Windows +IOCP. Every arm is required through the matrix job's `ci-gate` result. `python3 scripts/ci/run-tests.py native` runs all applicable modes on the current host; `--mode epoll-timerfd` selects just that Linux arm. `interop --mode MODE` @@ -381,15 +381,14 @@ protocol member, with default, executor and all features. The existing sans-IO u SCRAM, SDAM/selection fixture and allocation tests are portable. No unnecessary Unix test cfg exclusions were found. -Production IOCP contracts are explicitly pending through the contract member's -`windows-contracts-pending = "WINDOWS_HANDOFF.md"` metadata. The runner lists their -scope and handoff in the Windows job summary. Remove this metadata when IOCP lands; -the ordinary independent positive-count contract gate then applies on Windows too. -This marker applies only to Windows contracts; core/protocol zero counts and Unix -contract zero counts always fail. +The production IOCP backend implements revision 2 and runs the shared contracts, +including no-spin and allocation gates, plus Windows process, console, GUI and +lifetime tests. The pending-contract metadata has been removed; zero counts fail +on Windows just as on Unix. Cross-compilation does not establish runtime coverage. -- **Wave 2 Windows:** adapt `spikes/iocp` to the production Backend, instantiate - `turnloop-contract` on IOCP, run all contracts (including no-spin) on Windows. +- **Windows:** run `python3 scripts/ci/run-tests.py native` on Windows for all + three required modes. Keep platform-specific runtime results explicit in lane + reports when developing on another host. - **WASM providers and contracts:** production p2 and web adapters and the experimental p3 adapter now live in `crates/turnloop`. Run the mandatory `run-tests.py wasi --target wasm32-wasip2`, `... wasm32-wasip3`, `run-tests.py web` diff --git a/DESIGN.md b/DESIGN.md index 9083018..5d79ff6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -387,7 +387,7 @@ pub struct Error { pub kind: ErrorKind, pub os: Option } // host maps to - **Processes:** `CreateProcessW` with overlapped pipe handles, a Job Object for kill-tree semantics, and `RegisterWaitForSingleObject` on the process handle to post the exit completion. - **Signals:** `SetConsoleCtrlHandler` for CTRL_C, CTRL_BREAK and CTRL_CLOSE, mapped to `SIGINT`/`SIGBREAK`/`SIGHUP`. SIGTERM has no console equivalent; documented as such, matching Perry today (`os/signal.rs:441–531`). - **Wake:** `PostQueuedCompletionStatus` with a reserved completion key. -- **Timer precision:** the default system tick is about 15.6 ms and `GetQueuedCompletionStatusEx` timeouts round to it. The plan is a **high-resolution waitable timer** (`CREATE_WAITABLE_TIMER_HIGH_RESOLUTION`, Windows 10 1803+) armed to the next deadline, waking the wait through an alertable `GetQueuedCompletionStatusEx` (APC) or by associating the timer with the port. M1 prototypes both. `timeBeginPeriod` is not an acceptable default because it changes the tick system-wide. +- **Timer precision:** the default system tick is about 15.6 ms and `GetQueuedCompletionStatusEx` timeouts round to it. Use a **high-resolution waitable timer** (`CREATE_WAITABLE_TIMER_HIGH_RESOLUTION`, Windows 10 1803+) armed to the next deadline and associated with the port through dynamically resolved `NtAssociateWaitCompletionPacket`. `GetQueuedCompletionStatusEx` is nonalertable. Cancellation returning `STATUS_PENDING` requires dequeuing the generation-tagged timer packet before reuse. M1 prototyped both APC and NT packet routes; §15 question 3 records the NT packet decision and measured lateness. `timeBeginPeriod` is not an acceptable default because it changes the tick system-wide. - **Host integration:** `Integration::Event(HANDLE)` via the helper thread (D7). - **Measuring cost:** there is no `perf`; use `QueryThreadCycleTime`/`QueryProcessCycleTime` (CPU cycles) for A/B comparisons. diff --git a/crates/turnloop-contract/tests/allocations.rs b/crates/turnloop-contract/tests/allocations.rs index b369aba..0b51484 100644 --- a/crates/turnloop-contract/tests/allocations.rs +++ b/crates/turnloop-contract/tests/allocations.rs @@ -1185,3 +1185,121 @@ fn stdio_reads_reuse_caller_buffers_through_eof() { assert_eq!((reads, eofs), (36, 2)); assert_eq!(allocations, 0, "stdio reads and repeated CLI EOF results"); } + +#[cfg(windows)] +#[test] +fn windows_child_watch_cancel_exit_and_close_allocate_nothing_after_setup() { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::{ + Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}, + System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject}, + }; + let mut driver = Loop::new(Config::default()).expect("loop"); + let mut spec = ProcessSpec::new(env!("CARGO_BIN_EXE_native_child")); + spec.args.push("sleep".into()); + spec.stdio = [ProcessStdio::Null; 3]; + let children: [_; 16] = + std::array::from_fn(|i| driver.spawn(&spec, Token(i as u64)).expect("child")); + let waits: [OwnedHandle; 16] = std::array::from_fn(|i| { + // SAFETY: driver's owned process handle prevents PID reuse; wait-only access. + let raw = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, children[i].pid) }; + assert!(!raw.is_null()); + // SAFETY: successful OpenProcess transfers unique ownership. + unsafe { OwnedHandle::from_raw_handle(raw) } + }); + let mut out = Completions::default(); + driver.turn(Timeout::Now, &mut out).expect("warm services"); + assert!(out.is_empty()); + let mut terminal = [false; 16]; + let mut closed = [false; 16]; + let (mut cancellations, mut exits, mut closes) = (0, 0, 0); + ALLOCS.with(|count| count.set(0)); + ACTIVE.with(|active| active.set(true)); + for child in &children[..8] { + assert_eq!( + driver + .detach(child.handle) + .expect_err("cancel exit watch") + .kind, + ErrorKind::WouldBlock + ); + } + let info = driver + .turn(Timeout::After(Duration::from_secs(2)), &mut out) + .expect("cancel watch turn"); + assert_eq!(info.os_waits, 0); + assert_eq!(out.len(), 8); + for completion in out.drain() { + let i = completion.token.0 as usize; + assert!(i < 8); + assert_eq!(completion.handle, Some(children[i].handle)); + assert!(completion.terminal && completion.op.is_some()); + assert!(matches!(completion.result, OpResult::Cancelled)); + assert!(!std::mem::replace(&mut terminal[i], true)); + cancellations += 1; + } + for (child, wait) in children.iter().zip(&waits) { + assert_eq!( + // SAFETY: live process query proves cancellation did not kill it. + unsafe { WaitForSingleObject(wait.as_raw_handle(), 0) }, + WAIT_TIMEOUT + ); + driver.kill(child.handle, Signal::Kill).expect("kill"); + } + // Cancelled watches no longer report exit; the fixture explicitly waits on + // all owned identities before asking close to release those process resources. + for wait in &waits { + assert_eq!( + // SAFETY: owned wait handle, bounded native wait after termination request. + unsafe { WaitForSingleObject(wait.as_raw_handle(), 10_000) }, + WAIT_OBJECT_0 + ); + } + let deadline = driver.now() + Duration::from_secs(10); + while exits < 8 { + assert!(driver.now() < deadline); + driver + .turn(Timeout::Until(deadline), &mut out) + .expect("exit delivery"); + for completion in out.drain() { + let i = completion.token.0 as usize; + assert!((8..16).contains(&i)); + assert_eq!(completion.handle, Some(children[i].handle)); + assert!(completion.terminal && completion.op.is_some()); + assert!(matches!( + completion.result, + OpResult::Exited(ExitStatus { + code: Some(1), + signal: None + }) + )); + assert!(!std::mem::replace(&mut terminal[i], true)); + exits += 1; + } + } + for (i, child) in children.iter().enumerate() { + driver.close(child.handle, Token(i as u64)).expect("close"); + } + while driver.alive() { + assert!(driver.now() < deadline); + driver + .turn(Timeout::Until(deadline), &mut out) + .expect("close delivery"); + for completion in out.drain() { + let i = completion.token.0 as usize; + assert!(i < 16 && terminal[i]); + assert_eq!(completion.handle, Some(children[i].handle)); + assert!(completion.terminal && completion.op.is_none()); + assert!(matches!(completion.result, OpResult::Closed)); + assert!(!std::mem::replace(&mut closed[i], true)); + closes += 1; + } + } + ACTIVE.with(|active| active.set(false)); + assert_eq!( + ALLOCS.with(Cell::get), + 0, + "Windows service completion allocations" + ); + assert_eq!((cancellations, exits, closes), (8, 8, 16)); +} diff --git a/crates/turnloop-contract/tests/windows_lifetimes.rs b/crates/turnloop-contract/tests/windows_lifetimes.rs index fa35008..370dcbc 100644 --- a/crates/turnloop-contract/tests/windows_lifetimes.rs +++ b/crates/turnloop-contract/tests/windows_lifetimes.rs @@ -1,18 +1,200 @@ #![cfg(all(windows, not(loom)))] #![deny(unsafe_op_in_unsafe_fn)] use std::{ - os::windows::io::{FromRawHandle, OwnedHandle}, + io::Write, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, ptr, + sync::Mutex, time::Duration, }; + +// GetProcessHandleCount measures the entire test process. Every test in this +// binary must hold this guard until its handles and helper threads are dropped. +static HANDLES: Mutex<()> = Mutex::new(()); use turnloop::*; -use windows_sys::Win32::System::{ - Pipes::CreatePipe, - Threading::{GetCurrentProcess, GetProcessHandleCount}, +use windows_sys::Win32::{ + Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}, + System::{ + Pipes::CreatePipe, + Threading::{ + GetCurrentProcess, GetProcessHandleCount, OpenProcess, PROCESS_SYNCHRONIZE, + WaitForSingleObject, + }, + }, }; +fn child_wait_handle(pid: u32) -> OwnedHandle { + // SAFETY: the driver still owns this child, preventing PID reuse. Only wait access. + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) }; + assert!( + !handle.is_null(), + "open owned child: {}", + std::io::Error::last_os_error() + ); + // SAFETY: successful OpenProcess transfers unique ownership. + let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; + handle.try_clone().expect("duplicate child wait handle") +} + +fn assert_cancelled_then_closed(driver: &mut Loop, child: Handle, wait: &OwnedHandle) { + let mut out = Completions::with_capacity(1); + let deadline = driver.now() + Duration::from_secs(10); + let mut count = 0; + while driver.alive() { + assert!(driver.now() < deadline, "child close deadline"); + driver + .turn(Timeout::Until(deadline), &mut out) + .expect("close turn"); + for completion in out.drain() { + assert_eq!(completion.handle, Some(child)); + assert!(completion.terminal); + match count { + 0 => { + assert_eq!(completion.token, Token(1)); + assert!(completion.op.is_some()); + assert!(matches!(completion.result, OpResult::Cancelled)); + } + 1 => { + assert_eq!(completion.token, Token(2)); + assert!(completion.op.is_none()); + assert!(matches!(completion.result, OpResult::Closed)); + assert_eq!( + // SAFETY: duplicated child handle is live for this nonblocking query. + unsafe { WaitForSingleObject(wait.as_raw_handle(), 0) }, + WAIT_OBJECT_0, + "Closed must follow child termination" + ); + } + _ => panic!("duplicate child completion: {completion:?}"), + } + count += 1; + } + } + assert_eq!(count, 2); + driver + .turn(Timeout::Now, &mut out) + .expect("check duplicates"); + assert!(out.is_empty()); +} + +#[test] +fn kill_then_close_children_completes_once() { + let _guard = HANDLES.lock().expect("handle test lock"); + let mut driver = Loop::new(Config::default()).expect("loop"); + let mut completed = 0; + for group in [false, true] { + let mut spec = ProcessSpec::new(env!("CARGO_BIN_EXE_native_child")); + spec.args.push("sleep".into()); + spec.stdio = [ProcessStdio::Null; 3]; + spec.new_process_group = group; + for _ in 0..200 { + let child = driver.spawn(&spec, Token(1)).expect("sleeping child"); + let wait = child_wait_handle(child.pid); + assert_eq!( + // SAFETY: owned duplicate, zero-timeout liveness query. + unsafe { WaitForSingleObject(wait.as_raw_handle(), 0) }, + WAIT_TIMEOUT, + "kill subject must still be alive" + ); + if group { + driver + .kill_group(child.handle, Signal::Kill) + .expect("kill tree"); + } else { + driver.kill(child.handle, Signal::Kill).expect("kill child"); + } + driver + .close(child.handle, Token(2)) + .expect("close after kill"); + assert_cancelled_then_closed(&mut driver, child.handle, &wait); + completed += 1; + } + } + assert_eq!(completed, 400); +} + +#[test] +fn close_after_raw_child_wait_before_servicing_exit() { + let _guard = HANDLES.lock().expect("handle test lock"); + let mut driver = Loop::new(Config::default()).expect("loop"); + let mut spec = ProcessSpec::new(env!("CARGO_BIN_EXE_native_child")); + spec.args.push("exit".into()); + spec.stdio = [ProcessStdio::Null; 3]; + let child = driver + .spawn(&spec, Token(1)) + .expect("immediately exiting child"); + let wait = child_wait_handle(child.pid); + assert_eq!( + // SAFETY: duplicated process handle, bounded wait without servicing the loop. + unsafe { WaitForSingleObject(wait.as_raw_handle(), 10_000) }, + WAIT_OBJECT_0 + ); + assert_eq!( + driver + .kill(child.handle, Signal::Kill) + .expect_err("already exited") + .kind, + ErrorKind::NotFound + ); + driver + .close(child.handle, Token(2)) + .expect("close before exit turn"); + assert_cancelled_then_closed(&mut driver, child.handle, &wait); +} + +#[test] +fn batch_programs_are_rejected_before_spawn() { + let _guard = HANDLES.lock().expect("handle test lock"); + let directory = std::env::temp_dir().join(format!("turnloop-batch-{}", std::process::id())); + std::fs::create_dir(&directory).expect("private batch directory"); + let marker = directory.join("marker"); + let mut driver = Loop::new(Config::default()).expect("loop"); + let mut out = Completions::default(); + let mut rejected = 0; + for extension in ["cmd", "CmD", "bat", "BaT"] { + let name = format!("fixture.{extension}"); + let path = directory.join(&name); + std::fs::write(&path, "@echo executed>\"%~dp0marker\"\r\n").expect("batch fixture"); + for program in [path.as_os_str(), std::ffi::OsStr::new(&name)] { + let mut spec = ProcessSpec::new(program); + spec.env + .push(("PATH".into(), directory.as_os_str().to_owned())); + spec.args.push("\"&echo injected".into()); + assert_eq!( + driver + .spawn(&spec, Token(1)) + .expect_err("reject batch") + .kind, + ErrorKind::InvalidInput + ); + assert!( + !driver.alive(), + "failed spawn retained a child or operation" + ); + driver.turn(Timeout::Now, &mut out).expect("empty turn"); + assert!(out.is_empty(), "failed spawn produced a child completion"); + assert!(!marker.exists(), "batch ran despite rejection"); + rejected += 1; + } + // Positive control: the same file really creates the marker when the + // caller explicitly opts into cmd.exe and its shell parsing. + let status = std::process::Command::new("cmd.exe") + .args(["/d", "/c"]) + .arg(&path) + .status() + .expect("explicit batch shell"); + assert!(status.success()); + assert_eq!(std::fs::read(&marker).expect("marker"), b"executed\r\n"); + std::fs::remove_file(&marker).expect("remove control marker"); + std::fs::remove_file(&path).expect("remove batch"); + } + assert_eq!(rejected, 8); + std::fs::remove_dir(directory).expect("remove private batch directory"); +} + #[test] fn imported_overlapped_pipe_routes_away_from_its_existing_port() { + let _guard = HANDLES.lock().expect("handle test lock"); use std::os::windows::{fs::OpenOptionsExt, io::AsRawHandle}; use windows_sys::Win32::{ Foundation::INVALID_HANDLE_VALUE, Storage::FileSystem::FILE_FLAG_OVERLAPPED, @@ -65,6 +247,7 @@ fn imported_overlapped_pipe_routes_away_from_its_existing_port() { #[test] fn overlapped_regular_files_are_rejected_before_worker_submission() { + let _guard = HANDLES.lock().expect("handle test lock"); use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OVERLAPPED; let file = std::fs::OpenOptions::new() @@ -92,6 +275,7 @@ fn handles() -> u32 { } #[test] fn cancelled_synchronous_reads_release_buffers_and_threads() { + let _guard = HANDLES.lock().expect("handle test lock"); let (mut read, mut write) = (ptr::null_mut(), ptr::null_mut()); // SAFETY: two output slots; noninherited anonymous synchronous pipe. assert_ne!( @@ -100,12 +284,14 @@ fn cancelled_synchronous_reads_release_buffers_and_threads() { 0 ); // SAFETY: successful CreatePipe transferred two independent handle owners. - let (read, _write) = unsafe { + let (read, write) = unsafe { ( OwnedHandle::from_raw_handle(read), OwnedHandle::from_raw_handle(write), ) }; + let mut writer = std::fs::File::from(write); + let mut checked = 0; for round in 0..128 { let mut memory = [0xa5; 64]; // outlives backend on every unwind/drop path let mut driver = Loop::new(Config { @@ -159,11 +345,61 @@ fn cancelled_synchronous_reads_release_buffers_and_threads() { assert!(cancelled); } drop(driver); + let payload = [round as u8; 32]; + writer + .write_all(&payload) + .expect("write after cancellation/drop"); + let mut fresh = Loop::new(Config { + max_handles: 4, + max_operations: 4, + pooled_buffers: 2, + ..Config::default() + }) + .expect("fresh reader loop"); + let h = fresh + .attach( + Detached::from_handle(read.try_clone().expect("fresh read handle")) + .expect("fresh pipe"), + Token(4), + ) + .expect("fresh reader"); + let op = fresh + .read(h, ReadBuf::Pooled, Token(5)) + .expect("fresh read"); + let deadline = fresh.now() + Duration::from_secs(3); + while out.is_empty() { + assert!( + fresh.now() < deadline, + "cancelled read consumed the new data" + ); + fresh + .turn(Timeout::Until(deadline), &mut out) + .expect("fresh read turn"); + } + assert_eq!(out.len(), 1); + assert_eq!(out[0].op, Some(op)); + assert_eq!(out[0].handle, Some(h)); + let OpResult::Read { + n, + lease: Some(lease), + } = &out[0].result + else { + panic!("missing fresh read: {:?}", out[0]); + }; + assert_eq!(*n, payload.len()); + assert_eq!( + lease.as_slice(), + payload, + "cancelled read must not consume future bytes" + ); assert_eq!(memory, [0xa5; 64]); + checked += 1; } + assert_eq!(checked, 128); } #[test] fn loop_drop_and_stale_wakers_release_windows_handles() { + let _guard = HANDLES.lock().expect("handle test lock"); let cycle = || { let mut driver = Loop::new(Config::default()).expect("loop"); let (_, _, receiver) = turnloop_contract::pair(&mut driver); @@ -196,3 +432,57 @@ fn loop_drop_and_stale_wakers_release_windows_handles() { assert_eq!(handles(), baseline, "native handle leak"); } } + +#[test] +fn cancelled_child_watch_completes_while_child_is_alive() { + let _guard = HANDLES.lock().expect("handle test lock"); + let mut driver = Loop::new(Config::default()).expect("loop"); + let mut spec = ProcessSpec::new(env!("CARGO_BIN_EXE_native_child")); + spec.args.push("sleep".into()); + spec.stdio = [ProcessStdio::Null; 3]; + let child = driver.spawn(&spec, Token(1)).expect("sleeping child"); + let wait = child_wait_handle(child.pid); + // Process does not expose its implicit exit OpId. detach first cancels that + // watch and returns WouldBlock until its acknowledgement has been delivered. + assert_eq!( + driver + .detach(child.handle) + .expect_err("cancel pending exit watch") + .kind, + ErrorKind::WouldBlock + ); + let mut out = Completions::default(); + let info = driver + .turn(Timeout::After(Duration::from_secs(2)), &mut out) + .expect("one cancellation turn"); + assert_eq!(info.os_waits, 0, "cancelled watch is immediately ready"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].handle, Some(child.handle)); + assert_eq!(out[0].token, Token(1)); + assert!(out[0].terminal); + assert!(matches!(out[0].result, OpResult::Cancelled)); + assert!(!driver.cancel(out[0].op.expect("exit operation"))); + assert_eq!( + // SAFETY: live duplicated process handle, zero timeout checks actual liveness. + unsafe { WaitForSingleObject(wait.as_raw_handle(), 0) }, + WAIT_TIMEOUT, + "watch cancellation must not terminate the child" + ); + driver.kill(child.handle, Signal::Kill).expect("kill child"); + assert_eq!( + // SAFETY: bounded wait on the owned duplicate, without servicing an exit watch. + unsafe { WaitForSingleObject(wait.as_raw_handle(), 10_000) }, + WAIT_OBJECT_0 + ); + driver.close(child.handle, Token(2)).expect("close child"); + driver.turn(Timeout::Now, &mut out).expect("close turn"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].handle, Some(child.handle)); + assert_eq!(out[0].token, Token(2)); + assert!(matches!(out[0].result, OpResult::Closed)); + assert!(!driver.alive()); + driver + .turn(Timeout::Now, &mut out) + .expect("no duplicate cancellation"); + assert!(out.is_empty()); +} diff --git a/crates/turnloop/src/backend/iocp/README.md b/crates/turnloop/src/backend/iocp/README.md index 2dba902..0e6e757 100644 --- a/crates/turnloop/src/backend/iocp/README.md +++ b/crates/turnloop/src/backend/iocp/README.md @@ -28,6 +28,8 @@ I/O before releasing caller memory. - Child creation uses an explicit inherited handle list, correctly quoted argv, Unicode environment, suspended creation and optional Job Object assignment before resume. Parent stdio ends are overlapped; child ends are synchronous. + Direct `.bat`/`.cmd` programs are rejected with InvalidInput, including PATH + resolution; callers must explicitly select a shell for batch scripts. One-shot process waits publish to the owning notifier. Close terminates live owned children, then acknowledges exit before Closed. uid/gid and non-Kill process signals return Unsupported; console signal subscriptions are separate. diff --git a/crates/turnloop/src/backend/iocp/mod.rs b/crates/turnloop/src/backend/iocp/mod.rs index eced7b0..fb0e1c9 100644 --- a/crates/turnloop/src/backend/iocp/mod.rs +++ b/crates/turnloop/src/backend/iocp/mod.rs @@ -181,6 +181,7 @@ pub struct Iocp { port: Arc, wake: Arc, timer: timer::PacketTimer, + timer_pending: bool, event: Option, notifier: Option, services: services::Services, @@ -811,61 +812,69 @@ impl Iocp { ))) } fn entries(&mut self, entries: &[Entry]) -> Result<()> { + let mut result = Ok(()); for entry in entries { - if entry.key == WAKE { - continue; - } - if entry.key == TIMER { - // SAFETY: this port has exactly one associated deadline timer. - unsafe { - self.timer.dequeued(entry.overlapped); - } - if self.event.is_some() - && self - .deadline - .is_some_and(|deadline| deadline > Instant::now()) - { - self.arm_event_deadline()?; - } - continue; + // Every dequeued packet must be applied even after an earlier error. + // Keeping the first error must not strand later native operations. + let applied = self.entry(entry); + result = result.and(applied); + } + result + } + fn entry(&mut self, entry: &Entry) -> Result<()> { + if entry.key == WAKE { + return Ok(()); + } + if entry.key == TIMER { + // SAFETY: this port has exactly one associated deadline timer. + if unsafe { self.timer.dequeued(entry.overlapped) } { + self.timer_pending = false; } - let base = self.kernel.as_ptr() as usize; - let stride = size_of::>(); - let offset = entry.overlapped.checked_sub(base).ok_or_else(invalid)?; - if offset % stride != 0 || offset / stride >= self.ops.len() { - return Err(invalid()); + if self.event.is_some() + && self + .deadline + .is_some_and(|deadline| deadline > Instant::now()) + { + self.arm_event_deadline()?; } - let i = offset / stride; - let bridge_result = if entry.key == sync_io::KEY { - let p = self.ops[i].as_ref().ok_or_else(invalid)?; - Some( - self.workers[p.request.handle.index()] - .as_ref() - .ok_or_else(invalid)? - .finish(), - ) - } else if entry.key == bridge::KEY { - Some(self.bridges[i].as_mut().ok_or_else(invalid)?.finish()?) + return Ok(()); + } + let base = self.kernel.as_ptr() as usize; + let stride = size_of::>(); + let offset = entry.overlapped.checked_sub(base).ok_or_else(invalid)?; + if offset % stride != 0 || offset / stride >= self.ops.len() { + return Err(invalid()); + } + let i = offset / stride; + let p = self.ops[i].as_ref().ok_or_else(invalid)?; + if !p.waiting { + return Err(invalid()); + } + let bridge_result = if entry.key == sync_io::KEY { + Some( + self.workers[p.request.handle.index()] + .as_ref() + .ok_or_else(invalid)? + .finish(), + ) + } else if entry.key == bridge::KEY { + Some(self.bridges[i].as_mut().ok_or_else(invalid)?.finish()?) + } else { + None + }; + let p = self.ops[i].as_mut().ok_or_else(invalid)?; + p.waiting = false; + p.completion = Some(bridge_result.unwrap_or_else(|| { + if entry.status < 0 { + // SAFETY: pure NTSTATUS conversion, no pointers. + Err(socket::error( + unsafe { RtlNtStatusToDosError(entry.status) } as i32, + )) } else { - None - }; - let p = self.ops[i].as_mut().ok_or_else(invalid)?; - if !p.waiting { - return Err(invalid()); + Ok(entry.bytes) } - p.waiting = false; - p.completion = Some(bridge_result.unwrap_or_else(|| { - if entry.status < 0 { - // SAFETY: pure NTSTATUS conversion, no pointers. - Err(socket::error( - unsafe { RtlNtStatusToDosError(entry.status) } as i32, - )) - } else { - Ok(entry.bytes) - } - })); - self.schedule(i); - } + })); + self.schedule(i); Ok(()) } } @@ -901,6 +910,7 @@ unsafe impl Backend for Iocp { calls: AtomicU64::new(0), }), timer: timer::PacketTimer::new(Arc::clone(&port))?, + timer_pending: false, port, event: None, notifier: None, @@ -1051,7 +1061,7 @@ unsafe impl Backend for Iocp { }, )?; if addr.ip().is_loopback() { - socket::loopback_connect(raw)?; + socket::loopback_connect(raw); } if opts.nodelay { let value = 1i32; @@ -1252,11 +1262,14 @@ unsafe impl Backend for Iocp { return Ok(PollInfo::default()); } let mut entries = [Entry::default(); 64]; + let mut cancel_result = Ok(()); let (n, info) = if let Some(event) = &mut self.event { (event.drain(&mut entries)?, PollInfo::default()) } else { let armed = timeout.is_some_and(|d| !d.is_zero()); - if let Some(delay) = timeout.filter(|d| !d.is_zero()) { + if let Some(delay) = timeout.filter(|d| !d.is_zero()) + && !self.timer_pending + { self.timer.arm(delay)?; } // GQCSEx's millisecond timeout may expire at an earlier system tick. @@ -1265,7 +1278,7 @@ unsafe impl Backend for Iocp { .port .wait(if armed { None } else { timeout }, false, &mut entries); if armed { - self.timer.cancel()?; + cancel_result = self.cancel_timer().map(|_| ()); } match result? { // The private deadline timer packet has the meaning of a timed wait @@ -1290,7 +1303,9 @@ unsafe impl Backend for Iocp { ), } }; - self.entries(&entries[..n])?; + let entries_result = self.entries(&entries[..n]); + cancel_result?; + entries_result?; self.services.collect(events)?; self.run_ready(events); Ok(info) @@ -1332,8 +1347,15 @@ unsafe impl Backend for Iocp { } } impl Iocp { + fn cancel_timer(&mut self) -> Result { + if !self.timer_pending { + self.timer_pending = self.timer.cancel()? == 0x103; + } + // STATUS_PENDING retains ownership until entry() sees this generation. + Ok(!self.timer_pending) + } fn arm_event_deadline(&mut self) -> Result<()> { - if self.timer.cancel()? == 0x103 { + if !self.cancel_timer()? { return Ok(()); } // drain the in-flight packet before rearming if let Some(deadline) = self.deadline { @@ -1427,3 +1449,99 @@ fn write_buffers(op: &Operation, mut offset: usize, out: &mut [WSABUF; MAX_IOV]) } n } + +#[cfg(all(test, not(loom)))] +mod tests { + use super::*; + + // These operations model already-dequeued kernel completions. No native I/O + // is submitted, so unwind must discard their metadata rather than wait on it. + pub(super) struct Synthetic { + pub backend: Iocp, + pub entries: [Entry; 2], + pub ops: [OpId; 2], + } + impl Synthetic { + pub fn new() -> Self { + let config = Config { + max_handles: 2, + max_operations: 2, + ..Config::default() + }; + let mut backend = Iocp::new(&config, BufferPool::new(2, 64)).expect("backend"); + let handle = Handle { owner: 1, key: 0 }; + backend + .open( + handle, + Open::Tcp { + addr: ([127, 0, 0, 1], 9).into(), + opts: TcpOpts::default(), + }, + ) + .expect("socket resource"); + let ops = [OpId { owner: 1, key: 0 }, OpId { owner: 1, key: 1 }]; + let entries = std::array::from_fn(|i| { + backend + .submit(Request { + op: ops[i], + handle, + operation: Operation::Write(WriteBuf::Owned(vec![0; i + 1])), + }) + .expect("synthetic request"); + let pending = backend.ops[i].as_mut().expect("op"); + pending.waiting = true; + pending.queued = false; + pending.stage = Stage::Io; + Entry { + key: 1, + overlapped: backend.kernel_ptr(i) as usize, + bytes: (i + 1) as u32, + status: 0, + } + }); + backend.ready.clear(); + Self { + backend, + entries, + ops, + } + } + pub fn assert_delivered(&mut self) { + let mut out = Vec::with_capacity(1); + for (i, op) in self.ops.into_iter().enumerate() { + let info = self + .backend + .poll(Some(Duration::ZERO), &mut out) + .expect("drain valid packet"); + assert_eq!(info.waits, 0); + assert_eq!(out.len(), 1); + assert_eq!(out[0].op, op); + assert!(out[0].terminal); + assert!(matches!(out[0].result, Ok(Outcome::Wrote(n)) if n == i + 1)); + out.clear(); + } + assert!(self.backend.ops.iter().all(Option::is_none)); + assert!(!self.backend.has_work()); + } + } + impl Drop for Synthetic { + fn drop(&mut self) { + self.backend.ops.iter_mut().for_each(|op| *op = None); + self.backend.ready.clear(); + } + } + #[test] + fn invalid_entry_does_not_discard_later_completions() { + let mut fixture = Synthetic::new(); + let batch = [fixture.entries[0], Entry::default(), fixture.entries[1]]; + assert_eq!( + fixture + .backend + .entries(&batch) + .expect_err("invalid packet") + .kind, + ErrorKind::InvalidInput + ); + fixture.assert_delivered(); + } +} diff --git a/crates/turnloop/src/backend/iocp/process.rs b/crates/turnloop/src/backend/iocp/process.rs index 0b2f96e..87b6ea3 100644 --- a/crates/turnloop/src/backend/iocp/process.rs +++ b/crates/turnloop/src/backend/iocp/process.rs @@ -53,6 +53,18 @@ fn quote(value: &OsStr, out: &mut Vec) -> Result<()> { Ok(()) } fn program(spec: &ProcessSpec) -> Result { + let path = resolve_program(spec)?; + // CreateProcess may dispatch batch files through cmd.exe, whose argument + // parsing does not obey the MSVCRT quoting used below. Require an explicit shell. + if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("bat") || ext.eq_ignore_ascii_case("cmd")) + { + return Err(invalid()); + } + Ok(path) +} +fn resolve_program(spec: &ProcessSpec) -> Result { let path = PathBuf::from(&spec.program); if path.is_absolute() || path.components().count() > 1 { return Ok(path); @@ -289,14 +301,24 @@ impl Child { if signal != Signal::Kill { return Err(unsupported()); } - if group { + let result = if group { let job = self.job.as_ref().ok_or_else(invalid)?; // SAFETY: exclusively owned process tree; no PID/handle reuse is possible. bool_result(unsafe { TerminateJobObject(job.as_raw_handle(), 1) }) } else { // SAFETY: process handle pins the identity even if its PID is later reused. bool_result(unsafe { TerminateProcess(self.process.as_raw_handle(), 1) }) + }; + if let Err(error) = result + && error.os == Some(ERROR_ACCESS_DENIED as i32) + // SAFETY: the owned process handle pins its identity; zero timeout + // only queries termination and never waits for the callback. + && unsafe { WaitForSingleObject(self.process.as_raw_handle(), 0) } == WAIT_OBJECT_0 + { + self.context.ready.store(true, Ordering::Release); + return Err(crate::Error::new(crate::ErrorKind::NotFound)); } + result } pub(super) fn close(&mut self) -> Result<()> { if self.status.is_some() { @@ -306,7 +328,10 @@ impl Child { self.status()?; return Ok(()); } - self.kill(Signal::Kill, self.job.is_some()) + match self.kill(Signal::Kill, self.job.is_some()) { + Err(error) if error.kind == crate::ErrorKind::NotFound => Ok(()), + result => result, + } } } impl Drop for Child { @@ -335,6 +360,7 @@ pub(super) fn spawn( if spec.uid.is_some() || spec.gid.is_some() || spec.program.is_empty() { return Err(unsupported()); } + let application = wide(program(spec)?.as_os_str())?; let mut parents = [None, None, None]; let mut child_ends = Vec::with_capacity(3); for (i, option) in spec.stdio.iter().enumerate() { @@ -355,7 +381,6 @@ pub(super) fn spawn( } let handles = std::array::from_fn::<_, 3, _>(|i| child_ends[i].as_raw_handle()); let mut attributes = Attributes::new(&handles)?; - let application = wide(program(spec)?.as_os_str())?; let mut command = Vec::new(); quote(&spec.program, &mut command)?; for arg in &spec.args { @@ -481,3 +506,54 @@ pub(super) fn spawn( })?; Ok((child, parents)) } + +#[cfg(all(test, not(loom)))] +mod tests { + use super::*; + + #[test] + fn exited_process_without_callback_maps_kill_and_close() { + let driver = crate::Loop::new(crate::Config::default()).expect("notifier owner"); + for close in [false, true] { + // Listing the unit-test binary exits normally without running any + // nested tests and needs no external fixture or shell argument rules. + let mut spec = ProcessSpec::new(std::env::current_exe().expect("test executable")); + spec.args.push("--list".into()); + spec.stdio = [ProcessStdio::Null; 3]; + let (mut child, _) = + spawn(&spec, [None; 3], driver.notifier()).expect("suspended child"); + // The child cannot exit while suspended. Removing its wait now forces + // the exact callback-lag window, independent of thread-pool scheduling. + child.join().expect("unregister before resume"); + let wait = child.process.try_clone().expect("duplicate process"); + child.resume().expect("resume child"); + assert_eq!( + // SAFETY: owned duplicate and bounded wait, with no callback installed. + unsafe { WaitForSingleObject(wait.as_raw_handle(), 10_000) }, + WAIT_OBJECT_0 + ); + assert!(!child.ready(), "the exit callback must not have run"); + if close { + child + .close() + .expect("close signaled process without callback"); + } else { + assert_eq!( + child + .kill(Signal::Kill, false) + .expect_err("already exited") + .kind, + crate::ErrorKind::NotFound + ); + } + assert!( + child.ready(), + "termination failure observed the signaled handle" + ); + assert_eq!( + child.status().expect("exit status").expect("exited").code, + Some(0) + ); + } + } +} diff --git a/crates/turnloop/src/backend/iocp/services.rs b/crates/turnloop/src/backend/iocp/services.rs index 1c02e6a..3a10606 100644 --- a/crates/turnloop/src/backend/iocp/services.rs +++ b/crates/turnloop/src/backend/iocp/services.rs @@ -12,6 +12,7 @@ struct Entry { service: Service, op: Option, cancelled: bool, + closing: bool, } pub(super) struct Services { entries: Vec>, @@ -36,6 +37,7 @@ impl Services { service: Service::Child(child), op: None, cancelled: false, + closing: false, }); } pub(super) fn signal( @@ -50,6 +52,7 @@ impl Services { service: Service::Signal(signal, subscription), op: None, cancelled: false, + closing: false, }); Ok(()) } @@ -74,7 +77,9 @@ impl Services { } pub(super) fn cancel(&mut self, op: OpId) -> bool { if let Some(entry) = self.entries.iter_mut().flatten().find(|e| e.op == Some(op)) { - entry.cancelled = true; + // Close retains the exit watch until the child has terminated. A + // standalone watch cancellation needs no process teardown or reap. + entry.cancelled = !entry.closing; true } else { false @@ -89,6 +94,8 @@ impl Services { && let Service::Child(child) = &mut entry.service { child.close()?; + entry.closing = true; + entry.cancelled = false; } Ok(()) } @@ -115,10 +122,11 @@ impl Services { } self.entries.iter().flatten().any(|e| { e.op.is_some() - && match &e.service { - Service::Child(child) => child.ready(), - Service::Signal(_, signal) => e.cancelled || signal.ready(), - } + && (e.cancelled + || match &e.service { + Service::Child(child) => child.ready(), + Service::Signal(_, signal) => signal.ready(), + }) }) } pub(super) fn collect(&mut self, out: &mut Vec>) -> Result<()> { @@ -132,17 +140,26 @@ impl Services { let Some(op) = entry.op else { continue; }; - let result = match &mut entry.service { - Service::Child(child) => child - .status()? - .map(|status| (Outcome::Exited(status), true)), - Service::Signal(signal, ticket) => { - if entry.cancelled { - Some((Outcome::Cancelled, true)) - } else if ticket.take() { - Some((Outcome::Signal(*signal), false)) - } else { - None + let result = if entry.cancelled { + Some((Outcome::Cancelled, true)) + } else { + match &mut entry.service { + Service::Child(child) => child.status()?.map(|status| { + ( + if entry.closing { + Outcome::Cancelled + } else { + Outcome::Exited(status) + }, + true, + ) + }), + Service::Signal(signal, ticket) => { + if ticket.take() { + Some((Outcome::Signal(*signal), false)) + } else { + None + } } } }; @@ -154,11 +171,7 @@ impl Services { out.push(Event { op, terminal, - result: Ok(if entry.cancelled { - Outcome::Cancelled - } else { - outcome - }), + result: Ok(outcome), }); } } diff --git a/crates/turnloop/src/backend/iocp/signals.rs b/crates/turnloop/src/backend/iocp/signals.rs index 5b26a44..7b9e361 100644 --- a/crates/turnloop/src/backend/iocp/signals.rs +++ b/crates/turnloop/src/backend/iocp/signals.rs @@ -18,7 +18,7 @@ struct Ticket { notifier: Notifier, } pub(super) struct Subscription { - ticket: Box, + ticket: *mut Ticket, index: usize, } @@ -68,20 +68,25 @@ impl Subscription { // SAFETY: process-lifetime function pointer; handler has no locks or allocations. bool_result(unsafe { SetConsoleCtrlHandler(Some(handler), 1) })?; } - let mut ticket = Box::new(Ticket { + let ticket = Box::into_raw(Box::new(Ticket { signal, pending: AtomicBool::new(false), notifier, - }); - SLOTS[index].store(ptr::from_mut(&mut *ticket), Ordering::SeqCst); + })); + SLOTS[index].store(ticket, Ordering::SeqCst); *count += 1; Ok(Self { ticket, index }) } pub(super) fn ready(&self) -> bool { - self.ticket.pending.load(Ordering::Acquire) + self.ticket().pending.load(Ordering::Acquire) } pub(super) fn take(&self) -> bool { - self.ticket.pending.swap(false, Ordering::AcqRel) + self.ticket().pending.swap(false, Ordering::AcqRel) + } + fn ticket(&self) -> &Ticket { + // SAFETY: this subscription owns the into_raw allocation until Drop joins + // dispatchers. Every concurrent access is shared and fields are atomic. + unsafe { &*self.ticket } } } impl Drop for Subscription { @@ -98,5 +103,39 @@ impl Drop for Subscription { while ACTIVE.load(Ordering::SeqCst) != 0 { std::thread::yield_now(); } + // SAFETY: uniquely owned into_raw allocation, unpublished before all + // dispatchers joined. No reference to this ticket can remain live. + drop(unsafe { Box::from_raw(self.ticket) }); + } +} + +#[cfg(all(test, not(loom)))] +mod tests { + use super::*; + #[test] + fn moved_subscription_keeps_its_published_ticket_alive() { + let driver = crate::Loop::new(crate::Config::default()).expect("notifier owner"); + let first = Subscription::new(Signal::WinCh, driver.notifier()).expect("first"); + let second = Subscription::new(Signal::WinCh, driver.notifier()).expect("second"); + let mut subscriptions = vec![first]; + assert_eq!(subscriptions.capacity(), 1); + subscriptions.push(second); // move both subscriptions by growing the vector + assert!(subscriptions.capacity() > 1); + let mut deliveries = 0; + for _ in 0..100 { + assert!(dispatch(Signal::WinCh)); + for subscription in &subscriptions { + assert!(subscription.ready()); + assert!(subscription.take()); + assert!(!subscription.take()); + deliveries += 1; + } + } + assert_eq!(deliveries, 200); + drop(subscriptions); + assert!( + !dispatch(Signal::WinCh), + "removed tickets cannot receive signals" + ); } } diff --git a/crates/turnloop/src/backend/iocp/socket.rs b/crates/turnloop/src/backend/iocp/socket.rs index e300262..4115369 100644 --- a/crates/turnloop/src/backend/iocp/socket.rs +++ b/crates/turnloop/src/backend/iocp/socket.rs @@ -161,7 +161,7 @@ pub(super) fn nonblocking(socket: usize) -> Result<()> { // SAFETY: live socket and valid FIONBIO input. check(unsafe { ioctlsocket(socket, FIONBIO, &mut value) }) } -pub(super) fn loopback_connect(socket: usize) -> Result<()> { +pub(super) fn loopback_connect(socket: usize) { // Loopback delivery does not lose SYN packets. Avoid Windows retrying a // refused local connection for ~2 seconds before reporting ECONNREFUSED. // https://learn.microsoft.com/en-us/windows/win32/api/mstcpip/ns-mstcpip-tcp_initial_rto_parameters @@ -170,8 +170,10 @@ pub(super) fn loopback_connect(socket: usize) -> Result<()> { MaxSynRetransmissions: TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS as u8, }; let mut bytes = 0; - // SAFETY: live TCP socket and documented synchronous IOCTL input layout. - check(unsafe { + // This is a best-effort latency optimization, not a prerequisite for connect. + // SAFETY: documented synchronous IOCTL input layout; Winsock validates the + // socket value and retains none of these stack pointers after returning. + let _ = unsafe { WSAIoctl( socket, SIO_TCP_INITIAL_RTO, @@ -183,7 +185,7 @@ pub(super) fn loopback_connect(socket: usize) -> Result<()> { ptr::null_mut(), None, ) - }) + }; } pub(super) fn ifs(socket: usize) -> Result { let mut info = WSAPROTOCOL_INFOW::default(); @@ -299,3 +301,22 @@ impl Detached { Ok(Self::new(Native::Socket(socket), kind, true)) } } + +#[cfg(all(test, not(loom)))] +mod tests { + use super::*; + #[test] + fn loopback_rto_failure_is_best_effort() { + startup().expect("Winsock"); + loopback_connect(INVALID_SOCKET); + assert_eq!(last_error().os, Some(WSAENOTSOCK), "the failing ioctl ran"); + let socket = create(false, false).expect("valid socket after failure"); + bind_to(socket.as_raw_socket() as usize, ([127, 0, 0, 1], 0).into()).expect("bind"); + assert_ne!( + address(socket.as_raw_socket() as usize, false) + .expect("address") + .port(), + 0 + ); + } +} diff --git a/crates/turnloop/src/backend/iocp/timer.rs b/crates/turnloop/src/backend/iocp/timer.rs index bdb6495..d8ed123 100644 --- a/crates/turnloop/src/backend/iocp/timer.rs +++ b/crates/turnloop/src/backend/iocp/timer.rs @@ -165,11 +165,16 @@ impl PacketTimer { Ok(()) } + /// Report whether this packet acknowledges the current generation. + /// /// # Safety /// Call only after dequeuing this timer's TIMER packet on its port. - pub unsafe fn dequeued(&mut self, generation: usize) { + pub unsafe fn dequeued(&mut self, generation: usize) -> bool { if generation == self.generation { self.active = false; + true + } else { + false } } @@ -195,3 +200,136 @@ impl Drop for PacketTimer { let _ = self.cancel(); } } + +#[cfg(all(test, not(loom)))] +mod tests { + use super::*; + use crate::{ + backend::{ + Backend, + iocp::{ + Iocp, + port::{Entry, WAKE}, + tests::Synthetic, + }, + }, + *, + }; + use std::cell::Cell; + use windows_sys::Win32::System::IO::{OVERLAPPED, PostQueuedCompletionStatus}; + + thread_local! { static CANCELS: Cell = const { Cell::new(0) }; } + unsafe extern "system" fn pending(_: HANDLE, _: u8) -> i32 { + CANCELS.with(|count| count.set(count.get() + 1)); + 0x103 + } + unsafe extern "system" fn failed(_: HANDLE, _: u8) -> i32 { + CANCELS.with(|count| count.set(count.get() + 1)); + 0xc0000001u32 as i32 + } + unsafe extern "system" fn associate( + _: HANDLE, + _: HANDLE, + _: HANDLE, + _: *const c_void, + _: *const c_void, + _: i32, + _: usize, + _: *mut u8, + ) -> i32 { + // Simulate an associated packet; tests explicitly supply its completion. + 0 + } + fn post(backend: &Iocp, entry: Entry) { + assert_ne!( + // SAFETY: live port; contexts are opaque synthetic identities, never + // dereferenced by Windows. The backend owns the corresponding slots. + unsafe { + PostQueuedCompletionStatus( + backend.port.raw(), + entry.bytes, + entry.key, + entry.overlapped as *const OVERLAPPED, + ) + }, + 0 + ); + } + #[test] + fn wait_cancellation_error_still_applies_dequeued_io() { + let mut fixture = Synthetic::new(); + fixture.backend.timer.associate = associate; + fixture.backend.timer.cancel = failed; + CANCELS.with(|count| count.set(0)); + for entry in fixture.entries { + post(&fixture.backend, entry); + } + let mut out = Vec::with_capacity(2); + assert!( + fixture + .backend + .poll(Some(Duration::from_secs(30)), &mut out) + .is_err() + ); + assert_eq!( + CANCELS.with(Cell::get), + 1, + "injected cancellation error ran" + ); + fixture.assert_delivered(); + } + #[test] + fn pending_wait_timer_is_not_rearmed_until_its_packet_is_dequeued() { + let config = Config::default(); + let mut backend = Iocp::new(&config, BufferPool::new(2, 64)).expect("backend"); + backend.timer.associate = associate; + backend.timer.cancel = pending; + CANCELS.with(|count| count.set(0)); + backend.port.post(WAKE, 0).expect("wake first wait"); + let mut out = Vec::with_capacity(1); + let info = backend + .poll(Some(Duration::from_secs(30)), &mut out) + .expect("first wait"); + assert_eq!((info.waits, info.zero_event_waits), (1, 0)); + assert!(out.is_empty()); + assert!(backend.timer_pending && backend.timer.active); + let generation = backend.timer.generation; + assert_eq!(CANCELS.with(Cell::get), 1); + // A forwarded stale generation must not make the current packet reusable. + backend + .entries(&[Entry { + key: TIMER, + overlapped: generation - 1, + ..Entry::default() + }]) + .expect("stale generation"); + assert!(backend.timer_pending && backend.timer.active); + post( + &backend, + Entry { + key: TIMER, + overlapped: generation, + ..Entry::default() + }, + ); + let info = backend + .poll(Some(Duration::from_secs(30)), &mut out) + .expect("drain pending timer"); + assert_eq!((info.waits, info.zero_event_waits), (1, 1)); + assert_eq!(backend.timer.generation, generation, "no premature rearm"); + assert_eq!( + CANCELS.with(Cell::get), + 1, + "no second cancel of in-flight packet" + ); + assert!(!backend.timer_pending && !backend.timer.active); + // Reuse is allowed after acknowledgement, and the next generation runs. + backend.port.post(WAKE, 0).expect("wake rearmed wait"); + backend + .poll(Some(Duration::from_secs(30)), &mut out) + .expect("reuse timer"); + assert_eq!(backend.timer.generation, generation + 1); + assert_eq!(CANCELS.with(Cell::get), 2); + assert!(backend.timer_pending && backend.timer.active); + } +} diff --git a/docs/BACKEND_REVISION_2.md b/docs/BACKEND_REVISION_2.md index 57920ed..744a558 100644 --- a/docs/BACKEND_REVISION_2.md +++ b/docs/BACKEND_REVISION_2.md @@ -142,32 +142,34 @@ including when cancelled before the first poll. `LocalExecutor` and adapters are ## Windows and WASM integration -Windows has the same resource/operation shapes needed by `spikes/iocp`: named pipe -accept/connect, duplicate stdio, duplicated sockets/handles, process waits and Job +The production Windows IOCP backend implements these resource/operation shapes: +named pipe accept/connect, duplicate stdio, duplicated sockets/handles, process waits and Job Objects, console modes/resize and console signal dispatch. Keep OVERLAPPED storage pinned through cancellation. Parent stdio pipe ends need overlapped operation; -child ends must work with ordinary synchronous child I/O. The wire format may use -peer PID negotiation for WSADuplicateSocketW/ DuplicateHandle; no Unix fd leaks +child ends must work with ordinary synchronous child I/O. The socket-transfer wire +format uses OS-reported peer PIDs with WSADuplicateSocketW; no Unix fd leaks through this trait. uid/gid and unavailable console signals must reject explicitly. The new shared scenarios are public generic functions in -`turnloop-contract::{native_surface,executor_contract}`. Instantiate them with -IOCP and supply a named-pipe name, the child fixture executable, and a supported -signal plus its real OS delivery closure for fan-out. The no-spin scenario also -accepts the platform's idle signal. Adapt the fixture's platform alias/cfg once -production IOCP exists; do not substitute ordinary stdio for its driver exercise. -Unix-only openpty/sigaction/waitpid assertions stay in native tests. IOCP needs its -console and process registration race equivalents in addition to the shared tests. +`turnloop-contract::{native_surface,executor_contract}`. Windows instantiates +them with IOCP, named pipes and the native child fixture, including the fixture's +actual stdio driver exercise. Windows console and process registration/lifetime +tests complement the shared scenarios; Unix-only openpty/sigaction/waitpid +assertions remain in native tests. Further Windows revision-2 coverage gaps are +listed in the IOCP lane report. Cancelling a Windows child exit watch alone +acknowledges immediately and leaves the child owned and running. Close with a +pending watch retains it until termination, then emits Cancelled before Closed. WASI 0.2/0.3 and web production adapters implement this revision; WASI 0.3 remains experimental. WASI stdio, single-agent external waits, Worker condition delivery -and executor contracts run through the required platform runners. Windows still -awaits its production IOCP adapter. Cross-checking is not runtime proof; browser -and Windows runtime status remains explicit in the root lane report. +and executor contracts run through the required platform runners. Windows IOCP +runs in all three required native CI modes. Cross-checking is not runtime proof; +browser and Windows runtime status remains explicit in the root lane report. ## Specification clarifications proposed for review -No changes were made to authoritative `DESIGN.md`. +DESIGN §7.3 now reflects the NT timer decision already recorded in §15 question 3. +The following broader clarifications remain proposals. 1. Specify local control-stream framing and independent send ownership explicitly. 2. Specify nonblocking kill/reap-on-close via `prepare_close`, delayed cancellation diff --git a/docs/iocp-fix1-commands.md b/docs/iocp-fix1-commands.md new file mode 100644 index 0000000..b85099d --- /dev/null +++ b/docs/iocp-fix1-commands.md @@ -0,0 +1,43 @@ +# Native verification commands + +Every row was executed by `python3 scripts/ci/run-tests.py native` on macOS arm64. +Ignored or cfg-excluded test bodies are not included in the pass counts. + +| Mode | Result | Passed tests | Command | +| --- | --- | --- | --- | +| default (default features) | PASS | 236 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml --workspace -- --test-threads=1` | +| default (default features) | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop -- --test-threads=1` | +| default (default features) | PASS | 26 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-http -- --test-threads=1` | +| default (default features) | PASS | 8 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-tls -- --test-threads=1` | +| default (default features) | PASS | 20 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mongodb -- --test-threads=1` | +| default (default features) | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mysql -- --test-threads=1` | +| default (default features) | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-postgres -- --test-threads=1` | +| default (default features) | PASS | 10 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-redis -- --test-threads=1` | +| default (default features) | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-smtp -- --test-threads=1` | +| default (default features) | PASS | 3 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-websocket -- --test-threads=1` | +| default (default features) | PASS | 3 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-io -- --test-threads=1` | +| default (default features) | PASS | 49 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-contract -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 243 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml --workspace --features turnloop/executor,turnloop-contract/executor -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 13 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop --features turnloop/executor -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 26 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-http -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 8 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-tls -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 20 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mongodb -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mysql -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-postgres -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 10 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-redis -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-smtp -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 3 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-websocket -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 3 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-io --features turnloop/executor -- --test-threads=1` | +| executor (--features turnloop/executor,turnloop-contract/executor) | PASS | 56 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-contract --features turnloop/executor,turnloop-contract/executor -- --test-threads=1` | +| all-features (--all-features) | PASS | 276 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml --workspace --all-features -- --test-threads=1` | +| all-features (--all-features) | PASS | 13 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop --all-features -- --test-threads=1` | +| all-features (--all-features) | PASS | 38 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-http --all-features -- --test-threads=1` | +| all-features (--all-features) | PASS | 11 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-tls --all-features -- --test-threads=1` | +| all-features (--all-features) | PASS | 21 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mongodb --all-features -- --test-threads=1` | +| all-features (--all-features) | PASS | 13 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mysql --all-features -- --test-threads=1` | +| all-features (--all-features) | PASS | 13 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-postgres --all-features -- --test-threads=1` | +| all-features (--all-features) | PASS | 11 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-redis --all-features -- --test-threads=1` | +| all-features (--all-features) | PASS | 13 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-smtp --all-features -- --test-threads=1` | +| all-features (--all-features) | PASS | 8 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-websocket --all-features -- --test-threads=1` | +| all-features (--all-features) | PASS | 3 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-io --all-features -- --test-threads=1` | +| all-features (--all-features) | PASS | 56 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-contract --all-features -- --test-threads=1` | diff --git a/docs/lanes/iocp-fix1.md b/docs/lanes/iocp-fix1.md new file mode 100644 index 0000000..15f5f46 --- /dev/null +++ b/docs/lanes/iocp-fix1.md @@ -0,0 +1,176 @@ +# IOCP fix1 lane report + +Base: PR #5 `windows/iocp-backend`, `28a49c8`. Development host: macOS arm64. +Status: requested fixes and eleven new Windows regressions/allocation tests +implemented; local verification complete. **All new Windows runtime tests are UNRUN (no Windows host).** The +integrator's green run 34899966713 concerns the base, not this working tree. +No commits or pushes are made here; `.git` is read-only. + +## Findings, fixes and tests + +1. **Batch argument injection — confirmed.** `program()` returned explicit/PATH + candidates without checking the extension before MSVCRT quoting/CreateProcessW. + Resolved `.bat`/`.cmd` extensions now reject with InvalidInput, ignoring ASCII + case, before stdio or process setup. `batch_programs_are_rejected_before_spawn` + tests eight explicit/PATH cases, exact error, empty loop/no child completions, + and absent marker. Explicit cmd.exe execution of each same fixture is the + positive control and must create the exact marker bytes. PATH cases use bare + filenames including the extension; no PATHEXT search behavior was added. +2. **Exit/kill/close race — confirmed.** Termination errors previously escaped + whenever the callback had not published readiness. ERROR_ACCESS_DENIED plus a + signaled owned process handle now records readiness and returns NotFound from + kill; close accepts that result. Other errors retain their original identity. + Windows lifetime tests require 200 process-kill + 200 job-kill/close cycles, + exact Cancelled/Closed order under capacity-one output, no duplicates, and a + signaled duplicated process handle at Closed. A natural-exit test waits on a + duplicated raw handle before any turn. A backend unit test unregisters the + callback while the child is suspended, then resumes/waits: both kill and close + must take the callback-lag path with exit code 0, independent of scheduling. + That unit fixture lists its own executable without running nested tests; the + integration fixture separately exercises native_child exit code 23. +3. **Discarded completion batch — confirmed.** Entry processing now evaluates + every entry, retains the first error, and returns it after the batch. poll also + applies dequeued entries before propagating timer cancellation errors. The + synthetic batch test places an invalid packet between two valid writes and + checks both exact OpIds/byte counts/terminal completions under capacity-one + output. The timer fault test injects a cancellation failure during poll and + verifies dequeued I/O remains deliverable. Synthetic fixtures discard their + unsubmitted metadata on unwind, so a failed assertion cannot hang teardown. +4. **STATUS_PENDING on timed waits — confirmed against timer.rs and Microsoft's + documented contract.** cancel leaves the packet active on 0x103. Both wait and + Event paths now retain pending state and defer rearm/cancel until the matching + generation is dequeued. Stale generations do not clear it. The deterministic + test substitutes NT association/cancellation functions, counts actual cancel + calls, and uses real port packets to force wake → pending → stale generation → + matching TIMER → successful next-generation reuse. It checks one wait and the + private-timer empty-wait accounting. Existing deadline/allocation/no-spin gates + remain unchanged; the mock test is not proof of NT runtime timing. +5. **Cancelled child watch stalled — confirmed.** Standalone cancellation is + ready for every service kind and returns terminal Cancelled before status is + queried. Close-induced cancellation retains the native exit watch until child + termination, preserving the existing Cancelled-before-Closed/reap contract. + The lifetime regression cancels via detach (Process exposes no exit OpId), + requires exactly one terminal Cancelled in one turn with zero OS waits, and + checks the raw process handle is still unsignaled. It then kills/waits/closes + and checks no duplicate exit or cancellation. A Windows counting-allocator + gate covers eight live-watch cancellations, eight exits and sixteen closes, + exact identities and zero allocations after setup. +6. **Racy exact handle baseline — confirmed.** Every test in windows_lifetimes.rs + holds one static mutex until its resources drop. The exact handle-count + comparison remains intact; default parallel libtest execution can no longer + interleave these tests' handle creation with that baseline. +7. **Quick wins — all confirmed.** Loopback SIO_TCP_INITIAL_RTO is best-effort; + a unit test forces WSAENOTSOCK and checks the failing call ran. Signal tickets + use Box::into_raw/from_raw, with reclamation after removing the published slot + and joining dispatchers; a moved-subscription regression asserts 200 deliveries + and no delivery after drop. Each of 128 synchronous-read cancel/close/drop + rounds now writes new bytes after quiescence, reads them with fresh ownership, + and checks both the payload and the original untouched buffer. CONTRIBUTING + and revision-2 docs describe the production IOCP provider and required positive + contract counts. DESIGN §7.3 now states §15 q3's existing NT packet decision. + +## Verification ledger + +Initial source inspection PASS: complete DESIGN, CONTRIBUTING, integration report, +relevant Windows/core lane reports, revision-2 contract and every IOCP source file; +no applicable AGENTS.md; initial working tree clean at the requested base. +Read-only inventory used rg/cat/sed, git status/log, rustup toolchain list and +source diff review. Two guessed source filenames were absent; subsequent reads +used the actual types.rs/driver.rs/native.rs files. + +- PASS `cargo fmt --all` after each edit group. +- PASS initial `cargo clippy --locked --target x86_64-pc-windows-msvc -p turnloop -p turnloop-contract -p turnloop-io --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks`. +- FAIL intermediate same Windows command: vec_init_then_push in the new moved-ticket + test. Fixed by constructing the initial vector directly and asserting capacity + growth; no lint suppression. Subsequent identical command PASS. + +The native runner passed all three modes: **236 / 243 / 276 workspace tests**, +with **49 / 56 / 56** independently executed contract tests. Total **1,297 passes** +includes package repetitions. The unchanged native allocation and no-spin gates +ran in every applicable mode. Ignored server and cfg-excluded Windows tests are +UNRUN, never included in those counts. [Every nested native test command and its +positive count](../iocp-fix1-commands.md) is recorded separately. + +Final Windows all-target/all-feature Clippy includes all eleven new tests. Native +Clippy passes with default and all features; WASI 0.2, experimental 0.3 and web core +all-target/all-feature Clippy pass. Stable is 1.97.1. The p3 compiler reports only +inherited Cargo manifest/config warnings; strict Rust Clippy still passes. +No-tokio passes eight targets plus their union in default/all-feature modes. Soak +passes all 251 locked versions with only the pre-existing exact rustls exception. + +Final static audit PASS: all eight tests in windows_lifetimes.rs acquire the mutex; +no new unwrap(); protected dependency/CI/soak files unchanged; new report whitespace +checked. One multi-file edit was rejected for a context mismatch without changing +the tree, then reapplied against the actual lines. Full local command logs and the +machine-readable ledger are under `.tools/iocp-fix1/`. + +### Exact verification commands + +Commands below are the recorded invocations; repeated Windows rows retain the +intermediate failure and the final passes. Formatting was also applied with +`cargo fmt --all` after each edit group (PASS). + +| Invocation | Result | Command | +| --- | --- | --- | +| native-clippy | PASS | `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| windows-clippy | FAIL | `cargo clippy --locked --target x86_64-pc-windows-msvc -p turnloop -p turnloop-contract -p turnloop-io --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| windows-clippy-fixed | PASS | `cargo clippy --locked --target x86_64-pc-windows-msvc -p turnloop -p turnloop-contract -p turnloop-io --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| no-tokio | PASS | `bash scripts/ci/no-tokio.sh` | +| stable | PASS | `cargo +stable check --locked --workspace --all-targets --all-features` | +| soak | PASS | `python3 scripts/ci/soak.py` | +| wasi-p2 | PASS | `cargo clippy --locked --target wasm32-wasip2 -p turnloop --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| wasi-p3 | PASS | `cargo +nightly-2026-09-07 clippy --locked --target wasm32-wasip3 -p turnloop --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| paths | PASS | `python3 scripts/ci/check-paths.py` | +| feature-modes | PASS | `python3 scripts/ci/feature_modes.py` | +| fmt | PASS | `cargo fmt --all --check` | +| diff-whitespace | PASS | `git diff --check` | +| native-tests | PASS | `python3 scripts/ci/run-tests.py native` | +| windows-final | PASS | `cargo clippy --locked --target x86_64-pc-windows-msvc -p turnloop -p turnloop-contract -p turnloop-io --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| native-default-final | PASS | `cargo clippy --locked --workspace --all-targets -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| web | PASS | `cargo clippy --locked --target wasm32-unknown-unknown -p turnloop --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| fmt-final | PASS | `cargo fmt --all --check` | +| stable-version | PASS | `rustc +stable --version` | + +## Deviations, design clarifications and open questions + +- No dependencies, manifests, lockfile, seven-day soak settings, CI requirements, + zero-tokio policy, allocation thresholds or no-spin assertions were changed. +- DESIGN §7.3 only reconciles the already-decided timer mechanism. Recorded 275.2 us + is **lateness**, not total elapsed time: spikes/iocp/tests/timer.rs subtracts the + requested delay before reporting, matching WINDOWS_RESULTS.md and §15 q3. +- Standalone Windows exit-watch cancellation intentionally differs from Unix's + retained-until-reap observation policy. A separate closing flag preserves the + existing asynchronous close behavior while a watch is pending. If the watch has + already completed cancellation, the revision-2 API has no separate teardown + operation to delay Closed: existing Child::Drop synchronizes termination on + release. Tests explicitly wait on the owned process before closing in this case. + Completion-independent asynchronous process teardown needs a contract decision; + it is not silently claimed as covered by these fixes. +- Native references checked: [TerminateProcess](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-terminateprocess) + documents asynchronous termination and ERROR_ACCESS_DENIED after exit; + [NtCancelWaitCompletionPacket](https://learn.microsoft.com/en-us/windows/win32/devnotes/ntcancelwaitcompletionpacket) + documents STATUS_PENDING prohibiting immediate packet reuse. + +## UNRUN and next steps + +- Integrator: commit/push the coherent tree and relay windows-2025 default, + executor and all-features CI. New Windows unit/contract/allocation/handle tests + remain UNRUN until those results arrive. Cross-Clippy is compilation only. +- Run `cargo test --locked -p turnloop --lib backend::iocp -- --nocapture` and + `cargo test --locked -p turnloop-contract --test windows_lifetimes -- --nocapture` + on Windows. Run the latter with default parallelism to exercise mutex isolation. +- Run `cargo test --locked -p turnloop-contract --test allocations -- --test-threads=1` + and `python3 scripts/ci/run-tests.py native` on Windows. +- Reproduce the integrator's intermittent curl hang on Windows (UNRUN here): + `1..200 | % { cargo test -p turnloop-http --features turnloop --test asynchronous curl_against; if ($LASTEXITCODE) { break } }`. + No claim that the batch fix explains or resolves that historical hang. +- Separate follow-up lane: named-pipe backlog/ERROR_PIPE_BUSY retry; avoiding the + bridge for accepted pipes on the same port; remaining Windows revision-2 tests + (live-child loop drop, argv/env/cwd, worker-file FIFO under drop, exit/signal + allocation coverage beyond this lane's child gate); CTRL_CLOSE_EVENT, + CREATE_NO_WINDOW and KILL_ON_JOB_CLOSE semantics. Also preserve the review's + synchronous-duplex FIFO and integration-helper error-reporting follow-ups. +- Linux/Windows runtime and Windows protocol cross-builds are UNRUN locally (no + hosts/Windows SDK for ring). SQL and browser runtime are UNRUN in this lane; + known sandbox failures are not counted as test passes. No unrelated service or + browser code was changed. From c487796d87501f9299d242dc0662ba42074dafd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 02:12:54 +0200 Subject: [PATCH 13/15] Treat a terminating IOCP child as killed: close after kill no longer fails windows-2025 CI for 87a44cd: the 200x spawn -> kill -> close loop failed with ERROR_ACCESS_DENIED. TerminateProcess only starts termination; close() called it again while the process was terminating but its handle was not yet signaled, and the previous check required a signaled handle. Child now records a successful termination request (process or job): close() after it proceeds to Cancelled -> real exit -> Closed without terminating again, and a second kill() returns NotFound. On ERROR_ACCESS_DENIED the exit code (set when termination begins, as libuv's uv_kill checks) or a signaled handle means exiting. Drop requests termination only if none succeeded and still joins the real exit. windows_lifetimes.rs recovers the handle-test mutex from poisoning so one failure no longer cascades. The 200x loops are unchanged. --- crates/turnloop-contract/tests/allocations.rs | 109 ++++++ .../tests/windows_lifetimes.rs | 32 +- crates/turnloop/src/backend/iocp/process.rs | 311 ++++++++++++++++-- docs/iocp-fix2-commands.md | 87 +++++ docs/lanes/iocp-fix1.md | 130 ++++++++ 5 files changed, 637 insertions(+), 32 deletions(-) create mode 100644 docs/iocp-fix2-commands.md diff --git a/crates/turnloop-contract/tests/allocations.rs b/crates/turnloop-contract/tests/allocations.rs index 0b51484..9f6eb2e 100644 --- a/crates/turnloop-contract/tests/allocations.rs +++ b/crates/turnloop-contract/tests/allocations.rs @@ -1186,6 +1186,115 @@ fn stdio_reads_reuse_caller_buffers_through_eof() { assert_eq!(allocations, 0, "stdio reads and repeated CLI EOF results"); } +#[cfg(windows)] +#[test] +fn windows_kill_repeat_and_immediate_close_allocate_nothing_after_setup() { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::{ + Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}, + System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject}, + }; + let mut driver = Loop::new(Config::default()).expect("loop"); + let mut spec = ProcessSpec::new(env!("CARGO_BIN_EXE_native_child")); + spec.args.push("sleep".into()); + spec.stdio = [ProcessStdio::Null; 3]; + // Cover a plain process, a process-only kill in a job, and a whole-job kill. + let children: [_; 18] = std::array::from_fn(|i| { + spec.new_process_group = i % 3 != 0; + driver.spawn(&spec, Token(i as u64)).expect("child") + }); + let waits: [OwnedHandle; 18] = std::array::from_fn(|i| { + // SAFETY: driver's owned child pins the PID; open only a wait handle. + let raw = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, children[i].pid) }; + assert!(!raw.is_null()); + // SAFETY: successful OpenProcess transferred this handle's ownership. + unsafe { OwnedHandle::from_raw_handle(raw) } + }); + let mut out = Completions::with_capacity(1); + driver.turn(Timeout::Now, &mut out).expect("warm services"); + assert!(out.is_empty()); + let mut cancelled = [false; 18]; + let mut closed = [false; 18]; + let (mut kills, mut repeats, mut cancellations, mut closes) = (0, 0, 0, 0); + let deadline = driver.now() + Duration::from_secs(10); + ALLOCS.with(|count| count.set(0)); + ACTIVE.with(|active| active.set(true)); + for (i, child) in children.iter().enumerate() { + assert_eq!( + // SAFETY: owned duplicate, nonblocking query proves a live kill subject. + unsafe { WaitForSingleObject(waits[i].as_raw_handle(), 0) }, + WAIT_TIMEOUT + ); + if i % 3 == 2 { + driver + .kill_group(child.handle, Signal::Kill) + .expect("kill job"); + assert_eq!( + driver + .kill_group(child.handle, Signal::Kill) + .expect_err("repeat job kill") + .kind, + ErrorKind::NotFound + ); + } else { + driver + .kill(child.handle, Signal::Kill) + .expect("kill process"); + assert_eq!( + driver + .kill(child.handle, Signal::Kill) + .expect_err("repeat process kill") + .kind, + ErrorKind::NotFound + ); + } + kills += 1; + repeats += 1; + driver + .close(child.handle, Token(i as u64)) + .expect("immediate close"); + } + while driver.alive() { + assert!(driver.now() < deadline, "termination deadline"); + driver + .turn(Timeout::Until(deadline), &mut out) + .expect("close delivery"); + for completion in out.drain() { + let i = completion.token.0 as usize; + assert!(i < children.len()); + assert_eq!(completion.handle, Some(children[i].handle)); + assert!(completion.terminal); + match completion.result { + OpResult::Cancelled => { + assert!(completion.op.is_some()); + assert!(!closed[i]); + assert!(!std::mem::replace(&mut cancelled[i], true)); + cancellations += 1; + } + OpResult::Closed => { + assert!(completion.op.is_none()); + assert!(cancelled[i]); + assert!(!std::mem::replace(&mut closed[i], true)); + assert_eq!( + // SAFETY: owned duplicate survives Closed; child must have exited. + unsafe { WaitForSingleObject(waits[i].as_raw_handle(), 0) }, + WAIT_OBJECT_0 + ); + closes += 1; + } + other => panic!("unexpected child completion: {other:?}"), + } + } + } + driver + .turn(Timeout::Now, &mut out) + .expect("no duplicate completion"); + assert!(out.is_empty()); + ACTIVE.with(|active| active.set(false)); + assert_eq!(ALLOCS.with(Cell::get), 0, "kill/repeat/close allocations"); + assert_eq!((kills, repeats, cancellations, closes), (18, 18, 18, 18)); +} + #[cfg(windows)] #[test] fn windows_child_watch_cancel_exit_and_close_allocate_nothing_after_setup() { diff --git a/crates/turnloop-contract/tests/windows_lifetimes.rs b/crates/turnloop-contract/tests/windows_lifetimes.rs index 370dcbc..61720be 100644 --- a/crates/turnloop-contract/tests/windows_lifetimes.rs +++ b/crates/turnloop-contract/tests/windows_lifetimes.rs @@ -79,7 +79,9 @@ fn assert_cancelled_then_closed(driver: &mut Loop, child: Handle, wait: &OwnedHa #[test] fn kill_then_close_children_completes_once() { - let _guard = HANDLES.lock().expect("handle test lock"); + let _guard = HANDLES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut driver = Loop::new(Config::default()).expect("loop"); let mut completed = 0; for group in [false, true] { @@ -115,7 +117,9 @@ fn kill_then_close_children_completes_once() { #[test] fn close_after_raw_child_wait_before_servicing_exit() { - let _guard = HANDLES.lock().expect("handle test lock"); + let _guard = HANDLES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut driver = Loop::new(Config::default()).expect("loop"); let mut spec = ProcessSpec::new(env!("CARGO_BIN_EXE_native_child")); spec.args.push("exit".into()); @@ -144,7 +148,9 @@ fn close_after_raw_child_wait_before_servicing_exit() { #[test] fn batch_programs_are_rejected_before_spawn() { - let _guard = HANDLES.lock().expect("handle test lock"); + let _guard = HANDLES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let directory = std::env::temp_dir().join(format!("turnloop-batch-{}", std::process::id())); std::fs::create_dir(&directory).expect("private batch directory"); let marker = directory.join("marker"); @@ -194,7 +200,9 @@ fn batch_programs_are_rejected_before_spawn() { #[test] fn imported_overlapped_pipe_routes_away_from_its_existing_port() { - let _guard = HANDLES.lock().expect("handle test lock"); + let _guard = HANDLES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); use std::os::windows::{fs::OpenOptionsExt, io::AsRawHandle}; use windows_sys::Win32::{ Foundation::INVALID_HANDLE_VALUE, Storage::FileSystem::FILE_FLAG_OVERLAPPED, @@ -247,7 +255,9 @@ fn imported_overlapped_pipe_routes_away_from_its_existing_port() { #[test] fn overlapped_regular_files_are_rejected_before_worker_submission() { - let _guard = HANDLES.lock().expect("handle test lock"); + let _guard = HANDLES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); use std::os::windows::fs::OpenOptionsExt; use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OVERLAPPED; let file = std::fs::OpenOptions::new() @@ -275,7 +285,9 @@ fn handles() -> u32 { } #[test] fn cancelled_synchronous_reads_release_buffers_and_threads() { - let _guard = HANDLES.lock().expect("handle test lock"); + let _guard = HANDLES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let (mut read, mut write) = (ptr::null_mut(), ptr::null_mut()); // SAFETY: two output slots; noninherited anonymous synchronous pipe. assert_ne!( @@ -399,7 +411,9 @@ fn cancelled_synchronous_reads_release_buffers_and_threads() { } #[test] fn loop_drop_and_stale_wakers_release_windows_handles() { - let _guard = HANDLES.lock().expect("handle test lock"); + let _guard = HANDLES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let cycle = || { let mut driver = Loop::new(Config::default()).expect("loop"); let (_, _, receiver) = turnloop_contract::pair(&mut driver); @@ -435,7 +449,9 @@ fn loop_drop_and_stale_wakers_release_windows_handles() { #[test] fn cancelled_child_watch_completes_while_child_is_alive() { - let _guard = HANDLES.lock().expect("handle test lock"); + let _guard = HANDLES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut driver = Loop::new(Config::default()).expect("loop"); let mut spec = ProcessSpec::new(env!("CARGO_BIN_EXE_native_child")); spec.args.push("sleep".into()); diff --git a/crates/turnloop/src/backend/iocp/process.rs b/crates/turnloop/src/backend/iocp/process.rs index 87b6ea3..94b95a5 100644 --- a/crates/turnloop/src/backend/iocp/process.rs +++ b/crates/turnloop/src/backend/iocp/process.rs @@ -251,9 +251,17 @@ pub(super) struct Child { process: OwnedHandle, thread: OwnedHandle, job: Option, + job_assigned: bool, + // A successful request (or an observed exit code) precedes actual exit. + // Only the wait callback/signaled handle may publish completion readiness. + terminating: bool, context: Arc, wait: HANDLE, status: Option, + #[cfg(test)] + terminate_override: Option Result<()>>, + #[cfg(test)] + exit_code_override: Option Result>, pub pid: u32, } impl Child { @@ -267,9 +275,7 @@ impl Child { if let Some(status) = self.status { return Ok(Some(status)); } - let mut code = 0; - // SAFETY: registered wait reported actual process termination. - bool_result(unsafe { GetExitCodeProcess(self.process.as_raw_handle(), &mut code) })?; + let code = self.exit_code()?; self.join()?; let status = ExitStatus { code: Some(code as i32), @@ -295,31 +301,64 @@ impl Child { } } pub(super) fn kill(&mut self, signal: Signal, group: bool) -> Result<()> { - if self.status.is_some() { + if self.status.is_some() || self.terminating { return Err(crate::Error::new(crate::ErrorKind::NotFound)); } if signal != Signal::Kill { return Err(unsupported()); } - let result = if group { - let job = self.job.as_ref().ok_or_else(invalid)?; - // SAFETY: exclusively owned process tree; no PID/handle reuse is possible. - bool_result(unsafe { TerminateJobObject(job.as_raw_handle(), 1) }) - } else { - // SAFETY: process handle pins the identity even if its PID is later reused. - bool_result(unsafe { TerminateProcess(self.process.as_raw_handle(), 1) }) - }; - if let Err(error) = result + let result = self.terminate(group); + if result.is_ok() { + self.terminating = true; + } else if let Err(error) = result && error.os == Some(ERROR_ACCESS_DENIED as i32) - // SAFETY: the owned process handle pins its identity; zero timeout - // only queries termination and never waits for the callback. - && unsafe { WaitForSingleObject(self.process.as_raw_handle(), 0) } == WAIT_OBJECT_0 { - self.context.ready.store(true, Ordering::Release); - return Err(crate::Error::new(crate::ErrorKind::NotFound)); + // The exit code may be set before the process handle is signaled. + // Preserve the original termination error if the query fails. + let exiting = self + .exit_code() + .is_ok_and(|code| code != STILL_ACTIVE as u32); + // SAFETY: owned identity; zero timeout queries actual exit without blocking. + let exited = + unsafe { WaitForSingleObject(self.process.as_raw_handle(), 0) } == WAIT_OBJECT_0; + if exited { + self.context.ready.store(true, Ordering::Release); + } + if exiting || exited { + self.terminating = true; + return Err(crate::Error::new(crate::ErrorKind::NotFound)); + } } result } + fn terminate(&self, group: bool) -> Result<()> { + let handle = if group { + self.job.as_ref().ok_or_else(invalid)?.as_raw_handle() + } else { + self.process.as_raw_handle() + }; + #[cfg(test)] + if let Some(terminate) = self.terminate_override { + return terminate(handle, group); + } + if group { + // SAFETY: exclusively owned process tree; no PID/handle reuse is possible. + bool_result(unsafe { TerminateJobObject(handle, 1) }) + } else { + // SAFETY: process handle pins the identity even if its PID is later reused. + bool_result(unsafe { TerminateProcess(handle, 1) }) + } + } + fn exit_code(&self) -> Result { + #[cfg(test)] + if let Some(exit_code) = self.exit_code_override { + return exit_code(self.process.as_raw_handle()); + } + let mut code = 0; + // SAFETY: owned process handle and writable exit-code output. + bool_result(unsafe { GetExitCodeProcess(self.process.as_raw_handle(), &mut code) })?; + Ok(code) + } pub(super) fn close(&mut self) -> Result<()> { if self.status.is_some() { return Ok(()); @@ -328,6 +367,9 @@ impl Child { self.status()?; return Ok(()); } + if self.terminating { + return Ok(()); + } match self.kill(Signal::Kill, self.job.is_some()) { Err(error) if error.kind == crate::ErrorKind::NotFound => Ok(()), result => result, @@ -337,13 +379,16 @@ impl Child { impl Drop for Child { fn drop(&mut self) { if self.status.is_none() { - // SAFETY: teardown owns the process and optional job; termination then - // blocking wait prevents a live child or outstanding callback escaping. + if !self.terminating && self.job_assigned { + let _ = self.kill(Signal::Kill, true); + } + if !self.terminating { + // Also covers failed job assignment during suspended spawn. + let _ = self.kill(Signal::Kill, false); + } + // SAFETY: teardown owns the process; a termination request is not + // completion, so join the real exit before releasing its resources. unsafe { - if let Some(job) = &self.job { - TerminateJobObject(job.as_raw_handle(), 1); - } - TerminateProcess(self.process.as_raw_handle(), 1); WaitForSingleObject(self.process.as_raw_handle(), INFINITE); } } @@ -477,12 +522,18 @@ pub(super) fn spawn( process: owned(info.hProcess)?, thread: owned(info.hThread)?, job, + job_assigned: false, + terminating: false, context: Arc::new(Context { ready: AtomicBool::new(false), notifier, }), wait: ptr::null_mut(), status: None, + #[cfg(test)] + terminate_override: None, + #[cfg(test)] + exit_code_override: None, pid: info.dwProcessId, } }; @@ -491,6 +542,7 @@ pub(super) fn spawn( bool_result(unsafe { AssignProcessToJobObject(job.as_raw_handle(), child.process.as_raw_handle()) })?; + child.job_assigned = true; } // SAFETY: initialized callback context and owned child precede registration; // immediate exits are safe and Drop joins the one-shot callback. @@ -510,6 +562,217 @@ pub(super) fn spawn( #[cfg(all(test, not(loom)))] mod tests { use super::*; + use std::cell::Cell; + + thread_local! { + static TERMINATIONS: Cell<[usize; 2]> = const { Cell::new([0; 2]) }; + static EXIT_QUERIES: Cell = const { Cell::new(0) }; + } + + fn count_termination(group: bool) { + TERMINATIONS.with(|count| { + let mut calls = count.get(); + calls[usize::from(group)] += 1; + count.set(calls); + }); + } + + fn denied(_: HANDLE, group: bool) -> Result<()> { + count_termination(group); + Err(std::io::Error::from_raw_os_error(ERROR_ACCESS_DENIED as i32).into()) + } + + fn suspended(driver: &crate::Loop, group: bool) -> Child { + let mut spec = ProcessSpec::new(std::env::current_exe().expect("test executable")); + spec.args.push("--list".into()); + spec.stdio = [ProcessStdio::Null; 3]; + spec.new_process_group = group; + spawn(&spec, [None; 3], driver.notifier()) + .expect("suspended child") + .0 + } + + // Fault injection never kills this suspended child. Restore real teardown on + // assertion failure as well as success, so a failed test cannot leak/hang it. + struct InjectedChild(Child); + impl Drop for InjectedChild { + fn drop(&mut self) { + self.0.terminate_override = None; + self.0.exit_code_override = None; + self.0.terminating = false; + } + } + + fn assert_unsignaled(child: &mut Child) { + assert_eq!( + // SAFETY: owned process handle, nonblocking liveness query. + unsafe { WaitForSingleObject(child.process.as_raw_handle(), 0) }, + WAIT_TIMEOUT + ); + assert!(!child.ready(), "termination request must not publish exit"); + assert_eq!(child.status().expect("pending exit"), None); + } + + #[test] + fn terminating_process_without_callback_does_not_repeat_kill_or_complete_early() { + let driver = crate::Loop::new(crate::Config::default()).expect("notifier owner"); + let mut checked = 0; + for group in [false, true] { + for access_denied in [false, true] { + let mut fixture = InjectedChild(suspended(&driver, group)); + let child = &mut fixture.0; + child.join().expect("remove callback while suspended"); + assert_unsignaled(child); + TERMINATIONS.with(|count| count.set([0; 2])); + EXIT_QUERIES.with(|count| count.set(0)); + child.terminate_override = Some(if access_denied { + denied + } else { + |_, group| { + count_termination(group); + Ok(()) + } + }); + child.exit_code_override = Some(|_| { + EXIT_QUERIES.with(|count| count.set(count.get() + 1)); + Ok(1) + }); + // Model the OS boundary deterministically: success or access + // denied with an exit code, while the real handle is unsignaled. + // Suspending a child alone cannot hold Windows in this window. + let result = child.kill(Signal::Kill, group); + if access_denied { + assert_eq!( + result.expect_err("termination underway").kind, + crate::ErrorKind::NotFound + ); + } else { + result.expect("termination accepted"); + } + assert!(child.terminating); + assert_unsignaled(child); + for repeat_group in [false, true] { + assert_eq!( + child + .kill(Signal::Kill, repeat_group) + .expect_err("repeat kill") + .kind, + crate::ErrorKind::NotFound + ); + } + child.close().expect("close while terminating"); + child.close().expect("repeat close while terminating"); + assert_unsignaled(child); + assert_eq!( + TERMINATIONS.with(Cell::get), + if group { [0, 1] } else { [1, 0] } + ); + assert_eq!(EXIT_QUERIES.with(Cell::get), usize::from(access_denied)); + checked += 1; + } + } + assert_eq!(checked, 4); + } + + #[test] + fn termination_errors_keep_their_identity_and_allow_retry() { + let driver = crate::Loop::new(crate::Config::default()).expect("notifier owner"); + let mut checked = 0; + for group in [false, true] { + for query_fails in [false, true] { + let mut fixture = InjectedChild(suspended(&driver, group)); + let child = &mut fixture.0; + child.join().expect("unregister suspended child"); + child.terminate_override = Some(denied); + if query_fails { + child.exit_code_override = Some(|_| { + Err(std::io::Error::from_raw_os_error(ERROR_INVALID_HANDLE as i32).into()) + }); + } // Otherwise GetExitCodeProcess really returns STILL_ACTIVE. + TERMINATIONS.with(|count| count.set([0; 2])); + let original: crate::Error = + std::io::Error::from_raw_os_error(ERROR_ACCESS_DENIED as i32).into(); + assert_eq!(child.kill(Signal::Kill, group), Err(original)); + assert_eq!(child.close(), Err(original)); + assert!(!child.terminating); + assert_unsignaled(child); + assert_eq!( + TERMINATIONS.with(Cell::get), + if group { [0, 2] } else { [2, 0] } + ); + // A different termination error must not be hidden by an exit query. + child.terminate_override = Some(|_, group| { + count_termination(group); + Err(std::io::Error::from_raw_os_error(ERROR_INVALID_HANDLE as i32).into()) + }); + child.exit_code_override = Some(|_| panic!("query after unrelated error")); + let original: crate::Error = + std::io::Error::from_raw_os_error(ERROR_INVALID_HANDLE as i32).into(); + assert_eq!(child.kill(Signal::Kill, group), Err(original)); + assert_eq!(child.close(), Err(original)); + assert!(!child.terminating); + assert_unsignaled(child); + assert_eq!( + TERMINATIONS.with(Cell::get), + if group { [0, 4] } else { [4, 0] } + ); + checked += 1; + } + } + assert_eq!(checked, 4); + } + + #[test] + fn drop_joins_termination_without_repeating_it_and_handles_unassigned_jobs() { + let driver = crate::Loop::new(crate::Config::default()).expect("notifier owner"); + let mut checked = 0; + for (group, kill_first, unassigned_job) in [ + (false, false, false), + (true, false, false), + (false, true, false), + (true, true, false), + (false, false, true), + ] { + let mut child = suspended(&driver, group); + if unassigned_job { + // Reproduce spawn unwinding before job assignment succeeds. + // SAFETY: unnamed owned job, never assigned this suspended child. + child.job = Some( + unsafe { owned(CreateJobObjectW(ptr::null(), ptr::null())) } + .expect("unassigned job"), + ); + assert!(!child.job_assigned); + } + let wait = child.process.try_clone().expect("duplicate process"); + TERMINATIONS.with(|count| count.set([0; 2])); + child.terminate_override = Some(|handle, group| { + count_termination(group); + // SAFETY: wrapper receives the same owned process/job as the real call. + bool_result(unsafe { + if group { + TerminateJobObject(handle, 1) + } else { + TerminateProcess(handle, 1) + } + }) + }); + if kill_first { + child.kill(Signal::Kill, group).expect("initial kill"); + } + drop(child); + assert_eq!( + TERMINATIONS.with(Cell::get), + if group { [0, 1] } else { [1, 0] } + ); + assert_eq!( + // SAFETY: owned duplicate survives Child::drop; nonblocking exit query. + unsafe { WaitForSingleObject(wait.as_raw_handle(), 0) }, + WAIT_OBJECT_0 + ); + checked += 1; + } + assert_eq!(checked, 5); + } #[test] fn exited_process_without_callback_maps_kill_and_close() { diff --git a/docs/iocp-fix2-commands.md b/docs/iocp-fix2-commands.md new file mode 100644 index 0000000..4f875db --- /dev/null +++ b/docs/iocp-fix2-commands.md @@ -0,0 +1,87 @@ +# IOCP fix2 verification commands + +Executed on macOS arm64 at base `87a44cd` plus the fix2 working tree. +Cross-Clippy compiles Windows code; all Windows runtime tests remain UNRUN locally. + +## Build and policy gates + +| Result | Command | +| --- | --- | +| PASS | `cargo clippy --locked --target x86_64-pc-windows-msvc -p turnloop -p turnloop-contract -p turnloop-io --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| PASS | `cargo clippy --locked --workspace --all-targets -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| PASS | `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| PASS | `cargo +stable check --locked --workspace --all-targets --all-features` | +| PASS | `cargo fmt --all --check` | +| PASS | `python3 scripts/ci/check-paths.py` | +| PASS | `python3 scripts/ci/feature_modes.py` | +| PASS | `bash scripts/ci/no-tokio.sh` | +| PASS | `python3 scripts/ci/soak.py` | +| PASS | `git diff --check` | +| PASS | `python3 scripts/ci/run-tests.py native` | +| PASS | `cargo fmt --all` (after each edit group) | +| PASS | `python3 .tools/iocp-fix2/audit.py` | + +The exact Windows Clippy command above also passed once before the allocation test +was added. No failed verification commands occurred in this follow-up. +Read-only source/reference inspection and `rg`/Git diff audits passed. + +## Native runner commands + +Each row was executed by `python3 scripts/ci/run-tests.py native`. +Ignored/cfg-excluded bodies are UNRUN and excluded from these counts. +Manifest paths are rendered relative to this clone; raw logs retain absolute paths. + +| Mode | Result | Passed tests | Command | +| --- | --- | --- | --- | +| default | PASS | 236 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml --workspace -- --test-threads=1` | +| default | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop -- --test-threads=1` | +| default | PASS | 26 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-http -- --test-threads=1` | +| default | PASS | 8 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-tls -- --test-threads=1` | +| default | PASS | 20 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mongodb -- --test-threads=1` | +| default | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mysql -- --test-threads=1` | +| default | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-postgres -- --test-threads=1` | +| default | PASS | 10 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-redis -- --test-threads=1` | +| default | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-smtp -- --test-threads=1` | +| default | PASS | 3 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-websocket -- --test-threads=1` | +| default | PASS | 3 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-io -- --test-threads=1` | +| default | PASS | 49 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-contract -- --test-threads=1` | +| executor | PASS | 243 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml --workspace --features turnloop/executor,turnloop-contract/executor -- --test-threads=1` | +| executor | PASS | 13 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop --features turnloop/executor -- --test-threads=1` | +| executor | PASS | 26 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-http -- --test-threads=1` | +| executor | PASS | 8 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-tls -- --test-threads=1` | +| executor | PASS | 20 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mongodb -- --test-threads=1` | +| executor | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mysql -- --test-threads=1` | +| executor | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-postgres -- --test-threads=1` | +| executor | PASS | 10 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-redis -- --test-threads=1` | +| executor | PASS | 12 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-smtp -- --test-threads=1` | +| executor | PASS | 3 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-websocket -- --test-threads=1` | +| executor | PASS | 3 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-io --features turnloop/executor -- --test-threads=1` | +| executor | PASS | 56 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-contract --features turnloop/executor,turnloop-contract/executor -- --test-threads=1` | +| all-features | PASS | 276 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml --workspace --all-features -- --test-threads=1` | +| all-features | PASS | 13 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop --all-features -- --test-threads=1` | +| all-features | PASS | 38 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-http --all-features -- --test-threads=1` | +| all-features | PASS | 11 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-tls --all-features -- --test-threads=1` | +| all-features | PASS | 21 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mongodb --all-features -- --test-threads=1` | +| all-features | PASS | 13 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-mysql --all-features -- --test-threads=1` | +| all-features | PASS | 13 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-postgres --all-features -- --test-threads=1` | +| all-features | PASS | 11 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-redis --all-features -- --test-threads=1` | +| all-features | PASS | 13 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-smtp --all-features -- --test-threads=1` | +| all-features | PASS | 8 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-websocket --all-features -- --test-threads=1` | +| all-features | PASS | 3 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-io --all-features -- --test-threads=1` | +| all-features | PASS | 56 | `cargo +nightly-2026-08-20 test --locked --manifest-path ./Cargo.toml -p turnloop-contract --all-features -- --test-threads=1` | + +## Windows runtime handoff + +All commands below are **UNRUN locally (no Windows host)**: + +| Result | Command | +| --- | --- | +| UNRUN | `cargo test --locked -p turnloop --lib backend::iocp::process -- --nocapture` | +| UNRUN | `cargo test --locked -p turnloop-contract --test windows_lifetimes -- --nocapture` | +| UNRUN | `cargo test --locked -p turnloop-contract --test allocations -- --test-threads=1` | +| UNRUN | `python3 scripts/ci/run-tests.py native` (on Windows; all three modes) | + +The lifetime suite must also run with normal libtest parallelism to check poison +recovery/isolation. Do not interpret native macOS zero-count Windows test binaries +as Windows passes. Linux runtime, WASI/web runtime and ignored service bodies are +UNRUN in this lane; SQL remains UNRUN (sandbox). diff --git a/docs/lanes/iocp-fix1.md b/docs/lanes/iocp-fix1.md index 15f5f46..988a5bc 100644 --- a/docs/lanes/iocp-fix1.md +++ b/docs/lanes/iocp-fix1.md @@ -174,3 +174,133 @@ intermediate failure and the final passes. Formatting was also applied with hosts/Windows SDK for ring). SQL and browser runtime are UNRUN in this lane; known sandbox failures are not counted as test passes. No unrelated service or browser code was changed. + +## iocp-fix2 + +Base: integrator commit `87a44cd`, PR #5. Host: macOS arm64, 2026-09-15. +Windows runtime for this follow-up is **UNRUN locally (no Windows host)**. + +### Root cause + +The integrator's windows-2025 run **34910929791** failed +`kill_then_close_children_completes_once` with OS error 5 in all three modes. +The prior check recognized only a fully signaled process. A successful termination +request can precede handle signaling, so immediate close issued another request +and leaked ERROR_ACCESS_DENIED. The prior unit test covered callback lag after +complete exit, leaving this earlier window untested. Its panic poisoned the +shared test mutex, causing the two reported knock-on failures. + +Microsoft documents asynchronous [TerminateProcess](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-terminateprocess) +and the same termination mechanism for [TerminateJobObject](https://learn.microsoft.com/en-us/windows/win32/api/jobapi2/nf-jobapi2-terminatejobobject). +[libuv's Windows kill path](https://github.com/libuv/libuv/blob/v1.x/src/win/process.c#L1299-L1324) +checks the exit code on access denied, retaining a zero-timeout handle check for +an actual exit code equal to STILL_ACTIVE (259). This supports the supplied +diagnosis; this lane cannot reproduce the kernel timing on macOS. + +### Fix and call-site audit + +- `Child::terminating` records an accepted process/job kill, or access denied + with evidence that exit has begun. A second kill returns NotFound. Close skips + repeated termination, while exit readiness still requires the real callback or + a signaled owned handle. No early status, cancellation or Closed is synthesized. +- Access denied checks `GetExitCodeProcess != STILL_ACTIVE` and retains the + signaled-handle fallback. Other termination errors preserve both kind and OS + code; a failed exit-code query does not replace the termination error. +- `Child::Drop` routes termination through the same logic, avoids repeats, waits + for actual exit and joins the callback. `job_assigned` distinguishes a usable + tree from failed suspended-spawn setup: an unassigned job cannot substitute for + terminating the child directly. A failed job kill still permits process cleanup. +- `Platform::kill` and `Services::kill` already delegate to Child. Services close + sets its closing flag only after `Child::close` succeeds, and collection retains + the exit watch until real exit. These paths require no additional mutation. +- The standalone spike's Drop deliberately ignores both termination return values + and always waits on the parent; repeated-call access denied cannot escape or + skip its wait. Its group-kill probe issues one request after asserting both live + subjects, then waits for parent and grandchild. These three spike call sites + remain unchanged. The unit-test forwarding calls count the real APIs and use + the same Child result handling as production. +- All eight lifetime-test mutex acquisitions recover with + `unwrap_or_else(std::sync::PoisonError::into_inner)`. Original test failures + still fail. A source comparison confirms the **200 process + 200 job** loop, + its completion helper and thresholds are unchanged (only the guard changed). + +### Added coverage + +Three Windows unit tests and one Windows allocation test were added: + +- `terminating_process_without_callback_does_not_repeat_kill_or_complete_early`: + four process/job × successful-request/access-denied cases. Per-child test-only + overrides model termination results and an exit code while a real suspended + child, with its wait unregistered, stays unsignaled. Counters require exactly + one termination call and the appropriate exit query; two repeat kills and two + closes must leave readiness/status pending. Cleanup restores real APIs even + during assertion unwinding. +- `termination_errors_keep_their_identity_and_allow_retry`: four process/job × + active-status/failed-query cases preserve exact errors, retry actual wrapper + calls, and prove other errors bypass the status query. +- `drop_joins_termination_without_repeating_it_and_handles_unassigned_jobs`: + five real suspended-child cases cover process/job Drop, prior successful + process/job kill and an unassigned job. Exact API counts and a signaled duplicate + after Drop prove termination and joining ran. +- `windows_kill_repeat_and_immediate_close_allocate_nothing_after_setup`: eighteen + children cover plain process kill, process-only kill within a job and group + kill. After setup, all kills/repeat NotFound/close operations and 18 Cancelled + + 18 Closed completions require **zero allocations**. Capacity-one output, exact + identities, positive counts, no duplicates and signaled handles at Closed are + asserted. The existing allocation test is unchanged. + +Deterministic kernel timing is unavailable: suspending the child's user thread +does not suspend kernel termination. The injected test establishes the unsignaled +state without a timing race; it is explicitly not proof of kernel timing. The +unchanged 400-cycle real-Windows contract remains the runtime regression. + +### Verification commands + +All available local checks passed. [Every command and all 36 nested native +invocations](../iocp-fix2-commands.md) are recorded separately; raw logs and the +machine-readable ledger are under `.tools/iocp-fix2/`. No verification failure +occurred in this follow-up. Cross-compilation is not Windows runtime proof. + +| Result | Command | +| --- | --- | +| PASS | `cargo clippy --locked --target x86_64-pc-windows-msvc -p turnloop -p turnloop-contract -p turnloop-io --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| PASS | `cargo clippy --locked --workspace --all-targets -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| PASS | `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::undocumented_unsafe_blocks` | +| PASS | `cargo +stable check --locked --workspace --all-targets --all-features` | +| PASS | `cargo fmt --all --check` (also applied `cargo fmt --all`) | +| PASS | `python3 scripts/ci/run-tests.py native` | +| PASS | `bash scripts/ci/no-tokio.sh` | +| PASS | `python3 scripts/ci/soak.py` | +| PASS | `python3 scripts/ci/check-paths.py` | +| PASS | `python3 scripts/ci/feature_modes.py` | +| PASS | `python3 .tools/iocp-fix2/audit.py` | +| PASS | `git diff --check` | +| UNRUN | `cargo test --locked -p turnloop --lib backend::iocp::process -- --nocapture` (Windows) | +| UNRUN | `cargo test --locked -p turnloop-contract --test windows_lifetimes -- --nocapture` (Windows, default parallelism) | +| UNRUN | `cargo test --locked -p turnloop-contract --test allocations -- --test-threads=1` (Windows) | +| UNRUN | `python3 scripts/ci/run-tests.py native` (Windows) | + +Native workspace counts are **236 / 243 / 276** (default/executor/all features), +with **49 / 56 / 56** independently executed contract tests. The total **1,297 +passes** includes package repetitions. Existing allocation/no-spin gates ran in +all applicable modes. Ignored services and cfg-excluded Windows tests are UNRUN. +No-tokio passes all eight targets plus their union, default/all features. The +soak gate passes all **251 locked versions**, with only the inherited exact rustls +exception. Linux runtime and WASI/web runtime are UNRUN in this Windows-only lane; +SQL bodies remain UNRUN (sandbox). + +### Deviations, open items and next steps + +No DESIGN change is proposed. No dependencies, lockfile, soak settings, CI gates, +allocation thresholds, no-spin code or assertions changed. The inherited rustls +security exception is unchanged. Test-only overrides add no production fields, +indirection or allocations. + +Integrator: commit the coherent tree and run Windows default/executor/all-features +CI, including `windows_lifetimes` with normal libtest parallelism. The supplied +87a44cd passing tests remain historical Windows evidence, not a follow-up pass. +This lane makes no commits or pushes because `.git` is read-only. + +Known unrelated open item: `protocol-wasi (wasm32-wasip3)` HTTP +`expect_continue_timeout_and_early_response` hit a usize assertion. Outside this +lane's scope; no WASI implementation or test was changed. From 79008a59c497c090e54df6232aa22874adc0a8a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 02:21:10 +0200 Subject: [PATCH 14/15] Keep the early-417 case of the async expect-continue test off the 2 ms timer protocol-wasi (wasm32-wasip3) failed twice at case 2's peer-EOF assertion: on a slow runtime the client's 2 ms continue timeout expired before the server's early 417 arrived, so the client correctly started the upload. The code built for WASI was unchanged between the green and red runs. Cases 0 and 1 still use the 2 ms timeout they depend on; case 2, which proves that an early final response suppresses the upload, uses a client with a 30 s continue timeout. --- protocols/turnloop-http/tests/asynchronous.rs | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/protocols/turnloop-http/tests/asynchronous.rs b/protocols/turnloop-http/tests/asynchronous.rs index 580af5a..1b972a3 100644 --- a/protocols/turnloop-http/tests/asynchronous.rs +++ b/protocols/turnloop-http/tests/asynchronous.rs @@ -690,19 +690,27 @@ fn expect_continue_timeout_and_early_response() { .spawn_local(async move { let tls = turnloop_tls::ClientConfig::new(Default::default(), 1_789_344_000) .expect("TLS config"); - let mut client = Client::new( - h, - tls, - 1_789_344_000, - Options { - continue_timeout: Duration::from_millis(2), - ..Default::default() - }, - ); + let client = |continue_timeout| { + Client::new( + h.clone(), + tls.clone(), + 1_789_344_000, + Options { + continue_timeout, + ..Default::default() + }, + ) + }; let mut request = Request::new(&format!("http://{address}/"), "POST").expect("request"); request.headers.push(Header::new("expect", "100-continue")); request.body = vec![b'x'; 16384]; + // Cases 0 and 1 need the 2 ms continue timeout to expire (case 1 never + // sends 100). Case 2 proves an early final response suppresses the upload, + // so its timeout must not be able to win the race on a slow runtime. + let mut short = client(Duration::from_millis(2)); + let mut long = client(Duration::from_secs(30)); for case in 0..3 { + let client = if case == 2 { &mut long } else { &mut short }; let head = client .request(&mut request, |_| panic!("empty response")) .await From 8566cd2214774c6f6f084f0fecc32d35249a561e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 02:27:37 +0200 Subject: [PATCH 15/15] Flush conhost's own input records before the console assertions console_input_resize_and_modes_restore failed once in windows-2025 all-features (run 34912831069) at the empty-output check after starting the reader: a new console can already hold conhost buffer-size/focus records, so the resize watch or reader completed before the test wrote anything. The input buffer is now flushed before the watch and read start, so the test observes only the records it writes, and the empty check prints any unexpected completion. --- crates/turnloop-contract/tests/windows_console.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/turnloop-contract/tests/windows_console.rs b/crates/turnloop-contract/tests/windows_console.rs index 1764edd..66b6280 100644 --- a/crates/turnloop-contract/tests/windows_console.rs +++ b/crates/turnloop-contract/tests/windows_console.rs @@ -126,13 +126,24 @@ fn console_input_resize_and_modes_restore() { assert_ne!(mode(&output) & ENABLE_VIRTUAL_TERMINAL_PROCESSING, 0); let size = driver.tty_window_size(h).expect("input window query"); assert!(size.columns > 0 && size.rows > 0); + // conhost may queue its own buffer-size/focus records for a new console; + // the assertions below must observe only the records this test writes. + assert_ne!( + // SAFETY: the test's isolated, owned console input handle. + unsafe { FlushConsoleInputBuffer(input.as_raw_handle()) }, + 0 + ); let resize = driver.tty_resize_start(h, Token(3)).expect("resize"); driver .read(h, ReadBuf::Pooled, Token(4)) .expect("console read"); let mut out = Completions::default(); driver.turn(Timeout::Now, &mut out).expect("start reader"); - assert!(out.is_empty()); + assert!( + out.is_empty(), + "completion before any input was written: {:?}", + out.drain().map(|c| c.result).collect::>() + ); // SAFETY: initialized native input records; fill the selected union arms. let mut records: [INPUT_RECORD; 2] = unsafe { std::mem::zeroed() }; records[0].EventType = WINDOW_BUFFER_SIZE_EVENT as u16;