Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f24691e
specialize: check the member descriptor's type before caching its slo…
youknowone Aug 14, 2026
40e67e1
socket: reserve recv()'s buffer fallibly
youknowone Aug 14, 2026
40a839b
types: count the __call__ and __get__ slot dispatches as recursion
youknowone Aug 14, 2026
bb744d6
typevar: show a ParamSpecArgs origin by its repr
youknowone Aug 14, 2026
7e4b96e
Do not hold a lock across a call back into Python
youknowone Aug 14, 2026
6e47646
Validate memoryview.cast() arguments and export negative strides corr…
youknowone Aug 14, 2026
2432fd7
Charge native recursion to the stack, not to the frame limit
youknowone Aug 14, 2026
02a6193
Report a size that cannot be allocated instead of aborting on it
youknowone Aug 14, 2026
ff52fe0
marshal: answer allow_code where a code object is written or read
youknowone Aug 14, 2026
d7cf971
Do not lock an object while running code that can reach it
youknowone Aug 14, 2026
61b989b
Stop asserting a pbkdf2 message that depends on the width of a C long
youknowone Aug 14, 2026
c9449a6
Publish and read the same pointer for a thread's top frame
youknowone Aug 15, 2026
ee782e0
Decide stop-the-world parking under the thread registry lock
youknowone Aug 15, 2026
fab2b3b
Keep an atexit callback alive while it is being compared
youknowone Aug 15, 2026
e3dcd8e
Hold atexit entries in PyRc rather than Arc
youknowone Aug 15, 2026
773ae23
Do not hold a buffer's storage while waiting for a peer
youknowone Aug 15, 2026
125d5aa
Do not hold a buffer's storage while waiting to hand it over
youknowone Aug 15, 2026
62ed618
Check select()'s descriptor limit while the sequence is walked
youknowone Aug 15, 2026
c65fe42
Bound native recursion where the C stack cannot be measured
youknowone Aug 15, 2026
00b64c3
Report the failure to allocate pbkdf2's key and Take.readinto's scratch
youknowone Aug 15, 2026
7c40cb9
Read the frozen-code tuple length as a signed length
youknowone Aug 15, 2026
ce43008
Make the snippets from the fuzzer sweep assert what they check
youknowone Aug 15, 2026
3d69690
Ask for a marshal container's room instead of assuming it
youknowone Aug 15, 2026
366ef3e
Compare the blocking-buffer snippet against something
youknowone Aug 15, 2026
ab0d4ff
Refuse array.frombytes() a source whose items are not bytes
youknowone Aug 15, 2026
7fad3b0
Tell apart the ways marshal data can be bad
youknowone Aug 15, 2026
a7f7021
Hash the collector's tables by address rather than by SipHash
youknowone Aug 16, 2026
691d6ca
Keep the collection's candidates and their counts in one table
youknowone Aug 16, 2026
3139b95
gc: collect referents into one buffer instead of a vector per object
youknowone Aug 16, 2026
dce42a3
memoryview and struct: match the checks and errors of the reference
youknowone Aug 16, 2026
5a5ba17
io: decide the readinto path by file type, not by seekability
youknowone Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Do not hold a buffer's storage while waiting for a peer
FileIO.readinto() and socket.recv_into()/recvfrom_into() took the target
buffer's write borrow and kept it for the whole call, including the wait
for data a pipe, socket or terminal may never deliver. What CPython holds
across that wait is the export, which only forbids resizing; the borrow is
a lock every other thread touching the same object waits on, so

    threading.Thread(target=lambda: sock.recv_into(buf)).start()
    len(buf)

did not answer until the peer sent. A thread parked on that lock is
ATTACHED and never reaches a safepoint, so gc.collect() in a third thread
waited for the peer as well: one incidental read of the buffer stopped the
world from being stopped at all.

The wait now runs against storage of its own and the bytes are copied over
once they arrive, with the export held throughout so the target still
cannot be resized meanwhile. A seekable file answers from itself rather
than from a peer, so FileIO.readinto() writes into the target directly
there and the buffered read path is unchanged.

Assisted-by: Claude
  • Loading branch information
youknowone committed Aug 16, 2026
commit 773ae2390a8fab2aece99245079217dcf5566dfd
51 changes: 34 additions & 17 deletions crates/stdlib/src/socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ mod _socket {
}

