diff --git a/majit/majit-backend-wasm/src/codegen.rs b/majit/majit-backend-wasm/src/codegen.rs index b903c9b45dc..cc1dd9f697a 100644 --- a/majit/majit-backend-wasm/src/codegen.rs +++ b/majit/majit-backend-wasm/src/codegen.rs @@ -850,6 +850,24 @@ impl RefHomes { } } } + // `store_force_descr` publishes the bracketing guard's fail arguments + // into the frame and leaves the bracket armed past the op, so a force + // arriving later is what reads them; x86 keeps that guard's gcmap as + // `finish_gcmap` for the same reason. Ordinary liveness stops at the + // guard — nothing consumes them after it — so a Ref that crosses no + // collecting call would take no home and `emit_force_arm` would publish + // its raw pointer into the untraced exit slots. Give every one of them + // a traced home to name instead. + for op in ops + .iter() + .filter(|op| matches!(op.opcode, OpCode::GuardNotForced | OpCode::GuardNotForced2)) + { + for arg in exit_fail_args(op) { + if ref_values.contains(arg) { + Self::assign(&mut by_id, &mut next, arg.raw()); + } + } + } // Resume-at-LABEL Ref captures must also have an ordinary home. The // high capture slot preserves the value while another bridge executes // on this frame; the ordinary home participates in the existing @@ -4429,7 +4447,7 @@ fn build_function( } guard_idx += 1; } - OpCode::GuardNotForced | OpCode::GuardNotForced2 => { + OpCode::GuardNotForced => { // x86/assembler.py genop_guard_guard_not_forced: // `CMP [rbp + jf_descr], 0`, fail when nonzero. `Backend::force` // stamps that mark on its way out, so this guard is what turns a @@ -4454,6 +4472,25 @@ fn build_function( ); guard_idx += 1; } + OpCode::GuardNotForced2 => { + // x86/regalloc.py consider_guard_not_forced_2 answers with + // `assembler.store_force_descr`, not with a branch: unlike + // GUARD_NOT_FORCED this one is not paired with a preceding call + // to test, it is what `store_token_in_vable` emits before a + // FINISH so a force arriving while the virtualizable is still + // armed can still rebuild a deadframe. Arm, do not test. + emit_force_arm( + &mut sink, + constants, + value_types, + ref_homes, + frame, + op, + exit_index(op, guard_idx), + None, + ); + guard_idx += 1; + } OpCode::GuardNoException => { // x86/assembler.py generate_guard_no_exception: // `CMP(pos_exception, imm0)` — fail the guard when a pending @@ -5455,6 +5492,8 @@ fn build_function( &mut sink, constants, value_types, + ref_homes, + frame, ops, op_idx, guard_idx, @@ -5832,6 +5871,8 @@ fn build_function( &mut sink, constants, value_types, + ref_homes, + frame, ops, op_idx, guard_idx, @@ -7728,18 +7769,14 @@ fn emit_guard_bridge_dispatch( } /// x86 `_store_force_index_if_next_guard`: a call that may force is bracketed -/// by the `GUARD_NOT_FORCED` immediately after it, and a force that lands -/// INSIDE the call reads the frame that guard describes -- upstream stores the -/// guard's descr into `jf_force_descr` before the call for exactly that reason. -/// Publish the same coordinate here: the guard's exit index in `frame[0]` and -/// its fail arguments in the exit slots, written BEFORE the call rather than on -/// a failure branch, because the reader runs while the call is still on the -/// stack. Without it `Backend::force` reads whatever exit last wrote the frame, -/// which is a different iteration's values. +/// by the `GUARD_NOT_FORCED` immediately after it, so publish that guard's +/// coordinate before the call runs. fn emit_force_bracket_before_call( sink: &mut PeepSink<'_, '_>, constants: &indexmap::IndexMap, value_types: &ValueLocals, + ref_homes: &RefHomes, + frame: FrameGeometry, ops: &[Op], op_idx: usize, guard_idx: u32, @@ -7755,25 +7792,65 @@ fn emit_force_bracket_before_call( } // Everything the guard names is defined by an op at or before the call -- // except the call's own result, whose local still holds the PREVIOUS - // iteration's value here. `build_callee_gcmap` marks the exit slots of a - // CALL_ASSEMBLER callee frame as traced, so parking a stale word there - // hands the collector a Ref that nothing keeps alive; store a null instead - // and let the guard's own failure branch fill in the real result. - // + // iteration's value here. + emit_force_arm( + sink, + constants, + value_types, + ref_homes, + frame, + next_op, + exit_index(next_op, guard_idx), + Some(ops[op_idx].pos.get().raw()), + ); +} + +/// x86 `store_force_descr` / `_store_force_index`: publish where a force that +/// lands while this frame is still reachable reads its state from — upstream +/// writes the guard's descr into `jf_force_descr` and its fail arguments into +/// the frame. Publish the same coordinate here: the guard's exit index plus +/// [`FORCE_ARMED_BIT`] in `frame[0]`, and its fail arguments in the exit slots. +/// This is written unconditionally, not on a failure branch, because the reader +/// runs while the bracketed call is still on the stack. +/// +/// A Ref argument is published as its **home slot offset**, tagged +/// `offset * 2 + 1`, rather than as its value. The exit slots are not in +/// `build_home_gcmap`'s traced set — that set is type-precise, and blanket +/// marking a slot that holds a scalar would offer the collector an integer to +/// mistake for a nursery address — so a Ref value copied here would not be +/// forwarded by a collection the bracketed call performs, and +/// `dead_frame_from_forced_frame` would read a from-space address. The home +/// slot IS traced and holds the same value, so naming it survives the +/// collection. Ref pointers are 8-aligned, which is what makes the low tag bit +/// free to tell an offset from a value; `undefined` and any Ref without a home +/// (a constant) still publish a literal, which is even. +#[allow(clippy::too_many_arguments)] +fn emit_force_arm( + sink: &mut PeepSink<'_, '_>, + constants: &indexmap::IndexMap, + value_types: &ValueLocals, + ref_homes: &RefHomes, + frame: FrameGeometry, + guard_op: &Op, + exit_idx: u32, + undefined: Option, +) { // `counter_value_spill` answers `None` for anything but a GUARD_VALUE, so // the counter slot has nothing to contribute to a force bracket. - let undefined = ops[op_idx].pos.get().raw(); - for (i, &arg_ref) in exit_fail_args(next_op).iter().enumerate() { + for (i, &arg_ref) in exit_fail_args(guard_op).iter().enumerate() { sink.local_get(0); - if arg_ref.raw() == undefined { + if undefined == Some(arg_ref.raw()) { sink.i64_const(0); + } else if let Some(home) = ref_homes.home(arg_ref) { + let ofs = frame.home_slot_base + home as u64 * SLOT_SIZE; + sink.i64_const((ofs as i64) * 2 + 1); } else { emit_resolve(sink, constants, value_types, arg_ref); } sink.i64_store(mem64(FRAME_SLOT_BASE + i as u64 * SLOT_SIZE)); } sink.local_get(0); - sink.i64_const(exit_index(next_op, guard_idx) as i64 | FORCE_ARMED_BIT); + sink.i64_const(exit_idx as i64 | FORCE_ARMED_BIT); sink.i64_store(mem64(0)); } diff --git a/majit/majit-backend-wasm/src/lib.rs b/majit/majit-backend-wasm/src/lib.rs index 00549a6e24b..fe2fe544bed 100644 --- a/majit/majit-backend-wasm/src/lib.rs +++ b/majit/majit-backend-wasm/src/lib.rs @@ -2995,8 +2995,22 @@ fn dead_frame_from_forced_frame(frame_ptr: usize) -> DeadFrame { let fail_descr = global_fail_descr(fail_index).expect("invalid fail_index from a forced wasm frame"); let num_outputs = exit_slot_count(&fail_descr); + let types = fail_descr.fail_arg_types.as_slice(); let raw_values: Vec = (0..num_outputs) - .map(|i| unsafe { *frame.add(1 + i) }) + .map(|i| { + let word = unsafe { *frame.add(1 + i) }; + // `emit_force_arm` publishes a Ref argument as `home_offset * 2 + 1` + // so the value is read out of the traced home slot a collection + // inside the bracketed call forwards, rather than out of an + // untraced copy in the exit slot. A literal is even (Ref pointers + // are 8-aligned; a null and a non-Ref argument are published as + // themselves). + if types.get(i) == Some(&majit_ir::Type::Ref) && word & 1 == 1 { + unsafe { *((frame_ptr + (word >> 1) as usize) as *const i64) } + } else { + word + } + }) .collect(); DeadFrame::Boxed(WasmFrameData::boxed(raw_values, fail_descr, 0)) } diff --git a/majit/majit-metainterp/src/warmstate.rs b/majit/majit-metainterp/src/warmstate.rs index ef84a152c13..c3bfe254d63 100644 --- a/majit/majit-metainterp/src/warmstate.rs +++ b/majit/majit-metainterp/src/warmstate.rs @@ -2247,7 +2247,8 @@ impl WarmEnterState { if cell.has_seen_a_procedure_token() { // A live TEMPORARY token still declines; a token that was // once seen but has since been invalidated falls through to - // the cleanup gate below (warmstate.py:483-491) rather than + // the cleanup gate below (`WarmEnterState.maybe_compile_and_run`) + // rather than // re-entering the never-traced retry. if cell.get_procedure_token().is_some() { return FunctionEntryStep::NotHot; @@ -2266,8 +2267,9 @@ impl WarmEnterState { return FunctionEntryStep::Proceed; } if cleanup_dead_token_cell { - // warmstate.py:483-500 — function-entry warmup must see an - // invalidated token as a removed cell and re-count from cold. + // `WarmEnterState.maybe_compile_and_run` — function-entry warmup + // must see an invalidated token as a removed cell and re-count from + // cold. crate::mc_diag_bump(24); self.cleanup_chain(self.bucket_of(cell_key)); return FunctionEntryStep::NotHot; diff --git a/pyre/bench/synth/foriter_exempt_nested_foriter.py b/pyre/bench/synth/foriter_exempt_nested_foriter.py index d63eee1dc9c..19efaed21a0 100644 --- a/pyre/bench/synth/foriter_exempt_nested_foriter.py +++ b/pyre/bench/synth/foriter_exempt_nested_foriter.py @@ -1,11 +1,16 @@ -# pyre-check: max-pypy-ratio=10 -# Measured ~9.0x on dynasm once the function-entry door resolved its -# bucket hash to a cell key: before that the door read another cell's answer, -# asked to trace at every call and never entered the compiled loop, and the -# ceiling here was 44. pypy's execution time is clamped to the runner's -# floor for this fixture, so check.py marks the ratio `~` and applies no gate -# to it; the ceiling records the level rather than enforcing it, and becomes -# enforceable if the fixture is ever sized past that floor. +# pyre-check: max-pypy-ratio=20 +# The function-entry door reading its own cell took this off the 44 it needed +# while the door read another cell's answer, asked to trace at every call and +# never entered the compiled loop. +# +# The ceiling is NOT the measured ratio. pypy's execution-only time here lands +# either side of EXEC_TIME_FLOOR_S, and check.py gates the ratio whenever it +# lands above (`?`) and skips it whenever it is clamped to the floor (`~`), so +# the same binary reads 17.7x on one runner and 27.9x on the next. Size the +# ceiling for the worst denominator in the gated band instead: dynasm's +# execution-only time over `2 * EXEC_TIME_FLOOR_S` -- the floor plus the grace +# `_compare_buffer` adds for a floor-sized baseline -- which is 0.14s / 0.01s, +# plus room for the run-to-run spread of that numerator. # gh#495 guard: fbw_abort_nested_unjournaled_residual prevents the ForIterNext exemption double-advance. # branch-bearing callee with a SECOND FOR_ITER (nested), not the loop header. # Two shared generators; inner FOR_ITER advance is a non-header foriter (Finding #2). diff --git a/pyre/bench/synth/foriter_exempt_shared_generator.py b/pyre/bench/synth/foriter_exempt_shared_generator.py index 15e1a607d4e..b0a044d9386 100644 --- a/pyre/bench/synth/foriter_exempt_shared_generator.py +++ b/pyre/bench/synth/foriter_exempt_shared_generator.py @@ -1,11 +1,9 @@ -# pyre-check: max-pypy-ratio=10 -# Measured ~7.0x on dynasm once the function-entry door resolved its -# bucket hash to a cell key: before that the door read another cell's answer, -# asked to trace at every call and never entered the compiled loop, and the -# ceiling here was 63. pypy's execution time is clamped to the runner's -# floor for this fixture, so check.py marks the ratio `~` and applies no gate -# to it; the ceiling records the level rather than enforcing it, and becomes -# enforceable if the fixture is ever sized past that floor. +# pyre-check: max-pypy-ratio=20 +# The function-entry door reading its own cell took this off the 63 it needed +# while the door read another cell's answer, asked to trace at every call and +# never entered the compiled loop. Its pypy baseline straddles +# EXEC_TIME_FLOOR_S the same way its nested-foriter sibling's does, so the +# ceiling is sized the same way -- see the header there. # gh#495 guard: fbw_abort_nested_unjournaled_residual prevents the ForIterNext exemption double-advance. # SHARED long generator consumed incrementally. step consumes ONE item (for..break), # FOR_ITER advance mutates shared counter (exempt). Then a declining nested-residual CALL. diff --git a/pyre/cpython_tests/baseline.win32-AMD64.json b/pyre/cpython_tests/baseline.win32-AMD64.json new file mode 100644 index 00000000000..c8286e2e80c --- /dev/null +++ b/pyre/cpython_tests/baseline.win32-AMD64.json @@ -0,0 +1,33 @@ +{ + "host": "win32-AMD64", + "modules": { + "test.test_eintr": { + "dynasm": "SKIP" + }, + "test.test_file_eintr": { + "dynasm": "SKIP" + }, + "test.test_import": { + "dynasm": "FAIL" + }, + "test.test_mmap": { + "dynasm": "FAIL" + }, + "test.test_msvcrt": { + "dynasm": "PASS" + }, + "test.test_startfile": { + "dynasm": "PASS" + }, + "test.test_venv": { + "dynasm": "FAIL" + }, + "test.test_winapi": { + "dynasm": "PASS" + }, + "test.test_winreg": { + "dynasm": "PASS" + } + }, + "stdlib_version": "3.14.6" +} diff --git a/pyre/cpython_tests/run.py b/pyre/cpython_tests/run.py index b5639c7c5e8..3e723657780 100644 --- a/pyre/cpython_tests/run.py +++ b/pyre/cpython_tests/run.py @@ -157,6 +157,26 @@ lambda p: p != "wasi", "cannot create socket on WASI", ), + # `if not support.has_fork_support: raise unittest.SkipTest(...)` + "test.test_fork1": ( + lambda p: p not in ("win32", "emscripten", "wasi"), + "os.fork() not available", + ), + # `if not hasattr(os, "openpty"): raise unittest.SkipTest(...)` + "test.test_openpty": ( + lambda p: p not in ("win32", "emscripten", "wasi"), + "os.openpty() not available", + ), + # `syslog = import_helper.import_module("syslog")` + "test.test_syslog": ( + lambda p: p not in ("win32", "emscripten", "wasi"), + "no syslog module", + ), + # `termios = import_module('termios')` + "test.test_tty": ( + lambda p: p not in ("win32", "emscripten", "wasi"), + "no termios module", + ), } diff --git a/pyre/pyre-interpreter/src/builtins.rs b/pyre/pyre-interpreter/src/builtins.rs index 6a430366a4d..df749caf9cc 100644 --- a/pyre/pyre-interpreter/src/builtins.rs +++ b/pyre/pyre-interpreter/src/builtins.rs @@ -3548,6 +3548,10 @@ pub fn install_default_builtins(ns: PyObjectRef) { crate::module_ns_store(ns, "IOError", os_error); // `exceptions.c` — `EnvironmentError` is a deprecated alias of `OSError`. crate::module_ns_store(ns, "EnvironmentError", os_error); + // `_PyBuiltins_AddExceptions` binds `WindowsError` to `OSError` under + // `MS_WINDOWS`, so the name exists only on Windows. + #[cfg(windows)] + crate::module_ns_store(ns, "WindowsError", os_error); crate::module_ns_store( ns, "FileNotFoundError", diff --git a/pyre/pyre-interpreter/src/module/_socket/interp_socket.rs b/pyre/pyre-interpreter/src/module/_socket/interp_socket.rs index 509b4230d43..ea8a426dd4b 100644 --- a/pyre/pyre-interpreter/src/module/_socket/interp_socket.rs +++ b/pyre/pyre-interpreter/src/module/_socket/interp_socket.rs @@ -770,9 +770,9 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { cst!("IP_RECVTTL", 21); cst!("IP_RECVTOS", 40); cst!("IP_RECVERR", 75); - cst!("IP_DEFAULT_MULTICAST_LOOP", 1); - cst!("IP_DEFAULT_MULTICAST_TTL", 1); - cst!("IP_MAX_MEMBERSHIPS", 20); + // `IP_DEFAULT_MULTICAST_LOOP`, `IP_DEFAULT_MULTICAST_TTL` and + // `IP_MAX_MEMBERSHIPS` are published under `#ifdef`, and the Winsock + // headers define none of them, so the module does not carry them here. // ── IPv6 ── cst!("IPV6_V6ONLY", ws::IPV6_V6ONLY); cst!("IPV6_CHECKSUM", ws::IPV6_CHECKSUM); @@ -840,7 +840,8 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { cst!("RCVALL_OFF", ws::RCVALL_OFF); cst!("RCVALL_ON", ws::RCVALL_ON); cst!("RCVALL_SOCKETLEVELONLY", ws::RCVALL_SOCKETLEVELONLY); - cst!("RCVALL_IPLEVEL", ws::RCVALL_IPLEVEL); + // `RCVALL_IPLEVEL` is a member of the `RCVALL_VALUE` enum that the + // module does not publish; `RCVALL_MAX` is the last name it does. cst!("RCVALL_MAX", 3); // Hyper-V socket ABI constants (`hvsocket.h`). GUIDs and Bluetooth // addresses are public strings rather than integer enum members. diff --git a/pyre/pyre-interpreter/src/module/_stat/mod.rs b/pyre/pyre-interpreter/src/module/_stat/mod.rs index 5c2d2825e31..1b1eeeae134 100644 --- a/pyre/pyre-interpreter/src/module/_stat/mod.rs +++ b/pyre/pyre-interpreter/src/module/_stat/mod.rs @@ -117,7 +117,13 @@ const SF_DATALESS: u32 = 0x40000000; /// The Apple headers reserve the top two flag bits for the synthetic flags, /// so the super-user mask stops short of them. -const SF_SETTABLE: u32 = libc_const!(target_vendor = "apple", SF_SETTABLE, 0xffff0000); +/// +/// `_stat.c` publishes each flag with `PyModule_AddIntMacro`, whose value +/// parameter is a C `long`. `0xffff0000` does not fit the 32-bit `long` of an +/// LLP64 target, so Windows publishes these bits as `-65536` while an LP64 +/// target publishes `4294901760`. +const SF_SETTABLE: std::ffi::c_long = + libc_const!(target_vendor = "apple", SF_SETTABLE, 0xffff_0000u32) as std::ffi::c_long; #[cfg(target_vendor = "apple")] const SF_SUPPORTED: u32 = 0x009f0000; diff --git a/pyre/pyre-interpreter/src/module/posix/interp_posix.rs b/pyre/pyre-interpreter/src/module/posix/interp_posix.rs index fba7f9fc7dc..f3e4ba5dc91 100644 --- a/pyre/pyre-interpreter/src/module/posix/interp_posix.rs +++ b/pyre/pyre-interpreter/src/module/posix/interp_posix.rs @@ -523,14 +523,20 @@ fn terminal_size_seq_type() -> PyObjectRef { }) as PyObjectRef } -/// `os.uname_result` structseq — `(sysname, nodename, release, version, -/// machine)`; repr renders "posix.uname_result(...)". -#[cfg(unix)] +/// `uname_result` structseq — `(sysname, nodename, release, version, +/// machine)`. `uname_result_desc` names the type after the module it is +/// registered in, which is what pickle imports to resolve it: `posix` where +/// this module is `posix`, `nt` on Windows, which has the type even though it +/// has no `uname` to build one with. fn uname_result_seq_type() -> PyObjectRef { static T: std::sync::OnceLock = std::sync::OnceLock::new(); *T.get_or_init(|| { crate::_structseq::make_struct_seq( - "posix.uname_result", + if cfg!(windows) { + "nt.uname_result" + } else { + "posix.uname_result" + }, &["sysname", "nodename", "release", "version", "machine"], ) as usize }) as PyObjectRef @@ -4564,10 +4570,6 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { crate::module_ns_store(ns, "terminal_size", terminal_size_seq_type()); crate::module_ns_store(ns, "statvfs_result", statvfs_result_seq_type()); crate::module_ns_store(ns, "times_result", times_result_seq_type()); - // `uname_result` names `posix` as its module, which is what pickle imports - // to resolve it; only the POSIX hosts have that module, and only they - // register `uname` below. - #[cfg(unix)] crate::module_ns_store(ns, "uname_result", uname_result_seq_type()); // ── posix.get_terminal_size(fd=1) → os.terminal_size(columns, lines) ── diff --git a/pyre/pyre-interpreter/src/module/sys/vm.rs b/pyre/pyre-interpreter/src/module/sys/vm.rs index 693f4f501a0..ea694dd08c3 100644 --- a/pyre/pyre-interpreter/src/module/sys/vm.rs +++ b/pyre/pyre-interpreter/src/module/sys/vm.rs @@ -1464,7 +1464,11 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { // directory and the PythonCore registry keys. site.getusersitepackages // reads it to build USER_SITE. A build without a global interpreter lock // carries the `t` here, which is how Windows spells what `sys.abiflags` - // spells elsewhere. + // spells elsewhere: the tag follows the ABI the build publishes + // (`Py_GIL_DISABLED` is 1 and `abi_thread` is `t`), not + // `sys._is_gil_enabled()`, which reports whether the lock is on right now. + // `venv.EnvBuilder.setup_python` reads the same config var to name + // `python3.14t.exe`. #[cfg(windows)] module_ns_store(ns, "winver", w_str_new("3.14t")); // sys.dllhandle — the handle of the DLL exporting the Python C API, @@ -2348,9 +2352,15 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { store_fn(mon, "restart_events", |_| Ok(w_none()), 0); module_ns_store(ns, "monitoring", mon); } - // sys.platlibdir — typically "lib" on POSIX; used by sysconfig to - // construct install paths. - module_ns_store(ns, "platlibdir", w_str_new("lib")); + // sys.platlibdir — the platform-specific library directory sysconfig and + // `site.addsitepackages` build install paths from: "lib" on POSIX, and + // "DLLs" on Windows, where the extension modules sit beside the interpreter + // instead of under a versioned lib directory. + module_ns_store( + ns, + "platlibdir", + w_str_new(if cfg!(windows) { "DLLs" } else { "lib" }), + ); // `sys/app.py exit(exitcode=None)` — raise SystemExit(exitcode), // de-tupelizing a tuple argument so `exit((a, b))` becomes // `SystemExit(a, b)` (the extra de-tupelizing normalize_exception does diff --git a/pyre/pyre-interpreter/src/module/thread/mod.rs b/pyre/pyre-interpreter/src/module/thread/mod.rs index 848ab70a62a..efc2772b8ad 100644 --- a/pyre/pyre-interpreter/src/module/thread/mod.rs +++ b/pyre/pyre-interpreter/src/module/thread/mod.rs @@ -11,11 +11,30 @@ use std::sync::atomic::{AtomicBool, AtomicI64, AtomicUsize, Ordering}; use std::sync::{LazyLock, OnceLock}; use std::time::{Duration, Instant}; -/// `_thread.TIMEOUT_MAX` — the whole-second bound of the nanosecond timestamp -/// an acquire timeout is converted to. PyPy exposes the microsecond bound -/// instead (`moduledef.py` `float(os_lock.TIMEOUT_MAX // 1000000)`), which -/// is a thousand times larger and is its 3.11-era surface. -const TIMEOUT_MAX: f64 = (i64::MAX / 1_000_000_000) as f64; +/// `PY_TIMEOUT_MAX` — the acquire-timeout bound in microseconds. A POSIX host +/// waits on a nanosecond deadline and bounds it at `LLONG_MAX / 1000`; Windows +/// waits through `WaitForSingleObject`, which takes a DWORD of milliseconds and +/// reserves `0xFFFFFFFF` for `INFINITE`, so its bound is `0xFFFFFFFE * 1000`. +const PY_TIMEOUT_MAX: i64 = if cfg!(windows) { + 0xFFFF_FFFE * 1000 +} else { + i64::MAX / 1000 +}; + +/// `PyTime_MAX` in the same microseconds: the timestamp an acquire timeout is +/// converted to is `i64` nanoseconds. +const PYTIME_MAX_US: i64 = i64::MAX / 1000; + +/// `_thread.TIMEOUT_MAX` — `floor(min(PY_TIMEOUT_MAX, PyTime_MAX))` in whole +/// seconds, so 4294967.0 on Windows and 9223372036.0 where the timestamp is the +/// smaller bound. PyPy exposes the microsecond bound instead (`moduledef.py` +/// `float(os_lock.TIMEOUT_MAX // 1000000)`), which is a thousand times larger +/// and is its 3.11-era surface. +const TIMEOUT_MAX: f64 = (if PY_TIMEOUT_MAX < PYTIME_MAX_US { + PY_TIMEOUT_MAX +} else { + PYTIME_MAX_US +} / 1_000_000) as f64; static THREAD_COUNT: AtomicI64 = AtomicI64::new(0); static STACK_SIZE: AtomicUsize = AtomicUsize::new(0); static FINALIZING: AtomicBool = AtomicBool::new(false); @@ -726,7 +745,16 @@ fn parse_acquire_args( // `_PyTime_AsMicroseconds`. Truncating instead would collapse any // positive sub-microsecond timeout to 0, which `acquire_timed` reads // as a non-blocking poll rather than a timed wait. - Ok((timeout * 1e6).ceil() as i64) + let microseconds = (timeout * 1e6).ceil() as i64; + // `lock_acquire_parse_args` bounds the MICROSECONDS against + // `PY_TIMEOUT_MAX`, which the nanosecond range above does not stand in + // for: on Windows the wait primitive takes a DWORD of milliseconds, so + // its bound is three orders of magnitude below what an `i64` of + // nanoseconds can carry. + if microseconds > PY_TIMEOUT_MAX { + return Err(crate::PyError::overflow_error("timeout value is too large")); + } + Ok(microseconds) } } @@ -2573,7 +2601,6 @@ crate::py_module! { functions: { "allocate_lock" / 0 = new_lock, "allocate" / 0 = new_lock, - "_set_sentinel" / 0 = new_lock, "_make_thread_handle" / 1 = make_thread_handle, "get_ident" / 0 = get_ident, "get_native_id" / 0 = get_native_id, diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs index 3988aa0a274..f2f5fd18ef8 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs @@ -2237,6 +2237,29 @@ pub(crate) fn emit_walker_loop_callee_call_assembler( let Some(portal_view) = portal_descr.as_call_descr() else { return resolved_inline_decline(op.pc, line!()); }; + // The live frame this resume runs, resolved under the same rule as the + // portal target above: a callee_frame with no concrete shadow declines + // before the `last_instr` pin and the vable/vref bookkeeping are recorded, + // so the generic residual path that then re-enters the callee at its entry + // does not run against a trace already carrying this fold's ops. + let concrete_callee_frame = match ctx.trace_ctx.concrete_of_opref(callee_frame) { + Some(majit_ir::Value::Ref(gcref)) if gcref.0 != 0 => { + gcref.0 as *mut pyre_interpreter::PyFrame + } + _ => return resolved_inline_decline(op.pc, line!()), + }; + // A resume coordinate is `(last_instr, valuestackdepth)`, not `last_instr` + // alone: a header entered with operands on the stack — a `for` header holds + // its iterator there — executes against a height the frame has to + // advertise, and the portal runner hands that frame to the interpreter + // whenever its entry gate declines. The sub-walk's own `setfield_vable_i` + // writes keep the frame in step, so this is a check and not a publish; + // decline rather than resume at a coordinate the frame does not carry. + if let Some(depth_vsd) = crate::state::depth_based_vsd_for_wcode(w_code as usize, target_pc) + && depth_vsd != unsafe { (*concrete_callee_frame).valuestackdepth } + { + return resolved_inline_decline(op.pc, line!()); + } // Pin the loop-entry resume position on the (still-virtual) callee frame: // override `last_instr` from -1 (the fresh-frame entry value @@ -2249,19 +2272,17 @@ pub(crate) fn emit_walker_loop_callee_call_assembler( // `FieldDescr` (the same field-set the builder uses), so // `optimize_setfield_gc` records it into the virtual's `vinfo.fields`. // - // MEASURED INCONSISTENCY, no failing case known. That seeded depth is the - // right one only for a header whose static operand-stack depth is zero, - // which is what "empty stack at the while-header" above assumes. A `for` - // header is entered with the iterator on the stack. Census over the 447 - // `pyre/bench/synth` fixtures (a `stack_depth_at(target_pc)` probe on this - // arm, dynasm, 84131dc4da8): 36 emits reach here, 25 at depth 0 and 11 at - // depth 1 — all 11 in `str_search_index_bounds.py`, all with `ForIter` at - // `target_pc`, pinning `last_instr` beside an operand height one slot short - // of what that header executes against. That fixture still passes its own - // asserts and its output comparison, and pinning the analysis depth here - // instead changed nothing on it, so no wrong answer is attributable to this - // and no gate is warranted yet. Written down because a measured inconsistency nobody - // recorded is one somebody re-derives: see + // The seeded depth reads as the whole answer only for a header whose static + // operand-stack depth is zero, which is what "empty stack at the + // while-header" above assumes; a `for` header is entered with the iterator + // on the stack. Census over the 447 `pyre/bench/synth` fixtures (dynasm, + // 84131dc4da8): 36 emits reach here, 25 at static depth 0 and 11 at depth 1 + // — all 11 in `str_search_index_bounds.py`, all with `ForIter` at + // `target_pc`. Measured on those 11, the frame this resume runs reads + // `valuestackdepth` 4 against a `depth_at_py_pc` of 4: the sub-walk's own + // `setfield_vable_i` writes carried the push, so the seed is not what the + // frame still holds by the time the header is reached. The gate above is + // what keeps that true rather than assumed. See // `pyre/bench/synth/_pending/loop_callee_for_header_resume.py` for the same // "resume pc pinned without its operand height" shape that DOES produce a // wrong answer, through a different writer. @@ -2329,12 +2350,6 @@ pub(crate) fn emit_walker_loop_callee_call_assembler( // own-frame write into it as the sub-walk records them — but the walk // stopped AT the merge point, before the opcode that would have spilled // `last_instr` for it. - let concrete_callee_frame = match ctx.trace_ctx.concrete_of_opref(callee_frame) { - Some(majit_ir::Value::Ref(gcref)) if gcref.0 != 0 => { - gcref.0 as *mut pyre_interpreter::PyFrame - } - _ => return resolved_inline_decline(op.pc, line!()), - }; unsafe { (*concrete_callee_frame).last_instr = target_pc as isize - 1 }; let funcbox = ctx.trace_ctx.const_int(portal_runner_adr); let next_instr_box = ctx.trace_ctx.const_int(target_pc as i64); @@ -6616,6 +6631,20 @@ fn try_walker_inline_resolved_user_call_inner( // position: the sub-walk block above is an expression that always // completes, so every callee exit — return, exception, or decline — // arrives here before any of the early returns below. + // + // A callee that stopped at its OWN loop header has not returned, so this + // `leave` is recorded before the `CALL_ASSEMBLER` that runs the rest of it, + // and a loop body reading the live frame (`sys._getframe()`, a traceback) + // names the caller rather than its own frame. MEASURED 2026-08-27: moving + // the `leave` past that op — the obvious fix — hangs + // `synth/exception_traceback_frame_lineno` (dynasm, 6/6; the deferral + // switched off in the same binary is 3/3 clean). The guards between the + // two, and `GUARD_NO_EXCEPTION` above all in a callee that raises every + // iteration, leave the trace before the deferred `leave` is reached, so + // `ec.topframeref` keeps the callee and the frame chain never unwinds. + // Converging needs the `leave` reachable from those guard exits — a + // resume-side leave, or the exception path recording its own — not a + // reorder. if entered_ec { let concrete_ec = unsafe { (*ca_concrete_frame).execution_context } as *mut pyre_interpreter::PyExecutionContext; diff --git a/pyre/pyre-jit/src/eval.rs b/pyre/pyre-jit/src/eval.rs index e3349da7ea6..fa19a8378c8 100644 --- a/pyre/pyre-jit/src/eval.rs +++ b/pyre/pyre-jit/src/eval.rs @@ -228,11 +228,15 @@ impl FrameRoot { /// Inlinable, unlike its two siblings, and `eval_loop_jit` is the reason: /// it re-seeds the frame pointer after every collection point, four times - /// per opcode on the no-tracer fast path. `#[dont_look_inside]` implies - /// `#[inline(never)]`, so each of those was a call to a body that reads one - /// slot -- measured at ~17 ns per opcode against + /// per opcode on the no-tracer fast path, and each of those was a call to a + /// body that reads one slot -- measured at ~17 ns per opcode against /// `PyFrame::execute_frame_plain`, i.e. the whole `PYRE_NO_JIT=1` versus - /// `PYRE_JIT=0` gap, before any JIT decision is taken. + /// `PYRE_JIT=0` gap, before any JIT decision is taken. The `#[inline]` is + /// what collapses the seeds into the loop body; `@dont_look_inside` does not + /// carry `#[inline(never)]` (its expansion says so), and for a `&mut self` + /// receiver it emits no call-target wrapper either, so what its removal + /// drops here is the `_jit_look_inside_` marker and the policy/prebuild fns + /// beside it. /// /// Dropping the marker costs no tracing policy: `@dont_look_inside` names /// what the tracer must call as a black box, and nothing the walker records @@ -11098,6 +11102,18 @@ pub fn try_function_entry_jit(frame: &mut PyFrame) -> Option { pyre_jit_trace::driver::make_green_key_typed(code_ptr, entry_pc, is_being_profiled) }); + // `maybe_compile_and_run` tests `cell.flags & JC_TRACING` on the cell the + // chain walk found and returns there, BEFORE `cell.get_procedure_token()`. + // Asking the door first read the token and its compiled meta for a cell + // this then declines anyway, and ticked the counter for a call upstream + // never counts. + if driver.meta_interp().is_tracing_key(( + frame_root.frame().pycode as usize, + frame_root.frame().next_instr(), + )) { + return None; + } + // RPython warmstate.py maybe_compile_and_run: read the cell's procedure // token, and only when it is absent ask the counter. A bare // `compile_tmp_callback` token (a token, but no `compiled_loops` meta) is @@ -11112,13 +11128,6 @@ pub fn try_function_entry_jit(frame: &mut PyFrame) -> Option { return None; } - // RPython warmstate.py: per-cell JC_TRACING. - if driver.meta_interp().is_tracing_key(( - frame_root.frame().pycode as usize, - frame_root.frame().next_instr(), - )) { - return None; - } // `warmstate.py maybe_compile_and_run` carries the token the cell read // produced out through `EnterJitAssembler(procedure_token, *execute_args)`; // holding it here is what keeps it alive across the run the way upstream's