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
This commit is contained in:
Jeong, YunWon authored and GitHub committed 2026-09-22 14:54:46 +09:00
1 parent f56bfc9a5c
commit eb1546ae08
4 files changed
+122 -36

No files matched your search

+4 -3
View File
@@ -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)
+28
View File
@@ -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<OsString> {
nix::unistd::ttyname(fd)
.map(std::path::PathBuf::into_os_string)
+46 -33
View File
@@ -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<Selectable>, 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)]
+44
View File
@@ -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"));