diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2e8de1..04f5083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,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 @@ -148,6 +147,13 @@ 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 + # Shell condition, not a step `if:`: native mode steps stay unconditional for the feature-coverage gate. + run: | + if [ "$RUNNER_OS" = "Windows" ]; then + python3 scripts/ci/install-tools.py curl + echo "TURNLOOP_TEST_CURL=$GITHUB_WORKSPACE/.tools/bin/curl.exe" >> "$GITHUB_ENV" + fi - name: Workspace and single-threaded contracts in the selected mode run: python3 scripts/ci/run-tests.py native --mode "$NATIVE_MODE" - name: HTTP, TLS and WebSocket Node/curl interop @@ -176,6 +182,10 @@ 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 + 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/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/Cargo.lock b/Cargo.lock index 4556c87..6c3da85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1772,6 +1772,7 @@ dependencies = [ "wasip2", "wasip3", "wasm-bindgen", + "windows-sys 0.61.2", ] [[package]] @@ -1798,6 +1799,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", + "windows-sys 0.61.2", ] [[package]] diff --git a/DESIGN.md b/DESIGN.md index 028aa94..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. @@ -566,7 +566,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 ecfaddf..171dcf6 100644 --- a/crates/turnloop-contract/Cargo.toml +++ b/crates/turnloop-contract/Cargo.toml @@ -22,10 +22,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" web-tests = ["browser"] node-tests = ["node"] web-tests-features = ["web-worker", "executor"] diff --git a/crates/turnloop-contract/src/bin/native_child.rs b/crates/turnloop-contract/src/bin/native_child.rs index 1ff839f..100a6f0 100644 --- a/crates/turnloop-contract/src/bin/native_child.rs +++ b/crates/turnloop-contract/src/bin/native_child.rs @@ -73,7 +73,8 @@ fn blocked_signal() { target_vendor = "apple", target_os = "linux", target_os = "android", - target_os = "freebsd" + target_os = "freebsd", + target_os = "windows" ))] fn native_stdio() { use turnloop::*; @@ -121,7 +122,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"); @@ -130,7 +132,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::*; @@ -177,7 +180,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 ebb6ae3..288743d 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 9d6711e..9f6eb2e 100644 --- a/crates/turnloop-contract/tests/allocations.rs +++ b/crates/turnloop-contract/tests/allocations.rs @@ -9,6 +9,7 @@ target_os = "linux", target_os = "android", target_os = "freebsd", + target_os = "windows", all( target_os = "wasi", any( @@ -583,7 +584,10 @@ fn wasi_random_scalar_imports_allocate_nothing() { #[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"); @@ -650,16 +654,14 @@ 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"); } #[cfg(not(target_os = "wasi"))] #[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) @@ -669,9 +671,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; @@ -736,7 +741,7 @@ fn regular_file_jobs_reuse_pool_storage() { #[cfg(not(target_os = "wasi"))] #[test] fn file_readiness_survives_pool_backpressure_without_allocations_or_spin() { - use std::{io::Write, os::fd::OwnedFd}; + use std::io::Write; let path = std::env::temp_dir().join(format!("tl-file-backpressure-{}", std::process::id())); let mut file = std::fs::OpenOptions::new() .write(true) @@ -752,13 +757,12 @@ fn file_readiness_survives_pool_backpressure_without_allocations_or_spin() { .expect("loop"); let mut handles = [None; 3]; for h in &mut handles { - let fd: OwnedFd = std::fs::File::open(&path) - .expect("independent file offset") - .into(); - *h = Some( - l.attach(Detached::from_fd(fd).expect("file"), Token(0)) - .expect("attach"), - ); + let file = std::fs::File::open(&path).expect("independent file offset"); + #[cfg(unix)] + let detached = Detached::from_fd(file.into()).expect("file"); + #[cfg(windows)] + let detached = Detached::from_handle(file.into()).expect("file"); + *h = Some(l.attach(detached, Token(0)).expect("attach")); } let mut out = Completions::with_capacity(1); l.read(handles[0].expect("first"), ReadBuf::Pooled, Token(1)) @@ -924,7 +928,7 @@ fn executor_steady_io_poll_and_sleep_allocate_nothing() { assert_eq!(count, 1001); } -#[cfg(not(target_os = "wasi"))] +#[cfg(unix)] #[test] fn signal_exit_and_external_notification_delivery_allocate_nothing() { let mut l = Loop::new(Config::default()).expect("loop"); @@ -1181,3 +1185,230 @@ 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_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() { + 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/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..66b6280 --- /dev/null +++ b/crates/turnloop-contract/tests/windows_console.rs @@ -0,0 +1,209 @@ +#![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); + // 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(), + "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; + 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..61720be --- /dev/null +++ b/crates/turnloop-contract/tests/windows_lifetimes.rs @@ -0,0 +1,504 @@ +#![cfg(all(windows, not(loom)))] +#![deny(unsafe_op_in_unsafe_fn)] +use std::{ + 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::{ + 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() + .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] { + 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() + .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()); + 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() + .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"); + 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() + .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, + 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() { + 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() + .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 _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!( + // 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), + ) + }; + 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 { + 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); + 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() + .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); + 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"); + } +} + +#[test] +fn cancelled_child_watch_completes_while_child_is_alive() { + 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()); + 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/Cargo.toml b/crates/turnloop/Cargo.toml index 630f76e..0f431a4 100644 --- a/crates/turnloop/Cargo.toml +++ b/crates/turnloop/Cargo.toml @@ -25,6 +25,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..0e6e757 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/README.md @@ -0,0 +1,59 @@ +# 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. + 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. +- Standard streams are duplicated, preserving the host originals. Synchronous + files/pipes reserve one worker at adoption; operations on a handle are FIFO, + with no worker allocation needed when the first read receives a buffer lease. + 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..fb0e1c9 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/mod.rs @@ -0,0 +1,1547 @@ +//! 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, + timer_pending: bool, + 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; + } + } + if transport.kind == Kind::Sync { + // Adoption is resource setup. Reserve the worker here so even the + // first read after pooled-buffer backpressure needs no allocation. + self.workers[h.index()] = Some(sync_io::Worker::new(raw, Arc::clone(&self.port))?); + } + 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(); + 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<()> { + let mut result = Ok(()); + for entry in entries { + // 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; + } + if self.event.is_some() + && self + .deadline + .is_some_and(|deadline| deadline > Instant::now()) + { + self.arm_event_deadline()?; + } + 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 { + 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))?, + timer_pending: false, + 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 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()) + && !self.timer_pending + { + 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 { + cancel_result = self.cancel_timer().map(|_| ()); + } + match result? { + // 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 { + waits: 1, + zero_event_waits: 1, + }, + ), + } + }; + let entries_result = self.entries(&entries[..n]); + cancel_result?; + entries_result?; + 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 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.cancel_timer()? { + 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 +} + +#[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/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..94b95a5 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/process.rs @@ -0,0 +1,822 @@ +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 = 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); + } + 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, + 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 { + 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 code = self.exit_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() || self.terminating { + return Err(crate::Error::new(crate::ErrorKind::NotFound)); + } + if signal != Signal::Kill { + return Err(unsupported()); + } + 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) + { + // 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(()); + } + if self.ready() { + 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, + } + } +} +impl Drop for Child { + fn drop(&mut self) { + if self.status.is_none() { + 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 { + 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 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() { + 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 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, + 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, + } + }; + 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()) + })?; + child.job_assigned = true; + } + // 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)) +} + +#[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() { + 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 new file mode 100644 index 0000000..3a10606 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/services.rs @@ -0,0 +1,180 @@ +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, + closing: 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, + closing: 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, + closing: 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)) { + // 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 + } + } + 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()?; + entry.closing = true; + entry.cancelled = false; + } + 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() + && (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<()> { + 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 = 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 + } + } + } + }; + if let Some((outcome, terminal)) = result { + if terminal { + entry.op = None; + self.pending -= 1; + } + out.push(Event { + op, + terminal, + result: Ok(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..7b9e361 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/signals.rs @@ -0,0 +1,141 @@ +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: *mut Ticket, + 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 ticket = Box::into_raw(Box::new(Ticket { + signal, + pending: AtomicBool::new(false), + notifier, + })); + SLOTS[index].store(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) + } + 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 { + 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(); + } + // 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 new file mode 100644 index 0000000..4115369 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/socket.rs @@ -0,0 +1,322 @@ +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) { + // 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; + // 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, + 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)) + } +} + +#[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/sync_io.rs b/crates/turnloop/src/backend/iocp/sync_io.rs new file mode 100644 index 0000000..2b006d6 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/sync_io.rs @@ -0,0 +1,401 @@ +//! One worker per explicitly adopted synchronous 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..d8ed123 --- /dev/null +++ b/crates/turnloop/src/backend/iocp/timer.rs @@ -0,0 +1,335 @@ +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(()) + } + + /// 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) -> bool { + if generation == self.generation { + self.active = false; + true + } else { + 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(); + } +} + +#[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/crates/turnloop/src/backend/mod.rs b/crates/turnloop/src/backend/mod.rs index 95de259..09c5eab 100644 --- a/crates/turnloop/src/backend/mod.rs +++ b/crates/turnloop/src/backend/mod.rs @@ -308,6 +308,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(turnloop_backend = "wasi_p2")] pub mod wasi_p2; #[cfg(turnloop_backend = "wasi_p2")] 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 dae931f..50c2f22 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() {} //! ``` //! @@ -58,12 +58,15 @@ pub use notifier::{Notifier, PostError, Poster}; #[cfg(any( turnloop_backend = "kqueue", turnloop_backend = "epoll", + turnloop_backend = "iocp", turnloop_backend = "wasi_p2", turnloop_backend = "web", all(turnloop_backend = "wasi_p3", feature = "wasi-p3-experimental") ))] /// The platform driver selected for the compilation target; owned by one agent. 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/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/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 new file mode 100644 index 0000000..988a5bc --- /dev/null +++ b/docs/lanes/iocp-fix1.md @@ -0,0 +1,306 @@ +# 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. + +## 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. diff --git a/protocols/turnloop-http/tests/asynchronous.rs b/protocols/turnloop-http/tests/asynchronous.rs index ba9da74..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 @@ -759,7 +767,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 +783,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); diff --git a/protocols/turnloop-http/tests/interop.rs b/protocols/turnloop-http/tests/interop.rs index cfcb002..10b2211 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,7 +30,7 @@ fn curl_capability(version: &str, field: &str, capability: &str) -> bool { }) } fn curl_supports(http2: bool) -> bool { - match Command::new("curl").arg("-V").output() { + match curl().arg("-V").output() { Ok(output) => { assert!( output.status.success(), @@ -30,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}"), } } @@ -220,7 +240,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") + curl() .args([ "--silent", "--show-error", @@ -377,7 +397,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") + curl() .args([ "--http2-prior-knowledge", "--silent", 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 }) 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/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_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/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 c9eb895..cfd35d3 100644 --- a/scripts/ci/tools.json +++ b/scripts/ci/tools.json @@ -158,6 +158,16 @@ "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" + ] + } + }, "wasi-sdk": { "Darwin-aarch64": { "version": "34.0", 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 new file mode 100644 index 0000000..5931fee --- /dev/null +++ b/spikes/iocp/WINDOWS_RESULTS.md @@ -0,0 +1,170 @@ +# 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; 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. + +## 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. + +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`: + 48 tests: 30 shared/backend, six 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. +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`. 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. + +The subsequent merge of main's `b78d533` retains its repository-wide byte-preserving +attributes and core fixes. Its new file backpressure/allocation test now executes +on Windows as well: synchronous workers are reserved at handle adoption, before +the allocation measurement, and pool return starts the first read without allocating. +The test asserts an actual NT timer packet on Windows (zero empty waits), versus +an empty timed wait on Unix. All 48 all-feature contracts, including six allocation +gates, pass after this merge; existing timing and allocation bounds are unchanged. + +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. diff --git a/spikes/iocp/backend_draft/mod.rs b/spikes/iocp/backend_draft/mod.rs index 55643e4..da9b07e 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,14 @@ 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 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()?; @@ -531,6 +538,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()