From cd90f0077c84397c4f1170d059a0aacd445f9426 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Fri, 7 Aug 2026 21:49:24 +0900 Subject: [PATCH 01/10] extra_tests: report the parity runner's failures where a CI log is read The per-failure detail and the `N failure(s)` count went to stderr while the 213-row result table went to stdout. A piped stdout is block-buffered and stderr is not, so the whole report arrived in the log *above* the run's own header, and the last thing before the runner's non-zero exit was a passing row -- a failed job named none of what failed in it. The report is printed last now, on stdout, and echoes each child's stderr verbatim instead of a `repr` of the whole thing on one line: a traceback only reads as one when its line breaks survive. Each failing row also carries its one-line verdict beneath it, and under `GITHUB_ACTIONS` every failure emits an `::error file=` annotation carrying that verdict and the exception line, which shows on the pull request without opening the log. stdout is line-buffered so a run that dies mid-way names the scripts it got through, and pinned to UTF-8 because the report echoes a child's stderr: these scripts are largely about names no console codepage can spell, and printing one of those raised `UnicodeEncodeError` out of the runner instead of the failure it was in the middle of explaining. `_run` returns the reason and the stderr as separate values, which `Failure` carries. --- pyre/extra_tests/parity_tests/run.py | 124 +++++++++++++++++++++------ 1 file changed, 99 insertions(+), 25 deletions(-) diff --git a/pyre/extra_tests/parity_tests/run.py b/pyre/extra_tests/parity_tests/run.py index 4167f2744ee..a0e9ba0fcb6 100644 --- a/pyre/extra_tests/parity_tests/run.py +++ b/pyre/extra_tests/parity_tests/run.py @@ -48,6 +48,7 @@ import subprocess import sys from pathlib import Path +from typing import NamedTuple HERE = Path(__file__).resolve().parent ROOT = HERE.parent.parent.parent @@ -111,7 +112,30 @@ def _script_env(script: Path) -> dict[str, str]: return {m.group(1): m.group(2) for m in _ENV_DIRECTIVE.finditer(text)} -def _run(cmd: list[str], script: Path, env: dict[str, str] | None) -> tuple[bool, str]: +TIMEOUT = 30 + + +class Failure(NamedTuple): + """One (script, runner) pair that did not pass, and the evidence.""" + + script: str + backend: str + # What the runner concluded, in one line — fits beside the table row. + reason: str + # The child's stderr, verbatim. A traceback is the answer to "why", and it + # only reads as one when its own line breaks survive. + stderr: str + + +def _run( + cmd: list[str], script: Path, env: dict[str, str] | None +) -> tuple[bool, str, str]: + """Whether the script passed, why it did not, and the child's stderr. + + The reason is kept apart from the stderr rather than formatted into it: a + `repr` of a whole traceback is one unreadable line, and the run wants the + short verdict beside the row and the traceback in the report at the end. + """ try: proc = subprocess.run( cmd + [str(script)], @@ -119,27 +143,25 @@ def _run(cmd: list[str], script: Path, env: dict[str, str] | None) -> tuple[bool text=True, encoding="utf-8", errors="replace", - timeout=30, + timeout=TIMEOUT, env=None if env is None else {**os.environ, **env}, ) - except subprocess.TimeoutExpired: - return False, "timeout" - out = proc.stdout - err = proc.stderr - lines = [line for line in out.splitlines() if line.strip()] + except subprocess.TimeoutExpired as expired: + partial = expired.stderr or "" + return False, f"timed out after {TIMEOUT}s", partial + lines = [line for line in proc.stdout.splitlines() if line.strip()] last = lines[-1] if lines else "" - ok = proc.returncode == 0 and last == "OK" - if ok: - detail = "" - elif proc.returncode == 0: + if proc.returncode == 0 and last == "OK": + return True, "", "" + if proc.returncode == 0: # The script ran to completion and never announced itself. Reporting # this as `rc=0 last=''` reads like an interpreter that produced # nothing, and it is reported once per runner, so three of them make a # sound fixture that forgot its last line look like a pyre failure. - detail = f"exited 0 without a final 'OK' line (last non-empty line {last!r})" + reason = f"exited 0 without a final 'OK' line (last non-empty line {last!r})" else: - detail = f"rc={proc.returncode} last={last!r} stderr={err.strip()!r}" - return ok, detail + reason = f"exited {proc.returncode} (last non-empty stdout line {last!r})" + return False, reason, proc.stderr PROBE = "import sys; print(sys.version_info[0], sys.version_info[1]); print(sys.executable)" @@ -206,6 +228,45 @@ def _cpython() -> str: ) +def _report(failures: list[Failure]) -> None: + """The evidence for every failure, printed last and on stdout. + + Last because a CI log is read from its end, and the table above it is two + hundred rows: a reader who has to scroll up for the reason reruns the job + instead. On stdout because the table is — these lines used to go to stderr, + which is unbuffered where a piped stdout is not, so the whole report + arrived in the log *above* the run's own header and the last thing before + the runner's non-zero exit was a passing row. + """ + print("=" * 72) + print(f"{len(failures)} failure(s)") + for failure in failures: + print() + print(f" {failure.script} [{failure.backend}]: {failure.reason}") + for line in failure.stderr.strip().splitlines(): + print(f" {line}") + print("=" * 72) + + +def _annotate(failures: list[Failure]) -> None: + """One GitHub Actions error annotation per failure. + + An annotation shows on the pull request and the job summary, so the + reason survives even for a reader who never opens the log. The message is + a single line by the format's own rule, so it carries the verdict and the + exception line rather than the whole traceback. + """ + for failure in failures: + spoken = [line.strip() for line in failure.stderr.splitlines() if line.strip()] + tail = spoken[-1] if spoken else "" + message = f"{failure.backend}: {failure.reason}" + if tail: + message += f" | {tail}" + escaped = message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + path = (HERE / failure.script).relative_to(ROOT).as_posix() + print(f"::error file={path},title=parity::{escaped}") + + def _runners( only_dynasm: bool, only_cranelift: bool, gc_poison: bool ) -> list[tuple[str, list[str], dict[str, str] | None]]: @@ -228,6 +289,15 @@ def main() -> int: parser.add_argument("--gc-poison", action="store_true") args = parser.parse_args() + # A piped stdout is block-buffered, so without line buffering the rows + # arrive in the log in one burst at the end and a run that dies mid-way + # names none of the scripts it got through. The encoding is pinned because + # the report echoes a child's stderr, and several of these scripts are + # about names no console codepage can spell — printing one of those on a + # non-UTF-8 console used to kill the runner with a `UnicodeEncodeError` + # instead of reporting the failure it was in the middle of explaining. + sys.stdout.reconfigure(encoding="utf-8", errors="replace", line_buffering=True) + runners = _runners(args.dynasm_only, args.cranelift_only, args.gc_poison) scripts, skipped = _scripts() if not scripts: @@ -240,27 +310,31 @@ def main() -> int: print(f"skipped ({sys.platform} not in its platforms): {script.name}") print() - fail = 0 + failures: list[Failure] = [] for script in scripts: name = script.name pinned = _script_env(script) row: list[str] = [f" {name:<36s}"] + reasons: list[str] = [] for backend, cmd, env in runners: merged = {**(env or {}), **pinned} - ok, detail = _run(cmd, script, merged or None) - mark = "OK" if ok else "FAIL" - row.append(f"{backend}={mark}") + ok, reason, err = _run(cmd, script, merged or None) + row.append(f"{backend}={'OK' if ok else 'FAIL'}") if not ok: - fail += 1 - print(f" {backend} {name}: {detail}", file=sys.stderr) + failures.append(Failure(name, backend, reason, err)) + reasons.append(f" {backend}: {reason}") print(" ".join(row)) + for line in reasons: + print(line) print() - if fail: - print(f"{fail} failure(s)", file=sys.stderr) - return 1 - print("all parity tests pass") - return 0 + if not failures: + print("all parity tests pass") + return 0 + _report(failures) + if os.environ.get("GITHUB_ACTIONS") == "true": + _annotate(failures) + return 1 if __name__ == "__main__": From 07cf09eef2a92adec15cbe8f345b9cc98caf3c0d Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sat, 8 Aug 2026 01:22:26 +0900 Subject: [PATCH 02/10] posix: the pre-epoch utime Windows refused, and six answers beside it `os.utime` on Windows turned away every time before 1970. The host call takes its times as a `Duration`, which has no second below its epoch at all, so `u64::try_from(sec)` was the whole pre-epoch range's refusal. A FILETIME counts 100ns ticks from 1601-01-01, so shifting the epoch is what makes such a second a positive tick count; `SetFileTime` is called here now, over the handle `host_env::fs::open_write_with_custom_flags` opens, with the same wrapping `__int64` arithmetic `time_t_to_FILE_TIME` is written in. Measured against CPython 3.14 on the same host, `os.utime(p, times=(-5.0, -6.0))` now reads back as -6_000_000_000 where it raised, and `ns=(-1, -1)` as the -100 a FILETIME's granularity leaves. Six more answers along the same argument, each measured against 3.14: ('a', 'b') ValueError: could not convert string to float -> TypeError: argument must be int or float, not str (1e30, 0) ValueError: utime: timestamp out of range -> OverflowError: timestamp out of range for platform time_t (2**200, 0) the same, and by the exact integer rather than through a float that rounded the seconds it could not hold (nan, 0) ValueError: utime: timestamp out of range -> ValueError: Invalid value NaN (not a number) (1,) utime: 'times' must be a tuple of two ints -> ... must be either a tuple of two ints or None ns=(2**80, 0) OverflowError -> written. `split_py_long_to_s_and_ns` splits with `divmod` BEFORE it narrows anything, so a nanosecond count too wide for a `time_t` is refused only when the second it names is; 2**80 ns is a second that fits. Dividing after the narrowing turned away the range. `divmod` is also what answers for `ns=('a', 'b')`. `os.truncate`/`os.ftruncate` on Windows read their length with a bare `int_w`: no `__index__`, and `int too large to convert to int` where `Py_off_t_converter` says `int too big to convert`. Both now go through `truncate_length_w`, which is hoisted out of the unix arm and names the C type the platform's converter names. `st_atime_ns` and its two siblings took `sec * 1_000_000_000` in `i64`, which runs out in 2262 -- a file dated later, which every FILETIME up to the year 30828 can be, read back as the wrap. The product is taken in `i128` and the field is an int of whatever width it needs. `parity_tests/os_utime_pathconf_truncate` covers all of it and no longer skips its negative-time section on Windows; only the exact-nanosecond value is platform-dependent there. Its `pathconf` section is now gated on the name existing, because neither CPython nor this build carries `pathconf` on Windows and the reference failed the script before any backend could -- which is what made the whole script red on every Windows runner. --- .../os_utime_pathconf_truncate.py | 128 +++++--- pyre/pyre-interpreter/src/builtins.rs | 2 +- .../src/module/posix/interp_posix.rs | 282 ++++++++++++------ 3 files changed, 273 insertions(+), 139 deletions(-) diff --git a/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py b/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py index 4f68ea9cccd..754395de8a8 100644 --- a/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py +++ b/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py @@ -40,47 +40,71 @@ def raises(call, exc): # above it, so -1ns is the last nanosecond of 1969 rather than a value with no # representation. # -# Windows is left out of this block: a FILETIME counts 100ns ticks, so -# ns=(-1, -1) is not a time that filesystem can hold (it reads -1 back as -# -100), and the descriptor form below is not one Windows advertises. The -# keyword `times` case after the block, which lands on whole seconds, is -# written through SetFileTime everywhere. -if sys.platform != "win32": - os.utime(p, ns=(-1, -1)) - check(os.stat(p).st_mtime_ns == -1, f"utime(ns=(-1,-1)) -> {os.stat(p).st_mtime_ns}") - os.utime(p, ns=(-2_500_000_000, -2_500_000_000)) - check(os.stat(p).st_mtime_ns == -2_500_000_000, "utime(ns) lost a negative second") - - os.utime(p, (-1.5, -2.5)) - check( - os.stat(p).st_mtime_ns == -2_500_000_000, - f"utime((-1.5,-2.5)) -> {os.stat(p).st_mtime_ns}", - ) - check(os.stat(p).st_atime_ns == -1_500_000_000, "utime(times) lost the access time") - - # The same through a descriptor, which is the form supports_fd advertises. - if os.utime in os.supports_fd: - fd = os.open(p, os.O_RDWR) - try: - os.utime(fd, ns=(-3_000_000_000, -4_000_000_000)) - check( - os.stat(p).st_mtime_ns == -4_000_000_000, - "utime(fd, ns) lost a negative second", - ) - finally: - os.close(fd) - -# `times` is the one argument here that may be spelled either way — it sits -# before the keyword-only marker. The pair is one the platform holds: Windows -# refuses times before the epoch, for the reason given above. -atime, mtime = (5.0, 6.0) if sys.platform == "win32" else (-5.0, -6.0) -os.utime(p, times=(atime, mtime)) +# A Windows FILETIME counts 100ns ticks, so only the nanoseconds that are a +# multiple of 100 are times that filesystem can hold: -1ns reads back as -100 +# there, and the exact-nanosecond checks are the ones left out rather than the +# negative range itself. +os.utime(p, ns=(-1, -1)) +tick = -100 if sys.platform == "win32" else -1 +check(os.stat(p).st_mtime_ns == tick, f"utime(ns=(-1,-1)) -> {os.stat(p).st_mtime_ns}") +os.utime(p, ns=(-2_500_000_000, -2_500_000_000)) +check(os.stat(p).st_mtime_ns == -2_500_000_000, "utime(ns) lost a negative second") + +os.utime(p, (-1.5, -2.5)) check( - os.stat(p).st_mtime_ns == int(mtime) * 1_000_000_000, - f"utime(times=...) by keyword -> {os.stat(p).st_mtime_ns}", + os.stat(p).st_mtime_ns == -2_500_000_000, + f"utime((-1.5,-2.5)) -> {os.stat(p).st_mtime_ns}", ) +check(os.stat(p).st_atime_ns == -1_500_000_000, "utime(times) lost the access time") + +# The same through a descriptor, which is the form supports_fd advertises. +if os.utime in os.supports_fd: + fd = os.open(p, os.O_RDWR) + try: + os.utime(fd, ns=(-3_000_000_000, -4_000_000_000)) + check( + os.stat(p).st_mtime_ns == -4_000_000_000, + "utime(fd, ns) lost a negative second", + ) + finally: + os.close(fd) + +# `times` is the one argument here that may be spelled either way — it sits +# before the keyword-only marker. +os.utime(p, times=(-5.0, -6.0)) +check(os.stat(p).st_mtime_ns == -6_000_000_000, "utime(times=...) by keyword") raises(lambda: os.utime(p, (1, 2), times=(3, 4)), TypeError) +# ── what a second may be spelled as ─────────────────────────────────────── +# A value that is not a number is refused by type rather than by a failed +# conversion, one no `time_t` can hold by overflow rather than by value, and +# a NaN has no floor to take at all. +e = raises(lambda: os.utime(p, ("a", "b")), TypeError) +check(str(e) == "argument must be int or float, not str", str(e)) +e = raises(lambda: os.utime(p, (None, 1)), TypeError) +check(str(e) == "argument must be int or float, not NoneType", str(e)) +for far in (1e30, float("inf"), 2**200, -(2**200)): + e = raises(lambda: os.utime(p, (far, 0)), OverflowError) + check(str(e) == "timestamp out of range for platform time_t", f"{far!r}: {e}") +e = raises(lambda: os.utime(p, (float("nan"), 0.0)), ValueError) +check(str(e) == "Invalid value NaN (not a number)", str(e)) +# `times` is the argument that also has a `None` spelling, and its message +# names it; `ns` has no such form. +e = raises(lambda: os.utime(p, (1,)), TypeError) +check(str(e) == "utime: 'times' must be either a tuple of two ints or None", str(e)) +e = raises(lambda: os.utime(p, ns=(1,)), TypeError) +check(str(e) == "utime: 'ns' must be a tuple of two ints", str(e)) + +# `ns` is split with divmod before anything is narrowed, so a nanosecond count +# too wide for a `time_t` is only refused when the SECOND it names is. +os.utime(p, ns=(2**62, 2**62)) +check(os.stat(p).st_mtime_ns == 4611686018427387900, os.stat(p).st_mtime_ns) + +# Nanoseconds run out of an `int64` in 2262, and a file may carry a later time +# than that — `st_mtime_ns` is the number it is rather than a wrap. +os.utime(p, (8.8e11, 8.8e11)) +check(os.stat(p).st_mtime_ns == 880_000_000_000_000_000_000, os.stat(p).st_mtime_ns) + # Back to a time the rest of the file can be reasoned about. os.utime(p, ns=(1_000_000_000, 2_000_000_000)) @@ -89,20 +113,26 @@ def raises(call, exc): # question — the terminal-only limits are not ones a regular file has. What no # answer may be is None: a host with no determinate value says so with -1. # -# pathconf and pathconf_names are POSIX-only; Windows has neither, so the -# section is skipped there rather than asked of a name that cannot answer. -if sys.platform != "win32": +# `pathconf` and the `pathconf_names` table it resolves through are a POSIX +# surface; neither runtime carries them on Windows, so there is nothing to +# compare there. `hasattr` rather than a platform list: what the section needs +# is the name, not the platform. +def limits(target): + for name in sorted(os.pathconf_names): + try: + limit = os.pathconf(target, name) + except OSError: + continue + check(isinstance(limit, int), f"pathconf({name!r}) answered {limit!r}") + check(limit >= -1, f"pathconf({name!r}) answered {limit}") + yield name, limit - def limits(target): - for name in sorted(os.pathconf_names): - try: - limit = os.pathconf(target, name) - except OSError: - continue - check(isinstance(limit, int), f"pathconf({name!r}) answered {limit!r}") - check(limit >= -1, f"pathconf({name!r}) answered {limit}") - yield name, limit +check( + hasattr(os, "pathconf") == hasattr(os, "pathconf_names"), + "one of pathconf / pathconf_names is here without the other", +) +if hasattr(os, "pathconf"): answered = dict(limits(p)) check(answered, "pathconf answered no name at all") check("PC_NAME_MAX" in answered, "pathconf refused PC_NAME_MAX on a regular file") diff --git a/pyre/pyre-interpreter/src/builtins.rs b/pyre/pyre-interpreter/src/builtins.rs index 11b08c68462..ac2c6d4b023 100644 --- a/pyre/pyre-interpreter/src/builtins.rs +++ b/pyre/pyre-interpreter/src/builtins.rs @@ -16279,7 +16279,7 @@ pub fn is_builtin_divmod_function(callable: PyObjectRef) -> bool { } /// `divmod(a, b)` — pypy/interpreter/baseobjspace.py divmod row. -fn builtin_divmod(args: &[PyObjectRef]) -> Result { +pub(crate) fn builtin_divmod(args: &[PyObjectRef]) -> Result { let (args, kwargs) = split_builtin_kwargs(args); if has_real_kwargs(kwargs) { return Err(crate::PyError::type_error( diff --git a/pyre/pyre-interpreter/src/module/posix/interp_posix.rs b/pyre/pyre-interpreter/src/module/posix/interp_posix.rs index e56f56212cc..162d75e079a 100644 --- a/pyre/pyre-interpreter/src/module/posix/interp_posix.rs +++ b/pyre/pyre-interpreter/src/module/posix/interp_posix.rs @@ -1650,6 +1650,39 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { ) } + /// The width the platform's truncating call takes its length in. + #[cfg(windows)] + type TruncateLen = i64; + #[cfg(not(windows))] + type TruncateLen = libc::off_t; + + /// `space.int_w` over the `r_longlong` half of `interp_posix.py:404`. + /// + /// `Py_off_t_converter` names the C type it could not fit the value + /// into, and that type is the platform's: a `long` where the converter + /// is `PyLong_AsLong`, and nothing at all where it is + /// `PyLong_AsLongLong`. + fn truncate_length_w(obj: PyObjectRef) -> Result { + const TOO_BIG: &str = if cfg!(windows) { + "int too big to convert" + } else { + "Python int too large to convert to C long" + }; + let w_length = crate::baseobjspace::space_index(obj)?; + let length = crate::baseobjspace::int_w(w_length).map_err(|err| { + if err.kind == crate::PyErrorKind::OverflowError { + crate::PyError::overflow_error(TOO_BIG) + } else { + err + } + })?; + // `off_t` is the width the call takes the length in, and a value + // above it is not a size the file can be given. An `as` cast would + // wrap it into one the caller never asked for and truncate the file + // to that instead. + TruncateLen::try_from(length).map_err(|_| crate::PyError::overflow_error(TOO_BIG)) + } + fn fs_err_with_filename(e: std::io::Error, w_path: PyObjectRef) -> crate::PyError { fs_err_with_filename2(e, 0, w_path, pyre_object::PY_NULL) } @@ -2493,8 +2526,16 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { if !unsafe { pyre_object::is_tuple(obj) } || unsafe { pyre_object::w_tuple_len(obj) } != 2 { + // `times` is the argument that also has a `None` spelling + // — it is the one whose default means "now" — and its + // message names that spelling. `ns` has no such form. + let shape = if what == "times" { + "either a tuple of two ints or None" + } else { + "a tuple of two ints" + }; return Err(crate::PyError::type_error(format!( - "utime: '{what}' must be a tuple of two ints" + "utime: '{what}' must be {shape}" ))); } Ok(( @@ -2502,6 +2543,15 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { unsafe { pyre_object::w_tuple_getitem(obj, 1) }.unwrap(), )) }; + /// The out-of-range answer for both spellings of a second. + /// + /// `_PyTime_ObjectToDenominator` refuses a value no `time_t` can hold + /// by overflow rather than by value, and `_PyLong_AsTime_t` gives the + /// same words for an integer too wide to be one — `(2**200, 0)` and + /// `(1e30, 0)` answer alike. + fn time_t_overflow() -> crate::PyError { + crate::PyError::overflow_error("timestamp out of range for platform time_t") + } // `_PyTime_ObjectToTimespec(..., _PyTime_ROUND_FLOOR)`: the seconds are // the floor of the value and the nanoseconds are what is left above // that floor, so they stay in `0..1_000_000_000` however negative the @@ -2509,34 +2559,88 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { // `(-3, 500000000)`, which reads back as `-1_500_000_000` and // `-2_500_000_000` nanoseconds. let time_from_secs = |v: PyObjectRef| -> Result { - let f = crate::builtins::builtin_float(&[v])?; + // An integer names its second exactly, so it is read as one + // rather than through a float that would round the seconds it is + // too wide to hold. + if unsafe { pyre_object::is_int_or_long(v) } { + let sec = crate::builtins::space_index_w(v).map_err(|_| time_t_overflow())?; + return Ok(UTime { sec, nsec: 0 }); + } + // Everything else has to name a float. What cannot is not a time + // at all, and is refused by type: `utime(p, ('a', 'b'))` names the + // type it was given rather than reporting a failed float parse. + let f = crate::builtins::builtin_float(&[v]).map_err(|err| { + if err.kind == crate::PyErrorKind::OverflowError { + time_t_overflow() + } else { + crate::PyError::type_error(format!( + "argument must be int or float, not {}", + crate::type_methods::arg_type_name(v) + )) + } + })?; let secs = unsafe { pyre_object::w_float_get_value(f) }; + // A NaN has no floor to take, and it is the one non-finite value + // answered by value rather than by range. + if secs.is_nan() { + return Err(crate::PyError::value_error( + "Invalid value NaN (not a number)", + )); + } let floor = secs.floor(); - // The floor of a non-finite or too-large value is not a second any + // The floor of an infinite or too-large value is not a second any // clock names; `i64::MIN`/`MAX` are what an `as` cast would answer // for both, so the range is checked before the cast rather than // read back out of it. if !(floor >= -(2f64.powi(63)) && floor < 2f64.powi(63)) { - return Err(crate::PyError::value_error("utime: timestamp out of range")); + return Err(time_t_overflow()); } let mut sec = floor as i64; let mut nsec = ((secs - floor) * 1e9).floor() as i64; if nsec >= 1_000_000_000 { nsec -= 1_000_000_000; - sec = sec - .checked_add(1) - .ok_or_else(|| crate::PyError::value_error("utime: timestamp out of range"))?; + sec = sec.checked_add(1).ok_or_else(time_t_overflow)?; } Ok(UTime { sec, nsec }) }; let time_from_ns = |v: PyObjectRef| -> Result { - let n = crate::builtins::space_index_w(v)?; - // `split_py_long_to_s_and_ns` divides by `1_000_000_000` the way - // Python's own `//` and `%` do, so a negative count of nanoseconds + // `split_py_long_to_s_and_ns` splits with `divmod` before it + // narrows anything, so a count of nanoseconds too wide for a + // `time_t` is only refused when the SECOND it names is — `ns=2**80` + // is a second that fits. Dividing after the narrowing turned away + // the whole range instead. `divmod` is also what answers for a + // value that is not a number at all. + let split = crate::builtins::builtin_divmod(&[ + v, + pyre_object::w_int_new(1_000_000_000), + ])?; + let (w_sec, w_nsec) = unsafe { + ( + pyre_object::w_tuple_getitem(split, 0), + pyre_object::w_tuple_getitem(split, 1), + ) + }; + let (Some(w_sec), Some(w_nsec)) = (w_sec, w_nsec) else { + return Err(crate::PyError::type_error( + "utime: divmod() returned a non-pair", + )); + }; + // Python's own `//` and `%`, so a negative count of nanoseconds // lands on the second below it with a positive remainder. + // + // Only an integer second can be out of a `time_t`'s range. A + // quotient that is not one at all — `divmod` answers a float pair + // for `ns=(1.5, 2.5)` — keeps the conversion's own refusal. + let sec = crate::builtins::space_index_w(w_sec).map_err(|err| { + if err.kind == crate::PyErrorKind::OverflowError { + time_t_overflow() + } else { + err + } + })?; Ok(UTime { - sec: n.div_euclid(1_000_000_000), - nsec: n.rem_euclid(1_000_000_000), + sec, + nsec: crate::builtins::space_index_w(w_nsec)?, }) }; @@ -2597,29 +2701,32 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { "utime: dir_fd and follow_symlinks=False are unavailable on this platform", )); } - // A FILETIME counts 100-ns intervals from 1601-01-01, so a - // pre-epoch time is an ordinary positive count rather than one with - // no representation; utimensat and every POSIX host write it, and - // 3.14 writes it through `SetFileTime` (rposix.win32_utime). The - // sub-100ns of a nanosecond is not a tick the filesystem holds and - // is floored, as the call floors it. + // `time_t_to_FILE_TIME` (`rwin32file.py:320-323`): a FILETIME + // counts 100ns ticks from 1601-01-01, so shifting the epoch is what + // makes a second before 1970 an ordinary positive tick count rather + // than one with no representation. The sub-100ns of a nanosecond is + // not a tick the filesystem holds and is floored, as the conversion + // floors it — which is why `ns=-1` reads back as `-100`. + // + // The arithmetic wraps rather than checks: `r_longlong` there, and + // the same `__int64` in the C the line is a transcription of. A + // second this filesystem cannot hold writes the bits the + // multiplication leaves rather than being diagnosed, and a value no + // `time_t` can hold has already been refused by `time_from_secs`. const EPOCH_DIFF: i64 = 11_644_473_600; - let to_filetime = |t: UTime| -> Result { + let to_filetime = |t: UTime| -> FILETIME { let ticks = t .sec - .checked_add(EPOCH_DIFF) - .filter(|s| *s >= 0) - .and_then(|s| s.checked_mul(10_000_000)) - .and_then(|s| s.checked_add(t.nsec / 100)) - .ok_or_else(|| crate::PyError::value_error("utime: timestamp out of range"))? - as u64; - Ok(FILETIME { + .wrapping_add(EPOCH_DIFF) + .wrapping_mul(10_000_000) + .wrapping_add(t.nsec / 100) as u64; + FILETIME { dwLowDateTime: ticks as u32, dwHighDateTime: (ticks >> 32) as u32, - }) + } }; - let atime = to_filetime(access)?; - let mtime = to_filetime(modified)?; + let atime = to_filetime(access); + let mtime = to_filetime(modified); let wide = wide_path(&path.as_bytes)?; // FILE_WRITE_ATTRIBUTES is the access `SetFileTime` takes; // FILE_FLAG_BACKUP_SEMANTICS lets the name open a directory too. @@ -3111,9 +3218,9 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { meta.atime(), meta.mtime(), meta.ctime(), - meta.atime() * 1_000_000_000 + meta.atime_nsec(), - meta.mtime() * 1_000_000_000 + meta.mtime_nsec(), - meta.ctime() * 1_000_000_000 + meta.ctime_nsec(), + whole_ns(meta.atime(), meta.atime_nsec()), + whole_ns(meta.mtime(), meta.mtime_nsec()), + whole_ns(meta.ctime(), meta.ctime_nsec()), ) }; #[cfg(windows)] @@ -3167,12 +3274,9 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { let atime_secs = (meta.last_access_time() as i64 / 10_000_000) - EPOCH_DIFF; let mtime_secs = (meta.last_write_time() as i64 / 10_000_000) - EPOCH_DIFF; let ctime_secs = (meta.creation_time() as i64 / 10_000_000) - EPOCH_DIFF; - let atime_ns = - ((meta.last_access_time() as i64 % 10_000_000) * 100) + atime_secs * 1_000_000_000; - let mtime_ns = - ((meta.last_write_time() as i64 % 10_000_000) * 100) + mtime_secs * 1_000_000_000; - let ctime_ns = - ((meta.creation_time() as i64 % 10_000_000) * 100) + ctime_secs * 1_000_000_000; + let atime_ns = whole_ns(atime_secs, (meta.last_access_time() as i64 % 10_000_000) * 100); + let mtime_ns = whole_ns(mtime_secs, (meta.last_write_time() as i64 % 10_000_000) * 100); + let ctime_ns = whole_ns(ctime_secs, (meta.creation_time() as i64 % 10_000_000) * 100); ( mode, 0i64, // st_ino — not available on Windows 0i64, // st_dev @@ -3222,6 +3326,25 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { /// The `stat_result` fields, read out of whichever source produced them: /// `std::fs::Metadata` for the path and descriptor forms, `libc::stat` /// for the `fstatat` form a `dir_fd`-relative name takes. + /// A whole timestamp in nanoseconds. + /// + /// `i64` nanoseconds run out in 2262, and a file can carry a later time + /// than that — every Windows FILETIME up to the year 30828 is one. The + /// product is taken wide so `st_mtime_ns` is the number it is rather than + /// the wrap `sec * 1_000_000_000` would answer with, and + /// [`w_time_ns`] hands back an int of whatever width it needs. + fn whole_ns(sec: i64, nsec: i64) -> i128 { + sec as i128 * 1_000_000_000 + nsec as i128 + } + + /// The `st_*_ns` field as a Python int, which has no width to run out of. + fn w_time_ns(ns: i128) -> pyre_object::PyObjectRef { + match i64::try_from(ns) { + Ok(n) => pyre_object::w_int_new(n), + Err(_) => pyre_object::longobject::w_long_new(pyre_object::rbigint::RBigInt::from(ns)), + } + } + struct StatFields { mode: i64, ino: i64, @@ -3233,9 +3356,9 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { atime: i64, mtime: i64, ctime: i64, - atime_ns: i64, - mtime_ns: i64, - ctime_ns: i64, + atime_ns: i128, + mtime_ns: i128, + ctime_ns: i128, #[cfg(unix)] blksize: i64, #[cfg(unix)] @@ -3294,31 +3417,31 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { // `_ll_get_st_atime` — float times keep sub-second precision: // `float(seconds) + 1e-9 * nanosecond_fraction`, where the // fraction is recovered from the full-nanosecond field. - let st_atime_f = st_atime as f64 + 1e-9 * (st_atime_ns - st_atime * 1_000_000_000) as f64; - let st_mtime_f = st_mtime as f64 + 1e-9 * (st_mtime_ns - st_mtime * 1_000_000_000) as f64; - let st_ctime_f = st_ctime as f64 + 1e-9 * (st_ctime_ns - st_ctime * 1_000_000_000) as f64; + let st_atime_f = st_atime as f64 + 1e-9 * (st_atime_ns - whole_ns(st_atime, 0)) as f64; + let st_mtime_f = st_mtime as f64 + 1e-9 * (st_mtime_ns - whole_ns(st_mtime, 0)) as f64; + let st_ctime_f = st_ctime as f64 + 1e-9 * (st_ctime_ns - whole_ns(st_ctime, 0)) as f64; #[allow(unused_mut)] let mut extras = vec![ ("st_atime", pyre_object::w_float_new(st_atime_f)), ("st_mtime", pyre_object::w_float_new(st_mtime_f)), ("st_ctime", pyre_object::w_float_new(st_ctime_f)), - ("st_atime_ns", pyre_object::w_int_new(st_atime_ns)), - ("st_mtime_ns", pyre_object::w_int_new(st_mtime_ns)), - ("st_ctime_ns", pyre_object::w_int_new(st_ctime_ns)), + ("st_atime_ns", w_time_ns(st_atime_ns)), + ("st_mtime_ns", w_time_ns(st_mtime_ns)), + ("st_ctime_ns", w_time_ns(st_ctime_ns)), // `build_stat_result` (interp_posix.py:554-557): the // sub-second remainder of each full-nanosecond timestamp, // `value % 1_000_000_000` (non-negative for pre-1970 times). ( "nsec_atime", - pyre_object::w_int_new(st_atime_ns.rem_euclid(1_000_000_000)), + pyre_object::w_int_new(st_atime_ns.rem_euclid(1_000_000_000) as i64), ), ( "nsec_mtime", - pyre_object::w_int_new(st_mtime_ns.rem_euclid(1_000_000_000)), + pyre_object::w_int_new(st_mtime_ns.rem_euclid(1_000_000_000) as i64), ), ( "nsec_ctime", - pyre_object::w_int_new(st_ctime_ns.rem_euclid(1_000_000_000)), + pyre_object::w_int_new(st_ctime_ns.rem_euclid(1_000_000_000) as i64), ), ]; #[cfg(unix)] @@ -3343,9 +3466,9 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { let st_atime = st.atime; let st_mtime = st.mtime; let st_ctime = st.ctime; - let st_atime_ns = st.atime * 1_000_000_000 + st.atime_nsec; - let st_mtime_ns = st.mtime * 1_000_000_000 + st.mtime_nsec; - let st_ctime_ns = st.ctime * 1_000_000_000 + st.ctime_nsec; + let st_atime_ns = whole_ns(st.atime, st.atime_nsec); + let st_mtime_ns = whole_ns(st.mtime, st.mtime_nsec); + let st_ctime_ns = whole_ns(st.ctime, st.ctime_nsec); let seq = vec![ pyre_object::w_int_new(st.mode as i64), pyre_object::w_int_new(st.ino as i64), @@ -3358,28 +3481,28 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { pyre_object::w_int_new(st_mtime), pyre_object::w_int_new(st_ctime), ]; - let st_atime_f = st_atime as f64 + 1e-9 * (st_atime_ns - st_atime * 1_000_000_000) as f64; - let st_mtime_f = st_mtime as f64 + 1e-9 * (st_mtime_ns - st_mtime * 1_000_000_000) as f64; - let st_ctime_f = st_ctime as f64 + 1e-9 * (st_ctime_ns - st_ctime * 1_000_000_000) as f64; + let st_atime_f = st_atime as f64 + 1e-9 * (st_atime_ns - whole_ns(st_atime, 0)) as f64; + let st_mtime_f = st_mtime as f64 + 1e-9 * (st_mtime_ns - whole_ns(st_mtime, 0)) as f64; + let st_ctime_f = st_ctime as f64 + 1e-9 * (st_ctime_ns - whole_ns(st_ctime, 0)) as f64; #[allow(unused_mut)] let mut extras = vec![ ("st_atime", pyre_object::w_float_new(st_atime_f)), ("st_mtime", pyre_object::w_float_new(st_mtime_f)), ("st_ctime", pyre_object::w_float_new(st_ctime_f)), - ("st_atime_ns", pyre_object::w_int_new(st_atime_ns)), - ("st_mtime_ns", pyre_object::w_int_new(st_mtime_ns)), - ("st_ctime_ns", pyre_object::w_int_new(st_ctime_ns)), + ("st_atime_ns", w_time_ns(st_atime_ns)), + ("st_mtime_ns", w_time_ns(st_mtime_ns)), + ("st_ctime_ns", w_time_ns(st_ctime_ns)), ( "nsec_atime", - pyre_object::w_int_new(st_atime_ns.rem_euclid(1_000_000_000)), + pyre_object::w_int_new(st_atime_ns.rem_euclid(1_000_000_000) as i64), ), ( "nsec_mtime", - pyre_object::w_int_new(st_mtime_ns.rem_euclid(1_000_000_000)), + pyre_object::w_int_new(st_mtime_ns.rem_euclid(1_000_000_000) as i64), ), ( "nsec_ctime", - pyre_object::w_int_new(st_ctime_ns.rem_euclid(1_000_000_000)), + pyre_object::w_int_new(st_ctime_ns.rem_euclid(1_000_000_000) as i64), ), ("st_blksize", pyre_object::w_int_new(st.blksize as i64)), ("st_blocks", pyre_object::w_int_new(st.blocks as i64)), @@ -3501,9 +3624,9 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { atime: st.st_atime as i64, mtime: st.st_mtime as i64, ctime: st.st_ctime as i64, - atime_ns: st.st_atime as i64 * 1_000_000_000 + st.st_atime_nsec as i64, - mtime_ns: st.st_mtime as i64 * 1_000_000_000 + st.st_mtime_nsec as i64, - ctime_ns: st.st_ctime as i64 * 1_000_000_000 + st.st_ctime_nsec as i64, + atime_ns: whole_ns(st.st_atime as i64, st.st_atime_nsec as i64), + mtime_ns: whole_ns(st.st_mtime as i64, st.st_mtime_nsec as i64), + ctime_ns: whole_ns(st.st_ctime as i64, st.st_ctime_nsec as i64), blksize: st.st_blksize as i64, blocks: st.st_blocks as i64, rdev: st.st_rdev as i64, @@ -3534,9 +3657,9 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { atime: st.st_atime as i64, mtime: st.st_mtime as i64, ctime: st.st_birthtime as i64, - atime_ns: st.st_atime as i64 * 1_000_000_000 + st.st_atime_nsec as i64, - mtime_ns: st.st_mtime as i64 * 1_000_000_000 + st.st_mtime_nsec as i64, - ctime_ns: st.st_birthtime as i64 * 1_000_000_000 + st.st_birthtime_nsec as i64, + atime_ns: whole_ns(st.st_atime as i64, st.st_atime_nsec as i64), + mtime_ns: whole_ns(st.st_mtime as i64, st.st_mtime_nsec as i64), + ctime_ns: whole_ns(st.st_birthtime as i64, st.st_birthtime_nsec as i64), } } @@ -5727,25 +5850,6 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { } } - /// `space.int_w` over the `r_longlong` half of `interp_posix.py:404`. - #[cfg(all(unix, not(feature = "sandbox")))] - fn truncate_length_w(obj: PyObjectRef) -> Result { - let w_length = crate::baseobjspace::space_index(obj)?; - let length = crate::baseobjspace::int_w(w_length).map_err(|err| { - if err.kind == crate::PyErrorKind::OverflowError { - crate::PyError::overflow_error("Python int too large to convert to C long") - } else { - err - } - })?; - // `off_t` is the width the call takes the length in, and a value - // above it is not a size the file can be given. An `as` cast would - // wrap it into one the caller never asked for and truncate the file - // to that instead. - libc::off_t::try_from(length).map_err(|_| { - crate::PyError::overflow_error("Python int too large to convert to C long") - }) - } // os.truncate(path, length) -> None // @@ -8014,7 +8118,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { "ftruncate() requires 2 arguments", )); } - let length = crate::baseobjspace::int_w(args[1])?; + let length = truncate_length_w(args[1])?; crt_result(crt_fd::ftruncate(borrowed_fd(args[0])?, length)) }, 2, @@ -8035,7 +8139,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { return Err(crate::PyError::type_error("truncate() requires 2 arguments")); } let path = crate::gateway::fsencode_path_or_fd_w(args[0], "truncate", true)?; - let length = crate::baseobjspace::int_w(args[1])?; + let length = truncate_length_w(args[1])?; if path.as_fd != -1 { let bfd = unsafe { crt_fd::Borrowed::borrow_raw(path.as_fd) }; return crt_result(crt_fd::ftruncate(bfd, length)); From 01f4ab90de168d01c68248a0b0a45e0941461c75 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sat, 8 Aug 2026 10:56:10 +0900 Subject: [PATCH 03/10] jit: assert `guard_exact_w_class` pins a `w_class` its operand carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_exact_builtin_instance` (pyobject.rs:149-163) reads a null `w_class` as a second spelling of "exact builtin", beside the one where the slot holds the canonical type object, and `is_plain_int1` (listobject.rs:424-460) accepts both for `int` and for a fits-int `W_LongObject`. `walker_guard_exact_w_class` reads the slot and pins a single value, so an operand admitted under the null spelling and pinned against the canonical gets a guard its own recorded operand fails — and nothing writes the slot afterwards, so it fails on every execution without converging, one bridge per `trace_eagerness` bucket. That is the shape `try_walker_specialize_load_type_name_attr` shipped with, where the fold took its metaclass from `typedef::type`'s `gettypefor(ob_type)` fallback while the guard read the raw field. The 40-odd other call sites establish the operand carries what they pin — through `walker_exact_builtin_class`, which returns `None` on a null slot, or through `is_plain_int1` / a local `is_exact_int` — but nothing checked that they do. Measured across `bench/synth`, `bench` and `extra_tests/parity_tests`, under a probe that reported the recorded slot against the pinned value at every site: 1217 pins over 131 files, all carrying what they pin, and no site reaching the guard without a concrete operand. The null spelling is reachable — `bool`, `None`, functions, generators, iterators, sets and the itertools objects are all built with a null slot, and `SMALL_INTS` is written that way behind `WITHPREBUILTINT` — so what holds is that no admitting predicate currently pairs one with a canonical pin, not that it could not. `debug_assert!` rather than a decline: there is no live site to decline, release codegen is unchanged, and the next occurrence fails loudly instead of costing a 20x jit-stats drift that takes a baseline diff to notice. --- pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs index 91c7a8537e0..8c6a39abb1a 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs @@ -8265,6 +8265,22 @@ fn walker_guard_exact_w_class( if expected_typeobj.is_null() || ctx.trace_ctx.heap_cache().is_unescaped(obj) { return Ok(()); } + // Every predicate that admits one of these folds — `is_exact_builtin_instance`, + // `is_plain_int1`, [`walker_exact_builtin_class`] — treats a null `w_class` as a + // second spelling of "exact builtin", while the guard below reads the slot and + // pins a single value. Pinning the canonical against an operand that carries + // the null spelling emits a guard the recorded operand itself fails, and nothing + // writes the slot afterwards, so it fails on every execution without ever + // converging — one bridge per `trace_eagerness` bucket. Establishing that the + // operand carries what is pinned is the caller's, which is what this checks. + debug_assert!( + walker_concrete_ref_object(ctx, obj).is_none_or(|concrete| { + (pyre_object::tagged_int::CAN_BE_TAGGED + && pyre_object::tagged_int::is_tagged_int(concrete)) + || std::ptr::eq(unsafe { (*concrete).w_class }, expected_typeobj) + }), + "guard_exact_w_class at pc={op_pc} would pin a `w_class` its recorded operand does not carry", + ); let actual = crate::state::opimpl_getfield_gc_r(ctx.trace_ctx, obj, crate::descr::w_class_descr()); let expected = ctx.trace_ctx.const_ref(expected_typeobj as i64); From 5eed95c4f08ad12e9f17823f14926159bac144c2 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sat, 8 Aug 2026 12:45:54 +0900 Subject: [PATCH 04/10] sys, pyrex: the three Windows parity failures behind a job that named none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pyre/check.py (windows-latest)` on #1109 ends on a passing row and exit code 1; the three scripts it failed on are 200 lines up, in the stderr the neighbouring commit here moves. They are main's, not that PR's, and independent of each other. **`keyboard_interrupt_exit_status`.** A Win32 process has no SIGINT to die of. `app_main.py:1146-1151` restores `SIG_DFL` and calls `raise(SIGINT)`, saying the MSVC runtime then exits with `STATUS_CONTROL_C_EXIT`; measured, that pair returns through the CRT's default action and the process ends with status 3 — `signal.signal(SIGINT, SIG_DFL); signal.raise_signal(SIGINT)` under CPython exits 3 as well, while an uncaught `KeyboardInterrupt` there exits 0xC000013A. `raise` never terminates, so `terminate_by_sigint` fell through to `process::abort`, whose status 3 is what the fixture read. Windows exits with `STATUS_CONTROL_C_EXIT` directly; both callers have already finalized. **`builtin_module_loader_spec`.** Two missing names, both reached from `test.support`'s import: - `_sysconfig.config_vars()` answered an empty dict. `sysconfig._init_non_posix` SUBSCRIPTS `Py_GIL_DISABLED` and `Py_DEBUG` to spell `ABIFLAGS`, so under `os.name == 'nt'` an absent key is a `KeyError` out of the first `get_config_var` rather than the `None` the `.get()` readers take. Both are 0, which is what an empty `sys.abiflags` already says. `EXT_SUFFIX` and `SOABI`, the other two keys the call carries, name an extension ABI that `_imp.extension_suffixes()` says does not exist here, so they stay absent. - `sys.getwindowsversion` was absent and `_init_config_vars` subscripts `sys._vpath` beside it. The version is a five-field sequence with five named-only fields over it, built the way `os.stat_result` carries its `st_*_ns` extras, off `host_env::windows::get_windows_version`. Every field matches CPython 3.14 on the same host except `build`: the sequence reports kernel32's file version, because `GetVersionEx` answers with the version an unmanifested binary is shimmed to, and `platform_version` — the field that exists because of that shimming — agrees with it here instead of correcting it. **`frame_clear_finalization`** imported `resource` for one CPU-time bound at the end. It is a POSIX module, absent from CPython on Windows too, so the import took all six checks off the platform and left the reference failing beside the backends. `time.process_time` is the same measurement and is everywhere. extra_tests/parity_tests: 213/213 on both backends, bar `builtin_module_loader_spec` under a local CPython with no `test` package installed — the runner that CI uses has it and reported `cpython=OK`. --- .../parity_tests/frame_clear_finalization.py | 11 ++- pyre/pyre-interpreter/src/importing.rs | 27 ++++++- pyre/pyre-interpreter/src/module/sys/vm.rs | 76 +++++++++++++++++++ pyre/pyrex/src/lib.rs | 27 ++++--- 4 files changed, 124 insertions(+), 17 deletions(-) diff --git a/pyre/extra_tests/parity_tests/frame_clear_finalization.py b/pyre/extra_tests/parity_tests/frame_clear_finalization.py index 54ac1910175..9e71ecd6f16 100644 --- a/pyre/extra_tests/parity_tests/frame_clear_finalization.py +++ b/pyre/extra_tests/parity_tests/frame_clear_finalization.py @@ -1,6 +1,6 @@ import gc -import resource import sys +import time import traceback import weakref @@ -124,11 +124,14 @@ def raises(): check = Check() count = 200 - before = resource.getrusage(resource.RUSAGE_SELF) + # `process_time` rather than `resource.getrusage`: the CPU this process has + # spent is the measurement, and `resource` is a POSIX module, so importing + # it would take the five checks above off Windows along with this one. + before = time.process_time() for _ in range(count): check.assertRaises(Expected, raises) - after = resource.getrusage(resource.RUSAGE_SELF) - per_op_ms = (after.ru_utime - before.ru_utime) * 1000.0 / count + after = time.process_time() + per_op_ms = (after - before) * 1000.0 / count assert per_op_ms < 1.0, per_op_ms diff --git a/pyre/pyre-interpreter/src/importing.rs b/pyre/pyre-interpreter/src/importing.rs index 42f208dba74..abb7d80a2e6 100644 --- a/pyre/pyre-interpreter/src/importing.rs +++ b/pyre/pyre-interpreter/src/importing.rs @@ -827,14 +827,33 @@ fn init_string_module(ns: PyObjectRef) { ); } -/// `_sysconfig` stub — exposes `config_vars()` returning an empty dict. On -/// POSIX `sysconfig` only consults this for the build variables that pyre does -/// not generate; importing it is enough to satisfy `test_sysconfig`. +/// `_sysconfig` — `config_vars()`, the build variables that come from the +/// running binary rather than from a generated `_sysconfigdata_*` module. +/// +/// `sysconfig._init_non_posix` SUBSCRIPTS `Py_GIL_DISABLED` and `Py_DEBUG` to +/// spell `ABIFLAGS`, so on Windows a missing key is a `KeyError` out of the +/// first `get_config_var` call rather than the `None` the `.get()` readers +/// take. Both are 0 here — pyre is neither build, which is what an empty +/// `sys.abiflags` already says. The other two keys `config_vars` carries, +/// `EXT_SUFFIX` and `SOABI`, name an extension ABI; `_imp.extension_suffixes()` +/// is empty, so there is none to name and they stay absent for `.get()`. fn init_sysconfig_stub(ns: PyObjectRef) { crate::module_ns_store( ns, "config_vars", - crate::make_builtin_function("config_vars", |_| Ok(pyre_object::w_dict_new())), + crate::make_builtin_function("config_vars", |_| { + let vars = pyre_object::w_dict_new(); + unsafe { + for name in ["Py_DEBUG", "Py_GIL_DISABLED"] { + pyre_object::w_dict_store( + vars, + pyre_object::w_str_new(name), + pyre_object::w_int_new(0), + ); + } + } + Ok(vars) + }), ); } diff --git a/pyre/pyre-interpreter/src/module/sys/vm.rs b/pyre/pyre-interpreter/src/module/sys/vm.rs index ea758b2c318..f49e1f082bb 100644 --- a/pyre/pyre-interpreter/src/module/sys/vm.rs +++ b/pyre/pyre-interpreter/src/module/sys/vm.rs @@ -1124,6 +1124,13 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { // reads it to build USER_SITE. #[cfg(windows)] module_ns_store(ns, "winver", w_str_new("3.14")); + // sys._vpath — the build's relative path from the executable's directory to + // the prefix. `sysconfig._init_config_vars` subscripts it under `os.name == + // 'nt'`, so it is an AttributeError out of the first `get_config_var` call + // when absent; it is stored into `_CONFIG_VARS['VPATH']` and read nowhere + // else, `sys._stdlib_dir` being what locates the stdlib here. + #[cfg(windows)] + module_ns_store(ns, "_vpath", w_str_new(r"..\..")); module_ns_store( ns, "byteorder", @@ -1337,6 +1344,75 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { "getdefaultencoding", make_builtin_function_with_arity("getdefaultencoding", |_| Ok(w_str_new("utf-8")), 0), ); + // sys.getwindowsversion — a five-field sequence over `OSVERSIONINFOEXW` + // with five named-only fields beyond it, the same shape `os.stat_result` + // carries its `st_*_ns` extras in. `test.support.os_helper` reads + // `.platform` at import, so every `test.support` consumer needs it. + // + // `major`/`minor`/`build` are kernel32's own file version rather than + // `GetVersionEx`'s answer: that call reports the version an unmanifested + // binary is shimmed to, and only an application manifest declaring + // compatibility makes it report the running one. `platform_version` — the + // field that exists because of exactly that shimming — therefore agrees + // with them here instead of correcting them. + #[cfg(windows)] + module_ns_store( + ns, + "getwindowsversion", + make_builtin_function_with_arity( + "getwindowsversion", + |args| { + if !args.is_empty() { + return Err(crate::PyError::type_error( + "getwindowsversion() takes no arguments", + )); + } + let info = rustpython_host_env::windows::get_windows_version().map_err(|e| { + crate::PyError::os_error_win32_syscall2( + e.raw_os_error().unwrap_or(0), + pyre_object::PY_NULL, + pyre_object::PY_NULL, + ) + })?; + let cls = crate::_structseq::make_struct_seq_with_extra( + "sys.getwindowsversion", + &["major", "minor", "build", "platform", "service_pack"], + &[ + "service_pack_major", + "service_pack_minor", + "suite_mask", + "product_type", + "platform_version", + ], + ); + Ok(crate::_structseq::new_instance_with_extra( + cls, + vec![ + w_int_new(info.major as i64), + w_int_new(info.minor as i64), + w_int_new(info.build as i64), + w_int_new(info.platform as i64), + w_str_new(&info.service_pack), + ], + vec![ + ("service_pack_major", w_int_new(info.service_pack_major as i64)), + ("service_pack_minor", w_int_new(info.service_pack_minor as i64)), + ("suite_mask", w_int_new(info.suite_mask as i64)), + ("product_type", w_int_new(info.product_type as i64)), + ( + "platform_version", + pyre_object::w_tuple_new(vec![ + w_int_new(info.major as i64), + w_int_new(info.minor as i64), + w_int_new(info.build as i64), + ]), + ), + ], + )) + }, + 0, + ), + ); // sys.getrecursionlimit / setrecursionlimit — pypy/module/sys/vm.py:45. // The runtime stack budget lives in `crate::stack_check`; both // helpers route through it so the interpreter, JIT prologue probe, diff --git a/pyre/pyrex/src/lib.rs b/pyre/pyrex/src/lib.rs index 14f395d2b97..9167175ee6e 100644 --- a/pyre/pyrex/src/lib.rs +++ b/pyre/pyrex/src/lib.rs @@ -1246,17 +1246,26 @@ fn finalize_system_exit( /// Die by SIGINT for an uncaught KeyboardInterrupt after shutdown has run /// (`app_main.py:1133-1153`). fn terminate_by_sigint() -> ! { - unsafe { - libc::signal(libc::SIGINT, libc::SIG_DFL); - #[cfg(windows)] - let signaled = libc::raise(libc::SIGINT); - #[cfg(not(windows))] - let signaled = libc::kill(libc::getpid(), libc::SIGINT); - if signaled != 0 { - std::process::exit(1); + // A Win32 process has no SIGINT to die of: restoring `SIG_DFL` and calling + // `raise(SIGINT)` runs the CRT's default action, which returns and ends the + // process with status 3 rather than the interrupt a shell reads. The status + // that reads as one is `STATUS_CONTROL_C_EXIT`, so it is exited with + // directly. Both callers have already finalized and flushed. + #[cfg(windows)] + { + const STATUS_CONTROL_C_EXIT: u32 = 0xC000_013A; + std::process::exit(STATUS_CONTROL_C_EXIT as i32); + } + #[cfg(not(windows))] + { + unsafe { + libc::signal(libc::SIGINT, libc::SIG_DFL); + if libc::kill(libc::getpid(), libc::SIGINT) != 0 { + std::process::exit(1); + } } + std::process::abort(); } - std::process::abort(); } fn is_keyboard_interrupt(error: &pyre_interpreter::PyError) -> bool { From 9fb392fd16d5de5b1eb53d32569f02a0e3a9d01b Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sat, 8 Aug 2026 20:52:03 +0900 Subject: [PATCH 05/10] display: spell a repr's address the way the platform's `%p` spells it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PyUnicode_FromFormat`'s `%p` hands the pointer to the platform's own `printf` and normalizes only the prefix — guaranteed to start with a literal `0x` "regardless of what the platform's printf yields". The platforms disagree about everything after it: the MSVC runtime pads to the pointer width and uppercases, glibc does neither. So on Windows CPython reads `` where it reads `` elsewhere, and Rust's `{:p}` — along with `{:?}` on a raw pointer and a hand-written `0x{:x}` — is only ever the second spelling. Every address-bearing repr was therefore wrong on Windows. Measured against CPython 3.14 on the same host, fifteen kinds disagreed: function, object, generator, coroutine, async_generator, bound and built-in method, method-wrapper, cell, weakref, memoryview, ContextVar, Token, code, frame and both `_thread` locks. They now go through one `display::repr_addr`, and the shapes are identical. `_pickle`'s cyclic-object message names an address the same way and is the one such site that is not a repr. `surrogate_name_messages` asserted the glibc spelling against `id()`, so it was the REFERENCE that failed it on every Windows runner — a script that fails on CPython measures nothing, and the backends were being compared against a failing oracle. It builds the platform's spelling now. The frame repr's `file '...'` is left raw: `pyframe.py:849-853` interpolates `'%s'`, and CPython's `%R` there escapes the backslashes a Windows path is full of. That is a parity-source disagreement rather than this fix's business. --- .../parity_tests/surrogate_name_messages.py | 21 +++++++++-- pyre/pyre-interpreter/src/builtins.rs | 5 ++- pyre/pyre-interpreter/src/display.rs | 26 +++++++++++-- pyre/pyre-interpreter/src/function.rs | 5 ++- .../src/module/_contextvars/mod.rs | 4 +- .../src/module/_ctypes/cdata.rs | 3 +- .../src/module/_pickle/pickler.rs | 4 +- .../src/module/_weakref/interp__weakref.rs | 6 ++- .../pyre-interpreter/src/module/thread/mod.rs | 11 ++++-- pyre/pyre-interpreter/src/pycode.rs | 3 +- pyre/pyre-interpreter/src/pyframe.rs | 4 +- pyre/pyre-interpreter/src/typedef.rs | 37 ++++++++++++------- 12 files changed, 92 insertions(+), 37 deletions(-) diff --git a/pyre/extra_tests/parity_tests/surrogate_name_messages.py b/pyre/extra_tests/parity_tests/surrogate_name_messages.py index adf24082ae1..99246fb5f7c 100644 --- a/pyre/extra_tests/parity_tests/surrogate_name_messages.py +++ b/pyre/extra_tests/parity_tests/surrogate_name_messages.py @@ -19,6 +19,19 @@ FFFD = "�" +def at(obj): + """The address a repr quotes, in the spelling this platform's repr uses. + + `PyUnicode_FromFormat`'s `%p` hands the pointer to the platform's own + `printf` and normalizes only the prefix, so the MSVC runtime's padding and + upper case are part of the repr there and glibc's bare lower case is part + of it everywhere else. + """ + if sys.platform == "win32": + return "0x%0*X" % (2 * (8 if sys.maxsize > 2**32 else 4), id(obj)) + return "0x%x" % id(obj) + + class Repr: def __repr__(self): return S @@ -29,7 +42,7 @@ def f(a): return a f.__qualname__ = S - assert repr(f) == "" % (S, id(f)), ascii(repr(f)) + assert repr(f) == "" % (S, at(f)), ascii(repr(f)) try: f(1, 2) @@ -86,13 +99,13 @@ def g(): def check_contextvars(): var = contextvars.ContextVar("n", default=Repr()) var_repr = repr(var) - assert var_repr == "" % (S, id(var)), ascii(var_repr) + assert var_repr == "" % (S, at(var)), ascii(var_repr) token = var.set("x") - assert repr(token) == "" % (var_repr, id(token)), ascii(repr(token)) + assert repr(token) == "" % (var_repr, at(token)), ascii(repr(token)) var.reset(token) used_repr = repr(token) - assert used_repr == "" % (var_repr, id(token)), ascii(used_repr) + assert used_repr == "" % (var_repr, at(token)), ascii(used_repr) try: var.reset(token) except RuntimeError as e: diff --git a/pyre/pyre-interpreter/src/builtins.rs b/pyre/pyre-interpreter/src/builtins.rs index ac2c6d4b023..31bdcc72797 100644 --- a/pyre/pyre-interpreter/src/builtins.rs +++ b/pyre/pyre-interpreter/src/builtins.rs @@ -1723,7 +1723,10 @@ fn memoryview_repr(args: &[PyObjectRef]) -> Result } else { "memory" }; - Ok(w_str_new(&format!("<{label} at {mv:?}>"))) + Ok(w_str_new(&format!( + "<{label} at {}>", + crate::display::repr_addr(mv as usize) + ))) } /// Drop an mmap-backed view's export directly, bypassing any Python-callable diff --git a/pyre/pyre-interpreter/src/display.rs b/pyre/pyre-interpreter/src/display.rs index 434eb14f56a..4ae17007ada 100644 --- a/pyre/pyre-interpreter/src/display.rs +++ b/pyre/pyre-interpreter/src/display.rs @@ -11,6 +11,24 @@ use crate::{ builtin_code_name, function_get_name, function_get_qualname, }; +/// The address a repr quotes, spelled the way `PyUnicode_FromFormat`'s `%p` +/// spells it. +/// +/// That conversion hands the pointer to the platform's own `printf` and +/// normalizes only the prefix — "guaranteed to start with the literal `0x` +/// regardless of what the platform's `printf` yields". The platforms disagree +/// about the rest: the MSVC runtime pads to the pointer width and uppercases, +/// glibc does neither. So `` and +/// `` are the same repr, each on its own platform, +/// and Rust's `{:p}` is only ever the second one. +pub(crate) fn repr_addr(addr: usize) -> String { + if cfg!(windows) { + format!("0x{addr:0width$X}", width = size_of::() * 2) + } else { + format!("0x{addr:x}") + } +} + /// Try to call a dunder method (__repr__, __str__, etc.) on an instance, /// returning the raw result object when it is a `str`. pub(crate) unsafe fn try_call_dunder_obj( @@ -755,7 +773,7 @@ pub unsafe fn py_repr_wtf8(obj: PyObjectRef) -> Result // which substitutes U+FFFD for a lone surrogate. let mut repr = Wtf8Buf::from_string("")); + repr.push_str(&format!(" at {}>", crate::display::repr_addr(obj as usize))); return Ok(repr); } else if unsafe { pyre_object::is_exception(obj) } { // A user subclass that overrides `__repr__` shadows the builtin @@ -956,7 +974,7 @@ pub unsafe fn py_repr_wtf8(obj: PyObjectRef) -> Result } else { "memory" }; - format!("<{label} at {obj:?}>") + format!("<{label} at {}>", repr_addr(obj as usize)) } else if std::ptr::eq(tp, &INSTANCE_TYPE as *const PyType) { // Try __repr__ first, then __str__ if let Some(w) = try_call_dunder_wtf8(obj, "__repr__")? { @@ -966,7 +984,7 @@ pub unsafe fn py_repr_wtf8(obj: PyObjectRef) -> Result return Ok(w); } let name = crate::baseobjspace::getfulltypename(obj); - format!("<{name} object at {obj:?}>") + format!("<{name} object at {}>", repr_addr(obj as usize)) } else { // A builtin type carrying its own `__repr__` dict entry (e.g. // `_struct.Struct`) — dispatch it before the generic @@ -987,7 +1005,7 @@ pub unsafe fn py_repr_wtf8(obj: PyObjectRef) -> Result } } let name = crate::baseobjspace::getfulltypename(obj); - format!("<{name} object at {obj:?}>") + format!("<{name} object at {}>", repr_addr(obj as usize)) }; Ok(Wtf8Buf::from_string(formatted)) } diff --git a/pyre/pyre-interpreter/src/function.rs b/pyre/pyre-interpreter/src/function.rs index 98a5c5ea358..5c09f76f3c5 100644 --- a/pyre/pyre-interpreter/src/function.rs +++ b/pyre/pyre-interpreter/src/function.rs @@ -989,7 +989,10 @@ pub unsafe fn builtin_function_repr_text(name: &str, w_self: PyObjectRef) -> Str let type_name = crate::typedef::r#type(w_self) .map(|tp| unsafe { pyre_object::w_type_get_name(tp.as_ptr()) }) .unwrap_or("object"); - format!("") + format!( + "", + crate::display::repr_addr(w_self as usize) + ) } /// CPython 3.14 `meth_reduce`: type-bound builtins reconstruct through diff --git a/pyre/pyre-interpreter/src/module/_contextvars/mod.rs b/pyre/pyre-interpreter/src/module/_contextvars/mod.rs index 1b148654e70..ae1ea26f182 100644 --- a/pyre/pyre-interpreter/src/module/_contextvars/mod.rs +++ b/pyre/pyre-interpreter/src/module/_contextvars/mod.rs @@ -244,7 +244,7 @@ fn context_var_repr_string(obj: PyObjectRef) -> Result", obj as usize), + format!(" at {}>", crate::display::repr_addr(obj as usize)), )) } @@ -379,7 +379,7 @@ fn token_repr_string(token: PyObjectRef) -> Result", token as usize), + format!(" at {}>", crate::display::repr_addr(token as usize)), )) } diff --git a/pyre/pyre-interpreter/src/module/_ctypes/cdata.rs b/pyre/pyre-interpreter/src/module/_ctypes/cdata.rs index ec487c44fb6..035c46639f0 100644 --- a/pyre/pyre-interpreter/src/module/_ctypes/cdata.rs +++ b/pyre/pyre-interpreter/src/module/_ctypes/cdata.rs @@ -311,7 +311,8 @@ fn simplecdata_repr(args: &[PyObjectRef]) -> Result if !direct { let name = unsafe { pyre_object::typeobject::w_type_get_name(cls) }; return Ok(pyre_object::w_str_new(&format!( - "<{name} object at {obj:?}>" + "<{name} object at {}>", + crate::display::repr_addr(obj as usize) ))); } let tc = type_code_of(cls).ok_or_else(|| crate::PyError::type_error("abstract class"))?; diff --git a/pyre/pyre-interpreter/src/module/_pickle/pickler.rs b/pyre/pyre-interpreter/src/module/_pickle/pickler.rs index a21f1ef2c92..1e82067dc57 100644 --- a/pyre/pyre-interpreter/src/module/_pickle/pickler.rs +++ b/pyre/pyre-interpreter/src/module/_pickle/pickler.rs @@ -1140,9 +1140,9 @@ fn fast_save_enter(ctx: &mut PickleCtx, obj_slot: usize) -> Result if is_cycle { ctx.fast_nesting -= 1; return Err(PyError::value_error(format!( - "fast mode: can't pickle cyclic objects including object type {} at {:p}", + "fast mode: can't pickle cyclic objects including object type {} at {}", crate::baseobjspace::object_functionstr_type_name(w_cur), - w_cur as *const u8, + crate::display::repr_addr(w_cur as usize), ))); } ctx.fast_memo.entry(h).or_default().push(obj_slot); diff --git a/pyre/pyre-interpreter/src/module/_weakref/interp__weakref.rs b/pyre/pyre-interpreter/src/module/_weakref/interp__weakref.rs index 72851c5dfa4..f086949852a 100644 --- a/pyre/pyre-interpreter/src/module/_weakref/interp__weakref.rs +++ b/pyre/pyre-interpreter/src/module/_weakref/interp__weakref.rs @@ -721,8 +721,10 @@ pub fn descr__repr__(args: &[PyObjectRef]) -> Result { }; let addr = w_self as usize; Ok(pyre_object::w_str_new(&format!( - "<{} at 0x{:x}{}>", - type_name, addr, state + "<{} at {}{}>", + type_name, + crate::display::repr_addr(addr), + state ))) } diff --git a/pyre/pyre-interpreter/src/module/thread/mod.rs b/pyre/pyre-interpreter/src/module/thread/mod.rs index d044229e872..b2e412d05d3 100644 --- a/pyre/pyre-interpreter/src/module/thread/mod.rs +++ b/pyre/pyre-interpreter/src/module/thread/mod.rs @@ -678,7 +678,10 @@ mod lock_class { fn __repr__(&self) -> String { let state = if self.locked() { "locked" } else { "unlocked" }; - format!("<{state} _thread.lock object at {:p}>", self) + format!( + "<{state} _thread.lock object at {}>", + crate::display::repr_addr(self as *const Self as usize) + ) } fn _at_fork_reinit(&self) { @@ -907,8 +910,10 @@ mod rlock_class { "locked" }; format!( - "<{locked} _thread.RLock object owner={} count={} at {:p}>", - state.owner, state.count, self + "<{locked} _thread.RLock object owner={} count={} at {}>", + state.owner, + state.count, + crate::display::repr_addr(self as *const Self as usize) ) } diff --git a/pyre/pyre-interpreter/src/pycode.rs b/pyre/pyre-interpreter/src/pycode.rs index 2e4fcf42489..2af8575fe28 100644 --- a/pyre/pyre-interpreter/src/pycode.rs +++ b/pyre/pyre-interpreter/src/pycode.rs @@ -1180,8 +1180,9 @@ pub unsafe fn code_repr(obj: PyObjectRef) -> Result let raw_line = (*(obj as *const PyCode)).co_firstlineno_raw as i64; let line = if raw_line == 0 { -1 } else { raw_line }; let mut repr = rustpython_wtf8::Wtf8Buf::from_string(format!( - " rustpython_wtf8::Wtf8Buf { let code = self.code(); let mut out = rustpython_wtf8::Wtf8Buf::from_string(format!( - "")); + repr.push_str(&format!( + " at {}>", + crate::display::repr_addr(function as usize) + )); Ok(pyre_object::w_str_from_wtf8(repr)) }, 1, @@ -13929,7 +13932,8 @@ fn init_method_wrapper_type(ns: PyObjectRef) { .map(|tp| unsafe { pyre_object::w_type_get_name(tp.as_ptr()) }) .unwrap_or("object"); Ok(pyre_object::w_str_new(&format!( - "" + "", + crate::display::repr_addr(w_self as usize) ))) }, 1, @@ -15060,15 +15064,19 @@ fn cell_descr_repr(args: &[PyObjectRef]) -> crate::PyResult { } let value = unsafe { pyre_object::w_cell_get(cell) }; let text = if value.is_null() { - format!("", cell as usize) + format!( + "", + crate::display::repr_addr(cell as usize) + ) } else { let type_name = crate::typedef::r#type(value) .map(|tp| unsafe { pyre_object::w_type_get_name(tp.as_ptr()) }) .unwrap_or_else(|| unsafe { (*(*value).ob_type).name }); let type_name: String = type_name.chars().take(80).collect(); format!( - "", - cell as usize, value as usize + "", + crate::display::repr_addr(cell as usize), + crate::display::repr_addr(value as usize) ) }; Ok(w_str_new(&text)) @@ -18778,14 +18786,15 @@ fn init_object_type(ns: PyObjectRef) { // `w_obj.getrepr(space, '%s object' % fulltypename)`. let name = crate::baseobjspace::getfulltypename(obj); return Ok(pyre_object::w_str_new_managed(&format!( - "<{name} object at {obj:?}>" + "<{name} object at {}>", + crate::display::repr_addr(obj as usize) ))); } } // For non-instances, delegate to display Ok(pyre_object::w_str_new_managed(&format!( - "", - obj + "", + crate::display::repr_addr(obj as usize) ))) }, 1, @@ -25232,27 +25241,27 @@ fn generator_frame(obj: PyObjectRef) -> *mut crate::pyframe::PyFrame { fn generator_descr_repr(args: &[PyObjectRef]) -> crate::PyResult { let name = generator_name_value(args[0], true)?; Ok(w_str_new(&format!( - "", + "", unsafe { pyre_object::w_str_get_value(name) }, - args[0] + crate::display::repr_addr(args[0] as usize) ))) } fn coroutine_descr_repr(args: &[PyObjectRef]) -> crate::PyResult { let name = generator_name_value(args[0], true)?; Ok(w_str_new(&format!( - "", + "", unsafe { pyre_object::w_str_get_value(name) }, - args[0] + crate::display::repr_addr(args[0] as usize) ))) } fn async_generator_descr_repr(args: &[PyObjectRef]) -> crate::PyResult { let name = generator_name_value(args[0], true)?; Ok(w_str_new(&format!( - "", + "", unsafe { pyre_object::w_str_get_value(name) }, - args[0] + crate::display::repr_addr(args[0] as usize) ))) } From 958f66caa571fd51ebca5c41d42b69d6e98be0dc Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sat, 8 Aug 2026 23:35:01 +0900 Subject: [PATCH 06/10] bench/synth: re-record pypy_type_surface's jit-stats baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bridges_compiled 102 -> 5` and `guard_failures 20497 -> 1011`, byte-identical on dynasm, cranelift and wasm. The recorded 102/20497 are the counters the `Cls.__name__` fold produced while its metaclass guard could not be discharged — it read the raw `w_class` slot where the fast path answers through the `gettypefor(ob_type)` fallback, so a receiver reached through that fallback got a `guard_value(NULL, type)` that failed on every execution. #1106 narrowed the fold; the baseline still names what the defect measured. Read back rather than predicted: ubuntu-24.04, macos-latest and windows-latest all report 5/1011 on main, and so does a local windows run. `retraces_compiled` joins the file because the recorder now writes it; it is 0, which is what the comparison already read for its absence. --- pyre/bench/synth/pypy_type_surface.cranelift.jitstats | 5 +++-- pyre/bench/synth/pypy_type_surface.dynasm.jitstats | 5 +++-- pyre/bench/synth/pypy_type_surface.wasm.jitstats | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pyre/bench/synth/pypy_type_surface.cranelift.jitstats b/pyre/bench/synth/pypy_type_surface.cranelift.jitstats index d6fdf92605f..81c8187a9e3 100644 --- a/pyre/bench/synth/pypy_type_surface.cranelift.jitstats +++ b/pyre/bench/synth/pypy_type_surface.cranelift.jitstats @@ -1,4 +1,4 @@ -bridges_compiled=102 +bridges_compiled=5 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 @@ -8,7 +8,8 @@ fbw_rolled_back_with_effects=0 fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 -guard_failures=20497 +guard_failures=1011 internal_compile_panics=0 loops_aborted=0 loops_compiled=11 +retraces_compiled=0 diff --git a/pyre/bench/synth/pypy_type_surface.dynasm.jitstats b/pyre/bench/synth/pypy_type_surface.dynasm.jitstats index d6fdf92605f..81c8187a9e3 100644 --- a/pyre/bench/synth/pypy_type_surface.dynasm.jitstats +++ b/pyre/bench/synth/pypy_type_surface.dynasm.jitstats @@ -1,4 +1,4 @@ -bridges_compiled=102 +bridges_compiled=5 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 @@ -8,7 +8,8 @@ fbw_rolled_back_with_effects=0 fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 -guard_failures=20497 +guard_failures=1011 internal_compile_panics=0 loops_aborted=0 loops_compiled=11 +retraces_compiled=0 diff --git a/pyre/bench/synth/pypy_type_surface.wasm.jitstats b/pyre/bench/synth/pypy_type_surface.wasm.jitstats index d6fdf92605f..81c8187a9e3 100644 --- a/pyre/bench/synth/pypy_type_surface.wasm.jitstats +++ b/pyre/bench/synth/pypy_type_surface.wasm.jitstats @@ -1,4 +1,4 @@ -bridges_compiled=102 +bridges_compiled=5 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 @@ -8,7 +8,8 @@ fbw_rolled_back_with_effects=0 fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 -guard_failures=20497 +guard_failures=1011 internal_compile_panics=0 loops_aborted=0 loops_compiled=11 +retraces_compiled=0 From 773fafe237b585a756def65784d65ec318208fc8 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sun, 9 Aug 2026 08:05:35 +0900 Subject: [PATCH 07/10] sys, parity: a structseq type built once, a decoded timeout stderr, and two NTFS-only assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sys.getwindowsversion` built its structseq type inside the call, so every answer was an instance of a different class and `type(sys.getwindowsversion()) is type(sys.getwindowsversion())` read False where CPython reads True. It was the one of pyre's ten structseq types that did not already cache its type in a `OnceLock` — `stat_result`, `terminal_size`, `uname_result`, `statvfs_result`, `waitid_result`, `times_result`, `struct_time`, `_ExceptHookArgs` and `UnraisableHookArgs` all do — and it now does the same. Measured: a probe over every structseq both runtimes carry reports the types equal for all nine others and for all of CPython's, so this constructor was the whole divergence. That the cache is a bare `usize` the GC cannot see is the established pattern rather than a new bet, and it holds: 200 `os.stat` answers dropped across forced collections plus 60MB of churn leave the cached type identical and its fields readable. `structseq_type_identity` pins the property for every structseq the host carries, and fails on the binary built before this commit naming exactly `sys.getwindowsversion`. The parity runner decoded its child's stderr on the normal path only. `subprocess.run(text=True)` decodes what `communicate()` returns; the timeout path raises with the raw chunks it had joined, which is bytes on POSIX and str on Windows. A timed-out script would have reported one `b'...\n...'` line — the unreadable shape the rest of this branch exists to remove. Two assertions in `os_utime_pathconf_truncate` could only ever have held on NTFS. The step that runs them has not reached ubuntu or macos: `check.py` runs first in that job and has been failing, so the parity suite never starts there. * `ns=(2**62, 2**62)` read back `4611686018427387900`, which is 2**62 rounded down to a FILETIME's 100ns tick. ext4 and APFS keep the nanosecond and answer `...904`. Both spellings now come from one `storable()`, which also replaces the `-1`/`-100` conditional above it. * `utime(p, (8.8e11, 8.8e11))` names the year 29880, which no filesystem but NTFS reaches — an APFS timestamp is itself an int64 of nanoseconds, so 2262 bounds it too, and ext4 stores a 34-bit second and stops in 2446. What every platform is held to is the identity the `i128` widening buys, `st_mtime_ns == int(st_mtime) * 1_000_000_000`, wherever the write succeeds; the exact value is asserted only where the second survived the round trip. The remaining review note asked for f-strings in `surrogate_name_messages`. Left alone: the file builds every expected repr with `%` formatting, including the assertions this branch did not touch, and nothing lints for it. --- .../os_utime_pathconf_truncate.py | 46 ++++++++++--- pyre/extra_tests/parity_tests/run.py | 7 ++ .../parity_tests/structseq_type_identity.py | 65 +++++++++++++++++++ pyre/pyre-interpreter/src/module/sys/vm.rs | 28 ++++---- 4 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 pyre/extra_tests/parity_tests/structseq_type_identity.py diff --git a/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py b/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py index 754395de8a8..eb601259735 100644 --- a/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py +++ b/pyre/extra_tests/parity_tests/os_utime_pathconf_truncate.py @@ -29,6 +29,19 @@ def raises(call, exc): raise AssertionError(f"{exc.__name__} was not raised") +# A Windows FILETIME counts 100ns ticks, so a nanosecond that is not a multiple +# of 100 is not a time that filesystem can hold; ext4 and APFS both store the +# nanosecond itself. The negative range is not what differs — only how finely +# it is kept — so the checks below round to what the host can hold rather than +# skipping a platform. +GRANULARITY_NS = 100 if sys.platform == "win32" else 1 + + +def storable(ns): + """`ns` as the filesystem under this test reads it back.""" + return ns // GRANULARITY_NS * GRANULARITY_NS + + d = tempfile.mkdtemp() atexit.register(shutil.rmtree, d, ignore_errors=True) p = os.path.join(d, "f") @@ -39,14 +52,11 @@ def raises(call, exc): # The seconds are the floor of the value and the nanoseconds are what is left # above it, so -1ns is the last nanosecond of 1969 rather than a value with no # representation. -# -# A Windows FILETIME counts 100ns ticks, so only the nanoseconds that are a -# multiple of 100 are times that filesystem can hold: -1ns reads back as -100 -# there, and the exact-nanosecond checks are the ones left out rather than the -# negative range itself. os.utime(p, ns=(-1, -1)) -tick = -100 if sys.platform == "win32" else -1 -check(os.stat(p).st_mtime_ns == tick, f"utime(ns=(-1,-1)) -> {os.stat(p).st_mtime_ns}") +check( + os.stat(p).st_mtime_ns == storable(-1), + f"utime(ns=(-1,-1)) -> {os.stat(p).st_mtime_ns}", +) os.utime(p, ns=(-2_500_000_000, -2_500_000_000)) check(os.stat(p).st_mtime_ns == -2_500_000_000, "utime(ns) lost a negative second") @@ -98,12 +108,28 @@ def raises(call, exc): # `ns` is split with divmod before anything is narrowed, so a nanosecond count # too wide for a `time_t` is only refused when the SECOND it names is. os.utime(p, ns=(2**62, 2**62)) -check(os.stat(p).st_mtime_ns == 4611686018427387900, os.stat(p).st_mtime_ns) +check(os.stat(p).st_mtime_ns == storable(2**62), os.stat(p).st_mtime_ns) # Nanoseconds run out of an `int64` in 2262, and a file may carry a later time # than that — `st_mtime_ns` is the number it is rather than a wrap. -os.utime(p, (8.8e11, 8.8e11)) -check(os.stat(p).st_mtime_ns == 880_000_000_000_000_000_000, os.stat(p).st_mtime_ns) +# +# Only a filesystem that reaches such a date can be asked about it, and by +# construction that cannot be every one: an APFS timestamp IS an int64 of +# nanoseconds, so 2262 is its ceiling too, and ext4 stores a 34-bit second and +# stops in 2446. Both clamp what they are handed (some hosts refuse it), where +# NTFS counts 100ns ticks to the year 30828. So the identity — the seconds in +# nanoseconds, whatever second was stored — is what every platform is held to, +# and the exact value only where the second survived the round trip. +FAR = 8.8e11 +try: + os.utime(p, (FAR, FAR)) +except OSError: + pass +else: + st = os.stat(p) + check(st.st_mtime_ns == int(st.st_mtime) * 1_000_000_000, st.st_mtime_ns) + if st.st_mtime == FAR: + check(st.st_mtime_ns == 880_000_000_000_000_000_000, st.st_mtime_ns) # Back to a time the rest of the file can be reasoned about. os.utime(p, ns=(1_000_000_000, 2_000_000_000)) diff --git a/pyre/extra_tests/parity_tests/run.py b/pyre/extra_tests/parity_tests/run.py index a0e9ba0fcb6..c9caba37676 100644 --- a/pyre/extra_tests/parity_tests/run.py +++ b/pyre/extra_tests/parity_tests/run.py @@ -147,7 +147,14 @@ def _run( env=None if env is None else {**os.environ, **env}, ) except subprocess.TimeoutExpired as expired: + # `text=True` decodes what `communicate()` RETURNS; the timeout path + # raises with the raw chunks it had joined so far, so what arrives here + # is bytes on POSIX and str on Windows. Reporting the bytes would print + # one `b'...\n...'` line, which is the unreadable shape this report + # exists to avoid. partial = expired.stderr or "" + if isinstance(partial, bytes): + partial = partial.decode("utf-8", "replace") return False, f"timed out after {TIMEOUT}s", partial lines = [line for line in proc.stdout.splitlines() if line.strip()] last = lines[-1] if lines else "" diff --git a/pyre/extra_tests/parity_tests/structseq_type_identity.py b/pyre/extra_tests/parity_tests/structseq_type_identity.py new file mode 100644 index 00000000000..24fd89669a0 --- /dev/null +++ b/pyre/extra_tests/parity_tests/structseq_type_identity.py @@ -0,0 +1,65 @@ +"""A structseq type is built once, not rebuilt for each answer. + +`PyStructSequence_InitType2` runs at module init and the type it leaves is what +every later result is an instance of, so `type(os.stat(a)) is type(os.stat(b))` +and an `isinstance` check written against one answer holds for the next. A +constructor that builds its type inside the call instead returns a fresh class +each time: the values compare equal, the types never do, and the failure only +shows up in code that kept a type from an earlier call. + +Each name is asked for twice and skipped when the platform does not carry it — +`statvfs`/`uname` are POSIX, `getwindowsversion` is Windows, and a terminal size +is not a question a redirected stdout can answer. +""" + +import os +import sys +import time + + +def check(cond, what): + if not cond: + raise AssertionError(what) + + +def answers(): + """Every structseq this platform can be asked for, as a name and a thunk.""" + yield "os.stat_result", lambda: os.stat(sys.executable) + yield "os.times_result", os.times + yield "time.struct_time", time.localtime + yield "sys.version_info", lambda: sys.version_info + yield "sys.flags", lambda: sys.flags + yield "os.terminal_size", os.get_terminal_size + if hasattr(os, "statvfs"): + yield "os.statvfs_result", lambda: os.statvfs(".") + if hasattr(os, "uname"): + yield "os.uname_result", os.uname + if hasattr(sys, "getwindowsversion"): + yield "sys.getwindowsversion", sys.getwindowsversion + + +asked = 0 +for name, call in answers(): + try: + first = call() + except (AttributeError, OSError): + # Not a question this host can answer; nothing to compare. + continue + second = call() + asked += 1 + check( + type(first) is type(second), + f"{name} built a second type for its second answer", + ) + # The type is a tuple subclass, which is what makes the sequence half of a + # structseq work at all — and what a fresh-per-call type would still get + # right, so it is checked beside the identity rather than instead of it. + check(isinstance(first, tuple), f"{name} is not a tuple subclass") + check( + type(first).__name__ == type(second).__name__, + f"{name} disagreed with itself about its own name", + ) + +check(asked >= 5, f"only {asked} structseq(s) were reachable to compare") + +print("OK") diff --git a/pyre/pyre-interpreter/src/module/sys/vm.rs b/pyre/pyre-interpreter/src/module/sys/vm.rs index f49e1f082bb..b5101ac3bd8 100644 --- a/pyre/pyre-interpreter/src/module/sys/vm.rs +++ b/pyre/pyre-interpreter/src/module/sys/vm.rs @@ -1374,17 +1374,23 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { pyre_object::PY_NULL, ) })?; - let cls = crate::_structseq::make_struct_seq_with_extra( - "sys.getwindowsversion", - &["major", "minor", "build", "platform", "service_pack"], - &[ - "service_pack_major", - "service_pack_minor", - "suite_mask", - "product_type", - "platform_version", - ], - ); + // Built once, like [`stat_result_seq_type`]: the type is the + // answer's identity, so making a fresh one per call would leave + // `type(sys.getwindowsversion())` a different class each time. + static SEQ_TYPE: std::sync::OnceLock = std::sync::OnceLock::new(); + let cls = *SEQ_TYPE.get_or_init(|| { + crate::_structseq::make_struct_seq_with_extra( + "sys.getwindowsversion", + &["major", "minor", "build", "platform", "service_pack"], + &[ + "service_pack_major", + "service_pack_minor", + "suite_mask", + "product_type", + "platform_version", + ], + ) as usize + }) as PyObjectRef; Ok(crate::_structseq::new_instance_with_extra( cls, vec![ From cbee0c1c441bf17c280da827341f92e4965b7dc4 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sun, 9 Aug 2026 08:47:21 +0900 Subject: [PATCH 08/10] cpython_tests: report the cases unittest named, the tail of a timed-out run, and decode the report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `classify` recorded `last_stderr_line` for a FAIL. This runner sets `MAJIT_STATS`, so the last thing every process writes is the JIT summary: every FAIL in the suite recorded `rc=1 Compilation time: ms`, a line that names no test. The nightly report carries 118 of them, all the same. A FAIL now carries unittest's own account instead — the closing `FAILED (...)` and the `FAIL:`/`ERROR:` case headers, up to four of them and a count of the rest. An IMPORTERROR never reached unittest, so it keeps the tail. `TimeoutExpired` was caught and discarded, leaving `timeout 120s`. It carries the output the child had produced, which for a unittest module is the progress dots — the record of which case it stopped in. `text=True` decodes what `communicate()` returns, not what the timeout path raises, so the partial arrives as bytes on POSIX and str on Windows and both are accepted. The report's box-drawing killed the run with a `UnicodeEncodeError` on a console whose codepage cannot spell it, before any test ran; stdout is reconfigured the way the parity runner's is. --- pyre/cpython_tests/run.py | 54 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/pyre/cpython_tests/run.py b/pyre/cpython_tests/run.py index ada9b5f5bbe..1d0031e099c 100644 --- a/pyre/cpython_tests/run.py +++ b/pyre/cpython_tests/run.py @@ -156,6 +156,34 @@ def last_stderr_line(err: str) -> str: return "" +def failure_digest(out: str, err: str) -> str: + """Which cases unittest reported against, and its closing verdict. + + A FAIL's tail is not its cause: this runner sets `MAJIT_STATS`, so the last + thing every process writes is the JIT summary, and `last_stderr_line` reads + back `Compilation time: 8.1ms` for a module whose real answer is two named + assertions several hundred lines above it. Every FAIL in the suite recorded + that same line, which named no test at all. + + unittest writes `FAIL: ` / `ERROR: ` headers and one closing + `FAILED (...)`; those are the runner's own account of what went wrong, and + they are what a CI log needs to carry. + """ + lines = f"{out}\n{err}".splitlines() + cases: list[str] = [] + verdict = "" + for line in lines: + line = line.strip() + if line.startswith(("FAIL: ", "ERROR: ")) and line not in cases: + cases.append(line) + elif line.startswith("FAILED ("): + verdict = line + shown = cases[:4] + if len(cases) > len(shown): + shown.append(f"(+{len(cases) - len(shown)} more)") + return " | ".join(part for part in (verdict, *shown) if part) + + def death_signal(rc: int) -> str: """`SIGBUS` for a run killed by a signal, else a bare return code. @@ -199,8 +227,11 @@ def classify(rc: int, out: str, err: str) -> tuple[str, str]: # skips it too, so it is honestly SKIP, not an interpreter gap. if not ran and "SkipTest" in err: return "SKIP", f"rc={rc} {last}"[:120] - status = "FAIL" if ran else "IMPORTERROR" - return status, f"rc={rc} {last}"[:120] + if ran: + # An IMPORTERROR never reached unittest, so its tail is all there is; + # a FAIL has unittest's own account, and only that names a test. + return "FAIL", f"rc={rc} {failure_digest(out, err) or last}"[:300] + return "IMPORTERROR", f"rc={rc} {last}"[:120] # ── discovery ──────────────────────────────────────────────────────── @@ -324,8 +355,17 @@ def run_module(binary: Path, module: str, mode: str, timeout: int, cmd, cwd=cwd, env=module_env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, ) - except subprocess.TimeoutExpired: - return "TIMEOUT", f"timeout {timeout}s" + except subprocess.TimeoutExpired as expired: + # A bare "timeout 120s" says only that the module is slow, never + # which case it stopped in — and unittest's progress dots, the one + # record of how far it got, were being thrown away here. `text=True` + # decodes what `communicate()` returns; the timeout path raises with + # the raw chunks it had joined, which is bytes on POSIX and str on + # Windows, so both spellings have to be accepted. + partial = expired.stderr or "" + if isinstance(partial, bytes): + partial = partial.decode("utf-8", "replace") + return "TIMEOUT", f"timeout {timeout}s {last_stderr_line(partial)}"[:300] return classify(proc.returncode, proc.stdout or "", proc.stderr or "") @@ -390,6 +430,12 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() + # The report is box-drawn and the module names it echoes come from the + # stdlib, so a console whose codepage cannot spell one of those characters + # used to kill the run with a `UnicodeEncodeError` before it reached a + # single test — the header alone does it on cp949. + sys.stdout.reconfigure(encoding="utf-8", errors="replace", line_buffering=True) + binary = Path(args.binary) if args.binary else TARGET_RELEASE / f"{BIN_NAME[args.backend]}{EXE}" binary = binary.resolve() if not args.list and not binary.exists(): From ec9c196b38cd68c65f4e397cd035556bad496eb0 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sun, 9 Aug 2026 08:47:31 +0900 Subject: [PATCH 09/10] _locale: the Windows category numbers, no LC_MESSAGES there, and a setlocale that reaches the CRT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `LC_*` values came from libc under `cfg(unix)` and from a hardcoded POSIX table everywhere else, so Windows published `LC_CTYPE=0, LC_ALL=6` where the MSVC CRT numbers them `LC_ALL=0, LC_COLLATE=1, LC_CTYPE=2`. Every constant named a different category than the one it was passed to. They now come from libc there too, which carries the CRT's own numbering. `LC_MESSAGES` is a POSIX category the MSVC CRT has no counterpart for, and CPython does not define it on Windows; it is registered under `cfg(unix)`. `setlocale` was gated on `all(unix, host_env)`, so on Windows it fell to the no-libc arm and answered "C" for every name it was handed, reporting success for a locale that was not installed. `host_env::locale::setlocale` is not unix-only — it calls `libc::setlocale`, which Windows has. The gate now admits Windows, and an uninstallable name raises `locale.Error` as CPython's does. Measured against CPython 3.14 on Windows: the six category numbers agree, neither has `LC_MESSAGES`, `en_US.iso88591` raises `locale.Error` on both and `en_US.utf8` and `English_United States.1252` are accepted by both. `locale_categories` pins all three properties and fails on the prior binary. --- .../parity_tests/locale_categories.py | 85 +++++++++++++++++++ .../src/module/_locale/interp_locale.rs | 26 ++++-- 2 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 pyre/extra_tests/parity_tests/locale_categories.py diff --git a/pyre/extra_tests/parity_tests/locale_categories.py b/pyre/extra_tests/parity_tests/locale_categories.py new file mode 100644 index 00000000000..167a7116ecd --- /dev/null +++ b/pyre/extra_tests/parity_tests/locale_categories.py @@ -0,0 +1,85 @@ +"""Which locale categories a platform has, and a setlocale that admits failure. + +- The `LC_*` numbers belong to the host's C library, and POSIX and the MSVC CRT + number them differently — `LC_ALL` is 6 on one and 0 on the other. A table of + POSIX values published on Windows does not merely read wrong: every number + names a *different* category there, so setting one sets another. +- `LC_MESSAGES` is a POSIX category the MSVC CRT has no counterpart for, and + `locale.py` asks whether the name is here before using it. +- `setlocale` answered "C" for every name it was given wherever the real one + was not being called, so a locale the host cannot install looked installed. + `locale.Error` is how a caller finds out it is not — and code that asks for a + locale in order to skip when it is missing gets the skip wrong otherwise. +""" + +import locale +import sys + + +def check(cond, what): + if not cond: + raise AssertionError(what) + + +def raises(call, exc): + try: + call() + except exc as e: + return e + raise AssertionError(f"{exc.__name__} was not raised") + + +CATEGORIES = ["LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MONETARY", "LC_NUMERIC", + "LC_TIME"] + +# ── which categories exist, and what they are numbered ──────────────────── +for name in CATEGORIES: + check(hasattr(locale, name), f"locale has no {name}") + +values = [getattr(locale, name) for name in CATEGORIES] +check(len(set(values)) == len(values), f"two categories share a number: {values}") + +# `LC_MESSAGES` is the one category whose presence is the platform's answer +# rather than a constant, so it is asserted as presence and not as a number. +check( + hasattr(locale, "LC_MESSAGES") == (sys.platform != "win32"), + f"LC_MESSAGES presence is wrong for {sys.platform}", +) + +if sys.platform == "win32": + # The MSVC CRT's own numbering, which is not POSIX's in any position. + check(locale.LC_ALL == 0, locale.LC_ALL) + check(locale.LC_COLLATE == 1, locale.LC_COLLATE) + check(locale.LC_CTYPE == 2, locale.LC_CTYPE) + check(locale.LC_MONETARY == 3, locale.LC_MONETARY) + check(locale.LC_NUMERIC == 4, locale.LC_NUMERIC) + check(locale.LC_TIME == 5, locale.LC_TIME) + +# ── setlocale answers about the locale that was installed ───────────────── +# The one-argument form asks rather than sets, and what it answers has to be a +# name the two-argument form accepts back — the round trip is how `addCleanup` +# in a test, or a library restoring what it borrowed, puts the locale back. +for name in CATEGORIES: + category = getattr(locale, name) + current = locale.setlocale(category) + check(isinstance(current, str) and current, f"setlocale({name}) -> {current!r}") + check(locale.setlocale(category, current) == current, f"{name} did not round trip") + +# "C" is the one locale every host has. +check(locale.setlocale(locale.LC_ALL, "C") == "C", "LC_ALL could not be set to C") + +# A name no host can install is refused. Answering "C" here — the shape of a +# setlocale that never reached the C library — reports success for a locale +# that was not installed, and the caller then runs the work it meant to skip. +for absent in ("no-such-locale-xyz", "xx_YY.INVALID"): + raises(lambda: locale.setlocale(locale.LC_CTYPE, absent), locale.Error) + check( + locale.setlocale(locale.LC_CTYPE) == "C", + "a refused setlocale changed the installed locale", + ) + +# An embedded NUL cannot reach a C string, and is a value error rather than a +# locale that could not be found. +raises(lambda: locale.setlocale(locale.LC_CTYPE, "C\0C"), ValueError) + +print("OK") diff --git a/pyre/pyre-interpreter/src/module/_locale/interp_locale.rs b/pyre/pyre-interpreter/src/module/_locale/interp_locale.rs index 65b91909f88..07b7c88440f 100644 --- a/pyre/pyre-interpreter/src/module/_locale/interp_locale.rs +++ b/pyre/pyre-interpreter/src/module/_locale/interp_locale.rs @@ -121,7 +121,10 @@ fn c_locale_conv() -> LocaleConvData { pub fn register_module(ns: pyre_object::PyObjectRef) { // Locale category constants sourced from libc so the values match // the host (Linux: LC_CTYPE=0; macOS: LC_ALL=0, LC_CTYPE=2; ...). - #[cfg(unix)] + // Windows has a C runtime too, and its numbering is a third one again + // (LC_ALL=0, LC_COLLATE=1, LC_CTYPE=2); a POSIX value passed to it names + // a different category, so it must come from libc there as well. + #[cfg(any(unix, windows))] { crate::module_ns_store( ns, @@ -144,14 +147,19 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { "LC_MONETARY", pyre_object::w_int_new(libc::LC_MONETARY as i64), ); - crate::module_ns_store( - ns, - "LC_MESSAGES", - pyre_object::w_int_new(libc::LC_MESSAGES as i64), - ); crate::module_ns_store(ns, "LC_ALL", pyre_object::w_int_new(libc::LC_ALL as i64)); } - #[cfg(not(unix))] + // `LC_MESSAGES` is a POSIX category the MSVC CRT has no counterpart for. + // `locale.py:1984-1989` appends it to `__all__` only if the name survived + // its `from _locale import *`, so publishing it here on Windows puts a + // category into `from locale import *` that no call can be made with. + #[cfg(unix)] + crate::module_ns_store( + ns, + "LC_MESSAGES", + pyre_object::w_int_new(libc::LC_MESSAGES as i64), + ); + #[cfg(not(any(unix, windows)))] { crate::module_ns_store(ns, "LC_CTYPE", pyre_object::w_int_new(0)); crate::module_ns_store(ns, "LC_NUMERIC", pyre_object::w_int_new(1)); @@ -286,7 +294,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { } else { None }; - #[cfg(all(unix, feature = "host_env"))] + #[cfg(all(any(unix, windows), feature = "host_env"))] { let cat = (unsafe { pyre_object::w_int_get_value(args[0]) }) as i32; let c_locale = match locale_str.as_ref() { @@ -302,7 +310,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { None => Err(locale_error("unsupported locale setting")), } } - #[cfg(not(all(unix, feature = "host_env")))] + #[cfg(not(all(any(unix, windows), feature = "host_env")))] { // No libc available — every valid call resolves to the // POSIX "C" locale. `locale_str` is dropped on purpose. From 24807b77946f92a3d0fc686702b818eb9ed5956e Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sun, 9 Aug 2026 09:00:01 +0900 Subject: [PATCH 10/10] bench/synth: restore the three wasm jit-stats baselines #1086 reverted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1106 `4555e3d76b` dropped the wasm32 arm of the self-recursive root-bridge inline and recorded what that moved: `ca_bridge_multiframe_resume_double_call` 2581 -> 2592, `wasm_ca_trampoline_decline` 404 -> 601 and `recursion_memo_branch` 4724 -> 4704. #1086 `e5eff81684` wrote all three back to their pre-#1106 values, and ubuntu has failed on them every run since. #1086 resizes 130 fixtures so pypy's side clears the measurement floor, and re-recording a resized fixture's counters is part of that. It moves 34 baseline values; 26 sit in fixtures whose `.py` it also changed. These three — and `pypy_type_surface`, restored in 958f66caa5 — are among the eight it moved with no workload change beside them, which is a snapshot taken on a base predating #1106. Measured rather than reverted: a local wasm run reads 2592, 601 and 4704, which are #1106's figures and the ones ubuntu observes. The remaining two of those eight, `closure_per_call` 418 and `recursive_call_frame_relocation` 638, read what they already record and are left alone. The lower counts are not the better state. #1106 measured removing the decline at -20.1%/-20.7% wasm CPU on `wasm_ca_trampoline_decline`; the +197 guard failures buy that. `ca_bridge_multiframe_resume_double_call` pays +1.2%/+2.7% and in exchange reports the 16 bridges / 0 aborts the dynasm baseline records, where the decline left wasm at 16/1. `retraces_compiled=0` comes with the re-record: these were the only wasm baselines missing the key, which `_parse_jit_stats` was defaulting. --- .../ca_bridge_multiframe_resume_double_call.wasm.jitstats | 3 ++- pyre/bench/synth/recursion_memo_branch.wasm.jitstats | 3 ++- pyre/bench/synth/wasm_ca_trampoline_decline.wasm.jitstats | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pyre/bench/synth/ca_bridge_multiframe_resume_double_call.wasm.jitstats b/pyre/bench/synth/ca_bridge_multiframe_resume_double_call.wasm.jitstats index 943439afa35..2f53bb8072f 100644 --- a/pyre/bench/synth/ca_bridge_multiframe_resume_double_call.wasm.jitstats +++ b/pyre/bench/synth/ca_bridge_multiframe_resume_double_call.wasm.jitstats @@ -8,7 +8,8 @@ fbw_rolled_back_with_effects=0 fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 -guard_failures=2581 +guard_failures=2592 internal_compile_panics=0 loops_aborted=0 loops_compiled=3 +retraces_compiled=0 diff --git a/pyre/bench/synth/recursion_memo_branch.wasm.jitstats b/pyre/bench/synth/recursion_memo_branch.wasm.jitstats index cce369775d9..3e2283230f5 100644 --- a/pyre/bench/synth/recursion_memo_branch.wasm.jitstats +++ b/pyre/bench/synth/recursion_memo_branch.wasm.jitstats @@ -8,7 +8,8 @@ fbw_rolled_back_with_effects=0 fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 -guard_failures=4724 +guard_failures=4704 internal_compile_panics=0 loops_aborted=1 loops_compiled=3 +retraces_compiled=0 diff --git a/pyre/bench/synth/wasm_ca_trampoline_decline.wasm.jitstats b/pyre/bench/synth/wasm_ca_trampoline_decline.wasm.jitstats index 2af84b441ef..1b618851f7b 100644 --- a/pyre/bench/synth/wasm_ca_trampoline_decline.wasm.jitstats +++ b/pyre/bench/synth/wasm_ca_trampoline_decline.wasm.jitstats @@ -8,7 +8,8 @@ fbw_rolled_back_with_effects=0 fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 -guard_failures=404 +guard_failures=601 internal_compile_panics=0 loops_aborted=1 loops_compiled=2 +retraces_compiled=0