From eb1546ae08eb8b6d0ed43bd59c84e17218a68e4b Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:54:46 +0900 Subject: [PATCH] posix: add os.forkpty so pty.spawn does not hang (#8782) * posix: add os.forkpty pty.fork prefers forkpty so a nested spawn is a new session with its own controlling tty. The openpty+login_tty fallback hung the jsontests collector. Assisted-by: Claude * select: rebuild fd sets after EINTR select(2) updates the sets in place. Retrying an interrupted call with empty sets and a NULL timeout blocks forever. Assisted-by: Claude * http.server: keep CGI off the body-read timeout A millisecond handler timeout races the client thread writing the request and is inherited across CGI fork, which RSTs the client under load. Assisted-by: Claude * Revert "http.server: keep CGI off the body-read timeout" Lib edits outside a CPython copy are not allowed. Assisted-by: Claude * host_env: note why forkpty is libc not nix Assisted-by: Claude * benches: run deepcopy 10x more iterations Assisted-by: Claude --- benches/benchmarks/deepcopy.py | 7 +-- crates/host_env/src/posix.rs | 28 ++++++++++++ crates/stdlib/src/select.rs | 79 ++++++++++++++++++++-------------- crates/vm/src/stdlib/posix.rs | 44 +++++++++++++++++++ 4 files changed, 122 insertions(+), 36 deletions(-) diff --git a/benches/benchmarks/deepcopy.py b/benches/benchmarks/deepcopy.py index bcae0d156..24f512e69 100644 --- a/benches/benchmarks/deepcopy.py +++ b/benches/benchmarks/deepcopy.py @@ -66,6 +66,7 @@ def benchmark(n): dc.boolean = b _ = copy.deepcopy(dc) -benchmark(1) -benchmark_reduce(5) -benchmark_memo(1) +N = 10 +benchmark(N) +benchmark_reduce(5 * N) +benchmark_memo(N) diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index 928f8df39..4eb52aa68 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -1113,6 +1113,34 @@ pub fn openpty() -> std::io::Result<(OwnedFd, OwnedFd)> { Ok((pty.master, pty.slave)) } +/// `forkpty(3)`. The child is a session leader with the slave as its +/// controlling terminal. The parent receives the master fd. The child may +/// see `master_fd == -1` (Apple's `forkpty`); that is returned as `-1`. +/// +/// rustix has no `forkpty`. `nix::pty::forkpty` wraps the master in +/// `OwnedFd`, which panics on Apple's child `-1`, and its `Child` arm +/// drops the master fd the caller returns as the second item. +#[cfg(not(any(target_os = "redox", target_os = "wasi")))] +pub fn forkpty() -> std::io::Result<(pid_t, i32)> { + let mut master_fd: libc::c_int = -1; + let pid = unsafe { + libc::forkpty( + &mut master_fd, + core::ptr::null_mut(), + core::ptr::null_mut(), + core::ptr::null_mut(), + ) + }; + if pid < 0 { + return Err(std::io::Error::last_os_error()); + } + if pid != 0 && master_fd >= 0 { + let fd = unsafe { BorrowedFd::borrow_raw(master_fd) }; + set_inheritable(fd, false)?; + } + Ok((pid, master_fd)) +} + pub fn ttyname(fd: BorrowedFd<'_>) -> std::io::Result { nix::unistd::ttyname(fd) .map(std::path::PathBuf::into_os_string) diff --git a/crates/stdlib/src/select.rs b/crates/stdlib/src/select.rs index dd4d484e0..9de8bafc2 100644 --- a/crates/stdlib/src/select.rs +++ b/crates/stdlib/src/select.rs @@ -112,44 +112,29 @@ mod decl { Ok((v, fds)) }; - let (rlist, mut r) = seq2set(&rlist)?; - let (wlist, mut w) = seq2set(&wlist)?; - let (xlist, mut x) = seq2set(&xlist)?; + let (rlist, _) = seq2set(&rlist)?; + let (wlist, _) = seq2set(&wlist)?; + let (xlist, _) = seq2set(&xlist)?; let nfds = cfg_select! { windows => 0, // value is ignored on windows - _ => [&mut r, &mut w, &mut x] - .iter_mut() - .filter_map(|set| set.highest()) + _ => rlist + .iter() + .chain(&wlist) + .chain(&xlist) + .map(|fd| fd.fno) .max() .map_or(0, |n| n + 1) as _, }; - loop { - let mut tv = timeout.map(host_select::sec_to_timeval); - let res = - vm.allow_threads(|| host_select::select(nfds, &mut r, &mut w, &mut x, tv.as_mut())); - - match res { - Ok(_) => break, - Err(err) if err.kind() == io::ErrorKind::Interrupted => {} - Err(err) => return Err(err.to_pyexception(vm)), + let fill = |list: &[Selectable]| { + let mut fds = FdSet::new(); + for fd in list { + fds.insert(fd.fno); } - - vm.check_signals()?; - - if let Some(ref mut timeout) = timeout { - *timeout = deadline.unwrap() - time::time(vm).unwrap(); - if *timeout < 0.0 { - r.clear(); - w.clear(); - x.clear(); - break; - } - // retry select() if we haven't reached the deadline yet - } - } + fds + }; let set2list = |list: Vec, mut set: FdSet| { vm.ctx.new_list( @@ -160,11 +145,39 @@ mod decl { ) }; - let rlist = set2list(rlist, r); - let wlist = set2list(wlist, w); - let xlist = set2list(xlist, x); + loop { + // `select(2)` updates the fd sets in place. Rebuild them on every + // attempt, including EINTR retries; otherwise an interrupted call + // can leave empty sets and a NULL timeout, which blocks forever. + let mut r = fill(&rlist); + let mut w = fill(&wlist); + let mut x = fill(&xlist); - Ok((rlist, wlist, xlist)) + let mut tv = timeout.map(host_select::sec_to_timeval); + let res = + vm.allow_threads(|| host_select::select(nfds, &mut r, &mut w, &mut x, tv.as_mut())); + + match res { + Ok(_) => { + return Ok((set2list(rlist, r), set2list(wlist, w), set2list(xlist, x))); + } + Err(err) if err.kind() == io::ErrorKind::Interrupted => {} + Err(err) => return Err(err.to_pyexception(vm)), + } + + vm.check_signals()?; + + if let Some(ref mut timeout) = timeout { + *timeout = deadline.unwrap() - time::time(vm).unwrap(); + if *timeout < 0.0 { + return Ok(( + vm.ctx.new_list(Vec::new()), + vm.ctx.new_list(Vec::new()), + vm.ctx.new_list(Vec::new()), + )); + } + } + } } #[cfg(unix)] diff --git a/crates/vm/src/stdlib/posix.rs b/crates/vm/src/stdlib/posix.rs index 6ad0df603..4076523c8 100644 --- a/crates/vm/src/stdlib/posix.rs +++ b/crates/vm/src/stdlib/posix.rs @@ -832,6 +832,50 @@ pub mod module { } } + #[cfg(not(any(target_os = "redox", target_os = "wasi")))] + #[pyfunction] + fn forkpty(vm: &VirtualMachine) -> PyResult<(i32, i32)> { + if vm + .state + .finalizing + .load(core::sync::atomic::Ordering::Acquire) + { + return Err(vm.new_exception_msg( + vm.ctx.exceptions.python_finalization_error.to_owned(), + "can't fork at interpreter shutdown".into(), + )); + } + if !vm.state.allow_fork() { + return Err( + vm.new_runtime_error("fork not supported for isolated subinterpreters".to_owned()) + ); + } + + vm.sys_module + .get_attr("audit", vm)? + .call(("os.forkpty",), vm)?; + + py_os_before_fork(vm); + let result = rustpython_host_env::posix::forkpty(); + + match result { + Ok((0, master)) => { + py_os_after_fork_child(vm); + Ok((0, master)) + } + Ok((pid, master)) => { + let num_os_threads = get_number_of_os_threads(); + py_os_after_fork_parent(vm); + warn_if_multi_threaded("forkpty", num_os_threads, vm); + Ok((pid, master)) + } + Err(err) => { + py_os_after_fork_parent(vm); + Err(err.into_pyexception(vm)) + } + } + } + #[cfg(not(target_os = "redox"))] const MKNOD_DIR_FD: bool = cfg!(not(target_vendor = "apple"));