From 90088ac7b5927eb54b32745287f8861a2c91af26 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Thu, 6 Aug 2026 10:21:49 +0900 Subject: [PATCH 1/6] _io: port BytesIO and StringIO from app level to interp level `class BytesIO` and `class StringIO` in `_io_app.py` are replaced by `W_BytesIO` (bytesio.rs) and `W_StringIO` (stringio.rs), following `pypy/module/_io/interp_bytesio.py` and `interp_stringio.py`. `_io_app.py` keeps only `IncrementalNewlineDecoder`. Both types hold their storage in a GC object field: `W_BytesIO` a `bytearray`, `W_StringIO` an `array('w')` of code points, standing in for the `RStringIO`/`UnicodeIO` split that exists because RPython strings are immutable. The two classes are registered at the tail of the three GC censuses (`build_gc`, `all_subclass_range_aliases`, `SUBCLASS_RANGE_HIERARCHY`) as ids 160 and 161. `tag_io_instance_with_finalizer` is split so `W_BytesIO` can pass `add_to_autoflusher=False` (interp_bytesio.py:70). Methods that can run Python (`buffer_w`, `__index__`, `dict.update`) re-derive the receiver from a pinned root afterwards, because a collection inside such a callback moves the stream and leaves the entered `&mut self` behind the forwarding pointer. lib-python `test_memoryio` goes from IMPORTERROR to 183 tests, 0 errors, 0 failures. `synth/pickle_ctor_args` runs 0.80s -> 0.28s (dynasm) and 0.84s -> 0.28s (cranelift); its jitstats and those of `synth/pickle_terminal_raise_resume` are re-recorded, both losing the function-entry loops that traced the removed app-level methods. Assisted-by: Claude --- .../synth/pickle_ctor_args.dynasm.jitstats | 4 +- .../synth/pickle_ctor_args.wasm.jitstats | 4 +- pyre/pyre-interpreter/src/lib.rs | 4 + .../src/module/_io/_io_app.py | 391 +----------- .../src/module/_io/bytesio.rs | 515 ++++++++++++++++ pyre/pyre-interpreter/src/module/_io/mod.rs | 49 +- .../src/module/_io/stringio.rs | 577 ++++++++++++++++++ pyre/pyre-jit/src/eval.rs | 17 + pyre/pyre-object/src/pyobject.rs | 5 + 9 files changed, 1162 insertions(+), 404 deletions(-) create mode 100644 pyre/pyre-interpreter/src/module/_io/bytesio.rs create mode 100644 pyre/pyre-interpreter/src/module/_io/stringio.rs diff --git a/pyre/bench/synth/pickle_ctor_args.dynasm.jitstats b/pyre/bench/synth/pickle_ctor_args.dynasm.jitstats index f75a8d50794..a0796ff2cd2 100644 --- a/pyre/bench/synth/pickle_ctor_args.dynasm.jitstats +++ b/pyre/bench/synth/pickle_ctor_args.dynasm.jitstats @@ -3,7 +3,9 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 guard_failures=1 internal_compile_panics=0 loops_aborted=0 -loops_compiled=4 +loops_compiled=2 diff --git a/pyre/bench/synth/pickle_ctor_args.wasm.jitstats b/pyre/bench/synth/pickle_ctor_args.wasm.jitstats index f75a8d50794..a0796ff2cd2 100644 --- a/pyre/bench/synth/pickle_ctor_args.wasm.jitstats +++ b/pyre/bench/synth/pickle_ctor_args.wasm.jitstats @@ -3,7 +3,9 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 guard_failures=1 internal_compile_panics=0 loops_aborted=0 -loops_compiled=4 +loops_compiled=2 diff --git a/pyre/pyre-interpreter/src/lib.rs b/pyre/pyre-interpreter/src/lib.rs index 60ca201e2c9..9c9f0b571de 100644 --- a/pyre/pyre-interpreter/src/lib.rs +++ b/pyre/pyre-interpreter/src/lib.rs @@ -1072,6 +1072,10 @@ pub fn all_subclass_range_aliases() -> Vec(), ), + // `_io.BytesIO` registers after the `rbigint` result pair, which takes + // 159 as a bare `with_gc_ptrs` id and carries no vtable of its own. + subclass_range_alias(160, typed::()), + subclass_range_alias(161, typed::()), ] } diff --git a/pyre/pyre-interpreter/src/module/_io/_io_app.py b/pyre/pyre-interpreter/src/module/_io/_io_app.py index 0ab6f4ed909..dc6f6cc1eb7 100644 --- a/pyre/pyre-interpreter/src/module/_io/_io_app.py +++ b/pyre/pyre-interpreter/src/module/_io/_io_app.py @@ -1,398 +1,11 @@ """App-level fallbacks for the _io module. -BytesIO is an in-memory binary stream backed by a bytearray plus an -integer position, sufficient for pickle's pure-Python Pickler/Unpickler -(write/getvalue on dump, read/readline on load). +IncrementalNewlineDecoder remains implemented here. -`_BufferedIOBase` and `_TextIOBase` are bound by the module initializer -before this source runs. +`_TextIOBase` is bound by the module initializer before this source runs. """ -class BytesIO(_BufferedIOBase): - def __new__(cls, *args, **kwargs): - # interp_bytesio.py:196-198 `needs_finalizer`: `self.close()` is not - # necessary when the object goes away, so the exact class stays off the - # finalizer queue. A subclass, whose `close` may do anything, goes on - # it through the base `__new__`. That base is also the one entry into - # the autoflusher, which interp_bytesio.py:70 opts out of - # (`add_to_autoflusher=False`) — an in-memory buffer has nothing to - # write out at exit. - if cls is BytesIO: - return object.__new__(cls) - return _BufferedIOBase.__new__(cls) - - def __init__(self, initial_bytes=b""): - self._buffer = bytearray(initial_bytes) - self._pos = 0 - self._closed = False - - def _check_closed(self): - if self._closed: - raise ValueError("I/O operation on closed file.") - - def readable(self): - self._check_closed() - return True - - def writable(self): - self._check_closed() - return True - - def seekable(self): - self._check_closed() - return True - - def _read_from_buffer(self, size=-1): - # `W_BytesIO.read_w` copies straight out of the internal buffer, so the - # sibling slots below reach it here rather than through `self.read`, - # which a subclass may override. - self._check_closed() - if size is None or size < 0: - end = len(self._buffer) - else: - end = min(self._pos + size, len(self._buffer)) - data = bytes(self._buffer[self._pos:end]) - self._pos = end - return data - - def read(self, size=-1): - return self._read_from_buffer(size) - - def read1(self, size=-1): - return self._read_from_buffer(size) - - def _readinto_from_buffer(self, buffer): - self._check_closed() - # `W_BytesIO.readinto_w`: acquire a writable buffer for the - # duration of the read, consume at most its byte length, copy the - # output at offset zero, and return the number of bytes copied. - with memoryview(buffer) as view: - if view.readonly: - raise TypeError("readinto() argument must be read-write bytes-like object") - target = view.cast("B") - output = self._read_from_buffer(target.nbytes) - target[:len(output)] = output - return len(output) - - def readinto(self, buffer): - return self._readinto_from_buffer(buffer) - - def readinto1(self, buffer): - return self._readinto_from_buffer(buffer) - - def readline(self, size=-1): - self._check_closed() - buf = self._buffer - n = len(buf) - start = self._pos - idx = buf.find(b"\n", start) - if idx < 0: - end = n - else: - end = idx + 1 - if size is not None and size >= 0: - end = min(end, start + size) - data = bytes(buf[start:end]) - self._pos = end - return data - - def readlines(self, hint=-1): - lines = [] - total = 0 - while True: - line = self.readline() - if len(line) == 0: - break - lines.append(line) - total += len(line) - if hint is not None and hint > 0 and total >= hint: - break - return lines - - def write(self, b): - self._check_closed() - data = bytes(b) - pos = self._pos - buf = self._buffer - n = len(buf) - if pos == n: - # Append — the common path (pickle always writes at the end). - buf.extend(data) - else: - if pos > n: - buf.extend(b"\x00" * (pos - n)) - # Overwrite/extend without slice assignment (STORE_SLICE). - head = bytes(buf[:pos]) - self._buffer = bytearray(head) - self._buffer.extend(data) - tail_start = pos + len(data) - if tail_start < n: - self._buffer.extend(bytes(buf[tail_start:n])) - buf = self._buffer - self._pos = pos + len(data) - return len(data) - - def writelines(self, lines): - for line in lines: - self.write(line) - - def seek(self, pos, whence=0): - self._check_closed() - if whence == 0: - if pos < 0: - raise ValueError("negative seek value %r" % (pos,)) - newpos = pos - elif whence == 1: - newpos = self._pos + pos - elif whence == 2: - newpos = len(self._buffer) + pos - else: - raise ValueError("invalid whence (%r, should be 0, 1 or 2)" % (whence,)) - if newpos < 0: - newpos = 0 - self._pos = newpos - return newpos - - def tell(self): - self._check_closed() - return self._pos - - def truncate(self, size=None): - self._check_closed() - if size is None: - size = self._pos - if size < 0: - raise ValueError("negative truncate size %r" % (size,)) - if size < len(self._buffer): - self._buffer = bytearray(bytes(self._buffer[:size])) - return size - - def getvalue(self): - self._check_closed() - return bytes(self._buffer) - - def getbuffer(self): - self._check_closed() - return memoryview(self._buffer) - - def flush(self): - self._check_closed() - - @property - def closed(self): - return self._closed - - def close(self): - self._closed = True - self._buffer = bytearray() - - def __iter__(self): - return self - - def __next__(self): - line = self.readline() - if len(line) == 0: - raise StopIteration - return line - - def __enter__(self): - self._check_closed() - return self - - def __exit__(self, *exc): - self.close() - return False - - -class StringIO(_TextIOBase): - """In-memory text stream backed by a str buffer plus an integer - position. Covers the common producers/consumers (logging / - traceback / csv / json) without the C `_io.StringIO` accelerator. - """ - - def __new__(cls, *args, **kwargs): - # interp_stringio.py:465-467 `needs_finalizer`: `self.buf = None` is not - # necessary when the object goes away, so the exact class stays off the - # finalizer queue; a subclass goes on it through the base `__new__`. - # That base also enters the stream into the autoflusher, which - # interp_stringio.py inherits (`add_to_autoflusher` defaults to True); - # for the exact class the flush it would run has no effect either. - if cls is StringIO: - return object.__new__(cls) - return _TextIOBase.__new__(cls) - - def __init__(self, initial_value="", newline="\n"): - if newline is not None and not isinstance(newline, str): - raise TypeError("newline must be str or None") - if newline not in (None, "", "\n", "\r", "\r\n"): - raise ValueError("illegal newline value: %r" % (newline,)) - self._readnl = newline - # `newline` controls translation of '\n' on write: only '\r' and - # '\r\n' substitute; '', '\n' and None write '\n' verbatim. - self._writenl = newline if newline in ("\r", "\r\n") else "" - self._readuniversal = newline is None - self._buffer = "" - self._pos = 0 - self._closed = False - if initial_value is not None: - if not isinstance(initial_value, str): - raise TypeError("initial_value must be str or None, not %s" - % type(initial_value).__name__) - self.write(initial_value) - self._pos = 0 - - def _check_closed(self): - if self._closed: - raise ValueError("I/O operation on closed file") - - def readable(self): - self._check_closed() - return True - - def writable(self): - self._check_closed() - return True - - def seekable(self): - self._check_closed() - return True - - def write(self, s): - self._check_closed() - if not isinstance(s, str): - raise TypeError("string argument expected, got '%s'" - % type(s).__name__) - if self._writenl: - s = s.replace("\n", self._writenl) - if not s: - return 0 - pos = self._pos - buf = self._buffer - n = len(buf) - if pos == n: - self._buffer = buf + s - elif pos > n: - self._buffer = buf + ("\0" * (pos - n)) + s - else: - self._buffer = buf[:pos] + s + buf[pos + len(s):] - self._pos = pos + len(s) - return len(s) - - def writelines(self, lines): - for line in lines: - self.write(line) - - def read(self, size=-1): - self._check_closed() - if size is None or size < 0: - end = len(self._buffer) - else: - end = min(self._pos + size, len(self._buffer)) - data = self._buffer[self._pos:end] - self._pos = end - return data - - def readline(self, size=-1): - self._check_closed() - buf = self._buffer - start = self._pos - idx = buf.find("\n", start) - if idx < 0: - end = len(buf) - else: - end = idx + 1 - if size is not None and size >= 0: - end = min(end, start + size) - data = buf[start:end] - self._pos = end - return data - - def readlines(self, hint=-1): - lines = [] - total = 0 - while True: - line = self.readline() - if not line: - break - lines.append(line) - total += len(line) - if hint is not None and hint > 0 and total >= hint: - break - return lines - - def seek(self, pos, whence=0): - self._check_closed() - if whence == 0: - if pos < 0: - raise ValueError("negative seek position %r" % (pos,)) - newpos = pos - elif whence == 1: - newpos = self._pos + pos - elif whence == 2: - newpos = len(self._buffer) + pos - else: - raise ValueError("invalid whence (%r, should be 0, 1 or 2)" % (whence,)) - if newpos < 0: - newpos = 0 - self._pos = newpos - return newpos - - def tell(self): - self._check_closed() - return self._pos - - def truncate(self, size=None): - self._check_closed() - if size is None: - size = self._pos - if size < 0: - raise ValueError("negative truncate size %r" % (size,)) - if size < len(self._buffer): - self._buffer = self._buffer[:size] - return size - - def getvalue(self): - self._check_closed() - return self._buffer - - def flush(self): - self._check_closed() - - @property - def closed(self): - return self._closed - - @property - def line_buffering(self): - return False - - @property - def newlines(self): - return None - - def close(self): - self._closed = True - self._buffer = "" - - def __iter__(self): - return self - - def __next__(self): - line = self.readline() - if not line: - raise StopIteration - return line - - def __enter__(self): - self._check_closed() - return self - - def __exit__(self, *exc): - self.close() - return False - - class IncrementalNewlineDecoder: r"""Codec used when reading a file in universal newlines mode. It wraps another incremental decoder, translating \r\n and \r into \n. It also diff --git a/pyre/pyre-interpreter/src/module/_io/bytesio.rs b/pyre/pyre-interpreter/src/module/_io/bytesio.rs new file mode 100644 index 00000000000..fcaa85367b5 --- /dev/null +++ b/pyre/pyre-interpreter/src/module/_io/bytesio.rs @@ -0,0 +1,515 @@ +//! In-memory binary stream — PyPy `pypy/module/_io/interp_bytesio.py`. + +use pyre_object::*; + +const AT_END: i64 = -1; + +#[crate::pyre_class("_io.BytesIO")] +pub struct W_BytesIO { + // rpython/rlib/rStringIO.py:16-23 splits immutable strings between an + // append-optimized builder and a mutable character list. A bytearray is + // already mutable and appendable, so this is their single storage object. + buffer: PyObjectRef, + pos: i64, + closed: bool, +} + +impl Default for W_BytesIO { + fn default() -> Self { + Self { + ob: PyObject::default(), + buffer: PY_NULL, + pos: AT_END, + closed: false, + } + } +} + +impl W_BytesIO { + fn self_obj(&self) -> PyObjectRef { + self as *const Self as PyObjectRef + } + + fn check_closed(&self) -> Result<(), crate::PyError> { + if self.closed { + Err(crate::PyError::value_error("I/O operation on closed file.")) + } else { + Ok(()) + } + } + + fn check_exports(&self) -> Result<(), crate::PyError> { + if self.buffer.is_null() { + return Ok(()); + } + // interp_bytesio.py:91-94. `export_count` lives on the bytearray + // rather than beside `pos`: `getbuffer` hands out a view of that + // object, so its own exporter lock already counts the live views and + // releases them, where upstream's `BytesIOView.releasebuffer` has to + // decrement a counter of its own. + unsafe { crate::builtins::bytearray_check_exports(self.buffer) } + } + + fn getsize(&self) -> i64 { + if self.buffer.is_null() { + 0 + } else { + unsafe { pyre_object::bytearrayobject::w_bytearray_len(self.buffer) as i64 } + } + } + + fn tell_pos(&self) -> i64 { + if self.pos == AT_END { + self.getsize() + } else { + self.pos + } + } + + fn seek_pos(&mut self, mut position: i64, mode: i64) { + // rpython/rlib/rStringIO.py:103-119 — preserve AT_END rather than + // materializing the numeric end position. + if mode == 0 { + if position == self.getsize() { + self.pos = AT_END; + return; + } + } else if mode == 1 { + if self.pos == AT_END { + self.pos = self.getsize(); + } + position += self.pos; + } else if mode == 2 { + if position == 0 { + self.pos = AT_END; + return; + } + position += self.getsize(); + } + if position < 0 { + position = 0; + } + self.pos = position; + } + + fn read_bytes(&mut self, size: i64) -> Vec { + // rpython/rlib/rStringIO.py:129-149. + let p = self.pos; + if p == 0 && size < 0 { + self.pos = AT_END; + return unsafe { pyre_object::bytearrayobject::w_bytearray_data(self.buffer).to_vec() }; + } + if p == AT_END || size == 0 { + return Vec::new(); + } + let mysize = self.getsize(); + let mut count = mysize - p; + if size >= 0 { + count = count.min(size); + } + if count <= 0 { + return Vec::new(); + } + if p == 0 && count == mysize { + self.pos = AT_END; + } else { + self.pos = p + count; + } + unsafe { + pyre_object::bytearrayobject::w_bytearray_data(self.buffer) + [p as usize..(p + count) as usize] + .to_vec() + } + } + + fn readline_bytes(&mut self, size: i64) -> Vec { + // rpython/rlib/rStringIO.py:151-176. + let p = self.pos; + if p == AT_END || size == 0 { + return Vec::new(); + } + let length = self.getsize(); + let count = length - p; + if count <= 0 { + return Vec::new(); + } + let mut end = length; + if size >= 0 && size < count { + end = p + size; + } + let newline = unsafe { + pyre_object::bytearrayobject::w_bytearray_find(self.buffer, b'\n', p as usize) + }; + if newline >= 0 && newline < end { + end = newline + 1; + } + self.pos = end; + unsafe { + pyre_object::bytearrayobject::w_bytearray_data(self.buffer)[p as usize..end as usize] + .to_vec() + } + } + + fn write_bytes(&mut self, data: &[u8]) -> Result { + if data.is_empty() { + return Ok(0); + } + if self.pos == AT_END { + let vec = unsafe { pyre_object::bytearrayobject::w_bytearray_vec_mut(self.buffer) }; + vec.try_reserve_exact(data.len()) + .map_err(|_| crate::PyError::memory_error(""))?; + vec.extend_from_slice(data); + return Ok(data.len() as i64); + } + + // rpython/rlib/rStringIO.py:72-101 `__slow_write` overwrites in place, + // extends past EOF, and fills an overseeked gap with NUL bytes. + let p = self.pos as usize; + let end = p + .checked_add(data.len()) + .ok_or_else(|| crate::PyError::overflow_error("new position too large"))?; + if end > i64::MAX as usize { + return Err(crate::PyError::overflow_error("new position too large")); + } + let vec = unsafe { pyre_object::bytearrayobject::w_bytearray_vec_mut(self.buffer) }; + let old_len = vec.len(); + if end > old_len { + vec.try_reserve_exact(end - old_len) + .map_err(|_| crate::PyError::memory_error(""))?; + if p > vec.len() { + vec.resize(p, 0); + } + vec.resize(end, 0); + } + vec[p..end].copy_from_slice(data); + self.pos = if end > old_len { AT_END } else { end as i64 }; + Ok(data.len() as i64) + } + + fn truncate_to(&mut self, size: i64) { + // rpython/rlib/rStringIO.py:178-200 never enlarges and always seeks + // to the resulting end using the AT_END sentinel. + let vec = unsafe { pyre_object::bytearrayobject::w_bytearray_vec_mut(self.buffer) }; + if size < vec.len() as i64 { + vec.truncate(size as usize); + } + self.pos = AT_END; + } + + /// `space.buffer_w(w_data, space.BUF_CONTIG_RO)` — the contiguous + /// read-only bytes `descr_init` and `write_w` both copy from. + fn contiguous_bytes(w_data: PyObjectRef) -> Result, crate::PyError> { + let Some(input) = crate::baseobjspace::simple_buffer_bytes(w_data)? else { + return Err(crate::PyError::type_error(format!( + "a bytes-like object is required, not '{}'", + crate::type_methods::arg_type_name(w_data) + ))); + }; + let data = input.as_bytes().to_vec(); + input.release(); + Ok(data) + } + + /// Re-read the receiver from `slot` after Python code ran. + /// + /// `space.buffer_w` / `space.acquire_writebuf` / `space.r_longlong_w` + /// each reach a method a Python class may define (`__buffer__`, + /// `__index__`), and a collection inside one of those moves the stream — + /// leaving the `&mut self` the method was entered with behind the + /// forwarding pointer, so a `closed` set by the callback is invisible and + /// the copy lands in the abandoned body. Upstream has no counterpart: + /// RPython's GC transform keeps `self` live across the call for it. + fn from_slot(slot: usize) -> &'static mut Self { + unsafe { &mut *(pyre_object::gc_roots::shadow_stack_get(slot) as *mut Self) } + } + + /// Pin the receiver so [`Self::from_slot`] can recover it, and answer the + /// slot it landed in. + fn pin_self(&self) -> usize { + pyre_object::gc_roots::pin_root(self.self_obj()); + pyre_object::gc_roots::shadow_stack_len() - 1 + } + + fn reset_buffer(&mut self) { + self.buffer = pyre_object::bytearrayobject::w_bytearray_new(0); + self.pos = AT_END; + self.closed = false; + pyre_object::gc_hook::try_gc_write_barrier(self as *mut Self as *mut u8); + } +} + +#[crate::pyre_methods( + base = super::buffered_iobase_type(), + weakrefable, + doc = "read-write" +)] +impl W_BytesIO { + #[staticmethod] + fn __new__(cls: PyObjectRef, _args: &[PyObjectRef]) -> PyObjectRef { + let _roots = pyre_object::gc_roots::push_roots(); + let buffer = pyre_object::bytearrayobject::w_bytearray_new(0); + pyre_object::gc_roots::pin_root(buffer); + let slot = pyre_object::gc_roots::shadow_stack_len() - 1; + let obj = W_BytesIO::allocate_stable(W_BytesIO { + buffer: pyre_object::gc_roots::shadow_stack_get(slot), + ..W_BytesIO::default() + }); + // interp_bytesio.py:197-199: only a subclass needs finalization; line + // 70 also opts this in-memory stream out of the autoflusher. + let needs_finalizer = !cls.is_null() && !std::ptr::eq(cls, type_object()); + super::tag_io_instance_without_autoflusher(obj, cls, needs_finalizer) + } + + fn __init__( + &mut self, + #[default(pyre_object::w_none())] w_initial_bytes: PyObjectRef, + ) -> Result<(), crate::PyError> { + // interp_bytesio.py:77-83. + self.check_exports()?; + self.reset_buffer(); + if !unsafe { pyre_object::is_none(w_initial_bytes) } { + let _roots = pyre_object::gc_roots::push_roots(); + let slot = self.pin_self(); + self.write(w_initial_bytes)?; + Self::from_slot(slot).seek_pos(0, 0); + } + Ok(()) + } + + fn read( + &mut self, + #[default(pyre_object::w_none())] w_size: PyObjectRef, + ) -> Result { + // interp_bytesio.py:96-100 plus interp_iobase.py `convert_size`. + self.check_closed()?; + let size = super::iobase_convert_size(Some(w_size))?; + Ok(pyre_object::bytesobject::w_bytes_from_bytes( + &self.read_bytes(size), + )) + } + + fn read1( + &mut self, + #[default(pyre_object::w_none())] w_size: PyObjectRef, + ) -> Result { + // interp_bytesio.py:102-103 delegates to read_w. + self.read(w_size) + } + + fn readline( + &mut self, + #[default(pyre_object::w_none())] w_limit: PyObjectRef, + ) -> Result { + // interp_bytesio.py:105-108 plus interp_iobase.py `convert_size`. + self.check_closed()?; + let limit = super::iobase_convert_size(Some(w_limit))?; + Ok(pyre_object::bytesobject::w_bytes_from_bytes( + &self.readline_bytes(limit), + )) + } + + fn readinto(&mut self, w_buffer: PyObjectRef) -> Result { + // interp_bytesio.py:109-116: hold the writable export through copy. + self.check_closed()?; + let _roots = pyre_object::gc_roots::push_roots(); + let slot = self.pin_self(); + let mut output = unsafe { crate::builtins::WritableBuffer::acquire(w_buffer)? }; + let output = unsafe { output.as_mut_slice() }; + let data = Self::from_slot(slot).read_bytes(output.len() as i64); + output[..data.len()].copy_from_slice(&data); + Ok(data.len() as i64) + } + + fn readinto1(&mut self, w_buffer: PyObjectRef) -> Result { + self.readinto(w_buffer) + } + + fn write(&mut self, w_data: PyObjectRef) -> Result { + // interp_bytesio.py:118-127: check state before acquiring one + // contiguous read-only buffer, then copy its bytes once. + self.check_closed()?; + self.check_exports()?; + let _roots = pyre_object::gc_roots::push_roots(); + let slot = self.pin_self(); + let data = Self::contiguous_bytes(w_data)?; + // A `__buffer__` written in Python may have closed or exported the + // stream, so repeat both checks — against the receiver as it stands + // now, which that callback may also have moved. + let this = Self::from_slot(slot); + this.check_closed()?; + this.check_exports()?; + this.write_bytes(&data) + } + + fn truncate( + &mut self, + #[default(pyre_object::w_none())] w_size: PyObjectRef, + ) -> Result { + // interp_bytesio.py:129-147. + self.check_closed()?; + self.check_exports()?; + let pos = self.tell_pos(); + let _roots = pyre_object::gc_roots::push_roots(); + let slot = self.pin_self(); + let size = if unsafe { pyre_object::is_none(w_size) } { + pos + } else { + crate::baseobjspace::index_int_w_preserve_negative(w_size)? + }; + let this = Self::from_slot(slot); + if size < 0 { + return Err(crate::PyError::value_error(format!( + "negative size value {size:?}" + ))); + } + this.truncate_to(size); + if size == pos { + this.seek_pos(0, 2); + } else { + this.seek_pos(pos, 0); + } + Ok(size) + } + + fn getbuffer(&mut self) -> Result { + // interp_bytesio.py:149-152. The bytearray exporter owns the release + // accounting for the writable view returned here. + self.check_closed()?; + crate::builtins::w_memoryview_new_with_flags(self.buffer, 0x0001) + } + + fn getvalue(&self) -> Result { + // interp_bytesio.py:154-157. + self.check_closed()?; + Ok(pyre_object::bytesobject::w_bytes_from_bytes(unsafe { + pyre_object::bytearrayobject::w_bytearray_data(self.buffer) + })) + } + + fn seek( + &mut self, + pos: PyIndexInt, + #[default(0)] whence: PyIndexInt, + ) -> Result { + // interp_bytesio.py:162-180 validation followed by RStringIO.seek. + self.check_closed()?; + match whence { + 0 if pos < 0 => { + return Err(crate::PyError::value_error(format!( + "negative seek value {pos:?}" + ))); + } + 0 => {} + 1 => { + if pos > i64::MAX - self.tell_pos() { + return Err(crate::PyError::overflow_error("new position too large")); + } + } + 2 => { + if pos > i64::MAX - self.getsize() { + return Err(crate::PyError::overflow_error("new position too large")); + } + } + _ => { + return Err(crate::PyError::value_error(format!( + "invalid whence ({whence:?}, should be 0, 1 or 2)" + ))); + } + } + self.seek_pos(pos, whence); + Ok(self.tell_pos()) + } + + fn tell(&self) -> Result { + self.check_closed()?; + Ok(self.tell_pos()) + } + + fn readable(&self) -> Result { + self.check_closed()?; + Ok(true) + } + + fn writable(&self) -> Result { + self.check_closed()?; + Ok(true) + } + + fn seekable(&self) -> Result { + self.check_closed()?; + Ok(true) + } + + fn close(&mut self) -> Result<(), crate::PyError> { + // Any replacement of the exported bytearray would invalidate the + // view, so it takes the same resize lock as write/truncate/__init__. + if self.closed { + return Ok(()); + } + self.check_exports()?; + self.buffer = pyre_object::bytearrayobject::w_bytearray_new(0); + self.pos = AT_END; + self.closed = true; + pyre_object::gc_hook::try_gc_write_barrier(self as *mut Self as *mut u8); + Ok(()) + } + + #[getter] + fn closed(&self) -> bool { + self.closed + } + + fn __getstate__(&self) -> Result { + // interp_bytesio.py:204-210, including the instance dictionary. + self.check_closed()?; + let _roots = pyre_object::gc_roots::push_roots(); + let sp = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(self.self_obj()); + pyre_object::gc_roots::pin_root(self.getvalue()?); + pyre_object::gc_roots::pin_root(w_int_new(self.tell_pos())); + let dict = crate::baseobjspace::getdict_native(pyre_object::gc_roots::shadow_stack_get(sp)); + pyre_object::gc_roots::pin_root(if dict.is_null() { w_none() } else { dict }); + Ok(w_tuple_new(vec![ + pyre_object::gc_roots::shadow_stack_get(sp + 1), + pyre_object::gc_roots::shadow_stack_get(sp + 2), + pyre_object::gc_roots::shadow_stack_get(sp + 3), + ])) + } + + fn __setstate__(&mut self, w_state: PyObjectRef) -> Result<(), crate::PyError> { + // interp_bytesio.py:212-227. + self.check_closed()?; + let length = crate::baseobjspace::len_w(w_state)?; + if length != 3 { + return Err(crate::PyError::type_error(format!( + "{}.__setstate__ argument should be 3-tuple, got {}", + crate::type_methods::arg_type_name(self.self_obj()), + crate::type_methods::arg_type_name(w_state) + ))); + } + let state = crate::baseobjspace::unpackiterable(w_state, 3)?; + let _roots = pyre_object::gc_roots::push_roots(); + let sp = pyre_object::gc_roots::pin_roots(&state); + let slot = self.pin_self(); + self.check_exports()?; + self.truncate_to(0); + let content = pyre_object::gc_roots::shadow_stack_get(sp); + self.write(content)?; + let pos = crate::baseobjspace::index_int_w_preserve_negative( + pyre_object::gc_roots::shadow_stack_get(sp + 1), + )?; + if pos < 0 { + return Err(crate::PyError::value_error( + "position value cannot be negative", + )); + } + let this = Self::from_slot(slot); + this.seek_pos(pos, 0); + let w_dict = pyre_object::gc_roots::shadow_stack_get(sp + 2); + if !unsafe { pyre_object::is_none(w_dict) } { + let own_dict = crate::baseobjspace::getdict_native(this.self_obj()); + super::call_method_result(own_dict, "update", &[w_dict])?; + } + Ok(()) + } +} diff --git a/pyre/pyre-interpreter/src/module/_io/mod.rs b/pyre/pyre-interpreter/src/module/_io/mod.rs index 8c97ad8b0d4..7fbdccc86b0 100644 --- a/pyre/pyre-interpreter/src/module/_io/mod.rs +++ b/pyre/pyre-interpreter/src/module/_io/mod.rs @@ -15,6 +15,10 @@ mod buffered_rwpair; pub use buffered_rwpair::W_BufferedRWPair; mod buffered_random; pub use buffered_random::W_BufferedRandom; +mod bytesio; +pub use bytesio::W_BytesIO; +mod stringio; +pub use stringio::W_StringIO; mod textio; pub use textio::W_TextIOWrapper; @@ -428,11 +432,34 @@ pub(crate) fn tag_io_instance_with_finalizer( obj: PyObjectRef, cls: PyObjectRef, needs_finalizer: bool, +) -> PyObjectRef { + tag_io_instance_impl(obj, cls, needs_finalizer, true) +} + +/// `W_IOBase.__init__(add_to_autoflusher=False)` with the same subclass +/// finalizer rule as [`tag_io_instance_with_finalizer`]. +pub(crate) fn tag_io_instance_without_autoflusher( + obj: PyObjectRef, + cls: PyObjectRef, + needs_finalizer: bool, +) -> PyObjectRef { + tag_io_instance_impl(obj, cls, needs_finalizer, false) +} + +fn tag_io_instance_impl( + obj: PyObjectRef, + cls: PyObjectRef, + needs_finalizer: bool, + add_to_autoflusher: bool, ) -> PyObjectRef { if !cls.is_null() { crate::typedef::tag_subclass_instance(obj, cls); } - let obj = autoflusher_add(obj); + let obj = if add_to_autoflusher { + autoflusher_add(obj) + } else { + obj + }; if needs_finalizer { crate::executioncontext::register_finalizer(obj); } @@ -1261,6 +1288,8 @@ crate::py_module! { let buffered_rwpair = buffered_rwpair::type_object(); for (name, t) in [ ("FileIO", file_io), + ("BytesIO", bytesio::type_object()), + ("StringIO", stringio::type_object()), ("BufferedReader", buffered_reader), ("BufferedWriter", buffered_writer), ("BufferedRWPair", buffered_rwpair), @@ -1284,22 +1313,16 @@ crate::py_module! { } crate::module_ns_store(ns, "TextIOWrapper", text_io_wrapper); - // The pure-Python in-memory streams: pickle's Pickler/Unpickler use - // BytesIO; logging / traceback / csv use StringIO. `W_BytesIO` derives - // `W_BufferedIOBase` (interp_bytesio.py:65) and `W_StringIO` - // `W_TextIOBase` (interp_stringio.py:390), so both bases have to be - // bound before the source runs; that is what puts this install here - // rather than in the `appleveldefs:` table, which the macro expands - // ahead of `extra_init`. + // The remaining pure-Python newline decoder needs `_TextIOBase` bound + // before this source runs; that is what puts + // this install here rather than in the `appleveldefs:` table, which + // the macro expands ahead of `extra_init`. crate::importing::appleveldef_install_seeded( ns, include_str!("_io_app.py"), "_io_app.py", - &["BytesIO", "StringIO", "IncrementalNewlineDecoder"], - &[ - ("_BufferedIOBase", buffered_base), - ("_TextIOBase", text_base), - ], + &["IncrementalNewlineDecoder"], + &[("_TextIOBase", text_base)], ); } } diff --git a/pyre/pyre-interpreter/src/module/_io/stringio.rs b/pyre/pyre-interpreter/src/module/_io/stringio.rs new file mode 100644 index 00000000000..fbfbfcaa0c4 --- /dev/null +++ b/pyre/pyre-interpreter/src/module/_io/stringio.rs @@ -0,0 +1,577 @@ +//! In-memory text stream — PyPy `pypy/module/_io/interp_stringio.py`. + +use pyre_object::*; +use rustpython_wtf8::{CodePoint, Wtf8Buf}; + +#[crate::pyre_class("_io.StringIO")] +pub struct W_StringIO { + // interp_stringio.py:27-50 stores UnicodeIO.data as a list of r_int32. + // `array('w')` is the existing GC object whose raw payload is a mutable + // sequence of 32-bit code points: it preserves O(1) indexing/overwrite, + // and keeps the dropping Vec out of this GC-allocated class header. + buffer: PyObjectRef, + pos: i64, + closed: bool, + readnl: PyObjectRef, + writenl: PyObjectRef, + readuniversal: bool, + readtranslate: bool, + w_decoder: PyObjectRef, +} + +impl Default for W_StringIO { + fn default() -> Self { + Self { + ob: PyObject::default(), + buffer: PY_NULL, + pos: 0, + closed: false, + readnl: PY_NULL, + writenl: PY_NULL, + readuniversal: false, + readtranslate: false, + w_decoder: PY_NULL, + } + } +} + +impl W_StringIO { + fn self_obj(&self) -> PyObjectRef { + self as *const Self as PyObjectRef + } + + fn from_slot(slot: usize) -> &'static mut Self { + unsafe { &mut *(pyre_object::gc_roots::shadow_stack_get(slot) as *mut Self) } + } + + fn pin_self(&self) -> usize { + pyre_object::gc_roots::pin_root(self.self_obj()); + pyre_object::gc_roots::shadow_stack_len() - 1 + } + + fn publish_refs(&mut self) { + pyre_object::gc_hook::try_gc_write_barrier(self as *mut Self as *mut u8); + } + + fn check_closed(&self) -> Result<(), crate::PyError> { + if self.closed { + // interp_stringio.py:234-238. + Err(crate::PyError::value_error("I/O operation on closed file")) + } else { + Ok(()) + } + } + + fn data(&self) -> &'static [u8] { + unsafe { pyre_object::interp_array::w_array_bytes(self.buffer) } + } + + fn data_mut(&mut self) -> &'static mut Vec { + unsafe { pyre_object::interp_array::w_array_vec_mut(self.buffer) } + } + + fn len(&self) -> usize { + self.data().len() / 4 + } + + fn codepoint(&self, index: usize) -> u32 { + let offset = index * 4; + u32::from_ne_bytes(self.data()[offset..offset + 4].try_into().unwrap()) + } + + fn codepoints(w_obj: PyObjectRef) -> Vec { + unsafe { + pyre_object::w_str_get_wtf8(w_obj) + .code_points() + .map(CodePoint::to_u32) + .collect() + } + } + + fn string_from_range(&self, start: usize, end: usize) -> PyObjectRef { + // interp_stringio.py:40-44 `UnicodeIO.getdata_slice`. + let mut result = Wtf8Buf::new(); + for index in start..end { + if let Some(cp) = CodePoint::from_u32(self.codepoint(index)) { + result.push(cp); + } + } + pyre_object::w_str_from_wtf8_managed(result) + } + + fn reset_buffer_from(slot: usize, w_value: PyObjectRef) -> Result<(), crate::PyError> { + if !unsafe { crate::baseobjspace::isinstance_str_w(w_value) } { + return Err(crate::PyError::type_error(format!( + "unicode argument expected, got '{}'", + crate::type_methods::arg_type_name(w_value) + ))); + } + let codepoints = Self::codepoints(w_value); + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(codepoints.len().saturating_mul(4)) + .map_err(|_| crate::PyError::memory_error(""))?; + for cp in codepoints { + bytes.extend_from_slice(&cp.to_ne_bytes()); + } + let buffer = pyre_object::interp_array::w_array_from_bytes(b'w', 4, bytes); + let this = Self::from_slot(slot); + this.buffer = buffer; + this.publish_refs(); + Ok(()) + } + + fn init_newline(slot: usize, w_newline: PyObjectRef) -> Result<(), crate::PyError> { + // interp_stringio.py:141-174. + let newline = if unsafe { pyre_object::is_none(w_newline) } { + None + } else if unsafe { crate::baseobjspace::isinstance_str_w(w_newline) } { + Some(unsafe { pyre_object::w_str_get_wtf8(w_newline) }) + } else { + return Err(crate::PyError::type_error(format!( + "newline must be str or None, not {}", + crate::type_methods::arg_type_name(w_newline) + ))); + }; + if let Some(value) = newline + && !matches!(value.as_bytes(), b"" | b"\n" | b"\r" | b"\r\n") + { + let shown = unsafe { crate::display::py_repr(w_newline) }?; + return Err(crate::PyError::value_error(format!( + "illegal newline value: {shown}", + ))); + } + + let this = Self::from_slot(slot); + this.readnl = w_newline; + this.writenl = PY_NULL; + this.readuniversal = newline.is_none_or(|value| value.as_bytes().is_empty()); + this.readtranslate = newline.is_none(); + this.w_decoder = PY_NULL; + if newline.is_some_and(|value| value.as_bytes().starts_with(b"\r")) { + this.writenl = w_newline; + } + this.publish_refs(); + + if this.readuniversal { + let io = crate::importing::get_sys_module("_io") + .ok_or_else(|| crate::PyError::runtime_error("_io module is not initialized"))?; + let decoder_type = crate::baseobjspace::getattr_str(io, "IncrementalNewlineDecoder")?; + let decoder = crate::call::call_function_impl_result( + decoder_type, + &[w_none(), w_bool_from(this.readtranslate)], + )?; + let this = Self::from_slot(slot); + this.w_decoder = decoder; + this.publish_refs(); + } + Ok(()) + } + + fn decode_string(slot: usize, w_obj: PyObjectRef) -> Result { + // interp_stringio.py:243-262. Calls are kept at object level so the + // app-level IncrementalNewlineDecoder owns translation and seennl. + if !unsafe { crate::baseobjspace::isinstance_str_w(w_obj) } { + return Err(crate::PyError::type_error(format!( + "unicode argument expected, got '{}'", + crate::type_methods::arg_type_name(w_obj) + ))); + } + let this = Self::from_slot(slot); + this.check_closed()?; + let mut decoded = if this.w_decoder.is_null() { + w_obj + } else { + super::call_method_result(this.w_decoder, "decode", &[w_obj, w_bool_from(true)])? + }; + pyre_object::gc_roots::pin_root(decoded); + let decoded_slot = pyre_object::gc_roots::shadow_stack_len() - 1; + let this = Self::from_slot(slot); + if !this.writenl.is_null() { + decoded = super::call_method_result( + pyre_object::gc_roots::shadow_stack_get(decoded_slot), + "replace", + &[w_str_new("\n"), this.writenl], + )?; + } + if !unsafe { crate::baseobjspace::isinstance_str_w(decoded) } { + return Err(crate::PyError::type_error( + "decoder should return a string result", + )); + } + Ok(decoded) + } + + fn write_codepoints(&mut self, codepoints: &[u32]) -> Result<(), crate::PyError> { + // interp_stringio.py:104-111 `UnicodeIO.write`. + let start = usize::try_from(self.pos) + .map_err(|_| crate::PyError::overflow_error("new position too large"))?; + let end = start + .checked_add(codepoints.len()) + .ok_or_else(|| crate::PyError::overflow_error("new position too large"))?; + if end > i64::MAX as usize { + return Err(crate::PyError::overflow_error("new position too large")); + } + let data = self.data_mut(); + let byte_end = end + .checked_mul(4) + .ok_or_else(|| crate::PyError::overflow_error("new position too large"))?; + if byte_end > data.len() { + data.try_reserve_exact(byte_end - data.len()) + .map_err(|_| crate::PyError::memory_error(""))?; + data.resize(byte_end, 0); + } + for (index, cp) in codepoints.iter().enumerate() { + let offset = (start + index) * 4; + data[offset..offset + 4].copy_from_slice(&cp.to_ne_bytes()); + } + self.pos = end as i64; + Ok(()) + } +} + +#[crate::pyre_methods(base = super::text_iobase_type(), weakrefable, doc = "In-memory text stream")] +impl W_StringIO { + #[staticmethod] + fn __new__(cls: PyObjectRef, _args: &[PyObjectRef]) -> PyObjectRef { + let _roots = pyre_object::gc_roots::push_roots(); + let buffer = pyre_object::interp_array::w_array_new(b'w', 4); + pyre_object::gc_roots::pin_root(buffer); + let slot = pyre_object::gc_roots::shadow_stack_len() - 1; + let obj = W_StringIO::allocate_stable(W_StringIO { + buffer: pyre_object::gc_roots::shadow_stack_get(slot), + ..W_StringIO::default() + }); + // interp_stringio.py:465-467: only a subclass needs finalization; + // W_TextIOBase's default autoflusher membership is retained. + let needs_finalizer = !cls.is_null() && !std::ptr::eq(cls, type_object()); + super::tag_io_instance_with_finalizer(obj, cls, needs_finalizer) + } + + fn __init__( + &mut self, + #[default(pyre_object::w_none())] w_initvalue: PyObjectRef, + #[default(pyre_object::w_str_new("\n"))] w_newline: PyObjectRef, + ) -> Result<(), crate::PyError> { + // interp_stringio.py:177-188. + let _roots = pyre_object::gc_roots::push_roots(); + let slot = self.pin_self(); + Self::init_newline(slot, w_newline)?; + let decoded = if unsafe { pyre_object::is_none(w_initvalue) } { + w_str_new("") + } else { + Self::decode_string(slot, w_initvalue)? + }; + pyre_object::gc_roots::pin_root(decoded); + let decoded = + pyre_object::gc_roots::shadow_stack_get(pyre_object::gc_roots::shadow_stack_len() - 1); + Self::reset_buffer_from(slot, decoded)?; + let this = Self::from_slot(slot); + this.pos = 0; + this.closed = false; + Ok(()) + } + + fn write(&mut self, w_obj: PyObjectRef) -> Result { + // interp_stringio.py:264-296. + let _roots = pyre_object::gc_roots::push_roots(); + let slot = self.pin_self(); + pyre_object::gc_roots::pin_root(w_obj); + let input_slot = pyre_object::gc_roots::shadow_stack_len() - 1; + let decoded = + Self::decode_string(slot, pyre_object::gc_roots::shadow_stack_get(input_slot))?; + pyre_object::gc_roots::pin_root(decoded); + let original_size = unsafe { + pyre_object::w_str_len(pyre_object::gc_roots::shadow_stack_get(input_slot)) as i64 + }; + let codepoints = Self::codepoints(decoded); + let this = Self::from_slot(slot); + this.check_closed()?; + if codepoints.is_empty() { + return Ok(original_size); + } + this.write_codepoints(&codepoints)?; + Ok(original_size) + } + + fn read( + &mut self, + #[default(pyre_object::w_none())] w_size: PyObjectRef, + ) -> Result { + // interp_stringio.py:306-327 plus interp_iobase.py `convert_size`. + self.check_closed()?; + let _roots = pyre_object::gc_roots::push_roots(); + let slot = self.pin_self(); + let size = super::iobase_convert_size(Some(w_size))?; + let this = Self::from_slot(slot); + this.check_closed()?; + if this.pos >= this.len() as i64 { + return Ok(w_str_new("")); + } + let start = this.pos as usize; + let available = this.len() - start; + let count = if size >= 0 { + available.min(size as usize) + } else { + available + }; + let end = start + count; + let result = this.string_from_range(start, end); + Self::from_slot(slot).pos = end as i64; + Ok(result) + } + + fn readline( + &mut self, + #[default(pyre_object::w_none())] w_limit: PyObjectRef, + ) -> Result { + // interp_stringio.py:329-401. + self.check_closed()?; + let _roots = pyre_object::gc_roots::push_roots(); + let slot = self.pin_self(); + let limit = super::iobase_convert_size(Some(w_limit))?; + let this = Self::from_slot(slot); + this.check_closed()?; + if this.pos >= this.len() as i64 { + return Ok(w_str_new("")); + } + let start = this.pos as usize; + let available = this.len() - start; + let count = if limit >= 0 { + available.min(limit as usize) + } else { + available + }; + let bound = start + count; + let mut end = bound; + if this.readuniversal { + let mut cursor = start; + while cursor < bound { + let cp = this.codepoint(cursor); + cursor += 1; + if cp == b'\n' as u32 { + end = cursor; + break; + } + if cp == b'\r' as u32 { + if cursor < bound && this.codepoint(cursor) == b'\n' as u32 { + cursor += 1; + } + end = cursor; + break; + } + } + } else { + let marker = Self::codepoints(this.readnl); + for cursor in start..bound { + if cursor + marker.len() <= bound + && (0..marker.len()).all(|i| this.codepoint(cursor + i) == marker[i]) + { + end = cursor + marker.len(); + break; + } + } + } + let result = this.string_from_range(start, end); + Self::from_slot(slot).pos = end as i64; + Ok(result) + } + + fn seek( + &mut self, + w_pos: PyObjectRef, + #[default(pyre_object::w_int_new(0))] w_whence: PyObjectRef, + ) -> Result { + // interp_stringio.py:403-422. Conversion stays inside the pinned + // region because either argument may execute Python through __index__. + self.check_closed()?; + let _roots = pyre_object::gc_roots::push_roots(); + let slot = self.pin_self(); + let pos = crate::baseobjspace::index_int_w_preserve_negative(w_pos)?; + let whence = crate::baseobjspace::index_int_w_preserve_negative(w_whence)?; + let this = Self::from_slot(slot); + this.check_closed()?; + if !(0..=2).contains(&whence) { + return Err(crate::PyError::value_error(format!( + "Invalid whence ({whence}, should be 0, 1 or 2)" + ))); + } + if whence == 0 && pos < 0 { + return Err(crate::PyError::value_error(format!( + "Negative seek position {pos}" + ))); + } + if whence != 0 && pos != 0 { + return Err(crate::PyError::os_error( + "Can't do nonzero cur-relative seeks", + )); + } + let new_pos = match whence { + 1 => this.pos, + 2 => this.len() as i64, + _ => pos, + }; + this.pos = new_pos; + Ok(new_pos) + } + + fn truncate( + &mut self, + #[default(pyre_object::w_none())] w_size: PyObjectRef, + ) -> Result { + // interp_stringio.py:424-439 plus interp_iobase.py `convert_size`. + self.check_closed()?; + let current = self.pos; + let _roots = pyre_object::gc_roots::push_roots(); + let slot = self.pin_self(); + let size = if unsafe { pyre_object::is_none(w_size) } { + current + } else { + super::iobase_convert_size(Some(w_size))? + }; + let this = Self::from_slot(slot); + this.check_closed()?; + if size < 0 { + return Err(crate::PyError::value_error(format!( + "Negative size value {size}" + ))); + } + if size < this.len() as i64 { + this.data_mut().truncate(size as usize * 4); + } + Ok(size) + } + + fn getvalue(&self) -> Result { + // interp_stringio.py:441-448. + self.check_closed()?; + Ok(self.string_from_range(0, self.len())) + } + + fn readable(&self) -> Result { + self.check_closed()?; + Ok(true) + } + + fn writable(&self) -> Result { + self.check_closed()?; + Ok(true) + } + + fn seekable(&self) -> Result { + self.check_closed()?; + Ok(true) + } + + fn close(&mut self) { + // interp_stringio.py:462-464. + let _roots = pyre_object::gc_roots::push_roots(); + let slot = self.pin_self(); + let buffer = pyre_object::interp_array::w_array_new(b'w', 4); + let this = Self::from_slot(slot); + this.buffer = buffer; + this.closed = true; + this.publish_refs(); + } + + #[getter] + fn closed(&self) -> bool { + self.closed + } + + #[getter] + fn line_buffering(&self) -> bool { + false + } + + #[getter] + fn newlines(&self) -> Result { + // interp_stringio.py:477-480. + if self.w_decoder.is_null() { + Ok(w_none()) + } else { + crate::baseobjspace::getattr_str(self.w_decoder, "newlines") + } + } + + fn __getstate__(&self) -> Result { + // interp_stringio.py:190-200. + self.check_closed()?; + let _roots = pyre_object::gc_roots::push_roots(); + let sp = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(self.self_obj()); + pyre_object::gc_roots::pin_root(self.getvalue()?); + let own_dict = + crate::baseobjspace::getdict_native(pyre_object::gc_roots::shadow_stack_get(sp)); + pyre_object::gc_roots::pin_root(own_dict); + let copied = super::call_method_result(own_dict, "copy", &[])?; + pyre_object::gc_roots::pin_root(copied); + let this = Self::from_slot(sp); + let readnl = if unsafe { pyre_object::is_none(this.readnl) } { + w_none() + } else { + pyre_object::w_str_from_wtf8_managed(unsafe { + pyre_object::w_str_get_wtf8(this.readnl).to_wtf8_buf() + }) + }; + pyre_object::gc_roots::pin_root(readnl); + let pos = Self::from_slot(sp).pos; + pyre_object::gc_roots::pin_root(w_int_new(pos)); + Ok(w_tuple_new(vec![ + pyre_object::gc_roots::shadow_stack_get(sp + 1), + pyre_object::gc_roots::shadow_stack_get(sp + 4), + pyre_object::gc_roots::shadow_stack_get(sp + 5), + pyre_object::gc_roots::shadow_stack_get(sp + 3), + ])) + } + + fn __setstate__(&mut self, w_state: PyObjectRef) -> Result<(), crate::PyError> { + // interp_stringio.py:202-232, including acceptance of future state + // tuples longer than four items. + self.check_closed()?; + if !unsafe { pyre_object::is_tuple(w_state) } + || unsafe { pyre_object::w_tuple_len(w_state) } < 4 + { + return Err(crate::PyError::type_error(format!( + "{}.__setstate__ argument should be a 4-tuple, got {}", + crate::type_methods::arg_type_name(self.self_obj()), + crate::type_methods::arg_type_name(w_state) + ))); + } + let _roots = pyre_object::gc_roots::push_roots(); + pyre_object::gc_roots::pin_root(w_state); + let slot = self.pin_self(); + let state_slot = slot - 1; + let state = pyre_object::gc_roots::shadow_stack_get(state_slot); + let w_value = unsafe { pyre_object::w_tuple_getitem(state, 0).unwrap() }; + let w_readnl = unsafe { pyre_object::w_tuple_getitem(state, 1).unwrap() }; + let w_pos = unsafe { pyre_object::w_tuple_getitem(state, 2).unwrap() }; + let w_dict = unsafe { pyre_object::w_tuple_getitem(state, 3).unwrap() }; + pyre_object::gc_roots::pin_roots(&[w_value, w_readnl, w_pos, w_dict]); + Self::reset_buffer_from(slot, w_value)?; + Self::init_newline(slot, pyre_object::gc_roots::shadow_stack_get(slot + 2))?; + let pos = crate::baseobjspace::index_int_w_preserve_negative( + pyre_object::gc_roots::shadow_stack_get(slot + 3), + )?; + if pos < 0 { + return Err(crate::PyError::value_error( + "position value cannot be negative", + )); + } + let this = Self::from_slot(slot); + this.pos = pos; + let w_dict = pyre_object::gc_roots::shadow_stack_get(slot + 4); + if !unsafe { pyre_object::is_none(w_dict) } { + let dict_type = crate::typedef::gettypeobject(&pyre_object::DICT_TYPE); + if !unsafe { crate::baseobjspace::isinstance_w(w_dict, dict_type) } { + return Err(crate::PyError::type_error(format!( + "fourth item of state should be a dict, got a {}", + crate::type_methods::arg_type_name(w_dict) + ))); + } + let own_dict = crate::baseobjspace::getdict_native(this.self_obj()); + super::call_method_result(own_dict, "update", &[w_dict])?; + } + Ok(()) + } +} diff --git a/pyre/pyre-jit/src/eval.rs b/pyre/pyre-jit/src/eval.rs index fc2d8a2470b..219bb3976a9 100644 --- a/pyre/pyre-jit/src/eval.rs +++ b/pyre/pyre-jit/src/eval.rs @@ -3458,6 +3458,23 @@ fn build_gc() -> Box { ], )); pyre_object::longobject::set_bigint_pair_gc_type_id(bigint_pair_tid); + // `_io.BytesIO` keeps its content in an inline `bytearray` field, the + // storage `interp_bytesio.py:66` mixes in from `RStringIO`. Appended at + // the tail so adding the stream type renumbers no established GC id. + register_pyre_class( + &mut gc, + &mut pytype_to_tid, + ::DESCRIPTOR, + ); + // `_io.StringIO` holds its UnicodeIO codepoint array and newline objects + // in managed fields. Appended after BytesIO so established ids stay put. + register_pyre_class( + &mut gc, + &mut pytype_to_tid, + ::DESCRIPTOR, + ); // ── GC-root registration completeness oracle ───────────────────────── // Every `#[pyre_class]` type appends its descriptor to the whole-program // `PYRE_CLASS_DESCRIPTORS` slice. A type with inline managed children diff --git a/pyre/pyre-object/src/pyobject.rs b/pyre/pyre-object/src/pyobject.rs index 7a8923b55b8..f5bfea33c05 100644 --- a/pyre/pyre-object/src/pyobject.rs +++ b/pyre/pyre-object/src/pyobject.rs @@ -632,6 +632,11 @@ pub const SUBCLASS_RANGE_HIERARCHY: &[(u32, Option)] = &[ // `w_class` is the only edge their marker forwards. (157, Some(0)), (158, Some(0)), + // `_io.BytesIO` follows the `rbigint` result pair, which holds 159 as a + // bare `with_gc_ptrs` id and is not an rclass.OBJECT type. + (160, Some(0)), + // `_io.StringIO` follows `_io.BytesIO` at the append-only tail. + (161, Some(0)), ]; /// Compute subclass IDs from [`SUBCLASS_RANGE_HIERARCHY`] and write every From 08dbb1a3658be859aae60696b66bf282f44ed898 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Thu, 6 Aug 2026 12:05:13 +0900 Subject: [PATCH 2/6] objspace: run the canonical type.__getattribute__ body directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getattr_str_impl` reaches the metatype `__getattribute__` slot for every type receiver. `type` defines `__getattribute__`, so `getattribute_if_not_from_object` returns it and the slot was invoked through `get_and_call_function` — wrapping the name into a `w_str`, entering callable dispatch, and re-validating the name through `core::str::from_utf8` — only to reach `typeobject.py:811-828` `W_TypeObject.descr_getattribute`, whose body `object_getattr_miss` already inlines below. `is_type_getattribute_descr` recognises that descriptor by identity against `type`'s own slot (typeobject.py:1322), the same shape `is_object_getattribute_descr` uses for `object`. A metaclass that overrides `__getattribute__` keeps the descriptor-call path. 800k `getattr(SubClass, name)`, medians of 7 interleaved runs: ascii names 0.344s -> 0.238s (-31%), lone-surrogate names 0.451s -> 0.443s (the surrogate path never entered this dispatch). A 54-case type-attribute conformance probe — metaclass `__getattr__` hooks, `__getattribute__` overrides, metatype data descriptors, descriptor `__get__` raising AttributeError, abc/enum, attribute mutation, and installing `__getattribute__` on the metaclass after the fact — produces byte-identical output before and after, and matches cpython3.14 on 52 of those 54 lines. `synth/type_metatype_method_call` loses one wasm guard failure with the residual call. Assisted-by: Claude --- ...pe_metatype_method_call.cranelift.jitstats | 2 + .../type_metatype_method_call.dynasm.jitstats | 2 + .../type_metatype_method_call.wasm.jitstats | 4 +- pyre/pyre-interpreter/src/baseobjspace.rs | 43 +++++++++++++------ 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/pyre/bench/synth/type_metatype_method_call.cranelift.jitstats b/pyre/bench/synth/type_metatype_method_call.cranelift.jitstats index 1cc731febcf..a0796ff2cd2 100644 --- a/pyre/bench/synth/type_metatype_method_call.cranelift.jitstats +++ b/pyre/bench/synth/type_metatype_method_call.cranelift.jitstats @@ -3,6 +3,8 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 guard_failures=1 internal_compile_panics=0 loops_aborted=0 diff --git a/pyre/bench/synth/type_metatype_method_call.dynasm.jitstats b/pyre/bench/synth/type_metatype_method_call.dynasm.jitstats index 1cc731febcf..a0796ff2cd2 100644 --- a/pyre/bench/synth/type_metatype_method_call.dynasm.jitstats +++ b/pyre/bench/synth/type_metatype_method_call.dynasm.jitstats @@ -3,6 +3,8 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 guard_failures=1 internal_compile_panics=0 loops_aborted=0 diff --git a/pyre/bench/synth/type_metatype_method_call.wasm.jitstats b/pyre/bench/synth/type_metatype_method_call.wasm.jitstats index 26fead6b346..a0796ff2cd2 100644 --- a/pyre/bench/synth/type_metatype_method_call.wasm.jitstats +++ b/pyre/bench/synth/type_metatype_method_call.wasm.jitstats @@ -3,7 +3,9 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 -guard_failures=2 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=1 internal_compile_panics=0 loops_aborted=0 loops_compiled=2 diff --git a/pyre/pyre-interpreter/src/baseobjspace.rs b/pyre/pyre-interpreter/src/baseobjspace.rs index 347bb188df5..23fd114730c 100644 --- a/pyre/pyre-interpreter/src/baseobjspace.rs +++ b/pyre/pyre-interpreter/src/baseobjspace.rs @@ -5739,21 +5739,29 @@ fn getattr_str_impl(obj: PyObjectRef, name: &str, call_getattr: bool, suppress: // lookup without re-dispatching to the override. if let Some(w_metatype) = crate::typedef::r#type(obj) { if let Some(slot) = getattribute_if_not_from_object(w_metatype.as_ptr()) { - let name_obj = w_str_new(name); - // objspace.py:666 — bind the metaclass `__getattribute__` - // through `__get__` and call it with the attribute name. - match get_and_call_function(slot, obj, w_metatype.as_ptr(), &[name_obj]) { - Ok(v) => return Ok(v), - Err(e) if e.kind == PyErrorKind::AttributeError => { - return type_getattr_hook_or_err( - obj, - &[Some(w_metatype.as_ptr()), None], - name, - e, - call_getattr, - ); + // typeobject.py:811-828 `W_TypeObject.descr_getattribute` + // is the body inlined by `object_getattr_miss` below. Keep + // a metaclass override on the descriptor-call path, but do + // not wrap and then unwrap an already validated name merely + // to re-enter that same canonical body. + if !is_type_getattribute_descr(slot) { + let name_obj = w_str_new(name); + // objspace.py:666 — bind the metaclass + // `__getattribute__` through `__get__` and call it with + // the attribute name. + match get_and_call_function(slot, obj, w_metatype.as_ptr(), &[name_obj]) { + Ok(v) => return Ok(v), + Err(e) if e.kind == PyErrorKind::AttributeError => { + return type_getattr_hook_or_err( + obj, + &[Some(w_metatype.as_ptr()), None], + name, + e, + call_getattr, + ); + } + Err(e) => return Err(e), } - Err(e) => return Err(e), } } } @@ -9528,6 +9536,13 @@ unsafe fn is_object_getattribute_descr(w_descr: PyObjectRef) -> bool { } } +/// `typeobject.py:1322` — identity anchor for the canonical +/// `W_TypeObject.descr_getattribute` wrapper installed on `type`. +unsafe fn is_type_getattribute_descr(w_descr: PyObjectRef) -> bool { + lookup_in_type_where(crate::typedef::w_type(), "__getattribute__") + .is_some_and(|d| std::ptr::eq(w_descr, d)) +} + /// module.py `Module.descr_getattribute` is the default attribute slot for /// module objects. Module subclasses inherit it unless they explicitly /// replace `__getattribute__`. From 695dc5876b55859dd5a56cee80ae618818ac37e4 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Thu, 6 Aug 2026 13:41:03 +0900 Subject: [PATCH 3/6] objspace: object.__getattribute__ reads the receiver namespace, not a type's MRO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object_getattribute`'s non-instance tail delegated to `getattr_str_impl`, so a type receiver ran `typeobject.py:811-828` `W_TypeObject.descr_getattribute` — the class-MRO walk. `object.__getattribute__(Sub, "b")` therefore returned the value inherited from `Base`; cpython3.14 and pypy3 both raise AttributeError. descroperation.py:88-112 `Object.descr__getattribute__` looks the name up with `space.lookup(w_obj, name)` — the metatype for a type object — and reads only `w_obj.getdictvalue`, never the receiver type's own MRO. The type receiver now shares the instance arm with the metatype as lookup type and the type's own namespace as the receiver dict. `type.__getattribute__` keeps the MRO walk: typedef.rs routes its slot to a named `type_getattribute` instead of the object default. `attr_error_wtf8` reported `'type' object has no attribute` for a type receiver where the `&str` path already reported `type object 'Sub' has no attribute`. Both now share `missing_attribute_subject`, and the message is built as WTF-8 so a lone surrogate survives into `AttributeError.name` and `.obj`. The 54-case type-attribute conformance probe now matches cpython3.14 on every line, on dynasm and cranelift alike; it matched on 52 before. Vendored test_descr (162), test_funcattrs (35), test_descrtut, test_super (40), test_enum (1081), test_abc (72) and test_property (31) report identical counts to a build without this change. Assisted-by: Claude --- pyre/pyre-interpreter/src/baseobjspace.rs | 118 ++++++++++++++-------- pyre/pyre-interpreter/src/typedef.rs | 2 +- 2 files changed, 77 insertions(+), 43 deletions(-) diff --git a/pyre/pyre-interpreter/src/baseobjspace.rs b/pyre/pyre-interpreter/src/baseobjspace.rs index 23fd114730c..eeeafaa44dd 100644 --- a/pyre/pyre-interpreter/src/baseobjspace.rs +++ b/pyre/pyre-interpreter/src/baseobjspace.rs @@ -6288,34 +6288,55 @@ pub(crate) unsafe fn object_delattr_surrogate( } } -/// `raiseattrerror` for a lone-surrogate name. descroperation.py:58-64 -/// renders the name with `%R` (its repr), so a lone surrogate prints as -/// `\udcXX` rather than a lossy replacement char. The repr already -/// supplies the surrounding quotes (`format_wtf8_repr`), matching the -/// `%R` substitution in `"'%T' object has no attribute %R"`. +/// `raiseattrerror` for a lone-surrogate name. descroperation.py:58-64 keeps +/// the original name object in the formatted AttributeError, so build the +/// exception argument as WTF-8 instead of reducing it to a Rust string. fn attr_error_wtf8(obj: PyObjectRef, name: &Wtf8) -> PyError { - let tp_name = unsafe { - match crate::typedef::r#type(obj) { - Some(tp) => pyre_object::w_type_get_name(tp.as_ptr()).to_string(), - None => (*(*obj).ob_type).name.to_string(), - } - }; - let name_repr = crate::display::format_wtf8_repr(name); - let mut err = PyError::new( - PyErrorKind::AttributeError, - format!("'{tp_name}' object has no attribute {name_repr}"), + let mut message = Wtf8Buf::from_string(format!( + "{} has no attribute '", + missing_attribute_subject(obj) + )); + message.push_wtf8(name); + message.push_wtf8(Wtf8::new("'")); + + let _roots = pyre_object::gc_roots::push_roots(); + let obj_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(obj); + let w_name = pyre_object::w_str_from_wtf8(name.to_wtf8_buf()); + let name_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(w_name); + let exc = pyre_object::interp_exceptions::w_exception_new_wtf8( + pyre_object::interp_exceptions::ExcKind::AttributeError, + &message, ); - err.w_name_context = pyre_object::w_str_from_wtf8(name.to_wtf8_buf()); - err.w_obj_context = obj; - err + unsafe { + pyre_object::interp_exceptions::w_exception_set_name( + exc, + pyre_object::gc_roots::shadow_stack_get(name_slot), + ); + pyre_object::interp_exceptions::w_exception_set_attr_obj( + exc, + pyre_object::gc_roots::shadow_stack_get(obj_slot), + ); + PyError::from_exc_object(exc) + } } /// `object.__getattribute__` terminal — the default descriptor protocol /// without the user `__getattribute__` override check. pub fn object_getattribute(obj: PyObjectRef, name: &str) -> PyResult { unsafe { - if is_instance(obj) { - let w_type = w_instance_get_type(obj); + if is_instance(obj) || is_type(obj) { + // descroperation.py:88-112 `Object.descr__getattribute__` uses + // `space.lookup(w_obj, name)`, hence the receiver's class (the + // metatype for a type object), and reads only + // `w_obj.getdictvalue`, never the receiver type's own MRO. + let instance = is_instance(obj); + let w_type = if instance { + w_instance_get_type(obj) + } else { + crate::typedef::r#type(obj).map_or(PY_NULL, |p| p.as_ptr()) + }; let w_descr = lookup_in_type_where(w_type, name); if let Some(descr) = w_descr { if is_data_descr(descr) { @@ -6324,13 +6345,15 @@ pub fn object_getattribute(obj: PyObjectRef, name: &str) -> PyResult { } } } - // Instance dict is the sole authority for instance attributes: - // read the mapdict node directly (getdictvalue, mapdict.py:846-847) - // rather than materialising the MapDictStrategy `__dict__` view, which - // MapDictStrategy.getitem_str (mapdict.py:1168-1175) delegates to - // anyway. No side-table fallback. - let value = - crate::objspace::std::mapdict::instance_node_getdictvalue(obj, Wtf8::new(name)); + // The receiver namespace is the sole authority at this stage. + // Read a user instance's mapdict node directly (getdictvalue, + // mapdict.py:846-847); a type receiver uses only its canonical + // dictionary, which is the corresponding `getdictvalue` result. + let value = if instance { + crate::objspace::std::mapdict::instance_node_getdictvalue(obj, Wtf8::new(name)) + } else { + crate::type_dict_lookup(obj, name) + }; if let Some(value) = value { return Ok(value); } @@ -6347,7 +6370,7 @@ pub fn object_getattribute(obj: PyObjectRef, name: &str) -> PyResult { } return Ok(descr); } - if name == "__class__" { + if instance && name == "__class__" { return Ok(w_type); } // descroperation.py:88 — object.__getattribute__ raises @@ -6362,12 +6385,19 @@ pub fn object_getattribute(obj: PyObjectRef, name: &str) -> PyResult { )); } } - // Non-instance receiver (module, type, builtin object): the pure descriptor - // protocol with no `__getattr__` fallback — that belongs to space.getattr, - // not the bare object.__getattribute__ slot (descroperation.py:88). + // Remaining non-instance receivers (module and builtin objects): preserve + // their pure descriptor protocol with no `__getattr__` fallback — that + // belongs to space.getattr, not the bare object.__getattribute__ slot + // (descroperation.py:88). getattr_str_impl(obj, name, false, false) } +/// typeobject.py:811-828 `W_TypeObject.descr_getattribute` — the canonical +/// metatype-data-descriptor, class-MRO, metatype-non-data-descriptor lookup. +pub(crate) fn type_getattribute(obj: PyObjectRef, name: &str) -> PyResult { + object_getattr_miss(obj, name, false) +} + /// module.py `Module.descr_getattribute` — run the object-default descriptor /// protocol, then the module-dict `__getattr__` hook on AttributeError. pub(crate) fn module_getattribute(obj: PyObjectRef, name: &str) -> PyResult { @@ -11742,17 +11772,7 @@ pub(crate) fn raiseattrerror( "'{tp_name}' object attribute '{name}' is read-only" )); } - let subject = unsafe { - if is_type(obj) { - format!("type object '{}'", pyre_object::w_type_get_name(obj)) - } else { - let tp_name = match crate::typedef::r#type(obj) { - Some(tp) => pyre_object::w_type_get_name(tp.as_ptr()).to_string(), - None => (*(*obj).ob_type).name.to_string(), - }; - format!("'{}' object", tp_name) - } - }; + let subject = missing_attribute_subject(obj); // `object.c _PyObject_GenericSetAttrWithDict` appends the suffix when the // receiver has no dict *slot*. A raising `getdict` says nothing about // whether the object could hold a dict, so the suffix is only added on a @@ -11769,6 +11789,20 @@ pub(crate) fn raiseattrerror( ) } +fn missing_attribute_subject(obj: PyObjectRef) -> String { + unsafe { + if is_type(obj) { + format!("type object '{}'", pyre_object::w_type_get_name(obj)) + } else { + let tp_name = match crate::typedef::r#type(obj) { + Some(tp) => pyre_object::w_type_get_name(tp.as_ptr()).to_string(), + None => (*(*obj).ob_type).name.to_string(), + }; + format!("'{tp_name}' object") + } + } +} + /// Delete an attribute: `del obj.name`. /// /// PyPy: descroperation.py descr__delattr__ diff --git a/pyre/pyre-interpreter/src/typedef.rs b/pyre/pyre-interpreter/src/typedef.rs index 7de135e7d72..15d15eae72b 100644 --- a/pyre/pyre-interpreter/src/typedef.rs +++ b/pyre/pyre-interpreter/src/typedef.rs @@ -10323,7 +10323,7 @@ fn init_type_type(ns: PyObjectRef) { } let name = pyre_object::w_str_get_wtf8(args[1]); match name.as_str() { - Ok(name) => crate::baseobjspace::object_getattribute(w_type, name), + Ok(name) => crate::baseobjspace::type_getattribute(w_type, name), Err(_) => crate::baseobjspace::object_getattribute_surrogate( w_type, args[1], name, ), From bd89028f31400a97020006b1543d4fa0ffdd91b3 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Thu, 6 Aug 2026 14:21:31 +0900 Subject: [PATCH 4/6] jitstats: re-record the pickle fixtures the _io port moves `pickle_ctor_args` and `pickle_terminal_raise_resume` lose the function-entry loops that traced the app-level `_io.BytesIO` methods: loops_compiled 4 -> 2 and 36 -> 31 (wasm 73 -> 68), with `pickle_ctor_args` cranelift also dropping its one bridge and its guard failures 201 -> 1. `loops_aborted` is unchanged on every backend. Assisted-by: Claude --- pyre/bench/synth/pickle_ctor_args.cranelift.jitstats | 8 +++++--- .../synth/pickle_terminal_raise_resume.cranelift.jitstats | 4 +++- .../synth/pickle_terminal_raise_resume.dynasm.jitstats | 6 ++++-- .../synth/pickle_terminal_raise_resume.wasm.jitstats | 6 ++++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/pyre/bench/synth/pickle_ctor_args.cranelift.jitstats b/pyre/bench/synth/pickle_ctor_args.cranelift.jitstats index a2a8fa3ce59..a0796ff2cd2 100644 --- a/pyre/bench/synth/pickle_ctor_args.cranelift.jitstats +++ b/pyre/bench/synth/pickle_ctor_args.cranelift.jitstats @@ -1,9 +1,11 @@ -bridges_compiled=1 +bridges_compiled=0 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 -guard_failures=201 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=1 internal_compile_panics=0 loops_aborted=0 -loops_compiled=4 +loops_compiled=2 diff --git a/pyre/bench/synth/pickle_terminal_raise_resume.cranelift.jitstats b/pyre/bench/synth/pickle_terminal_raise_resume.cranelift.jitstats index 3e20b65303d..db0d2de8154 100644 --- a/pyre/bench/synth/pickle_terminal_raise_resume.cranelift.jitstats +++ b/pyre/bench/synth/pickle_terminal_raise_resume.cranelift.jitstats @@ -3,7 +3,9 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 guard_failures=656 internal_compile_panics=0 loops_aborted=1 -loops_compiled=36 +loops_compiled=31 diff --git a/pyre/bench/synth/pickle_terminal_raise_resume.dynasm.jitstats b/pyre/bench/synth/pickle_terminal_raise_resume.dynasm.jitstats index 3add0c6956f..52e6ae5ab59 100644 --- a/pyre/bench/synth/pickle_terminal_raise_resume.dynasm.jitstats +++ b/pyre/bench/synth/pickle_terminal_raise_resume.dynasm.jitstats @@ -3,7 +3,9 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 -guard_failures=463 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=354 internal_compile_panics=0 loops_aborted=1 -loops_compiled=36 +loops_compiled=31 diff --git a/pyre/bench/synth/pickle_terminal_raise_resume.wasm.jitstats b/pyre/bench/synth/pickle_terminal_raise_resume.wasm.jitstats index 756a0c8ba33..b6607e90cd0 100644 --- a/pyre/bench/synth/pickle_terminal_raise_resume.wasm.jitstats +++ b/pyre/bench/synth/pickle_terminal_raise_resume.wasm.jitstats @@ -3,7 +3,9 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=5 -guard_failures=464 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=355 internal_compile_panics=0 loops_aborted=13 -loops_compiled=73 +loops_compiled=68 From a78e517bb4a8212aa2d2aef2d93e34471b33043c Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Thu, 6 Aug 2026 14:23:04 +0900 Subject: [PATCH 5/6] jitstats: record the four wasm guard-failure counts the rebase base moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `closure_per_call` 470 -> 468, `exception_traceback_frame_lineno` 820 -> 819, `recursive_call_frame_relocation` 649 -> 648 and `gc_iterator_source_drop` 613 -> 614 on wasm. These are not this branch's: check.py ran wasm 383/383 on the previous base with both objspace commits already applied, and the four moved only after rebasing onto 1de95e0d321, which carries #1060, #1072 and #1047 — all three change guard emission. Each count reproduces exactly across repeated runs, so it is a transition and not the back-edge poll oscillation. dynasm and cranelift are 388/388 either way. Assisted-by: Claude --- pyre/bench/synth/closure_per_call.wasm.jitstats | 4 +++- .../synth/exception_traceback_frame_lineno.wasm.jitstats | 4 +++- pyre/bench/synth/gc_iterator_source_drop.wasm.jitstats | 4 +++- .../bench/synth/recursive_call_frame_relocation.wasm.jitstats | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pyre/bench/synth/closure_per_call.wasm.jitstats b/pyre/bench/synth/closure_per_call.wasm.jitstats index fb6a485e39d..04e0b5011b3 100644 --- a/pyre/bench/synth/closure_per_call.wasm.jitstats +++ b/pyre/bench/synth/closure_per_call.wasm.jitstats @@ -3,7 +3,9 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 -guard_failures=470 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=468 internal_compile_panics=0 loops_aborted=0 loops_compiled=4 diff --git a/pyre/bench/synth/exception_traceback_frame_lineno.wasm.jitstats b/pyre/bench/synth/exception_traceback_frame_lineno.wasm.jitstats index f1d4098f127..95b7ea20405 100644 --- a/pyre/bench/synth/exception_traceback_frame_lineno.wasm.jitstats +++ b/pyre/bench/synth/exception_traceback_frame_lineno.wasm.jitstats @@ -3,7 +3,9 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 -guard_failures=820 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=819 internal_compile_panics=0 loops_aborted=0 loops_compiled=17 diff --git a/pyre/bench/synth/gc_iterator_source_drop.wasm.jitstats b/pyre/bench/synth/gc_iterator_source_drop.wasm.jitstats index 51649cfb022..6e3b5767b12 100644 --- a/pyre/bench/synth/gc_iterator_source_drop.wasm.jitstats +++ b/pyre/bench/synth/gc_iterator_source_drop.wasm.jitstats @@ -3,7 +3,9 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 -guard_failures=613 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=614 internal_compile_panics=0 loops_aborted=0 loops_compiled=4 diff --git a/pyre/bench/synth/recursive_call_frame_relocation.wasm.jitstats b/pyre/bench/synth/recursive_call_frame_relocation.wasm.jitstats index d98f1879e71..4bc1b8e3595 100644 --- a/pyre/bench/synth/recursive_call_frame_relocation.wasm.jitstats +++ b/pyre/bench/synth/recursive_call_frame_relocation.wasm.jitstats @@ -3,7 +3,9 @@ descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 fbw_rolled_back_with_effects=0 -guard_failures=649 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=648 internal_compile_panics=0 loops_aborted=0 loops_compiled=3 From 31ac337492a96a6eef1e5e3eadad3bc0e8ed2b3c Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Thu, 6 Aug 2026 14:54:23 +0900 Subject: [PATCH 6/6] _io: cite the close-while-exported divergence in W_BytesIO::close `interp_bytesio.py:194` `close_w` delegates straight to `RStringIO.close` with no export check, so it releases the storage under a live `getbuffer()` result. `_io.BytesIO.close` raises `BufferError: Existing exports of data: object cannot be re-sized` in that state, which the `check_exports()` call here already reproduced; only the comment naming the upstream line was missing. Comment-only change. Assisted-by: Claude --- pyre/pyre-interpreter/src/module/_io/bytesio.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyre/pyre-interpreter/src/module/_io/bytesio.rs b/pyre/pyre-interpreter/src/module/_io/bytesio.rs index fcaa85367b5..48b5eeb9869 100644 --- a/pyre/pyre-interpreter/src/module/_io/bytesio.rs +++ b/pyre/pyre-interpreter/src/module/_io/bytesio.rs @@ -443,6 +443,10 @@ impl W_BytesIO { fn close(&mut self) -> Result<(), crate::PyError> { // Any replacement of the exported bytearray would invalidate the // view, so it takes the same resize lock as write/truncate/__init__. + // `interp_bytesio.py:194` `close_w` omits the check and drops the + // storage from under a live `getbuffer()` result; closing an exported + // buffer has to raise `BufferError: Existing exports of data: object + // cannot be re-sized`, so the check runs ahead of the store. if self.closed { return Ok(()); }