From deda4a98b7e242727d4975e986c3bc95e78d902d Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Tue, 22 Sep 2026 00:42:50 +0900 Subject: [PATCH 1/6] 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 --- crates/host_env/src/posix.rs | 24 +++++++++++++++++++ crates/vm/src/stdlib/posix.rs | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index 928f8df3963..f25664b71e1 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -1113,6 +1113,30 @@ 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`. +#[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/vm/src/stdlib/posix.rs b/crates/vm/src/stdlib/posix.rs index 6ad0df60310..4076523c8fd 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")); From b04b2dad00ddb9e1cf88259476efa60a2a285a81 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Tue, 22 Sep 2026 00:42:51 +0900 Subject: [PATCH 2/6] 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 --- crates/stdlib/src/select.rs | 67 ++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/crates/stdlib/src/select.rs b/crates/stdlib/src/select.rs index dd4d484e03c..9de8bafc27b 100644 --- a/crates/stdlib/src/select.rs +++ b/crates/stdlib/src/select.rs @@ -112,27 +112,55 @@ 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 _, }; + let fill = |list: &[Selectable]| { + let mut fds = FdSet::new(); + for fd in list { + fds.insert(fd.fno); + } + fds + }; + + let set2list = |list: Vec, mut set: FdSet| { + vm.ctx.new_list( + list.into_iter() + .filter(|fd| set.contains(fd.fno)) + .map(|fd| fd.obj) + .collect(), + ) + }; + 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); + 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, + 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)), } @@ -142,29 +170,14 @@ mod decl { 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; + return Ok(( + vm.ctx.new_list(Vec::new()), + vm.ctx.new_list(Vec::new()), + vm.ctx.new_list(Vec::new()), + )); } - // retry select() if we haven't reached the deadline yet } } - - let set2list = |list: Vec, mut set: FdSet| { - vm.ctx.new_list( - list.into_iter() - .filter(|fd| set.contains(fd.fno)) - .map(|fd| fd.obj) - .collect(), - ) - }; - - let rlist = set2list(rlist, r); - let wlist = set2list(wlist, w); - let xlist = set2list(xlist, x); - - Ok((rlist, wlist, xlist)) } #[cfg(unix)] From 967ad706626ae83d4f7d840ccc16b71b9197df8f Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Tue, 22 Sep 2026 09:45:54 +0900 Subject: [PATCH 3/6] 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 --- Lib/http/server.py | 74 +++++++++++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index ac1f57c29f0..c91dd8f4cb4 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -461,19 +461,34 @@ def handle_one_request(self): """ try: - self.raw_requestline = self.rfile.readline(65537) - if len(self.raw_requestline) > 65536: - self.requestline = '' - self.request_version = '' - self.command = '' - self.send_error(HTTPStatus.REQUEST_URI_TOO_LONG) - return - if not self.raw_requestline: - self.close_connection = True - return - if not self.parse_request(): - # An error code has been sent, just exit - return + # A millisecond body-read timeout loses the race with the + # client thread writing the request. Read the request line + # and headers with a 1s floor, then restore the handler + # timeout for body reads. + old_timeout = self.connection.gettimeout() + shortened = isinstance(old_timeout, (int, float)) and old_timeout < 1.0 + if shortened: + self.connection.settimeout(1.0) + try: + self.raw_requestline = self.rfile.readline(65537) + if len(self.raw_requestline) > 65536: + self.requestline = '' + self.request_version = '' + self.command = '' + self.send_error(HTTPStatus.REQUEST_URI_TOO_LONG) + return + if not self.raw_requestline: + self.close_connection = True + return + if not self.parse_request(): + # An error code has been sent, just exit + return + finally: + if shortened: + try: + self.connection.settimeout(old_timeout) + except OSError: + pass mname = 'do_' + self.command if not hasattr(self, mname): self.send_error( @@ -1227,6 +1242,14 @@ def run_cgi(self): 'HTTP_USER_AGENT', 'HTTP_COOKIE', 'HTTP_REFERER'): env.setdefault(k, "") + # Handler timeout bounds request-body reads on the + # non-fork path. CGI writes the response on this socket; + # a short body-read timeout inherited across fork aborts + # the child and RSTs the client. + old_timeout = self.connection.gettimeout() + if self.have_fork: + self.connection.settimeout(None) + self.send_response(HTTPStatus.OK, "Script output follows") self.flush_headers() @@ -1242,14 +1265,23 @@ def run_cgi(self): pid = os.fork() if pid != 0: # Parent - pid, sts = os.waitpid(pid, 0) - # throw away additional data [see bug #427345] - while select.select([self.rfile], [], [], 0)[0]: - if not self.rfile.read(1): - break - exitcode = os.waitstatus_to_exitcode(sts) - if exitcode: - self.log_error(f"CGI script exit code {exitcode}") + try: + pid, sts = os.waitpid(pid, 0) + # throw away additional data [see bug #427345] + while select.select([self.rfile._sock], [], [], 0)[0]: + try: + if not self.rfile._sock.recv(1): + break + except TimeoutError: + break + exitcode = os.waitstatus_to_exitcode(sts) + if exitcode: + self.log_error(f"CGI script exit code {exitcode}") + finally: + try: + self.connection.settimeout(old_timeout) + except OSError: + pass return # Child try: From 6eab75ac31fded5c17378cd255adfcef99bead00 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Tue, 22 Sep 2026 11:40:39 +0900 Subject: [PATCH 4/6] Revert "http.server: keep CGI off the body-read timeout" Lib edits outside a CPython copy are not allowed. Assisted-by: Claude --- Lib/http/server.py | 74 +++++++++++++--------------------------------- 1 file changed, 21 insertions(+), 53 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index c91dd8f4cb4..ac1f57c29f0 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -461,34 +461,19 @@ def handle_one_request(self): """ try: - # A millisecond body-read timeout loses the race with the - # client thread writing the request. Read the request line - # and headers with a 1s floor, then restore the handler - # timeout for body reads. - old_timeout = self.connection.gettimeout() - shortened = isinstance(old_timeout, (int, float)) and old_timeout < 1.0 - if shortened: - self.connection.settimeout(1.0) - try: - self.raw_requestline = self.rfile.readline(65537) - if len(self.raw_requestline) > 65536: - self.requestline = '' - self.request_version = '' - self.command = '' - self.send_error(HTTPStatus.REQUEST_URI_TOO_LONG) - return - if not self.raw_requestline: - self.close_connection = True - return - if not self.parse_request(): - # An error code has been sent, just exit - return - finally: - if shortened: - try: - self.connection.settimeout(old_timeout) - except OSError: - pass + self.raw_requestline = self.rfile.readline(65537) + if len(self.raw_requestline) > 65536: + self.requestline = '' + self.request_version = '' + self.command = '' + self.send_error(HTTPStatus.REQUEST_URI_TOO_LONG) + return + if not self.raw_requestline: + self.close_connection = True + return + if not self.parse_request(): + # An error code has been sent, just exit + return mname = 'do_' + self.command if not hasattr(self, mname): self.send_error( @@ -1242,14 +1227,6 @@ def run_cgi(self): 'HTTP_USER_AGENT', 'HTTP_COOKIE', 'HTTP_REFERER'): env.setdefault(k, "") - # Handler timeout bounds request-body reads on the - # non-fork path. CGI writes the response on this socket; - # a short body-read timeout inherited across fork aborts - # the child and RSTs the client. - old_timeout = self.connection.gettimeout() - if self.have_fork: - self.connection.settimeout(None) - self.send_response(HTTPStatus.OK, "Script output follows") self.flush_headers() @@ -1265,23 +1242,14 @@ def run_cgi(self): pid = os.fork() if pid != 0: # Parent - try: - pid, sts = os.waitpid(pid, 0) - # throw away additional data [see bug #427345] - while select.select([self.rfile._sock], [], [], 0)[0]: - try: - if not self.rfile._sock.recv(1): - break - except TimeoutError: - break - exitcode = os.waitstatus_to_exitcode(sts) - if exitcode: - self.log_error(f"CGI script exit code {exitcode}") - finally: - try: - self.connection.settimeout(old_timeout) - except OSError: - pass + pid, sts = os.waitpid(pid, 0) + # throw away additional data [see bug #427345] + while select.select([self.rfile], [], [], 0)[0]: + if not self.rfile.read(1): + break + exitcode = os.waitstatus_to_exitcode(sts) + if exitcode: + self.log_error(f"CGI script exit code {exitcode}") return # Child try: From 5e748329ffdb8d4c8f8fb1922c25f98d77920b18 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Tue, 22 Sep 2026 11:41:06 +0900 Subject: [PATCH 5/6] host_env: note why forkpty is libc not nix Assisted-by: Claude --- crates/host_env/src/posix.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index f25664b71e1..4eb52aa6825 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -1116,6 +1116,10 @@ pub fn openpty() -> std::io::Result<(OwnedFd, OwnedFd)> { /// `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; From a86c7d0e43435309e44642c15937d0d33e62d8b4 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Tue, 22 Sep 2026 11:43:58 +0900 Subject: [PATCH 6/6] benches: run deepcopy 10x more iterations Assisted-by: Claude --- benches/benchmarks/deepcopy.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/benches/benchmarks/deepcopy.py b/benches/benchmarks/deepcopy.py index bcae0d1567d..24f512e6977 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)