From 5727b5c8a6d69fb486d8700d9ec3f019c4d2a63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 22 Oct 2026 22:30:55 +0200 Subject: [PATCH 1/5] Preserve shared Unix stdio settings until all managed handles close --- DESIGN.md | 1 + .../tests/stdio_ownership.rs | 334 ++++++++++++++++++ crates/turnloop/src/backend/ipc.rs | 8 +- crates/turnloop/src/backend/mod.rs | 2 + crates/turnloop/src/backend/stdio.rs | 91 +++++ crates/turnloop/src/backend/unix.rs | 23 +- crates/turnloop/src/driver.rs | 6 + 7 files changed, 459 insertions(+), 6 deletions(-) create mode 100644 crates/turnloop-contract/tests/stdio_ownership.rs create mode 100644 crates/turnloop/src/backend/stdio.rs diff --git a/DESIGN.md b/DESIGN.md index 19c7134..d9d779d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -265,6 +265,7 @@ There are two ways a host drives a loop: - **Between loops:** `Loop::detach(h) -> Detached` (`Send`) and `Loop::attach(Detached, token) -> Handle`. For sockets, pipes and servers across threads or workers, it cancels in-flight ops with the usual exactly-once completions before detaching. - **Between processes:** fd passing via `SCM_RIGHTS` on Unix and `WSADuplicateSocketW` / `DuplicateHandle` on Windows, exposed on pipe handles so `child.send(msg, handle)` and cluster round-robin can move sockets (today `emitter.rs:430` drops the handle). - **Out of turnloop entirely (host handoff):** the same `detach` followed by `Detached::into_fd()` on Unix, or `into_socket()` / `into_handle()` on Windows. This is Node's mid-stream `socket.upgradeToTLS` — PostgreSQL's `SSLRequest` hands a live, already-connected socket to a TLS layer — so the class of socket that *might* later be upgraded no longer has to choose its transport at creation. `detach` already proves quiescence, so the guarantee the host gets is total: no operation, no buffer, no registration, no completion, ever again, for that transport. Conversion restores what the backend changed on adoption (Unix status flags and termios, Windows console mode) and hands the descriptor over in the mode the loop held it: non-blocking for a loop-created socket. + - **Unix standard I/O:** `open_stdio` duplicates share status flags and terminal settings with the process and possibly with each other (for example `2>&1`). All managed standard-I/O transports across all loops share one restoration lifetime. Closing or handing off one duplicate leaves settings intact while others remain; releasing the last quiescent transport restores the settings captured before the first adoption. A detached transport retains its share until it is dropped or converted with `into_fd`. Do not replace or reconfigure process standard descriptors or aliases while this lifetime is active. A handed-off duplicate shares the remaining lifetime's settings and may not reconfigure them yet. - **Windows:** a handle's IOCP association is permanent and inescapable. Windows cannot dissociate one, rejects a second `CreateIoCompletionPort` with `ERROR_INVALID_PARAMETER`, and neither `WSADuplicateSocketW` nor `DuplicateHandle` escapes it — both produce another descriptor for the *same* socket or file object, which is where the association lives. Quiescence makes it inert (no packet can ever arrive for it), and the receiving host drives the transport with synchronous/non-blocking Winsock calls or with overlapped calls whose `OVERLAPPED.hEvent` has its low-order bit set, which suppresses the completion packet. A named-pipe instance keeps `FILE_FLAG_OVERLAPPED`, so for a pipe the tagged-`hEvent` form is the only one. The route back to completion-port-driven I/O is `attach`: an imported association is exactly what the IOCP backend's overlapped-event routing exists for. - **WASI 0.2/0.3 and web:** `Unsupported`. A WASI socket is a component-model resource handle in the component's own table, not a descriptor, and there is no interface that hands one to the embedder; a browser resource is a host JS object. Neither has an identity a host could act on. - **Reporting only:** `Loop::raw_transport(h) -> RawTransport` reports a live transport's native identity for Node's `socket._handle.fd`. The loop keeps ownership; the value is valid until the handle is closed or detached, and is for reporting and read-only queries, never for I/O, closing, mode changes or registration elsewhere. diff --git a/crates/turnloop-contract/tests/stdio_ownership.rs b/crates/turnloop-contract/tests/stdio_ownership.rs new file mode 100644 index 0000000..eb17242 --- /dev/null +++ b/crates/turnloop-contract/tests/stdio_ownership.rs @@ -0,0 +1,334 @@ +//! Process-isolated contracts for shared Unix stdio descriptions. +#![deny(unsafe_op_in_unsafe_fn)] +#![cfg(all( + not(loom), + any( + target_vendor = "apple", + target_os = "linux", + target_os = "android", + target_os = "freebsd" + ) +))] +use std::{process::Command, sync::Barrier, time::Duration}; +use turnloop::*; + +fn isolated(name: &str, body: impl FnOnce()) { + if std::env::var("TURNLOOP_STDIO_CASE").as_deref() == Ok(name) { + body(); + return; + } + let mut child = Command::new(std::env::current_exe().expect("test executable")) + .args(["--exact", name, "--nocapture"]) + .env("TURNLOOP_STDIO_CASE", name) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("isolated fixture"); + // Keep stdin open: a cancelled pending read must not depend on EOF. + let deadline = std::time::Instant::now() + Duration::from_secs(20); + while child.try_wait().expect("child status").is_none() { + if std::time::Instant::now() >= deadline { + child.kill().expect("kill timed out fixture"); + let output = child.wait_with_output().expect("reap fixture"); + panic!("{name} timed out: {output:?}"); + } + std::thread::sleep(Duration::from_millis(5)); + } + let output = child.wait_with_output().expect("fixture output"); + assert!(output.status.success(), "{name}: {output:?}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); +} + +fn flags(fd: i32) -> i32 { + // SAFETY: integer-only query on the fixture's standard descriptor. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + assert!(flags >= 0, "F_GETFL: {}", std::io::Error::last_os_error()); + flags +} +fn nonblocking(fd: i32) { + assert_ne!( + flags(fd) & libc::O_NONBLOCK, + 0, + "live stream became blocking" + ); +} +fn close(l: &mut Loop, h: Handle, pending_read: bool) { + l.close(h, Token(99)).expect("close"); + let mut out = Completions::default(); + let until = l.now() + Duration::from_secs(5); + let mut cancelled = 0; + let mut closed = false; + while !closed { + assert!(l.now() < until, "close timed out"); + l.turn(Timeout::Until(until), &mut out).expect("close turn"); + for completion in out.drain() { + match completion.result { + OpResult::Cancelled => cancelled += 1, + OpResult::Closed => { + assert_eq!(cancelled, usize::from(pending_read)); + assert!(!closed, "duplicate close"); + closed = true; + } + other => panic!("unexpected completion {other:?}"), + } + } + } +} + +#[test] +fn overlapping_stdio_close_orders_restore_original_flags() { + isolated( + "overlapping_stdio_close_orders_restore_original_flags", + || { + let before = flags(1); + for reverse in [false, true] { + let mut l = Loop::new(Config::default()).expect("loop"); + let a = l.open_stdio(Stdio::Stdout).expect("first stdout"); + let b = l.open_stdio(Stdio::Stdout).expect("second stdout"); + nonblocking(1); + let (first, last) = if reverse { (b, a) } else { (a, b) }; + close(&mut l, first, false); + nonblocking(1); + close(&mut l, last, false); + assert_eq!(flags(1), before); + } + }, + ); +} + +#[test] +fn aliased_stdout_stderr_survive_other_loop_drop() { + isolated("aliased_stdout_stderr_survive_other_loop_drop", || { + // SAFETY: isolated child, before any stdio adoption or worker exists. + assert_eq!(unsafe { libc::dup2(1, 2) }, 2); + let before = flags(1); + let mut first = Loop::new(Config::default()).expect("first loop"); + let mut last = Loop::new(Config::default()).expect("last loop"); + first.open_stdio(Stdio::Stdout).expect("stdout"); + let error = last.open_stdio(Stdio::Stderr).expect("stderr alias"); + drop(first); + nonblocking(1); + nonblocking(2); + close(&mut last, error, false); + assert_eq!(flags(1), before); + assert_eq!(flags(2), before); + }); +} + +#[test] +fn stdio_handoff_releases_lease_without_closing_host_descriptor() { + isolated( + "stdio_handoff_releases_lease_without_closing_host_descriptor", + || { + let before = flags(1); + let mut l = Loop::new(Config::default()).expect("loop"); + let a = l.open_stdio(Stdio::Stdout).expect("first stdout"); + let b = l.open_stdio(Stdio::Stdout).expect("second stdout"); + let fd = l.detach(a).expect("detach first").into_fd(); + nonblocking(1); + drop(fd); + let fd = l.detach(b).expect("detach last").into_fd(); + assert_eq!(flags(1), before); + drop(fd); + assert_eq!(flags(1), before); + }, + ); +} + +#[test] +fn pending_stdio_read_cancels_before_restoring_shared_flags() { + isolated( + "pending_stdio_read_cancels_before_restoring_shared_flags", + || { + let before = flags(0); + let mut l = Loop::new(Config::default()).expect("loop"); + let a = l.open_stdio(Stdio::Stdin).expect("first stdin"); + let b = l.open_stdio(Stdio::Stdin).expect("second stdin"); + l.read(a, ReadBuf::Pooled, Token(1)) + .expect("pending first read"); + let mut out = Completions::default(); + l.turn(Timeout::Now, &mut out).expect("submit read"); + assert_eq!(out.len(), 0, "stdin must still be open and empty"); + close(&mut l, a, true); + nonblocking(0); + l.read(b, ReadBuf::Pooled, Token(2)) + .expect("pending last read"); + l.turn(Timeout::Now, &mut out) + .expect("submit surviving read"); + assert_eq!(out.len(), 0); + close(&mut l, b, true); + assert_eq!(flags(0), before); + }, + ); +} + +#[test] +fn initially_nonblocking_stdio_and_closed_unused_descriptor() { + isolated( + "initially_nonblocking_stdio_and_closed_unused_descriptor", + || { + let before = flags(1) | libc::O_NONBLOCK; + // SAFETY: fixture owns its process stdio, before any adoption exists. + assert_eq!(unsafe { libc::fcntl(1, libc::F_SETFL, before) }, 0); + let mut l = Loop::new(Config::default()).expect("loop"); + // SAFETY: isolated fixture will not read its original stdin again. + assert_eq!(unsafe { libc::close(0) }, 0); + let h = l.open_stdio(Stdio::Stdout).expect("stdout"); + // SAFETY: integer-only query; none of the duplicates may fill slot zero. + assert_eq!(unsafe { libc::fcntl(0, libc::F_GETFL) }, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EBADF) + ); + assert!(l.open_stdio(Stdio::Stdin).is_err()); + close(&mut l, h, false); + assert_eq!(flags(1), before); + }, + ); +} + +#[test] +fn stdio_leases_overlap_across_threads_and_repeated_cohorts() { + isolated( + "stdio_leases_overlap_across_threads_and_repeated_cohorts", + || { + let before = flags(1); + let barrier = Barrier::new(4); + std::thread::scope(|scope| { + for _ in 0..4 { + let barrier = &barrier; + scope.spawn(move || { + let mut l = Loop::new(Config::default()).expect("thread loop"); + for _ in 0..64 { + let h = l.open_stdio(Stdio::Stdout).expect("thread stdout"); + barrier.wait(); + nonblocking(1); + barrier.wait(); + close(&mut l, h, false); + barrier.wait(); + assert_eq!(flags(1), before); + barrier.wait(); + } + }); + } + }); + assert_eq!(flags(1), before); + }, + ); +} + +#[test] +fn overlapping_terminal_modes_restore_the_first_snapshot() { + isolated( + "overlapping_terminal_modes_restore_the_first_snapshot", + || { + use std::os::fd::{FromRawFd, OwnedFd}; + let (mut master, mut slave) = (-1, -1); + assert_eq!( + // SAFETY: writable descriptor outputs; null pointers request defaults. + unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0 + ); + // SAFETY: openpty returned two fresh owned descriptors. + let (_master, _slave) = + unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) }; + // SAFETY: isolated child changes its stdin before managed adoption. + assert_eq!(unsafe { libc::dup2(slave, 0) }, 0); + let get = || { + let mut available = 0; + // SAFETY: live PTY and writable integer. Settle XNU's PENDIN state. + assert_eq!(unsafe { libc::ioctl(0, libc::FIONREAD, &mut available) }, 0); + // SAFETY: termios is plain C output storage. + let mut mode: libc::termios = unsafe { std::mem::zeroed() }; + // SAFETY: live PTY and writable initialized storage. + assert_eq!(unsafe { libc::tcgetattr(0, &mut mode) }, 0); + ( + mode.c_iflag, + mode.c_oflag, + mode.c_cflag, + mode.c_lflag, + mode.c_cc, + ) + }; + let original = get(); + let mut l = Loop::new(Config::default()).expect("loop"); + let a = l.open_stdio(Stdio::Stdin).expect("first terminal"); + l.tty_set_mode(a, TtyMode::Raw).expect("raw mode"); + let raw = get(); + assert_ne!(raw, original); + let b = l.open_stdio(Stdio::Stdin).expect("second terminal"); + close(&mut l, a, false); + assert_eq!(get(), raw, "first close reset a live terminal"); + l.tty_window_size(b).expect("terminal remains recognized"); + l.tty_set_mode(b, TtyMode::Normal) + .expect("original normal mode"); + assert_eq!( + get(), + original, + "second adoption captured an already raw mode" + ); + l.tty_set_mode(b, TtyMode::Raw).expect("raw again"); + close(&mut l, b, false); + assert_eq!(get(), original); + }, + ); +} + +#[test] +fn failed_stdio_snapshot_releases_partial_duplicates() { + isolated("failed_stdio_snapshot_releases_partial_duplicates", || { + let before = flags(1); + let mut l = Loop::new(Config::default()).expect("loop"); + // SAFETY: rlimit is plain C output storage. + let mut old: libc::rlimit = unsafe { std::mem::zeroed() }; + // SAFETY: writable output for this process's descriptor limit. + assert_eq!(unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut old) }, 0); + let small = libc::rlimit { + rlim_cur: 64.min(old.rlim_cur), + rlim_max: old.rlim_max, + }; + // SAFETY: isolated subprocess lowers only its own soft limit. + assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &small) }, 0); + let mut fillers = Vec::new(); + loop { + match std::fs::File::open("/dev/null") { + Ok(fd) => fillers.push(fd), + Err(e) => { + assert_eq!(e.raw_os_error(), Some(libc::EMFILE)); + break; + } + } + } + assert!(fillers.len() > 2); + drop(fillers.pop()); + drop(fillers.pop()); + // Room for the requested duplicate and one saved descriptor, but not + // the whole snapshot. Failure must release both, without changing flags. + assert_eq!( + l.open_stdio(Stdio::Stdout) + .expect_err("partial snapshot") + .os, + Some(libc::EMFILE) + ); + assert_eq!(flags(1), before); + let one = std::fs::File::open("/dev/null").expect("first slot returned"); + let two = std::fs::File::open("/dev/null").expect("second slot returned"); + drop((one, two, fillers)); + // SAFETY: restore the original soft limit without exceeding the hard limit. + assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &old) }, 0); + let h = l + .open_stdio(Stdio::Stdout) + .expect("fresh snapshot after failure"); + close(&mut l, h, false); + assert_eq!(flags(1), before); + }); +} diff --git a/crates/turnloop/src/backend/ipc.rs b/crates/turnloop/src/backend/ipc.rs index a9a6b2b..54b1f54 100644 --- a/crates/turnloop/src/backend/ipc.rs +++ b/crates/turnloop/src/backend/ipc.rs @@ -90,12 +90,16 @@ pub(super) fn accept(fd: RawFd) -> Result { pub(super) fn stdio(raw: RawFd) -> Result { // SAFETY: integer fcntl command duplicates the borrowed host fd with CLOEXEC. - let dup = unsafe { libc::fcntl(raw, libc::F_DUPFD_CLOEXEC, 0) }; + let dup = unsafe { libc::fcntl(raw, libc::F_DUPFD_CLOEXEC, 3) }; if dup < 0 { return Err(last_error()); } // SAFETY: successful fcntl returns a fresh owned descriptor. - classify(unsafe { OwnedFd::from_raw_fd(dup) }) + let fd = unsafe { OwnedFd::from_raw_fd(dup) }; + let (lease, mode) = super::stdio::Lease::acquire(raw)?; + let mut transport = classify(fd)?; + transport.share_stdio(lease, mode); + Ok(transport) } pub(super) fn classify(fd: OwnedFd) -> Result { diff --git a/crates/turnloop/src/backend/mod.rs b/crates/turnloop/src/backend/mod.rs index 285d30a..d13ea73 100644 --- a/crates/turnloop/src/backend/mod.rs +++ b/crates/turnloop/src/backend/mod.rs @@ -535,6 +535,8 @@ pub use wasi_p3::WasiP3 as Platform; mod ipc; #[cfg(any(turnloop_backend = "kqueue", turnloop_backend = "epoll"))] mod process; +#[cfg(any(turnloop_backend = "kqueue", turnloop_backend = "epoll"))] +mod stdio; #[cfg(any(turnloop_backend = "kqueue", turnloop_backend = "epoll"))] mod services; diff --git a/crates/turnloop/src/backend/stdio.rs b/crates/turnloop/src/backend/stdio.rs new file mode 100644 index 0000000..b0e52b1 --- /dev/null +++ b/crates/turnloop/src/backend/stdio.rs @@ -0,0 +1,91 @@ +//! Process stdio duplicates share open-file descriptions, even across loops. +//! Snapshot all three before changing any: stdout and stderr may be aliases. +use super::poller::last_error; +use crate::{Error, ErrorKind, Result}; +use std::{ + os::fd::{AsRawFd, FromRawFd, OwnedFd}, + sync::Mutex, +}; + +static STATE: Mutex> = Mutex::new(None); + +struct Saved { + fd: OwnedFd, + flags: i32, + mode: Option, +} +struct State { + users: usize, + saved: [Option; 3], +} + +/// Held by the native resource until all its operations have quiesced. +pub(super) struct Lease; +impl Lease { + pub(super) fn acquire(raw: i32) -> Result<(Self, Option)> { + let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(state) = state.as_mut() { + state.users = state + .users + .checked_add(1) + .ok_or_else(|| Error::new(ErrorKind::ResourceLimit))?; + } else { + let mut saved = [None, None, None]; + for (raw, slot) in saved.iter_mut().enumerate() { + // SAFETY: duplicates a borrowed descriptor. Start above stdio so + // a closed standard descriptor is never accidentally populated. + let dup = unsafe { libc::fcntl(raw as i32, libc::F_DUPFD_CLOEXEC, 3) }; + if dup < 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EBADF) { + continue; + } + return Err(error.into()); + } + // SAFETY: successful fcntl returned a fresh owned descriptor. + let fd = unsafe { OwnedFd::from_raw_fd(dup) }; + // SAFETY: integer-only command on a live descriptor. + let flags = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFL) }; + if flags < 0 { + return Err(last_error()); + } + // SAFETY: termios is plain C output storage. + let mut mode = unsafe { std::mem::zeroed() }; + // SAFETY: live descriptor and writable termios storage. + let mode = + (unsafe { libc::tcgetattr(fd.as_raw_fd(), &mut mode) } == 0).then_some(mode); + *slot = Some(Saved { fd, flags, mode }); + } + *state = Some(State { users: 1, saved }); + } + let mode = state + .as_ref() + .and_then(|s| s.saved[raw as usize].as_ref()) + .and_then(|s| s.mode); + Ok((Self, mode)) + } +} +impl Drop for Lease { + fn drop(&mut self) { + let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner()); + let Some(active) = state.as_mut() else { + return; + }; + active.users -= 1; + if active.users != 0 { + return; + } + // Serialize final restoration with the next acquisition. An Arc/Weak + // registry alone would let a new cohort snapshot flags while the last + // old owner is still restoring them in its destructor. + for saved in active.saved.iter().flatten() { + if let Some(mode) = &saved.mode { + // SAFETY: retained descriptor and previously captured termios. + unsafe { libc::tcsetattr(saved.fd.as_raw_fd(), libc::TCSANOW, mode) }; + } + // SAFETY: retained descriptor and flags from F_GETFL. + unsafe { libc::fcntl(saved.fd.as_raw_fd(), libc::F_SETFL, saved.flags) }; + } + *state = None; + } +} diff --git a/crates/turnloop/src/backend/unix.rs b/crates/turnloop/src/backend/unix.rs index fe78884..5a78438 100644 --- a/crates/turnloop/src/backend/unix.rs +++ b/crates/turnloop/src/backend/unix.rs @@ -37,6 +37,7 @@ pub struct Detached { pub(super) kind: Kind, pub(super) original_flags: Option, original_mode: Option, + stdio_lease: Option, /// A listener's per-connection defaults, applied to each socket it accepts. /// It travels with the listener across detach/attach, so a listener handed to /// another loop keeps configuring its connections there. @@ -63,21 +64,29 @@ impl Detached { /// settings captured when the descriptor was adopted are restored first, /// exactly as they would be on close, and then the close is *not* performed. /// + /// For standard I/O, restoration waits until the last managed standard-I/O + /// transport is released, including duplicates held by other loops. A handed + /// off duplicate shares those flags and must not reconfigure them meanwhile. + /// /// A descriptor turnloop created itself was created non-blocking with /// `FD_CLOEXEC`, and it is handed over that way: nothing is restored, /// because nothing was changed. Call `fcntl(F_SETFL)` (or /// `TcpStream::set_nonblocking(false)`) if the receiving code wants blocking /// I/O, which is what a synchronous TLS handshake on the descriptor needs. - pub fn into_fd(self) -> OwnedFd { + pub fn into_fd(mut self) -> OwnedFd { self.restore(); + drop(self.stdio_lease.take()); let this = std::mem::ManuallyDrop::new(self); // SAFETY: `this` is never dropped, so this move of the single owning - // field cannot be observed twice; the remaining fields are Copy/plain - // data whose Drop is a no-op. `restore` already ran, and it is the only - // thing this type's Drop does besides releasing `fd`. + // field cannot be observed twice; the lease was released above and + // all other remaining fields are Copy/plain data whose Drop is a no-op. + // Restoration and lease release have already run. unsafe { std::ptr::read(&this.fd) } } fn restore(&self) { + if self.stdio_lease.is_some() { + return; + } if let Some(mode) = &self.original_mode { // SAFETY: descriptor is still owned; restore before OwnedFd drops. unsafe { @@ -102,9 +111,15 @@ impl Detached { kind, original_flags: None, original_mode, + stdio_lease: None, accept_defaults: AcceptDefaults::EMPTY, } } + pub(super) fn share_stdio(&mut self, lease: super::stdio::Lease, mode: Option) { + self.original_flags = None; + self.original_mode = mode; + self.stdio_lease = Some(lease); + } /// Adopt an owned Unix descriptor, classifying stream/file/TTY or socket. /// The descriptor must have no concurrent I/O users. Status flags and terminal /// settings are restored when this transport is closed or dropped. diff --git a/crates/turnloop/src/driver.rs b/crates/turnloop/src/driver.rs index 276996c..ec1adc2 100644 --- a/crates/turnloop/src/driver.rs +++ b/crates/turnloop/src/driver.rs @@ -664,6 +664,12 @@ impl Driver { Ok(()) } /// Duplicate standard input, output or error, classifying pipe/file/terminal. + /// + /// Unix duplicates share nonblocking flags and terminal settings. Overlapping + /// managed standard-I/O transports, even on different loops, keep those + /// settings until the last transport is released; then the original settings + /// are restored. Do not replace or reconfigure process standard descriptors + /// (or their aliases) while any managed standard-I/O transport is live. pub fn open_stdio(&mut self, which: Stdio) -> Result { self.open(Open::Stdio(which)) } From e6b6c69aaff4e49717c9364eb9c0637b792b8679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 22 Oct 2026 22:36:17 +0200 Subject: [PATCH 2/5] fix: remove expired rustls dependency soak exception --- scripts/ci/policy.toml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/scripts/ci/policy.toml b/scripts/ci/policy.toml index c7ce572..25341a5 100644 --- a/scripts/ci/policy.toml +++ b/scripts/ci/policy.toml @@ -13,13 +13,3 @@ banned = [ "async-executor", "async-global-executor", "futures-executor", "futures-runtime", "http-body", "http-body-util", ] - -# Owner-approved (2026-10-16) one-time soak exception: rustls 0.23.46 fixes -# RUSTSEC-2026-0346/0347/0348/0349 against 0.23.45. Expires when the release -# completes its 7-day soak (published 2026-10-14). -[[security-exceptions]] -crate = "rustls" -version = "0.23.46" -advisory = "RUSTSEC-2026-0346" -reason = "rustls 0.23.46 fixes RUSTSEC-2026-0346, -0347, -0348 and -0349 in 0.23.45 (TLS secrets in Debug, ECH rejection data leak, brotli cert decompression DoS, ServerHello KX downgrade)" -expires = 2026-10-21 From 42281ec29d7a616382c350b5575bb7f8dda85043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 22 Oct 2026 22:45:50 +0200 Subject: [PATCH 3/5] fix: update certificate distrust dependencies Update rustls-webpki 0.103.16 (RUSTSEC-2026-0382) and webpki-roots 1.0.10 (RUSTSEC-2026-0381). Use the documented exact-version security exception policy through their registry publish times plus seven days; no advisory ignores or runtime-ban changes. --- Cargo.lock | 8 ++++---- scripts/ci/policy.toml | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 897dbfc..71b66a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1384,9 +1384,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -2193,9 +2193,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/scripts/ci/policy.toml b/scripts/ci/policy.toml index 25341a5..5aba93d 100644 --- a/scripts/ci/policy.toml +++ b/scripts/ci/policy.toml @@ -13,3 +13,20 @@ banned = [ "async-executor", "async-global-executor", "futures-executor", "futures-runtime", "http-body", "http-body-util", ] + +# Reviewed security fixes: reject distrusted emSign intermediates and remove +# their trust anchor. Registry checksums and all other dependency ages still +# apply. Expiries equal each exact release's publication date plus seven days. +[[security-exceptions]] +crate = "rustls-webpki" +version = "0.103.16" +advisory = "RUSTSEC-2026-0382" +reason = "Reject distrusted intermediate public keys; upstream rustls/webpki#546. Published 2026-10-20T08:58:28Z; normal soak resumes 2026-10-27T08:58:28Z." +expires = 2026-10-27 + +[[security-exceptions]] +crate = "webpki-roots" +version = "1.0.10" +advisory = "RUSTSEC-2026-0381" +reason = "Remove the distrusted emSign ECC Root CA - G3; upstream rustls/webpki-roots#133. Published 2026-10-19T20:11:32Z; normal soak resumes 2026-10-26T20:11:32Z." +expires = 2026-10-26 From 06257a315c3911a4f2c9fb4a409c7372cccb6ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 22 Oct 2026 23:02:16 +0200 Subject: [PATCH 4/5] Run protocol fixtures on MongoDB-compatible hosted kernel --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24ddead..9777303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -261,7 +261,10 @@ jobs: if: '!cancelled()' run: python3 scripts/ci/run-tests.py node protocol: - runs-on: ubuntu-24.04 + # MongoDB rejects the 24.04 image's affected 7.0 Azure kernel (SERVER-121912). + # Keep real-server coverage on 22.04's 6.8 kernel until the hosted image is fixed: + # https://github.com/actions/runner-images/issues/15005 + runs-on: ubuntu-22.04 permissions: contents: read timeout-minutes: 45 @@ -311,7 +314,7 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .tools/redis-build - key: redis-tls-ubuntu-24.04-${{ runner.arch }}-${{ hashFiles('scripts/ci/install-redis.py') }} + key: redis-tls-ubuntu-22.04-${{ runner.arch }}-${{ hashFiles('scripts/ci/install-redis.py') }} - name: Install and verify Redis 8.4.0 with TLS run: python3 scripts/ci/install-redis.py - name: Pull MongoDB before the bounded fixture startup wait From ee29d83e4e9b52e4b2031790643931ceb39a5334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 23 Oct 2026 20:49:50 +0200 Subject: [PATCH 5/5] Update ctutils for RUSTSEC-2026-0385 --- Cargo.lock | 4 ++-- scripts/ci/policy.toml | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 71b66a7..2125773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -358,9 +358,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" dependencies = [ "cmov", ] diff --git a/scripts/ci/policy.toml b/scripts/ci/policy.toml index 5aba93d..39344f0 100644 --- a/scripts/ci/policy.toml +++ b/scripts/ci/policy.toml @@ -30,3 +30,11 @@ version = "1.0.10" advisory = "RUSTSEC-2026-0381" reason = "Remove the distrusted emSign ECC Root CA - G3; upstream rustls/webpki-roots#133. Published 2026-10-19T20:11:32Z; normal soak resumes 2026-10-26T20:11:32Z." expires = 2026-10-26 + +# RUSTSEC-2026-0385: reviewed RustCrypto/utils#1567 and #1568 via release #1569. +[[security-exceptions]] +crate = "ctutils" +version = "0.4.4" +advisory = "RUSTSEC-2026-0385" +reason = "Restore constant-time Choice masks and 32-bit u128 comparisons; upstream RustCrypto/utils#1567 and #1568. Published 2026-10-23T01:04:28Z; normal soak resumes 2026-10-30T01:04:28Z." +expires = 2026-10-30