use core::{
mem::MaybeUninit,
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
time::Duration,
};
Expand Down Expand Up @@ -1611,8 +1610,6 @@ mod _socket {
) -> Result<usize, IoOrPyException> {
let flags = flags.unwrap_or(0);
let sock = self.sock()?;
let mut buf = buf.borrow_buf_mut();
let buf = &mut *buf;

// Handle nbytes parameter
let read_len = if let OptionalArg::Present(nbytes) = nbytes {
Expand All @@ -1624,10 +1621,13 @@ mod _socket {
buf.len()
};

let buf = &mut buf[..read_len];
self.sock_op(vm, SockWaitKind::Read, || {
sock.recv_with_flags(unsafe { slice_as_uninit(buf) }, flags)
})
let mut scratch = alloc_recv_scratch(read_len, vm)?;
let n = self.sock_op(vm, SockWaitKind::Read, || {
sock.recv_with_flags(&mut scratch.spare_capacity_mut()[..read_len], flags)
})?;
unsafe { scratch.set_len(n) };
buf.borrow_buf_mut()[..n].copy_from_slice(&scratch);
Ok(n)
}

#[pymethod]
Expand Down Expand Up @@ -1661,24 +1661,28 @@ mod _socket {
flags: OptionalArg<i32>,
vm: &VirtualMachine,
) -> Result<(usize, PyObjectRef), IoOrPyException> {
let mut buf = buf.borrow_buf_mut();
let buf = &mut *buf;
let buf = match nbytes {
let read_len = match nbytes {
OptionalArg::Present(i) => {
let i = i.to_usize().ok_or_else(|| {
vm.new_value_error("negative buffersize in recvfrom_into")
})?;
buf.get_mut(..i).ok_or_else(|| {
vm.new_value_error("nbytes is greater than the length of the buffer")
})?
if i > buf.len() {
return Err(vm
.new_value_error("nbytes is greater than the length of the buffer")
.into());
}
i
}
OptionalArg::Missing => buf,
OptionalArg::Missing => buf.len(),
};
let flags = flags.unwrap_or(0);
let sock = self.sock()?;
let mut scratch = alloc_recv_scratch(read_len, vm)?;
let (n, addr) = self.sock_op(vm, SockWaitKind::Read, || {
sock.recv_from_with_flags(unsafe { slice_as_uninit(buf) }, flags)
sock.recv_from_with_flags(&mut scratch.spare_capacity_mut()[..read_len], flags)
})?;
unsafe { scratch.set_len(n) };
buf.borrow_buf_mut()[..n].copy_from_slice(&scratch);
Ok((n, get_addr_tuple(&addr, vm)))
}

Expand Down Expand Up @@ -2386,8 +2390,21 @@ mod _socket {
Ok(s.to_string_lossy().into_owned())
}

unsafe fn slice_as_uninit<T>(v: &mut [T]) -> &mut [MaybeUninit<T>] {
unsafe { &mut *(v as *mut [T] as *mut [MaybeUninit<T>]) }
/// Room to receive into that belongs to no Python object.
///
/// A peer may never send, so the wait for it is unbounded. The export of
/// the caller's buffer is held for the whole call, which is what keeps it
/// from being resized, but the borrow that reaches its bytes is a lock
/// every other thread touching that object waits on, and a thread waiting
/// on a lock never reaches a safepoint — holding it across the wait stops
/// the world from being stopped at all. The bytes are copied over once
/// they have arrived.
fn alloc_recv_scratch(len: usize, vm: &VirtualMachine) -> PyResult<Vec<u8>> {
let mut scratch = Vec::new();
scratch
.try_reserve_exact(len)
.map_err(|_| vm.new_memory_error(""))?;
Ok(scratch)
}

enum IoOrPyException {
Expand Down
59 changes: 41 additions & 18 deletions crates/vm/src/stdlib/_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5826,6 +5826,26 @@ mod fileio {
Ok(Some(bytes))
}

/// One `read()` into `buf`, retried on EINTR (PEP 475). `None` on EAGAIN.
fn read_once_into(
zelf: &Py<Self>,
handle: crt_fd::Borrowed<'_>,
buf: &mut [u8],
vm: &VirtualMachine,
) -> PyResult<Option<usize>> {
loop {
match vm.allow_threads(|| host_io::read_once(handle, buf)) {
Ok(n) => return Ok(Some(n)),
Err(e) if host_io::is_interrupted_error(&e) => {
vm.check_signals()?;
}
// Non-blocking mode: return None if EAGAIN
Err(e) if host_io::is_would_block_error(&e) => return Ok(None),
Err(e) => return Err(Self::io_error(zelf, e, vm)),
}
}
}

#[pymethod]
fn readinto(
zelf: &Py<Self>,
Expand All @@ -5841,24 +5861,27 @@ mod fileio {

let handle = zelf.get_fd(vm)?;

let mut buf = obj.borrow_buf_mut();
// Loop on EINTR (PEP 475)
let ret = loop {
match vm.allow_threads(|| host_io::read_once(handle, &mut buf)) {
Ok(n) => break n,
Err(e) if host_io::is_interrupted_error(&e) => {
vm.check_signals()?;
continue;
}
// Non-blocking mode: return None if EAGAIN
Err(e) if host_io::is_would_block_error(&e) => {
return Ok(None);
}
Err(e) => return Err(Self::io_error(zelf, e, vm)),
}
};

Ok(Some(ret))
if zelf.seekable(vm)? {
// The read answers from the file itself, so it returns without
// waiting on anyone; write where the caller asked directly.
let mut buf = obj.borrow_buf_mut();
return Self::read_once_into(zelf, handle, &mut buf, vm);
}

// A pipe, socket or terminal answers only when the other end
// writes, which may be never. Holding the export for the whole
// call is what keeps the target from being resized meanwhile, as a
// Py_buffer does; but reaching its bytes takes a lock that every
// other thread touching the same object waits on, and a thread
// waiting on a lock never reaches a safepoint, so holding that one
// across the wait stops the world from being stopped at all. Read
// aside and take the lock for the copy.
let mut scratch = vm.new_zeroed_bytes(obj.len())?;
let ret = Self::read_once_into(zelf, handle, &mut scratch, vm)?;
if let Some(n) = ret {
obj.borrow_buf_mut()[..n].copy_from_slice(&scratch[..n]);
}
Ok(ret)
}

#[pymethod]
Expand Down
82 changes: 82 additions & 0 deletions extra_tests/snippets/stdlib_io_readinto_blocking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""readinto() waits for a peer that may never answer.

The target buffer is exported for the whole call, so it cannot be resized
meanwhile, but everything else about it stays reachable: another thread can
read it, and the interpreter can still stop the world. A reader that holds the
buffer's storage for the duration of the wait takes all of that away, and a
thread parked on that storage never reaches a safepoint, so a collection that
wants every thread stopped waits for the peer too.
"""

import gc
import os
import socket
import threading
import time

# The peer answers after DELAY; the checks below have to finish well inside it.
DELAY = 1.5
SLACK = DELAY / 2


def check(start_read, feed_peer, result):
buf = bytearray(16)
got = []
reading = threading.Event()

def read():
reading.set()
got.append(start_read(buf))

def feed():
time.sleep(DELAY)
feed_peer()

reader = threading.Thread(target=read)
feeder = threading.Thread(target=feed)
reader.start()
feeder.start()
reading.wait()
time.sleep(0.2) # the reader is now waiting on its peer

# None of this needs the peer, so none of it may wait for one.
start = time.monotonic()
assert len(buf) == 16, len(buf)
assert isinstance(bytes(buf), bytes)
gc.collect()
elapsed = time.monotonic() - start
assert elapsed < SLACK, "waited %.2fs on the peer" % elapsed

# The export is still held, so the target still cannot be resized.
try:
buf.append(0)
except BufferError:
pass
else:
raise AssertionError("append during an export should raise BufferError")

reader.join()
feeder.join()
assert got == [len(result)], got
assert bytes(buf[: len(result)]) == result, bytes(buf)


# A pipe read goes through FileIO.readinto.
read_fd, write_fd = os.pipe()
pipe = open(read_fd, "rb", buffering=0)
try:
check(pipe.readinto, lambda: os.write(write_fd, b"pipe"), b"pipe")
finally:
pipe.close()
os.close(write_fd)

# A socket read goes through socket.recv_into.
if hasattr(socket, "socketpair"):
left, right = socket.socketpair()
try:
check(left.recv_into, lambda: right.send(b"socket"), b"socket")
finally:
left.close()
right.close()

print("ok")