diff --git a/majit/majit-backend-dynasm/src/aarch64/assembler.rs b/majit/majit-backend-dynasm/src/aarch64/assembler.rs index 361e0ee0a2e..24f62df7162 100644 --- a/majit/majit-backend-dynasm/src/aarch64/assembler.rs +++ b/majit/majit-backend-dynasm/src/aarch64/assembler.rs @@ -4058,18 +4058,12 @@ impl<'a> AssemblerARM64<'a> { let fail_label = self.mc.new_dynamic_label(); if self.invalidated_flag_addr != 0 { self.emit_mov_imm64(16, self.invalidated_flag_addr as i64); - // `CBNZ` reaches +-32KB, but this guard sits at the head of the - // peeled loop body while its recovery stub is emitted after the - // whole trace, so a long body puts the stub out of range and - // dynasm rejects the relocation at commit. Branch over an - // unconditional `B` (+-128MB) instead, the standard veneer. - let continue_label = self.mc.new_dynamic_label(); - dynasm!(self.mc ; .arch aarch64 - ; ldrb w17, [x16] - ; cbz w17, =>continue_label - ; b =>fail_label - ; =>continue_label - ); + dynasm!(self.mc ; .arch aarch64 ; ldrb w17, [x16]); + // The recovery stub is emitted after the whole trace body, so a + // bare `cbnz` (19-bit, ±1MB) cannot reach it once the body passes + // 1MB — the same reach the other guards route around via + // `emit_bcond_to_label`. + self.emit_cbnz_w_to_label(17, fail_label); } self.append_guard_token_with_faillocs(op, op_index, fail_index, fail_label, faillocs); } @@ -4831,6 +4825,18 @@ impl<'a> AssemblerARM64<'a> { self.emit_bcond_to_label(fail_cc, fail_label); } + /// `cbnz W(reg), =>label` for a `label` that may sit past the 19-bit / + /// ±1MB reach of `cbnz`, using the same inversion as + /// [`Self::emit_bcond_to_label`]: `cbz skip; b =>label; skip:`. + fn emit_cbnz_w_to_label(&mut self, reg: u8, label: DynamicLabel) { + let skip = self.mc.new_dynamic_label(); + dynasm!(self.mc ; .arch aarch64 + ; cbz W(reg), =>skip + ; b =>label + ; =>skip + ); + } + /// Infer fail_arg_types from `op.type_` (via `opref_type`) or /// `op.fail_arg_types`. fn infer_fail_arg_types(&self, op: &Op, op_index: Option) -> Vec { diff --git a/majit/majit-backend-wasm/src/codegen.rs b/majit/majit-backend-wasm/src/codegen.rs index dcd510fc911..8e0d52671e4 100644 --- a/majit/majit-backend-wasm/src/codegen.rs +++ b/majit/majit-backend-wasm/src/codegen.rs @@ -62,6 +62,12 @@ pub struct FrameGeometry { pub home_slot_base: u64, /// Number of Ref-home slots the layout reserves. pub home_slots: usize, + /// Number of slots at the END of the Ref-home region reserved for + /// resume-at-LABEL live-ins. Ordinary per-trace Ref homes grow upward + /// from `home_slot_base`; these captures grow from the frozen boundary and + /// therefore survive execution of a chained bridge, whose own home map may + /// use the low slots. The whole home region remains covered by jf_gcmap. + pub label_ref_slots: usize, /// Bytes through the end of Ref homes. CA callee frames allocate exactly /// this many item bytes; the tail call area is intentionally omitted. pub ca_frame_bytes: u32, @@ -85,6 +91,7 @@ impl FrameGeometry { dispatch_key_ofs: DISPATCH_KEY_OFS, home_slot_base: HOME_SLOT_BASE, home_slots: 0, + label_ref_slots: 0, ca_frame_bytes: HOME_SLOT_BASE as u32, frame_bytes: (MIN_FRAME_BYTES + SLOT_SIZE as usize) as u32, } @@ -95,7 +102,8 @@ impl FrameGeometry { /// `value_slots` includes frame[0]. The trailing call area is always /// present, even for direct-only source traces, because later bridges are /// compiled against this immutable geometry. - pub fn compact(value_slots: usize, home_slots: usize) -> Self { + pub fn compact(value_slots: usize, home_slots: usize, label_ref_slots: usize) -> Self { + debug_assert!(label_ref_slots <= home_slots); let value_slots = value_slots.max(1); let dispatch_key_ofs = (value_slots as u64) * SLOT_SIZE; let home_slot_base = dispatch_key_ofs + SLOT_SIZE; @@ -114,10 +122,18 @@ impl FrameGeometry { dispatch_key_ofs, home_slot_base, home_slots, + label_ref_slots, ca_frame_bytes: ca_frame_bytes as u32, frame_bytes: frame_bytes as u32, } } + + /// Low Ref homes available to the trace currently executing on this + /// geometry. The high `label_ref_slots` belong to the source loop's LABEL + /// capture plan and must not be cleared or reused by a chained bridge. + pub const fn ordinary_home_slots(self) -> usize { + self.home_slots - self.label_ref_slots + } } /// Byte offset of the Ref-home region within the frame. Each Ref value that is @@ -367,7 +383,12 @@ impl RefHomes { } } - fn collect(inputargs: &[InputArg], ops: &[Op], include_ca_collects: bool) -> Self { + fn collect( + inputargs: &[InputArg], + ops: &[Op], + include_ca_collects: bool, + forced_refs: &[OpRef], + ) -> Self { let liveness = HomeLiveness::collect(inputargs, ops); let collect_positions = collecting_call_positions(ops, include_ca_collects); let ref_values = RefValues::collect(inputargs, ops); @@ -402,6 +423,15 @@ impl RefHomes { } } } + // 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 + // post-collection local reload machinery once the target resumes. + for &r in forced_refs { + if ref_values.contains(r) { + Self::assign(&mut by_id, &mut next, r.raw()); + } + } RefHomes { by_id, len: next as usize, @@ -440,6 +470,180 @@ impl RefHomes { } } +#[derive(Clone, Copy)] +enum LabelCaptureStorage { + /// Absolute frame value-slot index (slot zero is the fail index). + ValueSlot(usize), + /// Ordinal within the high, GC-rooted LABEL-capture home region. + RefSlot(usize), +} + +/// Backend-only preservation plan for values that remain live across a peeled +/// LABEL without appearing in that LABEL's semantic argument list. RPython's +/// assembler keeps such values in the frozen frame; wasm locals disappear on +/// a tail-call re-entry, so we explicitly mirror that storage shape here. +struct LabelResumeData { + per_label: Vec>, + uncapturable: Vec, + capture_by_id: Vec>, + captured_refs: Vec, + scalar_slots: usize, + ref_slots: usize, +} + +impl LabelResumeData { + fn collect(inputargs: &[InputArg], ops: &[Op]) -> Self { + let (_, num_vars) = collect_guards_and_vars(inputargs, ops); + let ref_values = RefValues::collect(inputargs, ops); + let normal_value_slots = normal_frame_value_slots(inputargs, ops); + let mut has_producer = vec![false; num_vars as usize]; + let mut is_input = vec![false; num_vars as usize]; + for ia in inputargs { + if let Some(v) = is_input.get_mut(ia.index as usize) { + *v = true; + } + } + for op in ops { + let r = op.pos.get(); + if r != OpRef::NONE && !r.is_constant() { + if let Some(v) = has_producer.get_mut(r.raw() as usize) { + *v = true; + } + } + } + let mut per_label = Vec::new(); + let mut uncapturable = Vec::new(); + + for (label_pos, label) in ops + .iter() + .enumerate() + .filter(|(_, op)| op.opcode == OpCode::Label) + { + let mut available = vec![false; num_vars as usize]; + let mut defined_before = vec![false; num_vars as usize]; + // Producer-less value ids are folded constant-pool seeds. Codegen + // binds them before the entry dispatch, so they dominate both the + // key-0 path and every LABEL resume and need no frame capture. + for (id, produced) in has_producer.iter().copied().enumerate() { + if !produced && !is_input[id] { + available[id] = true; + defined_before[id] = true; + } + } + for ia in inputargs { + if let Some(v) = defined_before.get_mut(ia.index as usize) { + *v = true; + } + } + for op in &ops[..label_pos] { + let r = op.pos.get(); + if r != OpRef::NONE && !r.is_constant() { + if let Some(v) = defined_before.get_mut(r.raw() as usize) { + *v = true; + } + } + } + for arg in label.getarglist() { + let r = arg.to_opref(); + if r != OpRef::NONE && !r.is_constant() { + if let Some(v) = available.get_mut(r.raw() as usize) { + *v = true; + } + } + } + + let mut missing = Vec::new(); + let mut bad = false; + for op in &ops[label_pos + 1..] { + let mut reads: Vec = op.getarglist().iter().map(|a| a.to_opref()).collect(); + if let Some(failargs) = op.getfailargs() { + reads.extend(failargs.iter().map(|a| a.to_opref())); + } + for r in reads { + if r == OpRef::NONE || r.is_constant() { + continue; + } + let id = r.raw() as usize; + if !available.get(id).copied().unwrap_or(false) { + if !defined_before.get(id).copied().unwrap_or(false) { + bad = true; + continue; + } + missing.push(r); + if let Some(v) = available.get_mut(id) { + *v = true; + } + } + } + let r = op.pos.get(); + if r != OpRef::NONE && !r.is_constant() { + if let Some(v) = available.get_mut(r.raw() as usize) { + *v = true; + } + } + } + per_label.push(missing); + uncapturable.push(bad); + } + + let mut capture_by_id = vec![None; num_vars as usize]; + let mut captured_refs = Vec::new(); + let mut scalar_slots = 0usize; + let mut ref_slots = 0usize; + for &r in per_label.iter().flatten() { + let id = r.raw() as usize; + if capture_by_id[id].is_some() { + continue; + } + let storage = if ref_values.contains(r) { + captured_refs.push(r); + let slot = LabelCaptureStorage::RefSlot(ref_slots); + ref_slots += 1; + slot + } else { + let slot = LabelCaptureStorage::ValueSlot(normal_value_slots + scalar_slots); + scalar_slots += 1; + slot + }; + capture_by_id[id] = Some(storage); + } + + Self { + per_label, + uncapturable, + capture_by_id, + captured_refs, + scalar_slots, + ref_slots, + } + } + + fn storage(&self, r: OpRef) -> Option { + self.capture_by_id.get(r.raw() as usize).copied().flatten() + } + + fn supported_by(&self, frame: FrameGeometry) -> bool { + self.ref_slots <= frame.label_ref_slots + && self + .capture_by_id + .iter() + .flatten() + .all(|storage| match storage { + LabelCaptureStorage::ValueSlot(slot) => *slot < frame.value_slots, + LabelCaptureStorage::RefSlot(slot) => *slot < frame.label_ref_slots, + }) + } + + fn frame_offset(&self, storage: LabelCaptureStorage, frame: FrameGeometry) -> u64 { + match storage { + LabelCaptureStorage::ValueSlot(slot) => slot as u64 * SLOT_SIZE, + LabelCaptureStorage::RefSlot(slot) => { + frame.home_slot_base + (frame.ordinary_home_slots() + slot) as u64 * SLOT_SIZE + } + } + } +} + /// Number of Ref-home slots a trace with these `inputargs`/`ops` reserves, /// matching the `num_ref_homes` [`build_wasm_module`] returns. Lets a CA-arena /// caller size the callee frame and the GC walker for a (wider) bridge's home @@ -447,13 +651,29 @@ impl RefHomes { pub fn count_ref_homes(inputargs: &[InputArg], ops: &[Op]) -> usize { // This pre-sizing query is used for CA bridges before `CaParams` exists, so // count CALL_ASSEMBLER as a collecting position to match CA codegen. - RefHomes::collect(inputargs, ops, true).len() + let resume = LabelResumeData::collect(inputargs, ops); + RefHomes::collect(inputargs, ops, true, &resume.captured_refs).len() +} + +/// Number of high GC-rooted homes reserved exclusively for LABEL live-ins. +pub fn label_ref_capture_slots(inputargs: &[InputArg], ops: &[Op]) -> usize { + LabelResumeData::collect(inputargs, ops).ref_slots +} + +/// First free value position — one past the highest id any input arg or op +/// result occupies. `majit_gc::rewrite::remove_ref_constants` numbers the +/// `LoadFromGcTable` results it emits from here upward, so the operand +/// numbering the optimizer produced stays untouched. Same id set +/// `collect_guards_and_vars` sizes `num_vars` from, so the loads land inside +/// the locals the function declares. +pub fn next_value_pos(inputargs: &[InputArg], ops: &[Op]) -> u32 { + collect_guards_and_vars(inputargs, ops).1 } /// Positional frame slots required for a token's inputs and guard spills. /// Slot zero is the fail index; the returned count therefore also gives the /// first free slot for the call trampoline. -pub fn frame_value_slots(inputargs: &[InputArg], ops: &[Op]) -> usize { +fn normal_frame_value_slots(inputargs: &[InputArg], ops: &[Op]) -> usize { let (guards, _) = collect_guards_and_vars(inputargs, ops); let max_fail_args = guards .iter() @@ -463,6 +683,10 @@ pub fn frame_value_slots(inputargs: &[InputArg], ops: &[Op]) -> usize { 1 + max_fail_args.max(inputargs.len()) } +pub fn frame_value_slots(inputargs: &[InputArg], ops: &[Op]) -> usize { + normal_frame_value_slots(inputargs, ops) + LabelResumeData::collect(inputargs, ops).scalar_slots +} + /// Argument index of the stored value for a GC ref-storing op. `SetfieldRaw` / /// `SetarrayitemRaw` store into non-GC memory and never need a write barrier, /// so only the `*Gc` variants are listed (rewrite.py only routes `SETFIELD_GC` @@ -1311,6 +1535,11 @@ pub fn build_wasm_module( // linear memory. GUARD_NOT_INVALIDATED reads this byte at runtime, like // the native backends bake the same Arc allocation's address. invalidated_flag_addr: u32, + // Base address of this trace's per-loop `GcTable` slot array in shared + // linear memory (`gcreftracer.py:9` `array_base_addr`), baked as the + // `LoadFromGcTable` base immediate exactly as the native backends bake + // it. `0` when the trace holds no reference constant. + gc_table_base: u32, fail_index_base: u32, // Table slot of the loop a JUMP-with-no-local-LABEL re-enters (a loop-closing // bridge). `0` for a loop trace (its JUMP is a local back-edge `br`) and for a @@ -1371,12 +1600,8 @@ pub fn build_wasm_module( // Ref homes, and the always-present tail call area; a chained bridge must // fit the source token's frozen value-slot count before it can share that // frame. - let max_fail_args = guards - .iter() - .map(|g| g.fail_arg_refs.len()) - .max() - .unwrap_or(0); - let max_value_slots = 1 + max_fail_args.max(inputargs.len()); + let label_resume = LabelResumeData::collect(inputargs, ops); + let max_value_slots = normal_frame_value_slots(inputargs, ops) + label_resume.scalar_slots; if max_value_slots > frame.value_slots { return Err(BackendError::Unsupported(format!( "wasm backend: {max_value_slots} frame value slots exceed frozen frame layout \ @@ -1387,12 +1612,14 @@ pub fn build_wasm_module( let value_types = collect_value_types(inputargs, ops, num_vars); let ref_values = RefValues::collect(inputargs, ops); - let ref_homes = RefHomes::collect(inputargs, ops, ca.emit_ca); + let ref_homes = RefHomes::collect(inputargs, ops, ca.emit_ca, &label_resume.captured_refs); let num_ref_homes = ref_homes.len(); - if num_ref_homes > frame.home_slots { + if num_ref_homes > frame.ordinary_home_slots() || !label_resume.supported_by(frame) { return Err(BackendError::Unsupported(format!( - "wasm backend: {num_ref_homes} ref homes exceed frozen frame layout ({})", - frame.home_slots, + "wasm backend: {num_ref_homes} ordinary ref homes and {} LABEL ref captures exceed frozen frame layout ({}, {})", + label_resume.ref_slots, + frame.ordinary_home_slots(), + frame.label_ref_slots, ))); } @@ -1599,9 +1826,11 @@ pub fn build_wasm_module( nursery, &ref_values, &ref_homes, + &label_resume, cells_base, bridge_dispatch, invalidated_flag_addr, + gc_table_base, fail_index_base, external_jump_slot, external_jump_key, @@ -1640,9 +1869,11 @@ fn build_function( nursery: Option<&NurseryAllocParams>, ref_values: &RefValues, ref_homes: &RefHomes, + label_resume: &LabelResumeData, cells_base: u32, bridge_dispatch: bool, invalidated_flag_addr: u32, + gc_table_base: u32, fail_index_base: u32, external_jump_slot: u32, // Resume-at-LABEL dispatch key the terminal external JUMP writes before @@ -1729,14 +1960,6 @@ fn build_function( let mut func = Function::new(locals); let mut sink = func.instructions(); - // Null-init every Ref-home slot so a slot read before its value is defined - // is null (forwarding-safe), not a stale word from the reused host frame. - for h in 0..ref_homes.len() as u64 { - sink.local_get(0); - sink.i64_const(0); - sink.i64_store(mem64(frame.home_slot_base + h * SLOT_SIZE)); - } - // Bind the folded constants the optimizer left under a plain op position // (see `unbound_pool_const_seeds`). Emitted before every block so the // binding dominates the whole body, including a resume-at-LABEL entry. @@ -1816,6 +2039,24 @@ fn build_function( sink.end(); // end D $dispatch — key-0 entry path continues here } + // Fresh entry owns key 0 and must clear both the trace's ordinary homes + // and its high LABEL-capture homes. A resume dispatch branches past this + // code, preserving captures written when the source loop first crossed + // the LABEL. Chained bridges have no capture plan and clear only their + // own low ordinary-home prefix. + for h in 0..ref_homes.len() as u64 { + sink.local_get(0); + sink.i64_const(0); + sink.i64_store(mem64(frame.home_slot_base + h * SLOT_SIZE)); + } + for h in 0..label_resume.ref_slots as u64 { + sink.local_get(0); + sink.i64_const(0); + sink.i64_store(mem64( + frame.home_slot_base + (frame.ordinary_home_slots() as u64 + h) * SLOT_SIZE, + )); + } + // Load inputs from frame into locals, and store Ref inputs to their homes. // The input value lives at the frame slot its producer wrote it to: the // caller fills slot `k` for the k-th input — `execute_token` for a loop @@ -1868,6 +2109,18 @@ fn build_function( // Branch over the resume loader, then close C_j, emit the loader // (resume path only), and close B_j. From inside C_j, `br 1` // targets B_j's end, skipping the loader. + // Preserve every non-argument live-in while its pre-LABEL local is + // still available. Scalar bits use frozen value slots; Refs use + // the high, GC-rooted capture region so a chained bridge cannot + // overwrite them with its own low home mapping. + for &r in &label_resume.per_label[labels_passed] { + let storage = label_resume + .storage(r) + .expect("LABEL live-in has assigned capture storage"); + sink.local_get(0); + emit_resolve(&mut sink, constants, value_types, r); + sink.i64_store(mem64(label_resume.frame_offset(storage, frame))); + } sink.br(1); // segment done -> past_loader_j, over the resume loader sink.end(); // end C_j (the br_table lands here for key j+1) // Resume loader: a loop-closing bridge wrote each label arg into @@ -1888,6 +2141,25 @@ fn build_function( sink.i64_store(mem64(frame.home_slot_base + h as u64 * SLOT_SIZE)); } } + // Restore backend-only live-ins after the semantic LABEL args. + // Ref restores also refresh the ordinary home used by the normal + // collecting-call reload path in the resumed loop body. + for &r in &label_resume.per_label[labels_passed] { + let storage = label_resume + .storage(r) + .expect("LABEL live-in has assigned capture storage"); + sink.local_get(0); + sink.i64_load(mem64(label_resume.frame_offset(storage, frame))); + if value_types[r.raw() as usize] == ValType::F64 { + sink.f64_reinterpret_i64(); + } + sink.local_set(1 + r.raw()); + if let Some(h) = ref_homes.home(r) { + sink.local_get(0); + sink.local_get(1 + r.raw()); + sink.i64_store(mem64(frame.home_slot_base + h as u64 * SLOT_SIZE)); + } + } sink.end(); // end B_j $past_loader labels_passed += 1; } @@ -2740,18 +3012,19 @@ fn build_function( op.opcode ))); } - // The bare GC_LOAD/GC_STORE forms are produced only by the GC rewrite - // (majit-gc/src/rewrite.rs): the true semantics are offset=arg1, - // size=arg2 (load) / value=arg2, size=arg3 (store), with no FieldDescr - // attached. The wasm backend does not run the GC rewrite, so these - // never reach here. The prior lowering read a nonexistent - // field_offset_from_descr (→ 0) and, for GcStore, stored arg(1) (the - // offset operand) as the value — a silent miscompile. Panic loudly like - // LoadFromGcTable rather than emit a wrong memory access. + // The bare GC_LOAD/GC_STORE forms are produced only by the GC rewrite's + // load/store lowering (majit-gc/src/rewrite.rs): the true semantics are + // offset=arg1, size=arg2 (load) / value=arg2, size=arg3 (store), with no + // FieldDescr attached. The wasm backend runs only the rewrite's + // reference-constant half (`remove_ref_constants`) and lowers loads, + // stores, allocations and barriers itself, so these never reach here. + // The prior lowering read a nonexistent field_offset_from_descr (→ 0) + // and, for GcStore, stored arg(1) (the offset operand) as the value — a + // silent miscompile. Panic loudly rather than emit a wrong memory access. OpCode::GcLoadI | OpCode::GcLoadR | OpCode::GcLoadF | OpCode::GcStore => { panic!( "wasm backend: {:?} is unsupported (GC_LOAD/GC_STORE); \ - the GC rewrite must not run for wasm", + the load/store GC rewrite must not run for wasm", op.opcode ); } @@ -3118,19 +3391,27 @@ fn build_function( // Zero-initialize array region — skip for MVP } OpCode::LoadFromGcTable => { - // `assembler.py:1545` `genop_load_from_gc_table`: this op is - // produced only by the GC rewrite's `remove_constptr` - // (`rewrite.py:1100`), whose arg is a `ConstInt(index)` into - // a per-loop `GcTable` whose base is baked absolute. The - // wasm backend does not run the GC rewrite and has no - // host-address gc_table model (linear memory), so this op - // never reaches here. Panic loudly rather than emit the old - // SAME_AS pass-through, which after the rewrite flip would - // load the raw index in place of the reference constant. - panic!( - "wasm backend: LoadFromGcTable is unsupported (no gc_table model); \ - the GC rewrite must not run for wasm" - ); + // `assembler.py:1545` `genop_load_from_gc_table`: the arg is a + // `ConstInt(index)` into the per-loop `GcTable` + // (`remove_ref_constants`, rewrite.py:1100 `remove_constptr`) + // whose base is baked absolute. The table is a plain guest heap + // allocation, so `base + index*WORD` is an ordinary linear-memory + // address; the collector forwards the slot in place, so the load + // reads the reference at its current address. + let vi = op.pos.get().raw(); + if !OpRef::raw_is_constant(vi) { + let index = resolve_const_bits(constants, op.arg(0).to_opref()); + let slot = gc_table_base as u64 + + index as u64 * std::mem::size_of::() as u64; + sink.i32_const(slot as i32); + sink.i32_load(MemArg { + offset: 0, + align: 2, + memory_index: 0, + }); + sink.i64_extend_i32_u(); + sink.local_set(1 + vi); + } } // ── CALL_ASSEMBLER ── @@ -4461,44 +4742,27 @@ pub fn label_arg_counts(ops: &[Op]) -> Vec { .collect() } -/// Per-label resume safety, in ordinal order: label `j` is safe to resume at -/// when every op after it references only values that are constants, defined -/// after the label, or listed in the label's own args — i.e. the label's args -/// are the complete live set, so the resume loader reconstructs every value -/// the remainder of the trace reads. A value defined before the label and -/// read after it without being a label arg would resume as a null local (the -/// resume path skips the entry loader and every earlier segment). Guard fail -/// args count as reads — they spill into the deopt frame. -pub fn label_resume_safety(ops: &[Op]) -> Vec { - ops.iter() +/// Per-label `(resume_safe, requires_own_frame)` metadata in ordinal order. +/// Missing pre-LABEL live-ins are safe when the frozen geometry contains the +/// capture plan. Such a plan is tied to the physical frame on which the owning +/// loop populated it; a sibling specialization may share the same geometry +/// but not those values, so bridge chaining must then stay on the owner. +pub fn label_resume_info( + inputargs: &[InputArg], + ops: &[Op], + frame: FrameGeometry, +) -> Vec<(bool, bool)> { + let resume = LabelResumeData::collect(inputargs, ops); + let storage_supported = resume.supported_by(frame); + resume + .per_label + .iter() .enumerate() - .filter(|(_, op)| op.opcode == OpCode::Label) - .map(|(p, label)| { - let mut live: std::collections::HashSet = label - .getarglist() - .iter() - .map(|a| a.to_opref()) - .filter(|r| *r != OpRef::NONE && !r.is_constant()) - .map(|r| r.raw()) - .collect(); - for op in &ops[p + 1..] { - let args = op.getarglist(); - let arg_reads = args.iter().map(|a| a.to_opref()); - let fail_reads = op - .getfailargs() - .map(|fa| fa.iter().map(|a| a.to_opref()).collect::>()) - .unwrap_or_default(); - for r in arg_reads.chain(fail_reads) { - if r != OpRef::NONE && !r.is_constant() && !live.contains(&r.raw()) { - return false; - } - } - let res = op.pos.get(); - if res != OpRef::NONE && !res.is_constant() { - live.insert(res.raw()); - } - } - true + .map(|(j, missing)| { + ( + !resume.uncapturable[j] && (missing.is_empty() || storage_supported), + !missing.is_empty(), + ) }) .collect() } @@ -5240,7 +5504,7 @@ mod tests { #[test] fn compact_geometry_keeps_tail_call_area_out_of_ca_prefix() { - let frame = FrameGeometry::compact(32, 16); + let frame = FrameGeometry::compact(32, 16, 0); assert_eq!(frame.dispatch_key_ofs, 32 * SLOT_SIZE); assert_eq!(frame.home_slot_base, 33 * SLOT_SIZE); assert_eq!(frame.ca_frame_bytes, 392); diff --git a/majit/majit-backend-wasm/src/failguard.rs b/majit/majit-backend-wasm/src/failguard.rs index 70b88521d00..1d2829c6fbf 100644 --- a/majit/majit-backend-wasm/src/failguard.rs +++ b/majit/majit-backend-wasm/src/failguard.rs @@ -78,6 +78,59 @@ pub struct WasmFrameData { /// exited through a GuardNoException / GuardException (0 = none), surfaced /// via `grab_exc_value`. pub exc_value: i64, + /// Slots handed to [`crate::wasm_gc_add_root`] by [`WasmFrameData::boxed`], + /// released again in `Drop`. + roots: Vec, +} + +impl WasmFrameData { + /// `llmodel.py:225-238` reads `get_ref_value` straight out of the JITFRAME, + /// which stays a GC root (its `jf_gcmap` covers the exit slots) for as long + /// as the deadframe lives. wasm has no host-visible JITFRAME to hand back: + /// `execute_token` copies the exit values into `raw_values` and drops the + /// guest frame, so the copies must carry that rooting themselves. Between + /// the copy and the last `get_ref_value`, resume/blackhole reconstruction + /// allocates freely, and a minor collection there moves exactly the objects + /// these slots name. + /// + /// Only `Type::Ref` exit slots are rooted, matching the gcmap the guest + /// frame carried. A wasm32 `GcRef` occupies the low half of its `i64` slot, + /// so the root address is the slot address (same aliasing the Ref home + /// slots already rely on). + pub fn boxed( + raw_values: Vec, + fail_descr: Arc, + exc_value: i64, + ) -> Box { + let mut data = Box::new(WasmFrameData { + raw_values, + fail_descr, + exc_value, + roots: Vec::new(), + }); + let mut roots: Vec = Vec::new(); + for i in 0..data.raw_values.len() { + if data.fail_descr.fail_arg_types.get(i) == Some(&Type::Ref) { + roots.push(&mut data.raw_values[i] as *mut i64 as usize); + } + } + // `grab_exc_value` hands this out as a `GcRef` too, and the resume path + // reads it after it has already allocated. + roots.push(&mut data.exc_value as *mut i64 as usize); + for slot in &roots { + unsafe { crate::wasm_gc_add_root(*slot as *mut majit_ir::GcRef) }; + } + data.roots = roots; + data + } +} + +impl Drop for WasmFrameData { + fn drop(&mut self) { + for slot in self.roots.drain(..) { + crate::wasm_gc_remove_root(slot as *mut majit_ir::GcRef); + } + } } /// A resumable `LABEL` of a compiled loop, published in `LABEL_TARGETS` so a @@ -93,9 +146,13 @@ pub struct LabelTarget { /// The label's arg count — the resume loader reads exactly this many /// positional frame slots, so the JUMP arity must equal it. pub num_args: usize, - /// Whether the label's args are the complete live set of the owning - /// trace's remainder (`codegen::label_resume_safety`). + /// Whether every live-in can be reconstructed from LABEL args or frozen + /// backend capture slots (`codegen::label_resume_info`). pub resume_safe: bool, + /// Backend capture slots are populated by this target loop's own + /// fall-through path. If true, a bridge may resume only its source loop, + /// not a sibling specialization that happens to share the geometry. + pub requires_own_frame: bool, /// Whether this is the owning loop's LAST label (the loop header). A /// bridge landing here re-runs no segment code before the `loop`, so the /// livelock advance-check applies; earlier labels execute the peeled @@ -371,7 +428,9 @@ pub fn publish_label_target(descr_id: usize, target: LabelTarget) { /// guard that lives inside an already-chained bridge: the failing guard's /// meta descr carries `(trace_id, per-trace fail_index)`, and this record /// supplies the owning bridge's cell array and livelock advance flags — the -/// same data `CompiledWasmLoop` holds for the loop's own guards. +/// same data `CompiledWasmLoop` holds for the loop's own guards. It also +/// publishes whether another bridge may safely compose a CALL_ASSEMBLER arm +/// while executing on this bridge's shared frozen frame. pub struct ChainedTraceMeta { /// Base address of the bridge's per-guard bridge-slot cell array /// (`CompiledWasmLoop::bridge_cells_base` analog); `0` = no dispatch. @@ -381,6 +440,10 @@ pub struct ChainedTraceMeta { /// Per-guard, per-fail-arg induction-advance flags /// (`CompiledWasmLoop::guard_fail_arg_advanced` analog). pub guard_fail_arg_advanced: Vec>, + /// This bridge contains no host-trampoline lowering, so a nested bridge's + /// movable CALL_ASSEMBLER callee cannot strand a stale source-frame + /// pointer when control returns here. + pub ca_reentry_safe: bool, } /// Compiled wasm loop metadata, stored in `JitCellToken.compiled`. @@ -390,7 +453,17 @@ pub struct CompiledWasmLoop { pub token_number: u64, pub trace_id: u64, pub input_types: Vec, - pub func_handle: u32, + /// Shared-table slot of the materialized wasm function. Straight-line + /// function-entry traces may keep this at zero until their first actual + /// execution: an invalidated trace that never reaches `execute_token` + /// must not pay the host Wasmtime compilation cost. + pub(crate) func_handle: Cell, + /// Encoded module retained until lazy host materialization. This is + /// backend assembler state, not metainterpreter state: the optimized trace + /// and all per-token descriptors have already been installed exactly as + /// in the eager path. + #[cfg_attr(any(not(target_arch = "wasm32"), target_os = "wasi"), allow(dead_code))] + pub(crate) pending_wasm_bytes: RefCell>>, /// This loop's own guard/finish exit descriptors (positions `[0, /// num_guard_cells)`, per-trace order), followed by the descr slices of /// every chained bridge `compile_bridge` appended (positional bookkeeping @@ -490,6 +563,43 @@ pub struct CompiledWasmLoop { } impl CompiledWasmLoop { + pub fn eager_func_handle(&self) -> u32 { + self.func_handle.get() + } + + /// Materialize a lazily-installed root trace. The wasm host is + /// single-threaded, matching the RefCell/Cell ownership used throughout + /// this structure, so one trace can only cross this gate once. + pub fn materialize_func_handle(&self) -> Result { + let current = self.func_handle.get(); + if current != 0 { + return Ok(current); + } + #[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] + { + return Ok(0); + } + #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + { + let pending = self.pending_wasm_bytes.borrow(); + let Some(bytes) = pending.as_deref() else { + return Err(majit_backend::BackendError::Unsupported( + "wasm trace has neither a function handle nor pending module bytes".into(), + )); + }; + let handle = crate::glue::compile_module_cached(bytes); + if handle == 0 { + return Err(majit_backend::BackendError::Unsupported( + "wasm host rejected the lazily compiled trace module".into(), + )); + } + self.func_handle.set(handle); + drop(pending); + self.pending_wasm_bytes.borrow_mut().take(); + Ok(handle) + } + } + /// Incorporate the normal (non-CA unless this bridge is the candidate) /// codegen census for a bridge after it has been chained onto this token. /// Every earlier bridge remains reachable from a later CA recursion's @@ -516,7 +626,7 @@ impl Drop for CompiledWasmLoop { for &id in &self.label_descrs { if id != 0 { if let Some(t) = map.get(&id) { - if t.func_handle == self.func_handle { + if t.func_handle == self.func_handle.get() { map.remove(&id); crate::BRIDGE_DIAG[22] .fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -537,7 +647,8 @@ mod tests { token_number: 0, trace_id: 0, input_types: Vec::new(), - func_handle: 0, + func_handle: Cell::new(0), + pending_wasm_bytes: RefCell::new(None), fail_descrs: RefCell::new(Vec::new()), num_inputs: 0, max_output_slots: 0, diff --git a/majit/majit-backend-wasm/src/glue.rs b/majit/majit-backend-wasm/src/glue.rs index fde8b43729b..1baeebf2e51 100644 --- a/majit/majit-backend-wasm/src/glue.rs +++ b/majit/majit-backend-wasm/src/glue.rs @@ -15,8 +15,17 @@ //! the rest of the backend stays binding-agnostic. use core::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; static JIT_EXECUTE_COUNT: AtomicU64 = AtomicU64::new(0); +static JIT_COMPILE_COUNT: AtomicU64 = AtomicU64::new(0); +static JIT_COMPILE_CACHE_HITS: AtomicU64 = AtomicU64::new(0); + +/// Process-global compiled-module cache. The host function table and linear +/// memory are process-global too, so this has the same owner as the handles it +/// stores (and deliberately is not TLS). `IndexMap` is used instead of an +/// unordered side table so insertion/teardown diagnostics remain stable. +static MODULE_CACHE: OnceLock, u32>>> = OnceLock::new(); #[cfg(all(feature = "web", feature = "host-import"))] compile_error!("features `web` and `host-import` are mutually exclusive; enable exactly one"); @@ -78,6 +87,26 @@ pub fn compile_module(wasm_bytes: &[u8]) -> u32 { } } +/// Compile an encoded trace once per byte-identical module. +/// +/// Token-specific immediates intentionally remain part of the key. Sharing a +/// module whose GUARD_NOT_INVALIDATED address or fail-descriptor base differs +/// would cross-wire token ownership; relocation of those fields can extend +/// this cache later without weakening that invariant. +pub fn compile_module_cached(wasm_bytes: &[u8]) -> u32 { + let cache = MODULE_CACHE.get_or_init(|| Mutex::new(indexmap::IndexMap::new())); + if let Some(&handle) = cache.lock().unwrap().get(wasm_bytes) { + JIT_COMPILE_CACHE_HITS.fetch_add(1, Ordering::Relaxed); + return handle; + } + let handle = compile_module(wasm_bytes); + if handle != 0 { + JIT_COMPILE_COUNT.fetch_add(1, Ordering::Relaxed); + cache.lock().unwrap().insert(wasm_bytes.into(), handle); + } + handle +} + /// Execute a compiled JIT function with the given frame pointer. pub fn execute(func_id: u32, frame_ptr: u32) -> u32 { #[cfg(feature = "web")] @@ -108,6 +137,14 @@ pub fn jit_execute_count() -> u64 { JIT_EXECUTE_COUNT.load(Ordering::Relaxed) } +pub fn jit_compile_count() -> u64 { + JIT_COMPILE_COUNT.load(Ordering::Relaxed) +} + +pub fn jit_compile_cache_hits() -> u64 { + JIT_COMPILE_CACHE_HITS.load(Ordering::Relaxed) +} + /// Free a compiled JIT function. #[expect( dead_code, diff --git a/majit/majit-backend-wasm/src/lib.rs b/majit/majit-backend-wasm/src/lib.rs index 8c6daa55e3d..7ba7576ead5 100644 --- a/majit/majit-backend-wasm/src/lib.rs +++ b/majit/majit-backend-wasm/src/lib.rs @@ -63,6 +63,18 @@ pub fn jit_execute_count() -> u64 { glue::jit_execute_count() } +/// Number of host modules materialized after the lazy-install gate. +#[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] +pub fn jit_compile_count() -> u64 { + glue::jit_compile_count() +} + +/// Number of materializations served by the byte-identical module cache. +#[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] +pub fn jit_compile_cache_hits() -> u64 { + glue::jit_compile_cache_hits() +} + #[inline] fn diag_bump(i: usize) { BRIDGE_DIAG[i].fetch_add(1, Ordering::Relaxed); @@ -316,10 +328,12 @@ fn register_active_hooks(supports_guard_gc_type: bool) { majit_gc::set_active_alloc_oldgen_typed(Some(wasm_alloc_oldgen_typed)); majit_gc::set_active_root_hooks(Some(wasm_gc_add_root), Some(wasm_gc_remove_root)); majit_gc::set_active_gc_owns_object(Some(wasm_gc_owns_object)); + majit_gc::set_active_gc_id_or_identityhash(Some(wasm_id_or_identityhash)); majit_gc::set_active_write_barrier(Some(wasm_active_gc_write_barrier)); majit_gc::set_active_get_objects(Some(wasm_get_objects)); majit_gc::set_active_get_referents(Some(wasm_get_referents)); majit_gc::set_active_is_tracked(Some(wasm_is_tracked)); + majit_gc::set_active_collect_full(Some(wasm_collect_full)); majit_gc::set_active_collect_oldgen(Some(wasm_collect_oldgen_nonmoving)); majit_gc::set_active_heap_stats(Some(active_gc_heap_stats)); majit_gc::set_active_major_threshold_reached(Some(active_gc_major_threshold_reached)); @@ -476,6 +490,18 @@ fn jf_top_addr() -> Option { .filter(|&addr| addr != 0) } +/// `majit_gc::CollectFullFn` installed by `register_active_hooks`. Drives +/// `gc.collect()` (`interp_gc.py:7-26`) through the active GC. Without it +/// `majit_gc::collect_full` has no hook to dispatch to and silently returns, +/// so no major cycle ever runs on this backend and +/// `deal_with_objects_with_finalizers` — which lives inside the major — never +/// executes: no `__del__`, no generator `finally`, not even under an explicit +/// `gc.collect()`. Mirrors dynasm's `dynasm_collect_full` and cranelift's +/// `collect_full_via_active_runtime`. +fn wasm_collect_full() { + with_wasm_active_gc_mut(|gc| gc.collect_full()); +} + /// `majit_gc::CollectOldgenFn` installed by `set_gc_allocator`. Drives the /// interpreter-safepoint non-moving old-gen major (`gc_interp::safepoint`, /// default-on on wasm) through the active GC. Needs mutable access, so it @@ -500,6 +526,16 @@ fn wasm_is_tracked(obj: GcRef) -> bool { with_wasm_active_gc_mut(|gc| gc.is_tracked(obj)).unwrap_or(false) } +/// `minimark.py:1900-1915 id_or_identityhash` trampoline. The collector +/// records a move-stable hash in its side table before the object can be +/// relocated; the unhooked `majit_gc::gc_id_or_identityhash` fallback returns +/// the raw address instead, which changes under the object when a minor +/// collection moves it out of the nursery. Mirrors dynasm's +/// `dynasm_id_or_identityhash`. +fn wasm_id_or_identityhash(addr: usize) -> usize { + with_wasm_active_gc_mut(|gc| gc.id_or_identityhash(addr)).unwrap_or(addr) +} + fn wasm_register_finalizer(fq_index: usize, obj: GcRef, trigger: majit_gc::FinalizerTriggerFn) { with_wasm_active_gc_mut(|gc| gc.register_finalizer(fq_index, obj, trigger)); } @@ -846,12 +882,12 @@ fn build_callee_gcmap( /// # Safety /// Caller must keep `slot` valid until [`wasm_gc_remove_root`] is /// called with the same pointer. -unsafe fn wasm_gc_add_root(slot: *mut GcRef) { +pub(crate) unsafe fn wasm_gc_add_root(slot: *mut GcRef) { with_wasm_active_gc_mut(|gc| unsafe { gc.add_root(slot) }); } /// Companion to [`wasm_gc_add_root`]. -fn wasm_gc_remove_root(slot: *mut GcRef) { +pub(crate) fn wasm_gc_remove_root(slot: *mut GcRef) { with_wasm_active_gc_mut(|gc| gc.remove_root(slot)); } @@ -1160,6 +1196,38 @@ impl WasmBackend { .unwrap_or_default() } + /// Pull every reference constant out of `ops` into a per-loop `GcTable` + /// and replace it with a `LoadFromGcTable` of its slot + /// (`majit_gc::rewrite::remove_ref_constants`, rewrite.py:106-116). + /// + /// A `GcRef` baked as a code immediate is invisible to the moving + /// collector: the first minor collection that promotes the referenced + /// object out of the nursery leaves the immediate pointing into nursery + /// space that is later reused or zeroed by `reset`. The table slot is a + /// GC root the collector forwards in place, so the emitted load always + /// reads the object at its current address. Returns `None` for a trace + /// with no reference constant, leaving the module byte-identical. + fn intern_ref_constants( + inputargs: &[InputArg], + ops: Vec, + ) -> (Vec, Option>) { + let next_pos = codegen::next_value_pos(inputargs, &ops); + let (ops, gcrefs) = majit_gc::rewrite::remove_ref_constants(&ops, next_pos); + let table = (!gcrefs.is_empty()).then(|| majit_gc::GcTable::from_gcrefs(&gcrefs)); + (ops, table) + } + + /// `x86/assembler.py:823` `gcreftracers.append(tracer)` — keep the + /// per-loop table alive for as long as the compiled trace that bakes its + /// base address. `LIVE_GC_TABLES` holds only a `Weak`, so this strong + /// reference is what keeps the slots rooted and forwardable. + fn register_gc_table(token: &JitCellToken, table: Arc) { + if let Some(clt) = token.compiled_loop_token() { + let tracer: Arc = table; + clt.asmmemmgr_gcreftracers.lock().push(tracer); + } + } + /// Validate that every constant OpRef appearing as an arg is resolvable. /// /// Inline-Const variants (`ConstInt`/`ConstFloat`/ @@ -1315,7 +1383,7 @@ fn general_int_call_assembler_target( return None; } let target_token = descr.call_target_token()?; - let registered = call_assembler_target(target_token)?; + let mut registered = call_assembler_target(target_token)?; let target = if let Some(self_) = pending_self.as_ref().filter(|self_| { target_token == self_.token_number // A self target must be precisely the placeholder installed @@ -1350,6 +1418,25 @@ fn general_int_call_assembler_target( has_trampoline_calls: false, } } else { + // A straight-line function trace may have deferred host module + // compilation. CALL_ASSEMBLER is its first real consumer, so + // materialize it before baking the stable dispatch entry. + if registered.func_handle == 0 && registered.compiled_ptr != 0 { + let loop_ = + unsafe { (registered.compiled_ptr as *const CompiledWasmLoop).as_ref() }?; + let handle = loop_.materialize_func_handle().ok()?; + if handle == 0 { + return None; + } + registered.func_handle = handle; + ca_dispatch_publish( + target_token, + handle, + registered.loop_finish_fi, + registered.compiled_ptr as u32, + ); + publish_call_assembler_target(target_token, registered.clone()); + } if registered.input_types.as_slice() != arg_types || registered.callee_frame_bytes == 0 || registered.callee_gcmap_ptr == 0 @@ -1544,11 +1631,7 @@ pub fn dead_frame_from_ran_frame(_compiled_ptr: usize, frame_ptr: usize) -> Dead .map(|i| unsafe { *frame.add(1 + i) }) .collect(); DeadFrame { - data: Box::new(WasmFrameData { - raw_values, - fail_descr, - exc_value, - }), + data: WasmFrameData::boxed(raw_values, fail_descr, exc_value), } } @@ -1702,6 +1785,8 @@ impl majit_backend::Backend for WasmBackend { majit_backend::record_compiled_loop_token(&self.cpu_tracker, &clt); } let ops_owned: Vec = normalize_ops_for_codegen(inputargs, ops); + let (ops_owned, gc_table) = Self::intern_ref_constants(inputargs, ops_owned); + let gc_table_base = gc_table.as_ref().map_or(0, |t| t.base_addr() as u32); let ops: &[Op] = &ops_owned; // Freeze this token's generated frame layout before CA resolution. A // self-recursive CALL_ASSEMBLER reaches this point while its token is @@ -1709,9 +1794,11 @@ impl majit_backend::Backend for WasmBackend { // geometry for both the loop and each nursery-allocated self callee. let raw_frame_value_slots = codegen::frame_value_slots(inputargs, ops); let raw_num_ref_homes = codegen::count_ref_homes(inputargs, ops); + let label_ref_slots = codegen::label_ref_capture_slots(inputargs, ops); let frame = codegen::FrameGeometry::compact( raw_frame_value_slots.max(FROZEN_CHAIN_VALUE_SLOTS), - raw_num_ref_homes.max(FROZEN_CHAIN_REF_HOMES), + raw_num_ref_homes.max(FROZEN_CHAIN_REF_HOMES) + label_ref_slots, + label_ref_slots, ); let input_types: Vec = inputargs.iter().map(|ia| ia.tp).collect(); // Count with CA direct-lowering enabled. This is the safety census @@ -1780,6 +1867,7 @@ impl majit_backend::Backend for WasmBackend { wb_fn_ptr, nursery_alloc_params(ops).as_ref(), Arc::as_ptr(&token.invalidated) as usize as u32, + gc_table_base, fail_index_base, 0, // external_jump_slot: a loop's JUMP is a local back-edge `br` 0, // external_jump_key: unused without an external JUMP @@ -1823,6 +1911,9 @@ impl majit_backend::Backend for WasmBackend { }) .collect(); register_fail_descrs(&fail_descrs); + if let Some(table) = gc_table { + Self::register_gc_table(token, table); + } let max_output_slots = guard_exits .iter() @@ -1831,10 +1922,28 @@ impl majit_backend::Backend for WasmBackend { .unwrap_or(0) .max(inputargs.len()); + // Straight-line function-entry traces finish the current invocation + // concretely. Do not ask the host to compile their wasm module until + // a later invocation actually enters the token: quasi-immutable + // invalidation can retire such a token before it ever executes (the + // module-global `except ... as e` stress case does exactly that). + // Loop-bearing and CALL_ASSEMBLER traces stay eager because their + // published label/CA targets need a live table slot immediately. + let defer_host_compile = !ops.iter().any(|op| { + op.opcode == majit_ir::OpCode::Label + || op.opcode == majit_ir::OpCode::Jump + || op.opcode.is_call_assembler() + }); + let code_size = wasm_bytes.len(); + // Instantiate via the host binding on wasm32, or store bytes for // testing on native (no wasm host available). #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] - let func_handle = glue::compile_module(&wasm_bytes); + let func_handle = if defer_host_compile { + 0 + } else { + glue::compile_module_cached(&wasm_bytes) + }; #[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))] let func_handle = 0u32; // Placeholder — no wasm host available @@ -1847,7 +1956,7 @@ impl majit_backend::Backend for WasmBackend { // Decline the compile so the metainterp keeps the interpreter fallback — // a backend capability limit, reported like any other unsupported shape. #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] - if func_handle == 0 { + if !defer_host_compile && func_handle == 0 { return Err(BackendError::Unsupported( "wasm host rejected the compiled trace module (oversized function body \ or invalid module)" @@ -1904,7 +2013,7 @@ impl majit_backend::Backend for WasmBackend { // arg count and the label's args are the complete live set of the // trace remainder. let label_num_args = codegen::label_arg_counts(ops); - let label_resume_safe = codegen::label_resume_safety(ops); + let label_resume_info = codegen::label_resume_info(inputargs, ops, frame); // Per-guard, per-fail-arg induction-advance flags for // `compile_bridge`'s livelock check (see `guard_fail_args_advanced`). let guard_fail_arg_advanced = guard_fail_args_advanced(ops, &guard_exits); @@ -1930,7 +2039,8 @@ impl majit_backend::Backend for WasmBackend { func_handle, key: j as u32 + 1, num_args: label_num_args[j], - resume_safe: label_resume_safe[j], + resume_safe: label_resume_info[j].0, + requires_own_frame: label_resume_info[j].1, is_last_label: j == last, frame, }, @@ -1952,6 +2062,7 @@ impl majit_backend::Backend for WasmBackend { key: 0, num_args: inputargs.len(), resume_safe: true, + requires_own_frame: false, // No real ops precede a non-peeled loop's header, so // an entry re-run lands at the header without any // advancing segment — the livelock check applies. @@ -1966,7 +2077,8 @@ impl majit_backend::Backend for WasmBackend { token_number: token.number, trace_id, input_types: inputargs.iter().map(|ia| ia.tp).collect(), - func_handle, + func_handle: std::cell::Cell::new(func_handle), + pending_wasm_bytes: std::cell::RefCell::new(defer_host_compile.then_some(wasm_bytes)), fail_descrs: std::cell::RefCell::new(fail_descrs), num_inputs: inputargs.len(), max_output_slots, @@ -2024,7 +2136,7 @@ impl majit_backend::Backend for WasmBackend { // modules load this stable entry at runtime. ca_dispatch_publish( token.number, - compiled.func_handle, + compiled.eager_func_handle(), loop_finish_fi, compiled as *const CompiledWasmLoop as usize as u32, ); @@ -2032,7 +2144,7 @@ impl majit_backend::Backend for WasmBackend { token.number, CallAssemblerTarget { token_number: token.number, - func_handle: compiled.func_handle, + func_handle: compiled.eager_func_handle(), input_types: compiled.input_types.clone(), callee_frame_bytes: compiled.frame.ca_frame_bytes, callee_gcmap_ptr, @@ -2049,7 +2161,7 @@ impl majit_backend::Backend for WasmBackend { Ok(AsmInfo { code_addr: 0, - code_size: wasm_bytes.len(), + code_size, }) } @@ -2086,6 +2198,9 @@ impl majit_backend::Backend for WasmBackend { // argument-recovery layout is needed — hence `caller_recovery_layout` // and `previous_tokens` are unused. let ops_owned: Vec = normalize_ops_for_codegen(inputargs, ops); + // A bridge gets its own table, like `compile_loop`'s. + let (ops_owned, gc_table) = Self::intern_ref_constants(inputargs, ops_owned); + let gc_table_base = gc_table.as_ref().map_or(0, |t| t.base_addr() as u32); let ops: &[Op] = &ops_owned; diag_bump(0); // compile_bridge entered @@ -2114,7 +2229,7 @@ impl majit_backend::Backend for WasmBackend { // `original_token` is released before the `&mut self` codegen calls. let ( source_guard, - source_is_direct, + source_ca_reentry_safe, source_func_handle, source_has_preamble, source_frame, @@ -2138,36 +2253,43 @@ impl majit_backend::Backend for WasmBackend { // per-fail-arg advance flags. `None` = foreign trace (declined // below, diag 3). let is_direct = source_trace_id == source_loop.trace_id; - let guard = if is_direct { - Some(( - source_loop.bridge_cells_base, - source_loop.num_guard_cells, - source_loop - .guard_fail_arg_advanced - .get(source_fail_index as usize) - .cloned() - .unwrap_or_default(), - )) + let (guard, ca_reentry_safe) = if is_direct { + ( + Some(( + source_loop.bridge_cells_base, + source_loop.num_guard_cells, + source_loop + .guard_fail_arg_advanced + .get(source_fail_index as usize) + .cloned() + .unwrap_or_default(), + )), + true, + ) } else { - source_loop + match source_loop .chained_trace_meta .borrow() .get(&source_trace_id) - .map(|m| { - ( + { + Some(m) => ( + Some(( m.cells_base, m.num_cells, m.guard_fail_arg_advanced .get(source_fail_index as usize) .cloned() .unwrap_or_default(), - ) - }) + )), + m.ca_reentry_safe, + ), + None => (None, false), + } }; ( guard, - is_direct, - source_loop.func_handle, + ca_reentry_safe, + source_loop.materialize_func_handle()?, source_loop.has_preamble, source_loop.frame, source_loop.ca_active.get(), @@ -2193,9 +2315,13 @@ impl majit_backend::Backend for WasmBackend { "wasm backend: bridge source guard index has no dispatch cell".into(), )); } - // Keep CA bridges attached directly to their source loop. Nested - // bridge metadata is not yet published as a CALL_ASSEMBLER target. - let mut allow_ca = ca_candidate && source_is_direct; + // A nested bridge may compose CALL_ASSEMBLER only when its owning + // bridge published that it has no host-trampoline lowering. Merely + // finding the nested guard's cell is insufficient: after a movable + // callee returns, a trampoline-bearing source would retain a stale + // frame pointer. Direct loop guards satisfy the same condition through + // the token-wide trampoline census below. + let mut allow_ca = ca_candidate && source_ca_reentry_safe; let ca_trampoline_decline = if allow_ca && source_has_trampoline_calls { Some( "wasm backend: self-recursive CA source token or chained bridge \ @@ -2231,7 +2357,7 @@ impl majit_backend::Backend for WasmBackend { let bridge_value_slots = codegen::frame_value_slots(inputargs, ops); let bridge_ref_homes = codegen::count_ref_homes(inputargs, ops); if bridge_value_slots > source_frame.value_slots - || bridge_ref_homes > source_frame.home_slots + || bridge_ref_homes > source_frame.ordinary_home_slots() || (source_ca_active && bridge_has_trampoline_calls) { if source_ca_active && bridge_has_trampoline_calls { @@ -2249,7 +2375,8 @@ impl majit_backend::Backend for WasmBackend { return Err(BackendError::Unsupported(format!( "wasm backend: bridge frame needs values={bridge_value_slots}, homes={bridge_ref_homes}; \ source frozen layout has values={}, homes={}", - source_frame.value_slots, source_frame.home_slots, + source_frame.value_slots, + source_frame.ordinary_home_slots(), ))); } @@ -2328,6 +2455,12 @@ impl majit_backend::Backend for WasmBackend { diag_bump(9); // label args not the full live set false } + Some(t) if t.requires_own_frame && t.func_handle != source_func_handle => { + // The target's high capture homes were populated by its + // own fall-through path, not by this sibling source loop. + diag_bump(9); + false + } Some(t) if t.frame != source_frame => { diag_bump(4); // target uses different frozen frame offsets false @@ -2491,6 +2624,7 @@ impl majit_backend::Backend for WasmBackend { wb_fn_ptr, nursery_alloc_params(ops).as_ref(), Arc::as_ptr(&bridge_flag) as usize as u32, + gc_table_base, base, // A loop-closing bridge's terminal JUMP re-enters the target // loop (own or sibling, resolved via `LABEL_TARGETS`) through @@ -2537,6 +2671,13 @@ impl majit_backend::Backend for WasmBackend { .to_string(), )); } + // Only a bridge that survived the decline above gets its reference + // constants rooted. The table is attached to the long-lived original + // loop token, so rooting a rejected bridge's table would keep its + // constants alive permanently, once per rejected attempt. + if let Some(table) = gc_table { + Self::register_gc_table(original_token, table); + } diag_bump(5); // bridge compiled — chained in-module { @@ -2575,6 +2716,7 @@ impl majit_backend::Backend for WasmBackend { cells_base: bridge_cells_base, num_cells: guard_exits.len(), guard_fail_arg_advanced: guard_fail_args_advanced(ops, &guard_exits), + ca_reentry_safe: !bridge_has_trampoline_calls, }, ); // The bridge module lives as long as this source loop, so hand its @@ -2805,6 +2947,10 @@ impl majit_backend::Backend for WasmBackend { .expect("no compiled code") .downcast_ref::() .expect("not CompiledWasmLoop"); + #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + let func_handle = compiled + .materialize_func_handle() + .expect("wasm backend failed to materialize a runnable trace"); // Host entry allocates the complete frozen geometry, including the tail // call area. Chained bridges share these exact offsets; only CA callee @@ -2878,7 +3024,7 @@ impl majit_backend::Backend for WasmBackend { } let saved = majit_gc::shadow_stack::push_jf(jf_ref); - glue::execute(compiled.func_handle, items_base as u32); + glue::execute(func_handle, items_base as u32); let exc_value = jit_exc_take(); let fail_index = unsafe { *(items_base as *const i64) } as u32; @@ -2906,11 +3052,7 @@ impl majit_backend::Backend for WasmBackend { drop(gcmap); return DeadFrame { - data: Box::new(WasmFrameData { - raw_values, - fail_descr, - exc_value, - }), + data: WasmFrameData::boxed(raw_values, fail_descr, exc_value), }; } @@ -2936,7 +3078,7 @@ impl majit_backend::Backend for WasmBackend { let slot = unsafe { frame.as_mut_ptr().add(home_base + h) } as *mut GcRef; unsafe { wasm_gc_add_root(slot) }; } - glue::execute(compiled.func_handle, frame_ptr); + glue::execute(func_handle, frame_ptr); for h in 0..compiled.frame.home_slots { let slot = unsafe { frame.as_mut_ptr().add(home_base + h) } as *mut GcRef; wasm_gc_remove_root(slot); @@ -2949,11 +3091,7 @@ impl majit_backend::Backend for WasmBackend { let num_outputs = fail_descr.fail_arg_types.len(); let raw_values: Vec = (0..num_outputs).map(|i| frame[1 + i]).collect(); DeadFrame { - data: Box::new(WasmFrameData { - raw_values, - fail_descr, - exc_value, - }), + data: WasmFrameData::boxed(raw_values, fail_descr, exc_value), } } } @@ -3068,6 +3206,29 @@ impl majit_backend::Backend for WasmBackend { old.number, new.number ))); } + if new_target.func_handle == 0 && new_target.compiled_ptr != 0 { + let new_loop = unsafe { + (new_target.compiled_ptr as *const CompiledWasmLoop) + .as_ref() + .expect("published CALL_ASSEMBLER target has a live compiled loop") + }; + let handle = new_loop.materialize_func_handle()?; + #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + if handle == 0 { + return Err(BackendError::Unsupported(format!( + "call-assembler redirect to token {} could not materialize wasm code", + new.number + ))); + } + new_target.func_handle = handle; + ca_dispatch_publish( + new.number, + handle, + new_target.loop_finish_fi, + new_target.compiled_ptr as u32, + ); + publish_call_assembler_target(new.number, new_target.clone()); + } let movable_callee = new_target.callee_frame_bytes != 0 && new_target.callee_gcmap_ptr != 0 && new_target.compiled_ptr != 0 @@ -3111,10 +3272,38 @@ impl majit_backend::Backend for WasmBackend { #[cfg(test)] mod tests { use super::*; - use majit_backend::Backend; + use majit_backend::{Backend, JitCellToken}; use majit_gc::collector::MiniMarkGC; use majit_gc::trace::TypeInfo; + #[test] + fn straightline_trace_defers_host_module_until_execution() { + let mut backend = WasmBackend::new(); + let token = JitCellToken::new(1); + let finish = Op::new(majit_ir::OpCode::Finish, &[]); + finish.pos.set(majit_ir::OpRef::void_op(0)); + finish.set_fail_arg_types(Vec::new()); + finish.setfailargs(Vec::new().into()); + + backend + .compile_loop(&[], &[std::rc::Rc::new(finish)], &token) + .expect("compile straight-line wasm trace"); + let compiled = token + .compiled + .get() + .and_then(|c| c.downcast_ref::()) + .expect("compiled wasm metadata"); + + assert_eq!(compiled.eager_func_handle(), 0); + assert!(compiled.pending_wasm_bytes.borrow().is_some()); + + // Retiring an unentered token must leave the module unmaterialized; + // this is the exception/global-version invalidation-storm case. + token.invalidate(); + assert_eq!(compiled.eager_func_handle(), 0); + assert!(compiled.pending_wasm_bytes.borrow().is_some()); + } + /// llsupport/gc.py:563 GcLLDescr_framework /// .get_typeid_from_classptr_if_gcremovetypeptr /// Verify the wasm backend's gc_ll_descr round-trips a registered diff --git a/majit/majit-backend-wasm/tests/codegen_test.rs b/majit/majit-backend-wasm/tests/codegen_test.rs index 237dab820a7..2dcd59e3fca 100644 --- a/majit/majit-backend-wasm/tests/codegen_test.rs +++ b/majit/majit-backend-wasm/tests/codegen_test.rs @@ -167,6 +167,72 @@ fn terminal_declined_call_assembler_matches_dynasm_at_runtime() { ); } +#[test] +#[ignore = "runtime integration test: needs the release pyre-dynasm, pyre-wasm-runner, and wasm-host module; \ + run via `cargo test -- --ignored` in the check.py job, which builds them"] +fn wasm_outlier_bridges_stay_compiled_at_runtime() { + let root = workspace_root(); + let dynasm = root.join("target/release/pyre-dynasm"); + let wasm_runner = root.join("target/release/pyre-wasm-runner"); + let host_module = root.join("target/wasm32-unknown-unknown/release/pyre_wasm.wasm-host.wasm"); + let plain_module = root.join("target/wasm32-unknown-unknown/release/pyre_wasm.wasm"); + let wasm_module = if host_module.exists() { + host_module + } else { + plain_module + }; + + for artifact in [&dynasm, &wasm_runner, &wasm_module] { + assert!( + artifact.exists(), + "runtime outlier regression needs {}; build the requested dynasm and wasm-host artifacts first", + artifact.display() + ); + } + + let module = wasm_module.to_str().expect("workspace paths must be UTF-8"); + for (bench, expected_counter) in [ + ("exception_oserror_fields.py", "BRIDGE_OK"), + ("generator_tree_recursion.py", "accepted_ca"), + ] { + let script = root.join("pyre/bench/synth").join(bench); + let dynasm_run = run_runtime_program(&dynasm, &script, &[]); + assert!(dynasm_run.status.success(), "dynasm failed for {bench}"); + let wasm_run = run_runtime_program( + &wasm_runner, + &script, + &[ + ("PYRE_WASM_MODULE", module), + ("PYRE_WASM_ENGINE", "wasmtime"), + ("PYRE_WASM_JIT_STATS", "1"), + ], + ); + let stderr = String::from_utf8_lossy(&wasm_run.stderr); + assert!( + wasm_run.status.success(), + "wasm failed for {bench}:\n{stderr}" + ); + assert_eq!( + wasm_run.stdout, dynasm_run.stdout, + "wasm output diverged from dynasm for {bench}:\n{stderr}" + ); + assert!( + stat_value(&stderr, expected_counter) > 0, + "{bench} did not compile its formerly-declined bridge:\n{stderr}" + ); + assert_eq!( + stat_value(&stderr, "ml_unsafe_label"), + 0, + "{bench} declined a LABEL resume:\n{stderr}" + ); + assert_eq!( + stat_value(&stderr, "decl_callasm"), + 0, + "{bench} declined a CALL_ASSEMBLER bridge:\n{stderr}" + ); + } +} + fn make_op(opcode: OpCode, args: &[OpRef], pos: OpRef) -> Op { let bx: Vec = args.iter().map(|a| rb(*a)).collect(); let op = Op::new(opcode, &bx); @@ -199,6 +265,24 @@ fn build_module( constants: &indexmap::IndexMap, vtable_offset: Option, gc_info: &codegen::GuardGcTypeInfo, +) -> (Vec, Vec) { + build_module_with_frame( + inputargs, + ops, + constants, + vtable_offset, + gc_info, + codegen::FrameGeometry::fixed(), + ) +} + +fn build_module_with_frame( + inputargs: &[InputArg], + ops: &[Op], + constants: &indexmap::IndexMap, + vtable_offset: Option, + gc_info: &codegen::GuardGcTypeInfo, + frame: codegen::FrameGeometry, ) -> (Vec, Vec) { let (bytes, guards, _, _, _) = codegen::build_wasm_module( inputargs, @@ -211,10 +295,11 @@ fn build_module( 0, None, // nursery 0, // invalidated_flag_addr + 0, // gc_table_base 0, // fail_index_base 0, // external_jump_slot 0, // external_jump_key - codegen::FrameGeometry::fixed(), + frame, codegen::CaParams::default(), ) .expect("wasm codegen should succeed"); @@ -583,10 +668,11 @@ fn test_guard_not_invalidated_loads_runtime_flag() { codegen::AllocHelpers::default(), 0, None, - 0x1000, - 0, - 0, - 0, + 0x1000, // invalidated_flag_addr + 0, // gc_table_base + 0, // fail_index_base + 0, // external_jump_slot + 0, // external_jump_key codegen::FrameGeometry::fixed(), codegen::CaParams::default(), ) @@ -909,6 +995,60 @@ fn test_single_label_peeled_loop_validates() { assert!(!guards[0].is_finish); } +#[test] +fn test_peeled_label_captures_missing_ref_livein_in_frozen_frame() { + let inputargs = vec![ + InputArg::from_type(Type::Ref, 0), + InputArg::from_type(Type::Int, 1), + ]; + let ops = vec![ + make_op( + OpCode::IntAdd, + &[OpRef::input_arg_int(1), OpRef::const_int(1)], + OpRef::int_op(2), + ), + // The Ref input remains live in the body but is intentionally absent + // from the semantic LABEL args: it must be restored from a GC-rooted + // backend capture home on bridge re-entry. + Op::new(OpCode::Label, &[rb(OpRef::int_op(2))]), + make_guard( + OpCode::GuardNonnull, + &[OpRef::input_arg_ref(0)], + &[OpRef::input_arg_ref(0), OpRef::int_op(2)], + ), + make_op( + OpCode::IntAdd, + &[OpRef::int_op(2), OpRef::const_int(1)], + OpRef::int_op(3), + ), + Op::new(OpCode::Jump, &[rb(OpRef::int_op(3))]), + ]; + + assert!(codegen::is_resumable_peeled(&ops)); + assert_eq!(codegen::label_ref_capture_slots(&inputargs, &ops), 1); + let ordinary_homes = codegen::count_ref_homes(&inputargs, &ops); + let frame = codegen::FrameGeometry::compact( + codegen::frame_value_slots(&inputargs, &ops), + ordinary_homes + 1, + 1, + ); + assert_eq!( + codegen::label_resume_info(&inputargs, &ops, frame), + vec![(true, true)] + ); + assert_eq!(frame.ordinary_home_slots(), ordinary_homes); + let (bytes, guards) = build_module_with_frame( + &inputargs, + &ops, + &indexmap::IndexMap::new(), + Some(0), + &codegen::GuardGcTypeInfo::default(), + frame, + ); + validate_wasm(&bytes); + assert_eq!(guards.len(), 1); +} + #[test] fn test_multi_label_peeled_resumes_at_last_label_validates() { // A MULTI-label peeled loop: a preamble precedes an outer entry LABEL and @@ -1122,6 +1262,7 @@ fn test_non_moving_descr_allocates_through_the_oldgen_helper() { 0, 0, 0, + 0, codegen::FrameGeometry::fixed(), codegen::CaParams::default(), ) diff --git a/majit/majit-gc/src/collector.rs b/majit/majit-gc/src/collector.rs index 4076f165fc1..7e826276d79 100644 --- a/majit/majit-gc/src/collector.rs +++ b/majit/majit-gc/src/collector.rs @@ -2938,6 +2938,49 @@ impl MiniMarkGC { } } + /// incminimark.py:1792-1799 turns every old object modified since the cycle + /// began back to gray — "precisely the old objects that have been modified + /// and need rescanning" — before the sweep decides survivors, and + /// :2478-2481 rescans the roots that can grow after the cycle's opening + /// snapshot. Upstream needs only the non-stack half there, because its + /// stack roots are covered by two invariants pyre does not share: a + /// JITFRAME is nursery-allocated, so every minor re-traces it and a + /// promotion during MARKING re-queues it (:2079-2083), and the objects a + /// mutator stores into a stack slot mid-cycle were promoted during MARKING + /// and are therefore born black. + /// + /// pyre's stack root sets are mutated with no write barrier and hold + /// pre-cycle objects: a JitFrame lives in the old gen so its pointer stays + /// valid across a collecting call while compiled code stores Refs into its + /// gcmap slots, the blackhole register banks and resume-construction roots + /// are plain slices, and `seed_major_root` arms a newly seeded old root + /// into the remembered set only once — the next minor drains that set and + /// nothing re-arms it. A black root can therefore come to hold the only + /// reference to a white object. Walk the root sets once more here and turn + /// the black ones gray again; this can only add survivors, never free a + /// reachable object. + fn rescan_major_stack_roots_black_and_drain(&mut self) { + for gcref in self.enumerate_root_walker_values() { + if gcref.is_null() { + continue; + } + // incminimark.py:1322-1340 keeps nursery objects out of a marking + // worklist, so a nursery root goes through the seeding path, which + // marks it without queueing it. + let regray = !self.is_in_nursery(gcref.0) + && self.is_managed_heap_object(gcref.0) + && unsafe { (*header_of(gcref.0)).has_flag(flags::VISITED) }; + if regray { + self.incr_state.gray_stack.push(gcref.0); + } else { + self.seed_major_root(gcref); + } + } + while let Some(obj_addr) = self.incr_state.gray_stack.pop() { + self.mark_object(obj_addr); + } + } + /// incminimark.py:2473-2533: finish MARKING and freeze this cycle's sweep /// candidates. Every VISITED-dependent consumer runs before either raw or /// arena memory is freed. @@ -2947,6 +2990,10 @@ impl MiniMarkGC { // modified since the last minor) before the sweep, or a cycle finished // between minors frees reachable old->old targets. self.rescan_remembered_black_and_drain(); + // Barrier-less stack-root stores (JitFrame gcmap slots, blackhole + // register banks, resume-construction roots): re-gray the black roots + // before the sweep freezes survivors. + self.rescan_major_stack_roots_black_and_drain(); // incminimark.py:2478-2481: process-global/non-stack roots can grow // after the cycle's initial snapshot. Rescan and trace them before // finalizers, weakrefs, and sweep inspect VISITED. diff --git a/majit/majit-gc/src/rewrite.rs b/majit/majit-gc/src/rewrite.rs index 37f0a2ae2ed..b0686ce3a7a 100644 --- a/majit/majit-gc/src/rewrite.rs +++ b/majit/majit-gc/src/rewrite.rs @@ -28,6 +28,82 @@ fn mk_op_descr(opcode: OpCode, args: &[Operand], descr: DescrRef) -> Op { Op::with_descr(opcode, args, descr) } +/// rewrite.py:106-116 `emit_op`'s reference-constant loop, run on its own. +/// +/// The full rewrite ([`GcRewriterImpl`]) also lowers mallocs, GC +/// loads/stores and write barriers. A backend that lowers those itself — +/// wasm emits its own inline nursery bump, its own barriers, and has no +/// descr-driven `GC_LOAD` model — still needs this half: a raw `GcRef` +/// baked as a code immediate is a pointer the moving collector can +/// neither find nor update, so the first minor collection that promotes +/// the referenced object out of the nursery leaves the immediate +/// dangling at an address the nursery later reuses or zeroes. Running +/// just this pass gives such a backend the same `LoadFromGcTable` + +/// [`GcTable`](crate::GcTable) contract the native backends get from the +/// full rewrite. +/// +/// `next_pos` is the first free value position; the emitted loads take +/// positions from there upward, so the caller's existing operand +/// numbering is untouched. Returns the rewritten ops and the +/// `gcrefs_output_list` (rewrite.py:352) the caller turns into the +/// per-loop table. +pub fn remove_ref_constants(ops: &[Op], mut next_pos: u32) -> (Vec, Vec) { + // rewrite.py:352-354 `gcrefs_output_list` / `gcrefs_map` / + // `gcrefs_recently_loaded`. + let mut gcrefs: Vec = Vec::new(); + let mut gcrefs_map: IndexMap = IndexMap::default(); + let mut recently_loaded: IndexMap = IndexMap::default(); + let mut out: Vec = Vec::with_capacity(ops.len()); + + for op in ops { + // rewrite.py:1005 — the per-basic-block CSE cache is dropped at + // every Label. A load emitted in the preamble must not be reused + // after the header: a resume entry jumps straight to the label + // and would read an unwritten slot. + if op.opcode == OpCode::Label { + recently_loaded.clear(); + } + let op = op.clone(); + // rewrite.py:105 `keep` — JIT_DEBUG keeps its constants inline. + if op.opcode != OpCode::JitDebug { + for i in 0..op.num_args() { + // rewrite.py:109 `bool(arg.value)` — null stays inline. + let Some(Value::Ref(gcref)) = op.arg(i).const_value() else { + continue; + }; + if gcref.is_null() { + continue; + } + // rewrite.py:1033-1043 `_gcref_index`. + let index = *gcrefs_map.entry(gcref.0).or_insert_with(|| { + let index = gcrefs.len() as u32; + gcrefs.push(gcref); + index + }); + // rewrite.py:1100-1115 `remove_constptr`. + let load = match recently_loaded.get(&index) { + Some(load) => load.clone(), + None => { + let load_op = std::rc::Rc::new(mk_op( + OpCode::LoadFromGcTable, + &[Operand::const_from_value(Value::Int(index as i64))], + )); + load_op.pos.set(OpRef::ref_op(next_pos)); + next_pos += 1; + out.push((*load_op).clone()); + let load = Operand::from_bound_op(&load_op); + recently_loaded.insert(index, load.clone()); + load + } + }; + op.setarg(i, load); + } + } + out.push(op); + } + (out, gcrefs) +} + /// Alignment for nursery allocations (8 bytes). const NURSERY_ALIGN: usize = 8; diff --git a/majit/majit-metainterp/src/jitcode/assembler.rs b/majit/majit-metainterp/src/jitcode/assembler.rs index d76a066250d..af3c8802965 100644 --- a/majit/majit-metainterp/src/jitcode/assembler.rs +++ b/majit/majit-metainterp/src/jitcode/assembler.rs @@ -1980,9 +1980,16 @@ impl JitCodeBuilder { } pub fn new_label(&mut self) -> u16 { - let label = self.labels.len() as u16; + // A label id addresses `self.labels` and is patched into a two-byte + // jump-target slot (`assembler.py:250-257 fix_labels`), so the id + // space ends at `u16::MAX`. Past it the `as u16` truncation would + // alias a fresh label onto an earlier one and silently patch the + // wrong target; record the overflow so `try_finish` declines the + // whole jitcode instead. + let label = self.labels.len(); + self.encoding_overflow |= label > u16::MAX as usize; self.labels.push(None); - label + label as u16 } pub fn mark_label(&mut self, label: u16) { diff --git a/majit/majit-metainterp/src/jitdriver.rs b/majit/majit-metainterp/src/jitdriver.rs index 732d1460242..a6496702566 100644 --- a/majit/majit-metainterp/src/jitdriver.rs +++ b/majit/majit-metainterp/src/jitdriver.rs @@ -457,7 +457,12 @@ pub(crate) fn spdiag_enabled() -> bool { static FLAG: std::sync::OnceLock = std::sync::OnceLock::new(); *FLAG.get_or_init(|| std::env::var_os("MAJIT_SPDIAG").is_some()) } -fn no_bridge_enabled() -> bool { +/// `MAJIT_NO_BRIDGE`: suppress bridge recording so every guard failure resumes +/// through the blackhole. Public because a frontend that owns its own +/// guard-failure entry point has to honour it there too — gating only the +/// jitdriver-internal paths leaves the variable set but inert, which reads as +/// "bridges are off" while they keep recording. +pub fn no_bridge_enabled() -> bool { static FLAG: std::sync::OnceLock = std::sync::OnceLock::new(); *FLAG.get_or_init(|| std::env::var_os("MAJIT_NO_BRIDGE").is_some()) } diff --git a/majit/majit-metainterp/src/lib.rs b/majit/majit-metainterp/src/lib.rs index 8c763cbfef5..76358281f6e 100644 --- a/majit/majit-metainterp/src/lib.rs +++ b/majit/majit-metainterp/src/lib.rs @@ -131,7 +131,7 @@ pub use jitdriver::{ DeclarativeJitDriver, JitDriver, JitDriverStaticData, MultiFrameBlackholeResult, PendingAbortBlackhole, SingleFrameBlackholeResult, TraceContinuationSuspendGuard, current_state_field_fvc_epoch, drive_multi_frame_blackhole, drive_single_frame_blackhole, - trace_continuation_suspended, + no_bridge_enabled, trace_continuation_suspended, }; pub use majit_backend::CompiledTraceInfo; pub use pyjitpl::{eval_binop_f, eval_binop_i, eval_float_cmp, eval_unary_f, eval_unary_i}; diff --git a/majit/majit-metainterp/src/resume.rs b/majit/majit-metainterp/src/resume.rs index e306a333bdb..157905a648a 100644 --- a/majit/majit-metainterp/src/resume.rs +++ b/majit/majit-metainterp/src/resume.rs @@ -7428,6 +7428,59 @@ pub fn read_frame_liveness_reg_indices( } /// RAII guard that pops every resume-construction ref-slice root pushed +/// `warmstate.py:416-422 handle_fail(deadframe, ...)` reads a failing guard's +/// exit values out of the JITFRAME, and the JITFRAME stays a GC root — the +/// collector forwards its `jf_gcmap` slots in place — for the whole deopt. +/// That matters because both halves of the deopt allocate: bridge tracing +/// (`compile.py:701-717`) and blackhole reconstruction (`resume.py:1312`), and +/// a minor collection in either moves exactly the objects those exit values +/// name. `decode_ref`'s `TAGBOX` arm then reads the moved-from address. +/// +/// pyre has no host-visible JITFRAME: the backend copies the exit values into +/// a host `Vec` when `execute_token` returns and hands that buffer down +/// through `handle_fail`. This scope gives the copy the rooting the JITFRAME +/// provided — registering the `Ref`-typed slots only, matching the gcmap's +/// precision rather than scanning the whole array (an `Int` slot whose value +/// happened to equal a nursery object start would otherwise be "forwarded" +/// into a different integer). +pub struct DeadFrameRefRoots { + base_depth: usize, +} + +impl DeadFrameRefRoots { + /// Root every `Ref`-typed slot of `values` until the returned scope drops. + /// `is_ref` selects the slots, and each is pushed as its own one-element + /// slice so the non-`Ref` slots in between stay untouched. + /// + /// # Safety + /// `values` must stay alive and at a fixed address for the scope's whole + /// lifetime; the collector writes forwarded addresses back through the + /// registered pointers. + pub unsafe fn enter(values: &[i64], is_ref: impl Fn(usize) -> bool) -> Self { + let base_depth = majit_gc::shadow_stack::resume_ref_roots_depth(); + let base = values.as_ptr() as *mut i64; + for index in 0..values.len() { + if is_ref(index) { + // SAFETY: `index < values.len()`, and the caller pins the + // buffer for the scope. The collector is the only writer. + unsafe { + majit_gc::shadow_stack::push_resume_ref_roots(std::slice::from_raw_parts_mut( + base.add(index), + 1, + )); + } + } + } + DeadFrameRefRoots { base_depth } + } +} + +impl Drop for DeadFrameRefRoots { + fn drop(&mut self) { + majit_gc::shadow_stack::pop_resume_ref_roots_to(self.base_depth); + } +} + /// during `blackhole_from_resumedata` back to the depth captured at entry. /// Drop runs on ordinary return, `?` propagation, and panic unwind, so the /// `virtuals_cache` / `registers_r` slices never outlive the construction diff --git a/majit/majit-metainterp/src/trace_ctx.rs b/majit/majit-metainterp/src/trace_ctx.rs index f52cf11077f..678defadbda 100644 --- a/majit/majit-metainterp/src/trace_ctx.rs +++ b/majit/majit-metainterp/src/trace_ctx.rs @@ -359,6 +359,23 @@ pub struct TraceCtx { /// namespace-length gate). Per-trace: a fresh ctx starts `false`, so no /// manual reset is needed. pub reads_module_global: bool, + /// Green keys whose cross-loop close this recording walk already attempted + /// and did not get compiled. + /// + /// `reached_loop_header` (pyjitpl.py:3020-3050) answers a cancelled close by + /// `cancel_count += 1` and, once `cancelled_too_many_times()` holds + /// (`max_unroll_loops` defaults to 0, `rlib/jit.py:598`), by + /// `SwitchToBlackhole(ABORT_BAD_LOOP)` — so upstream re-attempts a close at + /// most once per tracing pass. The walker instead keeps tracing when the + /// crossed key is not its own root, because closing there would store the + /// loop where nothing enters. Without this set it would also re-attempt the + /// close on every later crossing of the same header, and each attempt + /// re-optimizes the whole trace-so-far: an inner loop crossed N times costs + /// N optimizer passes over a trace that keeps growing. A structural decline + /// is deterministic — the same key rebuilds the same unsupported bridge — + /// which is the reasoning `MetaInterp::declined_bridge_guards` records for + /// guards. Per-trace, so a fresh walk retries once. + pub declined_cross_loop_closes: Vec, /// For a bridge trace (`is_bridge_trace`), the loop-header bytecode pc of /// the parent loop the bridge will JUMP into. The bridge closes when it /// reaches this pc (a real compiled-loop header), NOT when it transiently @@ -1421,6 +1438,7 @@ impl TraceCtx { compiled_key_for_greens_fn: None, is_bridge_trace: false, reads_module_global: false, + declined_cross_loop_closes: Vec::new(), bridge_target_header_pc: None, portal_call_depth_fn: None, seen_loop_header_for_jdindex: -1, @@ -1503,6 +1521,7 @@ impl TraceCtx { compiled_key_for_greens_fn: None, is_bridge_trace: false, reads_module_global: false, + declined_cross_loop_closes: Vec::new(), bridge_target_header_pc: None, portal_call_depth_fn: None, seen_loop_header_for_jdindex: -1, @@ -1869,6 +1888,20 @@ impl TraceCtx { self.root_green_key } + /// Whether this walk already tried, and failed, to close across `key`. + /// See [`TraceCtx::declined_cross_loop_closes`]. + pub fn cross_loop_close_declined(&self, key: u64) -> bool { + self.declined_cross_loop_closes.contains(&key) + } + + /// Record that closing across `key` did not compile, so a later crossing of + /// the same header does not re-run the optimizer on the trace-so-far. + pub fn note_cross_loop_close_declined(&mut self, key: u64) { + if !self.cross_loop_close_declined(key) { + self.declined_cross_loop_closes.push(key); + } + } + /// Mark that the current back-edge was reached inside an inline callee /// frame and must not be unrolled (opimpl_jit_merge_point /// portal_call_depth>0). The trace step drains this via diff --git a/majit/majit-translate/src/translator/rtyper/cutover.rs b/majit/majit-translate/src/translator/rtyper/cutover.rs index 419e7481b4c..99e65aeccc1 100644 --- a/majit/majit-translate/src/translator/rtyper/cutover.rs +++ b/majit/majit-translate/src/translator/rtyper/cutover.rs @@ -3067,7 +3067,7 @@ fn classify_unported_reason(reason: &str) -> &'static str { || reason.contains("TL_") || reason.contains("TYPEOBJECT_CACHE") || reason.contains("W_TYPE_TYPEOBJECT") - || reason.contains("CALL_DEPTH") + || reason.contains("PY_RECURSION_DEPTH") || reason.contains("PENDING_EXCEPTION") { "FRONTEND-THREADLOCAL/ONCELOCK (threadlocalref_get)" diff --git a/pyre/bench/synth/hot_loop_exit_then_class_stmt.py b/pyre/bench/synth/hot_loop_exit_then_class_stmt.py new file mode 100644 index 00000000000..734bab2ae16 --- /dev/null +++ b/pyre/bench/synth/hot_loop_exit_then_class_stmt.py @@ -0,0 +1,51 @@ +# A hot module-level `while` loop whose exit deopts into the blackhole, followed +# by observable statements and then a `class` statement. +# +# The blackhole resumes the module frame past the loop guard and executes the +# statements between the loop and the `class` concretely; the `class` statement +# then hits an op it cannot perform and aborts. When the abort path restored +# the pre-blackhole frame snapshot (locals / valuestackdepth / last_instr) and +# handed control back to the interpreter, the interpreter re-ran the region and +# every effect already performed by the blackhole was applied a second time: +# `print` fired twice, `append` appended twice, `n` counted twice. +# +# Everything here runs at the default thresholds — no `pypyjit.set_param`. +N = 3000 + +log = [] +n = 0 + +i = 0 +while i < N: + i = i + 1 + +print("after loop") +log.append("a") +n = n + 1 + + +class C: + pass + + +print("log =", log, "n =", n, "i =", i) + + +# The same shape one level down: the loop, the effects and the `class` all sit +# inside a function frame, so the blackhole resumes a non-module frame. +def inner(): + entries = [] + count = 0 + j = 0 + while j < N: + j = j + 1 + entries.append("b") + count += 1 + + class D: + pass + + return entries, count, D.__name__ + + +print(inner()) diff --git a/pyre/bench/synth/raise_reg_unbound_jitstress.py b/pyre/bench/synth/raise_reg_unbound_jitstress.py new file mode 100644 index 00000000000..39ce502c4a4 --- /dev/null +++ b/pyre/bench/synth/raise_reg_unbound_jitstress.py @@ -0,0 +1,55 @@ +# JIT-stress fixture for the malformed-jitcode `raise/r` register read. +# +# `pypyjit.set_param` drops the thresholds to 1 so every lambda below is +# recorded on its first call. In that regime the codewriter produced a jitcode +# for `lambda: [x for x in Boom()]` whose shared exception tail reads Ref +# registers that no op in the jitcode ever writes (the tail is the merge of +# three `catch_exception` handlers, and the value-stack flush it performs +# differs per raise site). The walk then handed `OpRef::NONE` to `raise/r`, +# which reached the backends as `Finish(_)` against an +# `ExitFrameWithExceptionDescrRef` whose fail-arg type is Ref — dynasm panicked +# in `RegisterManager.loc`, cranelift in `resolve_opref`, on the same trace. +# +# Every `show(...)` below is load-bearing: the walk only reaches the malformed +# tail once the earlier sections have accumulated their tracing state. Removing +# any one of them stops the fixture from exercising the path. +# +# CPython (the oracle) has no `pypyjit`; PyPy and pyre do. Guarding the import +# keeps the output identical across all three. +try: + import pypyjit + + pypyjit.set_param("threshold=1,function_threshold=1") +except ImportError: + pass + + +def show(label, fn): + # Swallowing the exception keeps the fixture's output stable while still + # driving the raise through the recorded frame. + try: + fn() + except BaseException as e: + "!%s: %s" % (type(e).__name__, e) + + +class Boom: + def __iter__(self): + return self + + def __next__(self): + # `self.n` does not exist: every iteration raises AttributeError from + # inside the comprehension's loop body. + return self.n + + +# The first two `exec` payloads run and print; the last two carry a stray +# indent and raise IndentationError, which `show` absorbs. All four are here +# for the tracing state they accumulate. +show("loop_var_leak", lambda: exec("for q in range(3): pass\nprint(' ', q)")) +show("loop_var_empty", lambda: exec("for q2 in []: pass\nprint(' ', 'q2' in dir())")) +show("break_in_finally", lambda: exec(" print(' fin', i)\n")) +show("continue_else", lambda: exec(" print(' else ran')\n")) +show("iter_raises_midloop", lambda: None) +show("midloop_error", lambda: [x for x in Boom()]) +print("done") diff --git a/pyre/extra_tests/parity_tests/builtin_subclass_registration.py b/pyre/extra_tests/parity_tests/builtin_subclass_registration.py new file mode 100644 index 00000000000..e4c22f3f8f6 --- /dev/null +++ b/pyre/extra_tests/parity_tests/builtin_subclass_registration.py @@ -0,0 +1,86 @@ +"""A builtin type registers itself on every entry of ``__bases__``. + +``typeobject.py:1789-1790 TypeCache.ready`` runs ``w_type.ready()`` for each +builtin typedef the space cache builds, exactly as ``_type_new`` +(``typeobject.py:970``) does for a heap type; ``ready`` walks the whole +``__bases__`` tuple and calls ``add_subclass`` on each entry +(``typeobject.py:1140-1142``). So the number of bases is irrelevant: a +builtin appears in ``base.__subclasses__()`` for *every* base, not just the +primary one that drives its layout. + +The two multi-base native types are ``ExceptionGroup(BaseExceptionGroup, +Exception)`` and ``io.UnsupportedOperation(OSError, ValueError)``. They are +the only cases that distinguish "readied on the primary base" from "readied +on all bases", which is why they are pinned by name here. + +``add_subclass`` (``typeobject.py:651-660``) is idempotent — it returns early +when an existing weakref already resolves to the subclass — so a type never +appears twice however many times it is readied. +""" + +import io + +MULTI_BASE = [ + (ExceptionGroup, ("BaseExceptionGroup", "Exception")), + (io.UnsupportedOperation, ("OSError", "ValueError")), +] + +for cls, base_names in MULTI_BASE: + assert tuple(b.__name__ for b in cls.__bases__) == base_names, ( + cls.__qualname__, + [b.__name__ for b in cls.__bases__], + ) + for base in cls.__bases__: + subs = base.__subclasses__() + assert cls in subs, f"{cls.__qualname__} missing from {base.__name__}.__subclasses__()" + assert subs.count(cls) == 1, ( + f"{cls.__qualname__} listed {subs.count(cls)}x in {base.__name__}.__subclasses__()" + ) + +# A single-base builtin is registered by the same call, so the two paths must +# not disagree. +assert bool in int.__subclasses__() +assert int not in bool.__subclasses__() + +# The multi-base entries are reachable by walking down from the base too, and +# the MRO orders the bases as recorded. +assert io.UnsupportedOperation in ValueError.__subclasses__() +assert io.UnsupportedOperation.__mro__[1:3] == (OSError, ValueError) +assert ExceptionGroup.__mro__[1:3] == (BaseExceptionGroup, Exception) + +# `__subclasses__()` holds weak references, so a heap subclass that is still +# alive is listed and one that has been collected is not. +import gc + + +class _KeptOSError(OSError): + pass + + +assert _KeptOSError in OSError.__subclasses__() + + +class _DroppedOSError(OSError): + pass + + +del _DroppedOSError +gc.collect() +assert not any(b.__name__ == "_DroppedOSError" for b in OSError.__subclasses__()) + +# Readying does not disturb instantiation or the exception hierarchy. +err = io.UnsupportedOperation("nope") +assert isinstance(err, OSError) +assert isinstance(err, ValueError) +try: + raise io.UnsupportedOperation("seek") +except ValueError as exc: + assert type(exc) is io.UnsupportedOperation +else: + raise AssertionError("UnsupportedOperation must be catchable as ValueError") + +group = ExceptionGroup("g", [ValueError("v")]) +assert isinstance(group, BaseExceptionGroup) +assert isinstance(group, Exception) + +print("OK") diff --git a/pyre/extra_tests/parity_tests/method_descriptor_kind.py b/pyre/extra_tests/parity_tests/method_descriptor_kind.py new file mode 100644 index 00000000000..6781ab691a2 --- /dev/null +++ b/pyre/extra_tests/parity_tests/method_descriptor_kind.py @@ -0,0 +1,197 @@ +"""Parity test: a `tp_methods` entry is a `method_descriptor`. + +`type_ready_fill_dict` hands every `PyMethodDef` in a static type's +`tp_methods` to `PyDescr_NewMethod`, so the namespace entry is a +`method_descriptor`; `descrobject.c method_get` then hands it to +`PyCMethod_New`, so an instance access yields a +`builtin_function_or_method` carrying the receiver as `m_self`. The slot +half of the same sweep (`add_operators` -> `PyDescr_NewWrapper`) produces +`wrapper_descriptor` instead, which is why the dunders below are excluded. + +Pinned here because pyre reaches the same shape from the other side: the +namespaces are built as plain function carriers and retagged once the +namespace is complete (`typedef.rs stamp_method_owners`), gated on the +`gateway::is_slot_wrapper` classification. A name that moves between the +two halves of that table silently changes both the descriptor kind and the +receiver-error wording, and only a differential check catches it. +""" + +# A representative `tp_methods` entry from each layout family. `bool` has +# none of its own — it inherits `int`'s — while `set` and `frozenset` each +# carry their own table, so both of those appear. +ORDINARY = [ + (list, "append"), + (list, "count"), + (list, "index"), + (dict, "get"), + (dict, "setdefault"), + (dict, "items"), + (tuple, "index"), + (tuple, "count"), + (int, "bit_length"), + (int, "to_bytes"), + (int, "conjugate"), + (float, "hex"), + (float, "is_integer"), + (complex, "conjugate"), + (str, "upper"), + (str, "split"), + (str, "encode"), + (bytes, "hex"), + (bytes, "decode"), + (bytearray, "append"), + (bytearray, "capitalize"), + (set, "add"), + (set, "union"), + (frozenset, "union"), + (range, "count"), + (object, "__sizeof__"), + (object, "__dir__"), + (type, "mro"), +] + +for owner, name in ORDINARY: + descr = owner.__dict__[name] + assert type(descr).__name__ == "method_descriptor", (owner, name, type(descr)) + assert descr.__name__ == name, (owner, name, descr.__name__) + assert descr.__qualname__ == f"{owner.__name__}.{name}", (owner, name) + assert descr.__objclass__ is owner, (owner, name) + assert repr(descr) == f"" + + +# The slot half stays a wrapper descriptor. `__contains__` and +# `__getitem__` are per-type: the mapping and sequence protocols fill +# different slots, so `dict.__getitem__` is a `tp_methods` entry while +# `tuple.__getitem__` is a slot. +# +# The in-place number slots and the `tp_as_async` trio are here because they +# are reachable on only a couple of types, so a table that forgets them stays +# green on the core types and misclassifies exactly those. +import types as _types +import weakref as _weakref + + +class _Referent: + pass + + +_referent = _Referent() +_ProxyType = type(_weakref.proxy(_referent)) + +SLOTS = [ + (list, "__len__"), + (tuple, "__getitem__"), + (int, "__add__"), + (int, "__repr__"), + (str, "__contains__"), + (object, "__init__"), + (object, "__setattr__"), + (type(x for x in ()), "__del__"), + (_ProxyType, "__ifloordiv__"), + (_ProxyType, "__ilshift__"), + (_ProxyType, "__imod__"), + (_ProxyType, "__ipow__"), + (_ProxyType, "__irshift__"), + (_ProxyType, "__itruediv__"), + (_types.CoroutineType, "__await__"), + (_types.AsyncGeneratorType, "__aiter__"), + (_types.AsyncGeneratorType, "__anext__"), +] + +for owner, name in SLOTS: + descr = owner.__dict__[name] + assert type(descr).__name__ != "method_descriptor", (owner, name, type(descr)) + +# The two protocol splits, stated as such rather than inferred. +assert type(dict.__dict__["__getitem__"]).__name__ == "method_descriptor" +assert type(list.__dict__["__getitem__"]).__name__ == "method_descriptor" +assert type(tuple.__dict__["__getitem__"]).__name__ != "method_descriptor" +assert type(dict.__dict__["__contains__"]).__name__ == "method_descriptor" +assert type(str.__dict__["__contains__"]).__name__ != "method_descriptor" + + +# `FrameLocalsProxy` is the third type on that split: `framelocalsproxy_methods` +# carries both names, so both are `tp_methods` entries even though the type +# also fills the mapping slots. +def _frame_locals_proxy_type(): + _unused = 1 + import sys + + return type(sys._getframe().f_locals) + + +_FLP = _frame_locals_proxy_type() +for _name in ("__contains__", "__getitem__", "keys", "get"): + _d = _FLP.__dict__[_name] + assert type(_d).__name__ == "method_descriptor", (_name, type(_d)) + assert _d.__qualname__ == f"FrameLocalsProxy.{_name}", _name + assert _d.__objclass__ is _FLP, _name + assert repr(_d) == f"" + + +# Instance access binds to a builtin carrier, not a `method`. +BOUND = [([], "append"), ({}, "get"), ((), "index"), (1, "bit_length"), + ("a", "upper"), (b"a", "hex"), (bytearray(b"a"), "append"), + (set(), "add"), (1.0, "hex")] + +for receiver, name in BOUND: + bound = getattr(receiver, name) + assert type(bound).__name__ == "builtin_function_or_method", (name, type(bound)) + assert type(bound) is type(len), name + assert bound.__self__ is receiver, name + assert bound.__qualname__ == f"{type(receiver).__name__}.{name}", name + assert repr(bound).startswith( + f"" +assert repr(sys.exit) == "" +# ... and an ordinary bound builtin names its receiver's own type. +assert repr([].append).startswith(" list[Path]: return out +def _expects_failure(script: Path) -> bool: + """An `xfail_`-prefixed snippet asserts the runner notices a failure. + + The imported RustPython corpus carries these as harness self-tests; they + pass when the interpreter exits non-zero. + """ + return script.name.startswith("xfail_") + + def _run(cmd: list[str], script: Path, timeout: int) -> tuple[bool, str]: try: proc = subprocess.run( @@ -72,6 +81,10 @@ def _run(cmd: list[str], script: Path, timeout: int) -> tuple[bool, str]: ) except subprocess.TimeoutExpired: return False, "timeout" + if _expects_failure(script): + if proc.returncode != 0: + return True, "" + return False, "rc=0 but the snippet is expected to fail" if proc.returncode == 0: return True, "" err = proc.stderr.strip().splitlines() diff --git a/pyre/extra_tests/snippets/README.md b/pyre/extra_tests/snippets/README.md new file mode 100644 index 00000000000..588a5f48e3c --- /dev/null +++ b/pyre/extra_tests/snippets/README.md @@ -0,0 +1,9 @@ +# snippets fixture file + +The snippets run with this directory as their working directory, and a +handful of them (`builtin_open.py`, `stdlib_io.py`, `stdlib_os.py`, +`stdlib_socket.py`) open `README.md` as a convenient read-only file — they +were imported from RustPython, whose runner started at the repository root. +This file stands in for that one, so the imported sources stay verbatim. + +`builtin_open.py` asserts the text `RustPython` appears here. diff --git a/pyre/extra_tests/snippets/builtin_bytes.py b/pyre/extra_tests/snippets/builtin_bytes.py index 2cb4c317f49..23c6242a75f 100644 --- a/pyre/extra_tests/snippets/builtin_bytes.py +++ b/pyre/extra_tests/snippets/builtin_bytes.py @@ -365,6 +365,35 @@ assert b"hjhtuyjyujuyj".translate(bytes.maketrans(b"hj", b"ab")) == b"abatuybyubuyb" assert b"hjhtuyfjtyhuhjuyj".translate(None, b"ht") == b"juyfjyujuyj" assert b"hjhtuyfjtyhuhjuyj".translate(None, delete=b"ht") == b"juyfjyujuyj" +assert b"hjhtuyfjtyhuhjuyj".translate(None, delete=b"") == b"hjhtuyfjtyhuhjuyj" + + +# `translate(table, /, delete=b'')`: `table` is positional-only, `delete` takes +# either the second positional slot or the keyword, the count check is reported +# before the missing-positional one and that before the keyword one, and an +# explicit `delete` must be bytes-like — `None` is not "no deletion". +NOPOS = "translate() takes at least 1 positional argument (0 given)" +TOOMANY = "translate() takes at most 2 arguments (3 given)" +NOTBYTES = "a bytes-like object is required, not '{}'" +BADKW = "translate() got an unexpected keyword argument '{}'" +for args, kw, msg in ( + ((), {}, NOPOS), + ((), {"table": None, "delete": b"h"}, NOPOS), + ((), {"bogus": 1}, NOPOS), + ((None,), {"bogus": 1}, BADKW.format("bogus")), + ((None,), {"table": None}, BADKW.format("table")), + ((None, b"h", b"t"), {}, TOOMANY), + ((None, b"h"), {"delete": b"t"}, TOOMANY), + ((None, None), {}, NOTBYTES.format("NoneType")), + ((None,), {"delete": None}, NOTBYTES.format("NoneType")), + ((None,), {"delete": "ht"}, NOTBYTES.format("str")), +): + try: + b"abc".translate(*args, **kw) + except TypeError as e: + assert str(e) == msg, (args, kw, str(e)) + else: + raise AssertionError((args, kw)) # strip lstrip rstrip diff --git a/pyre/extra_tests/snippets/iterator_type_identity.py b/pyre/extra_tests/snippets/iterator_type_identity.py new file mode 100644 index 00000000000..eb478121531 --- /dev/null +++ b/pyre/extra_tests/snippets/iterator_type_identity.py @@ -0,0 +1,114 @@ +# Python 3.14 gives str / bytes / bytearray / memoryview iteration its own +# concrete type per producer where PyPy serves all of them from one abstract +# `sequenceiterator`. Pyre keeps PyPy's single payload and gives each producer +# the 3.14 identity, so this pins both the reported type name and the surface +# that type exposes. (These assertions deliberately disagree with PyPy, so they +# cannot live in the synthetic suite, which requires cpython == pypy output.) +import array +import pickle + + +class Seq: + def __getitem__(self, i): + if i > 2: + raise IndexError + return i + + +NAMES = [ + (iter(Seq()), "iterator"), + (iter("abc"), "str_ascii_iterator"), + (iter("aéc"), "str_iterator"), + (iter(b"abc"), "bytes_iterator"), + (iter(bytearray(b"abc")), "bytearray_iterator"), + (iter(memoryview(b"abc")), "memory_iterator"), + (iter(array.array("i", [1, 2, 3])), "arrayiterator"), + (iter([1]), "list_iterator"), + (iter((1,)), "tuple_iterator"), + (iter(range(3)), "range_iterator"), + (reversed([1]), "list_reverseiterator"), +] +for it, name in NAMES: + assert type(it).__name__ == name, (type(it).__name__, name) + +# The split is per storage kind, not per object. +assert type(iter("a")) is type(iter("bb")) +assert type(iter("a")) is not type(iter("é")) +assert type(iter(b"a")) is not type(iter(bytearray(b"a"))) + +# None of them is instantiable or subclassable. +for it, name in NAMES: + try: + type(it)() + raise AssertionError("expected TypeError for " + name) + except TypeError: + pass + +# `memory_iterator` carries the iteration protocol only: no `__length_hint__`, +# no `__setstate__`, and pickling one is refused. +mem = iter(memoryview(b"abc")) +assert sorted(set(dir(type(mem))) - set(dir(object))) == ["__iter__", "__next__"] +assert not hasattr(mem, "__length_hint__") +assert not hasattr(mem, "__setstate__") +try: + mem.__reduce__() + raise AssertionError("expected TypeError pickling memory_iterator") +except TypeError: + pass +assert list(mem) == [97, 98, 99] + +# `arrayiterator` is qualified by its defining module and pickles, but it +# declares no `__length_hint__`. +arr = iter(array.array("i", [1, 2, 3])) +assert type(arr).__module__ == "array" +assert type(arr).__dict__["__module__"] == "array" +assert repr(type(arr)) == "" +assert sorted(set(dir(type(arr))) - set(dir(object))) == [ + "__iter__", + "__module__", + "__next__", + "__setstate__", +] +assert not hasattr(arr, "__length_hint__") + +# Every other flavour keeps the pickle protocol its 3.14 type declares. +for make in ( + lambda: iter("abc"), + lambda: iter("aéc"), + lambda: iter(b"abc"), + lambda: iter(bytearray(b"abc")), + lambda: iter(array.array("i", [1, 2, 3])), + lambda: iter(Seq()), +): + it = make() + next(it) + revived = pickle.loads(pickle.dumps(it)) + assert type(revived) is type(it), make + assert list(revived) == list(make())[1:], make + +# `__length_hint__` reports the remaining count; over a bare `__getitem__` +# sequence with no `__len__` there is nothing to report. +for make, expected in ( + (lambda: iter("abc"), 2), + (lambda: iter("aéc"), 2), + (lambda: iter(b"abc"), 2), + (lambda: iter(bytearray(b"abc")), 2), + (lambda: iter(Seq()), NotImplemented), +): + it = make() + next(it) + assert it.__length_hint__() == expected, (make, it.__length_hint__()) + +# Iterating a str through a hot loop must not collapse the identity back to +# the shared type when the loop is traced. +seen = set() +total = 0 +for _ in range(2000): + it = iter("abc") + seen.add(type(it).__name__) + for ch in it: + total += ord(ch) +assert seen == {"str_ascii_iterator"}, seen +assert total == 2000 * (97 + 98 + 99), total + +print("OK") diff --git a/pyre/extra_tests/snippets/pickle_seqiter.py b/pyre/extra_tests/snippets/pickle_seqiter.py index df1603487c2..28ca3b69c59 100644 --- a/pyre/extra_tests/snippets/pickle_seqiter.py +++ b/pyre/extra_tests/snippets/pickle_seqiter.py @@ -13,5 +13,5 @@ assert iter("abc").__reduce__() == (iter, ("abc",), 0) it3 = iter([9, 8]) it3.__setstate__(-5) -assert list(it3) == [9, 8] +assert list(it3) == [] print("pickle_seqiter OK") diff --git a/pyre/extra_tests/snippets/stdlib_sys.py b/pyre/extra_tests/snippets/stdlib_sys.py index 6090701b275..d8ccffadf92 100644 --- a/pyre/extra_tests/snippets/stdlib_sys.py +++ b/pyre/extra_tests/snippets/stdlib_sys.py @@ -183,8 +183,17 @@ def safe_path_flag(env, *opts): with assert_raises(TypeError): sys.getsizeof("x", 1, 2) +# The default is returned only when `__sizeof__` is missing or raises +# TypeError; an object with a working `__sizeof__` reports its size. default = object() -assert sys.getsizeof(object(), default) is default +assert sys.getsizeof(object(), default) == object().__sizeof__() + + +class NoSizeof: + __sizeof__ = None + + +assert sys.getsizeof(NoSizeof(), default) is default def test_getframemodulename(): diff --git a/pyre/extra_tests/snippets/type_name_bare.py b/pyre/extra_tests/snippets/type_name_bare.py index 9c82629e9e0..c6a334e2161 100644 --- a/pyre/extra_tests/snippets/type_name_bare.py +++ b/pyre/extra_tests/snippets/type_name_bare.py @@ -11,9 +11,10 @@ class Foo: assert Foo.__name__ == "Foo" -# types.UnionType (PEP 604): __name__ strips the module prefix, repr keeps it. +# PEP 604 unions: 3.14 unified `types.UnionType` with `typing.Union`, so the +# type reports the bare `Union` while its repr keeps the module prefix. u = int | str -assert type(u).__name__ == "UnionType" -assert repr(type(u)) == "" +assert type(u).__name__ == "Union" +assert repr(type(u)) == "" print("type_name_bare ok") diff --git a/pyre/pyre-interpreter/src/_structseq.rs b/pyre/pyre-interpreter/src/_structseq.rs index 24eaf4a81da..4de09836c06 100644 --- a/pyre/pyre-interpreter/src/_structseq.rs +++ b/pyre/pyre-interpreter/src/_structseq.rs @@ -428,7 +428,7 @@ fn make_struct_seq_impl( pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__new__", - crate::make_builtin_function("__new__", structseq_descr_new), + crate::typedef::make_new_descr(structseq_descr_new), ) }; unsafe { diff --git a/pyre/pyre-interpreter/src/argument.rs b/pyre/pyre-interpreter/src/argument.rs index 6954218d8f4..9ef65e0e1d9 100644 --- a/pyre/pyre-interpreter/src/argument.rs +++ b/pyre/pyre-interpreter/src/argument.rs @@ -233,15 +233,13 @@ pub fn do_combine_starstarargs_wrapped( std::collections::HashMap::new(); for (i, &w_key) in keys_w.iter().enumerate() { // argument.py:431 — `key = space.text_w(w_key)`; raise TypeError - // if w_key is not a string. argument.py:434-436 message: - // `"keywords must be strings, not '%T'"`. + // if w_key is not a string. `_PyStack_UnpackDict` reports this + // without the callable's qualname or the offending key's type, where + // argument.py:434-436 formats `"keywords must be strings, not '%T'"` + // through `raise_type_error`. let key = unsafe { if !pyre_object::is_str(w_key) { - let tp = type_name_of(w_key); - return Err(raise_type_error( - w_function, - format!("keywords must be strings, not '{tp}'"), - )); + return Err(crate::PyError::type_error("keywords must be strings")); } // `space.text_w` preserves lone surrogates as WTF-8 bytes; keep the // key in WTF-8 so a surrogate keyword name survives the seen-set and diff --git a/pyre/pyre-interpreter/src/baseobjspace.rs b/pyre/pyre-interpreter/src/baseobjspace.rs index 0269f72c224..d578b64ab38 100644 --- a/pyre/pyre-interpreter/src/baseobjspace.rs +++ b/pyre/pyre-interpreter/src/baseobjspace.rs @@ -550,7 +550,7 @@ fn check_class(w_obj: PyObjectRef, msg: &str) -> Result<(), PyError> { /// `w_type` is a real type object: tries the MRO walk via `isinstance_w` /// first, then consults `w_inst.__class__` to honour any custom class /// override. -unsafe fn p_recursive_isinstance_type_w( +pub(crate) unsafe fn p_recursive_isinstance_type_w( w_inst: PyObjectRef, w_type: PyObjectRef, ) -> Result { @@ -736,7 +736,7 @@ pub(crate) fn p_abstract_issubclass_w( /// abstractinst.py:150-169 `p_recursive_issubclass_w`. The both-types /// fast path is the common case; otherwise both arguments are validated /// via `check_class()` before entering the abstract walk. -unsafe fn p_recursive_issubclass_w( +pub(crate) unsafe fn p_recursive_issubclass_w( w_derived: PyObjectRef, w_cls: PyObjectRef, ) -> Result { @@ -1461,9 +1461,17 @@ pub(crate) fn getitem_slot(obj: PyObjectRef, index: PyObjectRef) -> PyResult { /// `pypy/interpreter/baseobjspace.py:1574 getindex_w` — the `TypeError` /// raised when a sequence subscript is neither an integer nor a slice: -/// `" indices must be integers or slices, not ''"` (the `%T` -/// operand names the key's own class). The reference `pypy3` quotes the -/// type name here; a later source tree emits it unquoted. +/// `" indices must be integers or slices, not "` (the `%T` +/// operand names the key's own class). The reference `pypy3` quotes the type +/// name here; 3.14 emits it unquoted, and only the `str` wording +/// ([`string_index_type_error`]) keeps the quotes. +/// +/// `getindex_w` also catches a `TypeError` raised *while* coercing the key +/// through `__index__` and rewrites it to this message. 3.14 does not: a key +/// that has an `__index__` at all surfaces whatever that `__index__` raised +/// ("__index__ returned non-int (type float)"), and this wording is reserved +/// for a key with no `__index__` to try. Every subscript path therefore +/// tests for the slot up front and lets `space_index`'s error propagate. fn index_type_error(descr: &str, index: PyObjectRef) -> PyError { let tp = if index.is_null() { "NULL".to_string() @@ -1471,13 +1479,13 @@ fn index_type_error(descr: &str, index: PyObjectRef) -> PyError { object_functionstr_type_name(index) }; PyError::type_error(format!( - "{descr} indices must be integers or slices, not '{tp}'" + "{descr} indices must be integers or slices, not {tp}" )) } /// Python 3.14 string-subscript wording. Unlike this PyPy source's generic /// `getindex_w(..., "string")` remap, 3.14 omits "or slices" after the slice -/// case has already been handled and preserves errors raised by `__index__`. +/// case has already been handled, and keeps the type name quoted. fn string_index_type_error(index: PyObjectRef) -> PyError { let tp = if index.is_null() { "NULL".to_string() @@ -1487,21 +1495,6 @@ fn string_index_type_error(index: PyObjectRef) -> PyError { PyError::type_error(format!("string indices must be integers, not '{tp}'")) } -/// `getindex_w` remaps a `TypeError` raised while coercing a subscript key -/// through `__index__` — a non-int `__index__` return, or a `TypeError` from -/// `__index__` itself — to the sequence-specific "indices must be integers or -/// slices" message (`baseobjspace.py:1574` catches `space.index`'s error when -/// `objdescr` is set). Any other error (e.g. a `ValueError` from `__index__`) -/// propagates unchanged. Only the subscript paths pass an `objdescr`; -/// `list.insert` / `list.pop` do not, and surface `space.index`'s error verbatim. -fn remap_getindex_type_error(err: PyError, descr: &str, index: PyObjectRef) -> PyError { - if err.kind == PyErrorKind::TypeError { - index_type_error(descr, index) - } else { - err - } -} - #[inline(never)] unsafe fn getitem_list(obj: PyObjectRef, index: PyObjectRef) -> PyResult { if is_slice(index) { @@ -1534,10 +1527,7 @@ unsafe fn getitem_list(obj: PyObjectRef, index: PyObjectRef) -> PyResult { let idx = if is_int(index) { w_int_get_value(index) } else if pyre_object::pyobject::is_int_or_long(index) || lookup(index, "__index__").is_some() { - let indexed = match space_index(index) { - Ok(w) => w, - Err(e) => return Err(remap_getindex_type_error(e, "list", index)), - }; + let indexed = space_index(index)?; if is_int(indexed) { w_int_get_value(indexed) } else { @@ -1596,10 +1586,7 @@ unsafe fn getitem_tuple(obj: PyObjectRef, index: PyObjectRef) -> PyResult { let idx = if is_int(index) { w_int_get_value(index) } else if pyre_object::pyobject::is_int_or_long(index) || lookup(index, "__index__").is_some() { - let indexed = match space_index(index) { - Ok(w) => w, - Err(e) => return Err(remap_getindex_type_error(e, "tuple", index)), - }; + let indexed = space_index(index)?; if is_int(indexed) { w_int_get_value(indexed) } else { @@ -1769,13 +1756,7 @@ unsafe fn getitem_bytes_like(obj: PyObjectRef, index: PyObjectRef) -> PyResult { let idx = if is_int(index) { w_int_get_value(index) } else if pyre_object::pyobject::is_int_or_long(index) || lookup(index, "__index__").is_some() { - let indexed = match space_index(index) { - Ok(w) => w, - Err(e) => { - let descr = if is_bytes { "byte" } else { "bytearray" }; - return Err(remap_getindex_type_error(e, descr, index)); - } - }; + let indexed = space_index(index)?; if is_int(indexed) { w_int_get_value(indexed) } else { @@ -3469,10 +3450,7 @@ unsafe fn setitem_list(obj: PyObjectRef, index: PyObjectRef, value: PyObjectRef) let idx = if is_int(index) { w_int_get_value(index) } else if pyre_object::pyobject::is_int_or_long(index) || lookup(index, "__index__").is_some() { - let indexed = match space_index(index) { - Ok(w) => w, - Err(e) => return Err(remap_getindex_type_error(e, "list", index)), - }; + let indexed = space_index(index)?; if is_int(indexed) { w_int_get_value(indexed) } else { @@ -3596,10 +3574,7 @@ unsafe fn subscript_index_w(descr: &str, index: PyObjectRef) -> Result w, - Err(e) => return Err(remap_getindex_type_error(e, descr, index)), - }; + let indexed = space_index(index)?; match int_w(indexed) { Ok(i) => Ok(i), // `baseobjspace.py getindex_w` — an index that overflows a machine @@ -3616,6 +3591,26 @@ unsafe fn subscript_index_w(descr: &str, index: PyObjectRef) -> Result Result { + let w_count = space_index(w_obj)?; + match int_w(w_count) { + Ok(_) => Ok(w_count), + Err(e) if e.kind == PyErrorKind::OverflowError => Err(PyError::new( + PyErrorKind::OverflowError, + format!("cannot fit '{}' into an index-sized integer", unsafe { + object_functionstr_type_name(w_obj) + }), + )), + Err(e) => Err(e), + } +} + /// `getindex_w(index, OverflowError)` with `objdescr=None` — the variant /// reached through the `@unwrap_spec(index='index')` of `list.insert` / /// `list.pop`. With no `objdescr`, `space.index`'s own error for a non-index @@ -3653,10 +3648,7 @@ unsafe fn setitem_bytearray(obj: PyObjectRef, index: PyObjectRef, value: PyObjec let idx = if is_int(index) { w_int_get_value(index) } else if pyre_object::pyobject::is_int_or_long(index) || lookup(index, "__index__").is_some() { - let indexed = match space_index(index) { - Ok(w) => w, - Err(e) => return Err(remap_getindex_type_error(e, "bytearray", index)), - }; + let indexed = space_index(index)?; if is_int(indexed) { w_int_get_value(indexed) } else { @@ -5124,9 +5116,22 @@ fn getattr_str_impl(obj: PyObjectRef, name: &str, call_getattr: bool) -> PyResul } // Per-iterator-type pickle protocol: `__reduce__` / // `__setstate__` / `__length_hint__` recreate the iterator's - // CPython 3.14 pickle shape. `arity` includes `self`. + // Python 3.14 pickle shape. `arity` includes `self`. The + // producer-specific seq-iter identities do not all declare the + // full trio: `memory_iterator` declares none of it and + // `arrayiterator` omits `__length_hint__`, so the shared payload's + // accessors stay hidden for those. let entry: Option<(fn(&[PyObjectRef]) -> PyResult, &str, u16)> = if is_seq_iter(obj) { + let undeclared: &[&str] = if unsafe { pyre_object::iterobject::is_memory_iter(obj) } + { + &["__reduce__", "__setstate__", "__length_hint__"] + } else if unsafe { pyre_object::iterobject::is_array_iter(obj) } { + &["__length_hint__"] + } else { + &[] + }; match name { + _ if undeclared.contains(&name) => None, "__reduce__" => Some((seq_iter_reduce_method, "__reduce__", 1)), "__setstate__" => Some((seq_iter_setstate_method, "__setstate__", 2)), "__length_hint__" => Some((seq_iter_length_hint_method, "__length_hint__", 1)), @@ -6409,7 +6414,7 @@ pub(crate) fn type_del_annotations(obj: PyObjectRef) -> PyResult { let removed_explicit = crate::type_dict_delete(obj, "__annotations__"); let removed = removed_cache || removed_explicit; if !removed { - return Err(raiseattrerror(obj, "__annotations__", None)); + return Err(raiseattrerror(obj, "__annotations__", None, true)); } crate::type_dict_store(obj, "__annotate_func__", w_none()); crate::type_dict_delete(obj, "__annotate__"); @@ -6424,10 +6429,7 @@ pub(crate) fn type_del_annotations(obj: PyObjectRef) -> PyResult { pub(crate) fn type_get_doc(obj: PyObjectRef) -> PyResult { unsafe { if std::ptr::eq(obj, crate::typedef::w_type()) { - return Ok(w_str_new( - "type(object) -> the object's type\n\ - type(name, bases, dict, **kwds) -> a new type", - )); + return Ok(w_str_new(crate::typedef::TYPE_DOC)); } if std::ptr::eq( obj, @@ -6482,6 +6484,381 @@ pub(crate) fn type_del_doc(obj: PyObjectRef) -> PyResult { ))) } +/// The `W_BaseException` typedef's attribute reads, shared by the +/// per-class `GetSetProperty` descriptors and the instance-attribute miss +/// path. `PY_NULL` means the name is not one this exception kind +/// declares, so the caller continues its own lookup. +pub(crate) fn exception_attr_get(obj: PyObjectRef, name: &str) -> PyResult { + match name { + "__traceback__" => { + // `interp_exceptions.py:196-201 W_BaseException.descr_gettraceback` + // returns the `w_traceback` slot stamped by + // `descr_settraceback` and the `raise` machinery's + // `record_application_traceback`; `None` when none has + // been set. The traceback reaches app level here, so its + // frame is marked escaped. + let stored = unsafe { pyre_object::interp_exceptions::w_exception_get_traceback(obj) }; + unsafe { crate::pytraceback::mark_traceback_escaped(stored) }; + return Ok(if stored.is_null() { w_none() } else { stored }); + } + "__cause__" => { + // `interp_exceptions.py:163-164 descr_getcause`. + let stored = unsafe { pyre_object::interp_exceptions::w_exception_get_cause(obj) }; + return Ok(if stored.is_null() { w_none() } else { stored }); + } + "__context__" => { + // `interp_exceptions.py:180-181 descr_getcontext`. + let stored = unsafe { pyre_object::interp_exceptions::w_exception_get_context(obj) }; + return Ok(if stored.is_null() { w_none() } else { stored }); + } + "__suppress_context__" => { + // `interp_exceptions.py:212-213 descr_getsuppresscontext` + // returns `space.newbool(self.suppress_context)`. + // Defaults to False per `:117 W_BaseException` class + // default; `descr_setcause` flips to True. + let b = + unsafe { pyre_object::interp_exceptions::w_exception_get_suppress_context(obj) }; + return Ok(pyre_object::w_bool_from(b)); + } + "args" => { + // `pypy/module/exceptions/interp_exceptions.py:153 + // W_BaseException.descr_getargs` returns + // `space.newtuple(self.args_w)` — a freshly-built + // tuple per call. `w_exception_get_args` does the + // same: it walks the internal list slot and rebuilds + // a `W_TupleObject`, returning the empty tuple when + // the slot was never stamped. + return Ok(unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }); + } + "message" | "exceptions" => { + if let Some(base_group) = crate::builtins::lookup_exc_class("BaseExceptionGroup") + && isinstance(obj, base_group)? + { + let w_dict = unsafe { pyre_object::interp_exceptions::w_exception_getdict(obj) }; + let key = if name == "message" { + "__pyre_exception_group_message" + } else { + "__pyre_exception_group_exceptions" + }; + if let Some(value) = unsafe { pyre_object::w_dict_getitem_str(w_dict, key) } { + return Ok(value); + } + } + } + "value" => { + // `pypy/module/exceptions/interp_exceptions.py + // W_StopIteration.descr_init` stores `value = w_args[0]`, + // exposed as `fget_value`. `generator_send_ex` stamps + // the generator's return value into the exception's + // `args` tuple; mirror PyPy by returning `args[0]` and + // defaulting to `None`. Only StopIteration uses this + // attribute — other exception kinds keep the regular + // attribute lookup fall-through. + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if kind == pyre_object::interp_exceptions::ExcKind::StopIteration { + // `readwrite_attrproperty_w('w_value')` is a slot of its + // own, so an explicit `e.value = x` wins over the + // constructor-time `args_w[0]`. Pyre keeps no dedicated + // slot and lands that write in the hasdict instance dict, + // so read it first — the same shape as + // `syntax_error_attr`. + let w_dict = getdict_backing_native(obj); + if !w_dict.is_null() { + if let Some(v) = unsafe { pyre_object::w_dict_getitem_str(w_dict, "value") } { + return Ok(v); + } + } + let args_tuple = + unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }; + // `w_exception_get_args` always returns a real + // tuple — empty tuple when `args_w` was never + // stamped — so the null-check above is unneeded. + let len = unsafe { pyre_object::w_tuple_len(args_tuple) }; + if len > 0 { + if let Some(v) = unsafe { pyre_object::w_tuple_getitem(args_tuple, 0) } { + return Ok(v); + } + } + return Ok(w_none()); + } + } + "code" => { + // `interp_exceptions.py:986-1006 W_SystemExit`: `code` is a + // writable `readwrite_attrproperty_w('w_code')` slot + // (`:1006`) set by `descr_init` to `args_w[0]` for a single + // argument, `newtuple(args_w)` for several, and the + // `__init__` default `None` when the instance carries no + // arguments. Read the slot first so an explicit + // `e.code = x` write persists, then derive from `args_w` + // (the internal-constructor path that bypasses the public + // setter), mirroring the OSError `errno` arm. + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if kind == pyre_object::interp_exceptions::ExcKind::SystemExit { + let stored = unsafe { pyre_object::interp_exceptions::w_exception_get_code(obj) }; + if !stored.is_null() { + return Ok(stored); + } + let args = unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }; + let len = unsafe { pyre_object::w_tuple_len(args) }; + if len == 1 { + if let Some(v) = unsafe { pyre_object::w_tuple_getitem(args, 0) } { + return Ok(v); + } + } else if len > 1 { + return Ok(args); + } + return Ok(w_none()); + } + } + // `interp_exceptions.py:739-742 W_OSError` exposes + // `errno` / `strerror` / `filename` / `filename2` as + // `readwrite_attrproperty_w('w_errno', ...)` slots, populated + // by the 2..=5-argument constructor (`errno = args[0]`, + // `strerror = args[1]`, `filename = args[2]`, + // `filename2 = args[4]`). Read the writable slot first so a + // `e.errno = ...` assignment (`object_setattr`) persists; when + // the slot is `PY_NULL` (the internal-constructor path that + // never goes through the public setter) fall back to deriving + // the value from `args_w` with the same argument-count gate. + // Fewer than two arguments leaves all four `None` (the class + // defaults). + "errno" | "strerror" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::OSError + | pyre_object::interp_exceptions::ExcKind::FileNotFoundError + ) { + let stored = if name == "errno" { + unsafe { pyre_object::interp_exceptions::w_exception_get_errno(obj) } + } else { + unsafe { pyre_object::interp_exceptions::w_exception_get_strerror(obj) } + }; + if !stored.is_null() { + return Ok(stored); + } + let args = unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }; + let n = unsafe { pyre_object::w_tuple_len(args) }; + if (2..=5).contains(&n) { + let idx = if name == "errno" { 0 } else { 1 }; + if let Some(v) = unsafe { pyre_object::w_tuple_getitem(args, idx) } { + return Ok(v); + } + } + return Ok(w_none()); + } + } + "filename" | "filename2" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::OSError + | pyre_object::interp_exceptions::ExcKind::FileNotFoundError + ) { + let stored = if name == "filename" { + unsafe { pyre_object::interp_exceptions::w_exception_get_filename(obj) } + } else { + unsafe { pyre_object::interp_exceptions::w_exception_get_filename2(obj) } + }; + if !stored.is_null() { + return Ok(stored); + } + // A `BlockingIOError` keeps `characters_written` (a number) + // in `args_w[2]`; it is not a filename (`_init_error`). + if name == "filename" && exc_blocking_written(obj) { + return Ok(w_none()); + } + let args = unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }; + let n = unsafe { pyre_object::w_tuple_len(args) }; + let idx: usize = if name == "filename" { 2 } else { 4 }; + if (3..=5).contains(&n) && idx < n { + if let Some(v) = unsafe { pyre_object::w_tuple_getitem(args, idx as i64) } { + return Ok(v); + } + } + return Ok(w_none()); + } + // `W_SyntaxError` also exposes `filename`, derived from its + // `(filename, lineno, ...)` details tuple (`filename2` is OSError-only). + if kind == pyre_object::interp_exceptions::ExcKind::SyntaxError && name == "filename" { + return Ok(syntax_error_attr(obj, name)); + } + } + // `interp_exceptions.py:704-707 descr_get_written` — a + // `BlockingIOError` constructed with a numeric third argument keeps + // it in `args_w[2]` as `characters_written`; otherwise the slot is + // unset (`written == -1`) and the attribute raises `AttributeError`. + "characters_written" if exc_blocking_written(obj) => { + let args = unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }; + if let Some(v) = unsafe { pyre_object::w_tuple_getitem(args, 2) } { + return Ok(v); + } + } + // `interp_exceptions.py:409-411 W_ImportError` exposes + // `msg` / `path` / `name_from` as `readwrite_attrproperty_w` + // slots stamped by `descr_init` from the keyword/positional + // arguments, with class default `None` (`:360`). Each is a + // plain slot read: an instance allocated via `__new__` (which + // never touches the slot) reads `None`. Gated on the + // ImportError-family kind (ImportError / ModuleNotFoundError). + // `name` is handled by the shared arm below since NameError / + // AttributeError expose it too. + "msg" | "path" | "name_from" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::ImportError + | pyre_object::interp_exceptions::ExcKind::ModuleNotFoundError + ) { + let stored = unsafe { + match name { + "msg" => pyre_object::interp_exceptions::w_exception_get_import_msg(obj), + "path" => pyre_object::interp_exceptions::w_exception_get_import_path(obj), + _ => pyre_object::interp_exceptions::w_exception_get_import_name_from(obj), + } + }; + if !stored.is_null() { + return Ok(stored); + } + return Ok(w_none()); + } + // `W_SyntaxError.msg` — the first constructor argument. + if kind == pyre_object::interp_exceptions::ExcKind::SyntaxError && name == "msg" { + return Ok(syntax_error_attr(obj, name)); + } + } + // Shared `name` attribute for the kinds that expose it — + // `W_ImportError` (and `W_ModuleNotFoundError`), `W_NameError` + // (and `W_UnboundLocalError`, which subclasses it and so inherits + // the descriptor), and `W_AttributeError` (Python 3.10+). Read + // from the shared `w_exc_name` slot (default `None`); falls + // through to normal attribute lookup on every other exception + // kind. + "name" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::ImportError + | pyre_object::interp_exceptions::ExcKind::ModuleNotFoundError + | pyre_object::interp_exceptions::ExcKind::NameError + | pyre_object::interp_exceptions::ExcKind::UnboundLocalError + | pyre_object::interp_exceptions::ExcKind::AttributeError + ) { + let stored = unsafe { pyre_object::interp_exceptions::w_exception_get_name(obj) }; + if !stored.is_null() { + return Ok(stored); + } + return Ok(w_none()); + } + } + // `W_AttributeError.obj` (Python 3.10+) — the object whose + // attribute lookup failed; default `None`. + "obj" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if kind == pyre_object::interp_exceptions::ExcKind::AttributeError { + let stored = + unsafe { pyre_object::interp_exceptions::w_exception_get_attr_obj(obj) }; + if !stored.is_null() { + return Ok(stored); + } + return Ok(w_none()); + } + } + // `interp_exceptions.py:468-471` + // `readwrite_attrproperty_w('w_object', W_UnicodeTranslateError)` + // (and `:1081-1083` / `:1201-1203` for Decode / Encode). + // PyPy surfaces these as direct slot reads — `None` when the + // exception was constructed without going through + // `descr_init`. Pyre stores `PY_NULL` in that case and + // resolves to `space.w_None` here, matching PyPy's + // class-default `w_object = None`. + // + // Gated on the three Unicode*Error kinds because PyPy + // attaches these `attrproperty_w` descriptors only on + // those typedefs — other exception kinds keep the regular + // attribute lookup fall-through. + "object" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError + | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + ) { + let stored = unsafe { pyre_object::interp_exceptions::w_exception_get_object(obj) }; + return Ok(if stored.is_null() { w_none() } else { stored }); + } + } + "start" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError + | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + ) { + let stored = unsafe { pyre_object::interp_exceptions::w_exception_get_start(obj) }; + return Ok(if stored.is_null() { w_none() } else { stored }); + } + } + "end" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError + | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + ) { + let stored = unsafe { pyre_object::interp_exceptions::w_exception_get_end(obj) }; + return Ok(if stored.is_null() { w_none() } else { stored }); + } + } + "reason" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError + | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + ) { + let stored = unsafe { pyre_object::interp_exceptions::w_exception_get_reason(obj) }; + return Ok(if stored.is_null() { w_none() } else { stored }); + } + } + "encoding" => { + // `interp_exceptions.py:1080 W_UnicodeDecodeError.encoding` + // / `:1200 W_UnicodeEncodeError.encoding`. Python 3.14 + // declares `encoding` on `UnicodeError` itself, so a + // `UnicodeTranslateError` — which never stamps the slot — + // reports `None` rather than raising; PyPy's typedef + // (`:461-471`) omits the attrproperty there. + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError + ) { + let stored = + unsafe { pyre_object::interp_exceptions::w_exception_get_encoding(obj) }; + return Ok(if stored.is_null() { w_none() } else { stored }); + } + } + // `W_SyntaxError` location attributes, derived from the + // `(filename, lineno, offset, text[, end_lineno, end_offset])` + // details tuple; `print_file_and_line` is a vestigial slot. + // `filename` / `msg` are handled by the shared arms above. + "lineno" | "offset" | "text" | "end_lineno" | "end_offset" | "print_file_and_line" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if kind == pyre_object::interp_exceptions::ExcKind::SyntaxError { + return Ok(syntax_error_attr(obj, name)); + } + } + _ => {} + } + Ok(pyre_object::PY_NULL) +} + fn object_getattr_miss(obj: PyObjectRef, name: &str, call_getattr: bool) -> PyResult { if name == "__dict__" && unsafe { is_module(obj) } { let dict = unsafe { pyre_object::w_module_get_w_dict(obj) }; @@ -6868,7 +7245,17 @@ fn object_getattr_miss(obj: PyObjectRef, name: &str, call_getattr: bool) -> PyRe return Ok(value); } if let Some(descr) = w_descr { - if crate::is_function(descr) { + // A plain Python function is the one callable `get` leaves + // unhandled; bind it here. A builtin-code carrier is not: + // a `method_descriptor` binds to a + // `builtin_function_or_method` and a `BuiltinFunction` + // class attribute (`class T(tuple): f = len`) stays + // unbound, both of which `get` decides. + if crate::is_function(descr) + && !crate::is_builtin_code( + crate::function_get_code(descr) as pyre_object::PyObjectRef + ) + { return Ok(pyre_object::w_method_new(descr, obj, w_type.as_ptr())); } match get(descr, obj, w_type.as_ptr()) { @@ -6947,7 +7334,14 @@ fn object_getattr_miss(obj: PyObjectRef, name: &str, call_getattr: bool) -> PyRe // typed slots, function/code attributes, the generic type-dict // path) — the terminal `__getattr__` hook runs at the final miss. } else if let Some(method) = unsafe { lookup_in_type_where(w_type.as_ptr(), name) } { - if unsafe { crate::is_function(method) } { + // Same split as the type-dict arm above: only a plain Python + // function binds here, every builtin-code carrier goes to `get`. + if unsafe { + crate::is_function(method) + && !crate::is_builtin_code( + crate::function_get_code(method) as pyre_object::PyObjectRef + ) + } { return Ok(pyre_object::w_method_new(method, obj, w_type.as_ptr())); } if let Some(result) = unsafe { get(method, obj, w_type.as_ptr())? } { @@ -7122,392 +7516,22 @@ fn object_getattr_miss(obj: PyObjectRef, name: &str, call_getattr: bool) -> PyRe if name == "__doc__" || name == "__module__" || name == "__annotations__" { // baseobjspace.py:46-50 W_Root.getdictvalue — consult the // instance dict (exception `w_dict` slot, hasdict objects). - if let Some(value) = getdictvalue(obj, name)? { - return Ok(value); - } - // `__module__` is not a universal attribute: an object whose - // type-MRO carries no `__module__` (e.g. a builtin instance like - // `(0).__module__`) raises AttributeError rather than reporting - // None. `__doc__`/`__annotations__` keep the None default. - if name != "__module__" { - return Ok(w_none()); - } - } - // Exception attributes — PyPy: W_BaseException attributes - if unsafe { pyre_object::is_exception(obj) } { - match name { - "__traceback__" => { - // `interp_exceptions.py:196-201 W_BaseException.descr_gettraceback` - // returns the `w_traceback` slot stamped by - // `descr_settraceback` and the `raise` machinery's - // `record_application_traceback`; `None` when none has - // been set. The traceback reaches app level here, so its - // frame is marked escaped. - let stored = - unsafe { pyre_object::interp_exceptions::w_exception_get_traceback(obj) }; - unsafe { crate::pytraceback::mark_traceback_escaped(stored) }; - return Ok(if stored.is_null() { w_none() } else { stored }); - } - "__cause__" => { - // `interp_exceptions.py:163-164 descr_getcause`. - let stored = unsafe { pyre_object::interp_exceptions::w_exception_get_cause(obj) }; - return Ok(if stored.is_null() { w_none() } else { stored }); - } - "__context__" => { - // `interp_exceptions.py:180-181 descr_getcontext`. - let stored = - unsafe { pyre_object::interp_exceptions::w_exception_get_context(obj) }; - return Ok(if stored.is_null() { w_none() } else { stored }); - } - "__suppress_context__" => { - // `interp_exceptions.py:212-213 descr_getsuppresscontext` - // returns `space.newbool(self.suppress_context)`. - // Defaults to False per `:117 W_BaseException` class - // default; `descr_setcause` flips to True. - let b = unsafe { - pyre_object::interp_exceptions::w_exception_get_suppress_context(obj) - }; - return Ok(pyre_object::w_bool_from(b)); - } - "args" => { - // `pypy/module/exceptions/interp_exceptions.py:153 - // W_BaseException.descr_getargs` returns - // `space.newtuple(self.args_w)` — a freshly-built - // tuple per call. `w_exception_get_args` does the - // same: it walks the internal list slot and rebuilds - // a `W_TupleObject`, returning the empty tuple when - // the slot was never stamped. - return Ok(unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }); - } - "message" | "exceptions" => { - if let Some(base_group) = crate::builtins::lookup_exc_class("BaseExceptionGroup") - && isinstance(obj, base_group)? - { - let w_dict = - unsafe { pyre_object::interp_exceptions::w_exception_getdict(obj) }; - let key = if name == "message" { - "__pyre_exception_group_message" - } else { - "__pyre_exception_group_exceptions" - }; - if let Some(value) = unsafe { pyre_object::w_dict_getitem_str(w_dict, key) } { - return Ok(value); - } - } - } - "value" => { - // `pypy/module/exceptions/interp_exceptions.py - // W_StopIteration.descr_init` stores `value = w_args[0]`, - // exposed as `fget_value`. `generator_send_ex` stamps - // the generator's return value into the exception's - // `args` tuple; mirror PyPy by returning `args[0]` and - // defaulting to `None`. Only StopIteration uses this - // attribute — other exception kinds keep the regular - // attribute lookup fall-through. - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if kind == pyre_object::interp_exceptions::ExcKind::StopIteration { - let args_tuple = - unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }; - // `w_exception_get_args` always returns a real - // tuple — empty tuple when `args_w` was never - // stamped — so the null-check above is unneeded. - let len = unsafe { pyre_object::w_tuple_len(args_tuple) }; - if len > 0 { - if let Some(v) = unsafe { pyre_object::w_tuple_getitem(args_tuple, 0) } { - return Ok(v); - } - } - return Ok(w_none()); - } - } - "code" => { - // `interp_exceptions.py:986-1006 W_SystemExit`: `code` is a - // writable `readwrite_attrproperty_w('w_code')` slot - // (`:1006`) set by `descr_init` to `args_w[0]` for a single - // argument, `newtuple(args_w)` for several, and the - // `__init__` default `None` when the instance carries no - // arguments. Read the slot first so an explicit - // `e.code = x` write persists, then derive from `args_w` - // (the internal-constructor path that bypasses the public - // setter), mirroring the OSError `errno` arm. - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if kind == pyre_object::interp_exceptions::ExcKind::SystemExit { - let stored = - unsafe { pyre_object::interp_exceptions::w_exception_get_code(obj) }; - if !stored.is_null() { - return Ok(stored); - } - let args = unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }; - let len = unsafe { pyre_object::w_tuple_len(args) }; - if len == 1 { - if let Some(v) = unsafe { pyre_object::w_tuple_getitem(args, 0) } { - return Ok(v); - } - } else if len > 1 { - return Ok(args); - } - return Ok(w_none()); - } - } - // `interp_exceptions.py:739-742 W_OSError` exposes - // `errno` / `strerror` / `filename` / `filename2` as - // `readwrite_attrproperty_w('w_errno', ...)` slots, populated - // by the 2..=5-argument constructor (`errno = args[0]`, - // `strerror = args[1]`, `filename = args[2]`, - // `filename2 = args[4]`). Read the writable slot first so a - // `e.errno = ...` assignment (`object_setattr`) persists; when - // the slot is `PY_NULL` (the internal-constructor path that - // never goes through the public setter) fall back to deriving - // the value from `args_w` with the same argument-count gate. - // Fewer than two arguments leaves all four `None` (the class - // defaults). - "errno" | "strerror" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::OSError - | pyre_object::interp_exceptions::ExcKind::FileNotFoundError - ) { - let stored = if name == "errno" { - unsafe { pyre_object::interp_exceptions::w_exception_get_errno(obj) } - } else { - unsafe { pyre_object::interp_exceptions::w_exception_get_strerror(obj) } - }; - if !stored.is_null() { - return Ok(stored); - } - let args = unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }; - let n = unsafe { pyre_object::w_tuple_len(args) }; - if (2..=5).contains(&n) { - let idx = if name == "errno" { 0 } else { 1 }; - if let Some(v) = unsafe { pyre_object::w_tuple_getitem(args, idx) } { - return Ok(v); - } - } - return Ok(w_none()); - } - } - "filename" | "filename2" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::OSError - | pyre_object::interp_exceptions::ExcKind::FileNotFoundError - ) { - let stored = if name == "filename" { - unsafe { pyre_object::interp_exceptions::w_exception_get_filename(obj) } - } else { - unsafe { pyre_object::interp_exceptions::w_exception_get_filename2(obj) } - }; - if !stored.is_null() { - return Ok(stored); - } - // A `BlockingIOError` keeps `characters_written` (a number) - // in `args_w[2]`; it is not a filename (`_init_error`). - if name == "filename" && exc_blocking_written(obj) { - return Ok(w_none()); - } - let args = unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }; - let n = unsafe { pyre_object::w_tuple_len(args) }; - let idx: usize = if name == "filename" { 2 } else { 4 }; - if (3..=5).contains(&n) && idx < n { - if let Some(v) = unsafe { pyre_object::w_tuple_getitem(args, idx as i64) } { - return Ok(v); - } - } - return Ok(w_none()); - } - // `W_SyntaxError` also exposes `filename`, derived from its - // `(filename, lineno, ...)` details tuple (`filename2` is OSError-only). - if kind == pyre_object::interp_exceptions::ExcKind::SyntaxError - && name == "filename" - { - return Ok(syntax_error_attr(obj, name)); - } - } - // `interp_exceptions.py:704-707 descr_get_written` — a - // `BlockingIOError` constructed with a numeric third argument keeps - // it in `args_w[2]` as `characters_written`; otherwise the slot is - // unset (`written == -1`) and the attribute raises `AttributeError`. - "characters_written" if exc_blocking_written(obj) => { - let args = unsafe { pyre_object::interp_exceptions::w_exception_get_args(obj) }; - if let Some(v) = unsafe { pyre_object::w_tuple_getitem(args, 2) } { - return Ok(v); - } - } - // `interp_exceptions.py:409-411 W_ImportError` exposes - // `msg` / `path` / `name_from` as `readwrite_attrproperty_w` - // slots stamped by `descr_init` from the keyword/positional - // arguments, with class default `None` (`:360`). Each is a - // plain slot read: an instance allocated via `__new__` (which - // never touches the slot) reads `None`. Gated on the - // ImportError-family kind (ImportError / ModuleNotFoundError). - // `name` is handled by the shared arm below since NameError / - // AttributeError expose it too. - "msg" | "path" | "name_from" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::ImportError - | pyre_object::interp_exceptions::ExcKind::ModuleNotFoundError - ) { - let stored = unsafe { - match name { - "msg" => { - pyre_object::interp_exceptions::w_exception_get_import_msg(obj) - } - "path" => { - pyre_object::interp_exceptions::w_exception_get_import_path(obj) - } - _ => pyre_object::interp_exceptions::w_exception_get_import_name_from( - obj, - ), - } - }; - if !stored.is_null() { - return Ok(stored); - } - return Ok(w_none()); - } - // `W_SyntaxError.msg` — the first constructor argument. - if kind == pyre_object::interp_exceptions::ExcKind::SyntaxError && name == "msg" { - return Ok(syntax_error_attr(obj, name)); - } - } - // Shared `name` attribute for the kinds that expose it — - // `W_ImportError` (and `W_ModuleNotFoundError`), `W_NameError` - // (and `W_UnboundLocalError`, which subclasses it and so inherits - // the descriptor), and `W_AttributeError` (Python 3.10+). Read - // from the shared `w_exc_name` slot (default `None`); falls - // through to normal attribute lookup on every other exception - // kind. - "name" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::ImportError - | pyre_object::interp_exceptions::ExcKind::ModuleNotFoundError - | pyre_object::interp_exceptions::ExcKind::NameError - | pyre_object::interp_exceptions::ExcKind::UnboundLocalError - | pyre_object::interp_exceptions::ExcKind::AttributeError - ) { - let stored = - unsafe { pyre_object::interp_exceptions::w_exception_get_name(obj) }; - if !stored.is_null() { - return Ok(stored); - } - return Ok(w_none()); - } - } - // `W_AttributeError.obj` (Python 3.10+) — the object whose - // attribute lookup failed; default `None`. - "obj" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if kind == pyre_object::interp_exceptions::ExcKind::AttributeError { - let stored = - unsafe { pyre_object::interp_exceptions::w_exception_get_attr_obj(obj) }; - if !stored.is_null() { - return Ok(stored); - } - return Ok(w_none()); - } - } - // `interp_exceptions.py:468-471` - // `readwrite_attrproperty_w('w_object', W_UnicodeTranslateError)` - // (and `:1081-1083` / `:1201-1203` for Decode / Encode). - // PyPy surfaces these as direct slot reads — `None` when the - // exception was constructed without going through - // `descr_init`. Pyre stores `PY_NULL` in that case and - // resolves to `space.w_None` here, matching PyPy's - // class-default `w_object = None`. - // - // Gated on the three Unicode*Error kinds because PyPy - // attaches these `attrproperty_w` descriptors only on - // those typedefs — other exception kinds keep the regular - // attribute lookup fall-through. - "object" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError - | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError - | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError - ) { - let stored = - unsafe { pyre_object::interp_exceptions::w_exception_get_object(obj) }; - return Ok(if stored.is_null() { w_none() } else { stored }); - } - } - "start" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError - | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError - | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError - ) { - let stored = - unsafe { pyre_object::interp_exceptions::w_exception_get_start(obj) }; - return Ok(if stored.is_null() { w_none() } else { stored }); - } - } - "end" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError - | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError - | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError - ) { - let stored = - unsafe { pyre_object::interp_exceptions::w_exception_get_end(obj) }; - return Ok(if stored.is_null() { w_none() } else { stored }); - } - } - "reason" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError - | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError - | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError - ) { - let stored = - unsafe { pyre_object::interp_exceptions::w_exception_get_reason(obj) }; - return Ok(if stored.is_null() { w_none() } else { stored }); - } - } - "encoding" => { - // `interp_exceptions.py:1080 W_UnicodeDecodeError.encoding` - // / `:1200 W_UnicodeEncodeError.encoding`. - // `W_UnicodeTranslateError` has no encoding property per - // PyPy; the kind check here excludes Translate so - // attribute lookup on `UnicodeTranslateError().encoding` - // falls through to the generic AttributeError, matching - // `interp_exceptions.py:461-471 typedef` (no `encoding` - // attrproperty). - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError - | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError - ) { - let stored = - unsafe { pyre_object::interp_exceptions::w_exception_get_encoding(obj) }; - return Ok(if stored.is_null() { w_none() } else { stored }); - } - } - // `W_SyntaxError` location attributes, derived from the - // `(filename, lineno, offset, text[, end_lineno, end_offset])` - // details tuple; `print_file_and_line` is a vestigial slot. - // `filename` / `msg` are handled by the shared arms above. - "lineno" | "offset" | "text" | "end_lineno" | "end_offset" | "print_file_and_line" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if kind == pyre_object::interp_exceptions::ExcKind::SyntaxError { - return Ok(syntax_error_attr(obj, name)); - } - } - _ => {} + if let Some(value) = getdictvalue(obj, name)? { + return Ok(value); + } + // `__module__` is not a universal attribute: an object whose + // type-MRO carries no `__module__` (e.g. a builtin instance like + // `(0).__module__`) raises AttributeError rather than reporting + // None. `__doc__`/`__annotations__` keep the None default. + if name != "__module__" { + return Ok(w_none()); + } + } + // Exception attributes — PyPy: W_BaseException attributes + if unsafe { pyre_object::is_exception(obj) } { + let found = exception_attr_get(obj, name)?; + if !found.is_null() { + return Ok(found); } } // __dict__: use getdict() — only returns a dict for hasdict objects, @@ -9587,19 +9611,28 @@ pub unsafe fn validate_c3_mro( .then_some(candidate) }); let Some(next) = next else { - let names = (0..n) - .filter_map(|i| w_tuple_getitem(bases, i as i64)) - .map(|base| { - if is_type_like_w(base) { - w_type_get_name(base).to_string() - } else { - "?".to_string() - } - }) - .collect::>() - .join(", "); + // The report names the head of every list still waiting to merge, + // deduplicated in first-seen order — those are the classes the + // linearization could not order, not the full base tuple. For + // `(L, Base, R)` with `L(Base)` and `R(Base)` the remaining heads + // are `Base, Base, R, Base`, so the message names `Base, R`. + let mut seen: Vec = Vec::with_capacity(lists.len()); + let mut names: Vec = Vec::with_capacity(lists.len()); + for list in &lists { + let head = list[0]; + if seen.iter().any(|&s| std::ptr::eq(s, head)) { + continue; + } + seen.push(head); + names.push(if is_type_like_w(head) { + w_type_get_name(head).to_string() + } else { + "?".to_string() + }); + } return Err(crate::PyError::type_error(format!( - "Cannot create a consistent method resolution\norder (MRO) for bases {names}" + "Cannot create a consistent method resolution order (MRO) for bases {}", + names.join(", ") ))); }; for list in &mut lists { @@ -10242,10 +10275,13 @@ pub(crate) fn descr_set___class__(w_obj: PyObjectRef, w_newcls: PyObjectRef) -> pyre_object::w_type_get_weakrefable(w_newcls), ); if !layouts_compatible { + // `objectobject.py:179-181` names the pair in the opposite order + // (`w_oldcls, w_newcls`); 3.14 `object_set_class` reports + // `newto->tp_name` first. return Err(crate::PyError::type_error(format!( "__class__ assignment: '{}' object layout differs from '{}'", - pyre_object::w_type_get_name(w_oldcls.as_ptr()), pyre_object::w_type_get_name(w_newcls), + pyre_object::w_type_get_name(w_oldcls.as_ptr()), ))); } // objectobject.py:150 — w_obj.setclass(space, w_newcls). For a mapdict @@ -10367,6 +10403,303 @@ pub fn type_immutable_attr_raise_is_stable(obj: PyObjectRef, name: &str, is_dele } } +/// The `W_BaseException` typedef's attribute writes, shared by the +/// per-class `GetSetProperty` descriptors and the instance-attribute store +/// path. `PY_NULL` means the name is not one this exception kind +/// declares, so the caller falls back to the instance dict. +pub(crate) fn exception_attr_set(obj: PyObjectRef, name: &str, value: PyObjectRef) -> PyResult { + if matches!(name, "message" | "exceptions") + && crate::builtins::lookup_exc_class("BaseExceptionGroup") + .is_some_and(|base_group| isinstance(obj, base_group).unwrap_or(false)) + { + return Err(PyError::attribute_error("readonly attribute")); + } + // `pypy/module/exceptions/interp_exceptions.py:156-157 + // W_BaseException.descr_setargs` → + // self.args_w = space.fixedview(w_newargs) + // `space.fixedview` materialises any iterable into a list of + // wrapped objects; pyre stores `args_w` as a tuple `PyObjectRef`, + // so coerce the incoming value into a tuple shape (tuple stays + // as-is, list wraps into tuple, anything else iterates). + if name == "args" { + let coerced = unsafe { coerce_to_list_for_args(value)? }; + unsafe { pyre_object::interp_exceptions::w_exception_set_args(obj, coerced) }; + return Ok(w_none()); + } + // `interp_exceptions.py:165-219` — the four special exception + // attributes (`__cause__`, `__context__`, `__traceback__`, + // `__suppress_context__`) are registered as `GetSetProperty` + // setters on `W_BaseException.typedef` and each validates its + // input before storing into the matching typed slot + // (`w_cause`/`w_context`/`w_traceback`/`suppress_context`, + // line 113-117). Storage lives on `W_BaseException` + // directly — no side store for these four names. + match name { + "__dict__" => { + // `interp_exceptions.py:293` registers + // `__dict__ = GetSetProperty(descr_get_dict, descr_set_dict)` + // whose setter routes to `setdict` (typedef.py + // descr_set_dict) — replaces the whole instance dict. + setdict(obj, value)?; + return Ok(w_none()); + } + "__cause__" => { + // `interp_exceptions.py:166-174 descr_setcause` — None + // OR an instance whose type derives from `BaseException`, + // and always flips `suppress_context` to True. + if !unsafe { pyre_object::is_none(value) } { + let value_type = + crate::typedef::r#type(value).map_or(pyre_object::PY_NULL, |p| p.as_ptr()); + if value_type.is_null() || !unsafe { exception_is_valid_class_w(value_type) } { + return Err(PyError::type_error( + "exception cause must be None or derive from BaseException", + )); + } + } + unsafe { + pyre_object::interp_exceptions::w_exception_set_cause(obj, value); + pyre_object::interp_exceptions::w_exception_set_suppress_context(obj, true); + }; + return Ok(w_none()); + } + "__context__" => { + // `interp_exceptions.py:183-190 descr_setcontext` — None + // OR an instance whose type derives from `BaseException`. + if !unsafe { pyre_object::is_none(value) } { + let value_type = + crate::typedef::r#type(value).map_or(pyre_object::PY_NULL, |p| p.as_ptr()); + if value_type.is_null() || !unsafe { exception_is_valid_class_w(value_type) } { + return Err(PyError::type_error( + "exception context must be None or derive from BaseException", + )); + } + } + unsafe { pyre_object::interp_exceptions::w_exception_set_context(obj, value) }; + return Ok(w_none()); + } + "__traceback__" => { + // `interp_exceptions.py:202-206 descr_settraceback` — + // accept None or PyTraceback only. Now that real + // PyTraceback exists, narrow the type check to the + // exact pair PyPy accepts; reject everything else as + // TypeError per PyPy. + let accept = + unsafe { pyre_object::is_none(value) || crate::pytraceback::is_pytraceback(value) }; + if !accept { + return Err(PyError::type_error( + "__traceback__ must be a traceback or None", + )); + } + let stored = if unsafe { pyre_object::is_none(value) } { + pyre_object::PY_NULL + } else { + value + }; + unsafe { pyre_object::interp_exceptions::w_exception_set_traceback(obj, stored) }; + return Ok(w_none()); + } + "__suppress_context__" => { + // `interp_exceptions.py:215-216 descr_setsuppresscontext` + // — `space.bool_w(w_value)` coerces via `__bool__`. + let b = is_true(value)?; + unsafe { pyre_object::interp_exceptions::w_exception_set_suppress_context(obj, b) }; + return Ok(w_none()); + } + // `interp_exceptions.py:468-471` + // `readwrite_attrproperty_w('w_object', W_UnicodeTranslateError)` + // and `:1081-1083` / `:1201-1203` for Decode / Encode. + // PyPy's `attrproperty_w` writer stores the raw `w_value` + // into the slot with no type coercion — that matches the + // direct slot write here. Gated on the three Unicode*Error + // kinds because PyPy installs these descriptors only on + // those typedefs. + "object" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError + | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + ) { + unsafe { pyre_object::interp_exceptions::w_exception_set_object(obj, value) }; + return Ok(w_none()); + } + } + "start" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError + | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + ) { + unsafe { pyre_object::interp_exceptions::w_exception_set_start(obj, value) }; + return Ok(w_none()); + } + } + "end" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError + | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + ) { + unsafe { pyre_object::interp_exceptions::w_exception_set_end(obj, value) }; + return Ok(w_none()); + } + } + "reason" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError + | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + ) { + unsafe { pyre_object::interp_exceptions::w_exception_set_reason(obj, value) }; + return Ok(w_none()); + } + } + "encoding" => { + // `interp_exceptions.py:1080 W_UnicodeDecodeError.encoding` + // / `:1200 W_UnicodeEncodeError.encoding`. Translate has + // no encoding attrproperty per `:461-471` typedef. + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError + ) { + unsafe { pyre_object::interp_exceptions::w_exception_set_encoding(obj, value) }; + return Ok(w_none()); + } + } + // `interp_exceptions.py:739-742` — + // `readwrite_attrproperty_w('w_errno' / 'w_strerror' / + // 'w_filename' / 'w_filename2', W_OSError)`. The + // `attrproperty_w` writer stores the raw `w_value` into the + // slot; the matching getattr arm reads it back ahead of the + // `args_w`-derived fallback. Gated on the OSError family + // (OSError / FileNotFoundError) because PyPy installs these + // descriptors only on `W_OSError.typedef`. + "errno" | "strerror" | "filename" | "filename2" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::OSError + | pyre_object::interp_exceptions::ExcKind::FileNotFoundError + ) { + unsafe { + match name { + "errno" => { + pyre_object::interp_exceptions::w_exception_set_errno(obj, value) + } + "strerror" => { + pyre_object::interp_exceptions::w_exception_set_strerror(obj, value) + } + "filename" => { + pyre_object::interp_exceptions::w_exception_set_filename(obj, value) + } + _ => pyre_object::interp_exceptions::w_exception_set_filename2(obj, value), + } + }; + return Ok(w_none()); + } + } + // `interp_exceptions.py:1006 + // readwrite_attrproperty_w('w_code', W_SystemExit)` — the + // writer stores the raw `w_value` into the slot; the matching + // getattr arm reads it back ahead of the `args_w`-derived + // fallback. Gated on SystemExit because PyPy installs the + // descriptor only on `W_SystemExit.typedef`. + "code" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if kind == pyre_object::interp_exceptions::ExcKind::SystemExit { + unsafe { pyre_object::interp_exceptions::w_exception_set_code(obj, value) }; + return Ok(w_none()); + } + } + // `interp_exceptions.py:679-681 W_ImportError` writable + // `msg` / `name` / `path` (plus `name_from`) slots; the + // matching getattr arm reads them back. Gated on the + // ImportError-family kind (ImportError / ModuleNotFoundError). + // `name` is handled by the shared arm below. + "msg" | "path" | "name_from" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::ImportError + | pyre_object::interp_exceptions::ExcKind::ModuleNotFoundError + ) { + unsafe { + match name { + "msg" => { + pyre_object::interp_exceptions::w_exception_set_import_msg(obj, value) + } + "path" => { + pyre_object::interp_exceptions::w_exception_set_import_path(obj, value) + } + _ => pyre_object::interp_exceptions::w_exception_set_import_name_from( + obj, value, + ), + } + }; + return Ok(w_none()); + } + } + // Shared writable `name` slot for ImportError / ModuleNotFoundError + // / NameError (and its UnboundLocalError subclass) / AttributeError; + // the matching getattr arm reads it back. + "name" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if matches!( + kind, + pyre_object::interp_exceptions::ExcKind::ImportError + | pyre_object::interp_exceptions::ExcKind::ModuleNotFoundError + | pyre_object::interp_exceptions::ExcKind::NameError + | pyre_object::interp_exceptions::ExcKind::UnboundLocalError + | pyre_object::interp_exceptions::ExcKind::AttributeError + ) { + unsafe { pyre_object::interp_exceptions::w_exception_set_name(obj, value) }; + return Ok(w_none()); + } + } + // Writable `obj` slot (W_AttributeError). + "obj" => { + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + if kind == pyre_object::interp_exceptions::ExcKind::AttributeError { + unsafe { pyre_object::interp_exceptions::w_exception_set_attr_obj(obj, value) }; + return Ok(w_none()); + } + } + _ => {} + } + // `W_SyntaxError`'s writable location slots. `syntax_error_attr` reads + // the instance dict before deriving from the `(filename, lineno, offset, + // text[, end_lineno, end_offset])` details tuple, so the store lands + // there; the `msg` / `filename` arms above belong to other kinds and fall + // through to here. + if matches!( + name, + "msg" + | "filename" + | "lineno" + | "offset" + | "text" + | "end_lineno" + | "end_offset" + | "print_file_and_line" + ) && unsafe { pyre_object::w_exception_get_kind(obj) } + == pyre_object::interp_exceptions::ExcKind::SyntaxError + && setdictvalue(obj, name, value)? + { + return Ok(w_none()); + } + Ok(pyre_object::PY_NULL) +} + /// `objectobject.py descr__setattr__` — the terminal implementation /// that bypasses user `__setattr__` overrides and writes directly /// through the descriptor / instance-dict path. Called by @@ -10556,275 +10889,9 @@ pub fn object_setattr(obj: PyObjectRef, name: &str, value: PyObjectRef) -> PyRes // Non-special names land in the lazily allocated instance dict on // `W_BaseException.w_dict` (interp_exceptions.py:113, 222-225). if unsafe { pyre_object::is_exception(obj) } { - if matches!(name, "message" | "exceptions") - && crate::builtins::lookup_exc_class("BaseExceptionGroup") - .is_some_and(|base_group| isinstance(obj, base_group).unwrap_or(false)) - { - return Err(PyError::attribute_error("readonly attribute")); - } - // `pypy/module/exceptions/interp_exceptions.py:156-157 - // W_BaseException.descr_setargs` → - // self.args_w = space.fixedview(w_newargs) - // `space.fixedview` materialises any iterable into a list of - // wrapped objects; pyre stores `args_w` as a tuple `PyObjectRef`, - // so coerce the incoming value into a tuple shape (tuple stays - // as-is, list wraps into tuple, anything else iterates). - if name == "args" { - let coerced = unsafe { coerce_to_list_for_args(value)? }; - unsafe { pyre_object::interp_exceptions::w_exception_set_args(obj, coerced) }; - return Ok(w_none()); - } - // `interp_exceptions.py:165-219` — the four special exception - // attributes (`__cause__`, `__context__`, `__traceback__`, - // `__suppress_context__`) are registered as `GetSetProperty` - // setters on `W_BaseException.typedef` and each validates its - // input before storing into the matching typed slot - // (`w_cause`/`w_context`/`w_traceback`/`suppress_context`, - // line 113-117). Storage lives on `W_BaseException` - // directly — no side store for these four names. - match name { - "__dict__" => { - // `interp_exceptions.py:293` registers - // `__dict__ = GetSetProperty(descr_get_dict, descr_set_dict)` - // whose setter routes to `setdict` (typedef.py - // descr_set_dict) — replaces the whole instance dict. - setdict(obj, value)?; - return Ok(w_none()); - } - "__cause__" => { - // `interp_exceptions.py:166-174 descr_setcause` — None - // OR an instance whose type derives from `BaseException`, - // and always flips `suppress_context` to True. - if !unsafe { pyre_object::is_none(value) } { - let value_type = - crate::typedef::r#type(value).map_or(pyre_object::PY_NULL, |p| p.as_ptr()); - if value_type.is_null() || !unsafe { exception_is_valid_class_w(value_type) } { - return Err(PyError::type_error( - "exception cause must be None or derive from BaseException", - )); - } - } - unsafe { - pyre_object::interp_exceptions::w_exception_set_cause(obj, value); - pyre_object::interp_exceptions::w_exception_set_suppress_context(obj, true); - }; - return Ok(w_none()); - } - "__context__" => { - // `interp_exceptions.py:183-190 descr_setcontext` — None - // OR an instance whose type derives from `BaseException`. - if !unsafe { pyre_object::is_none(value) } { - let value_type = - crate::typedef::r#type(value).map_or(pyre_object::PY_NULL, |p| p.as_ptr()); - if value_type.is_null() || !unsafe { exception_is_valid_class_w(value_type) } { - return Err(PyError::type_error( - "exception context must be None or derive from BaseException", - )); - } - } - unsafe { pyre_object::interp_exceptions::w_exception_set_context(obj, value) }; - return Ok(w_none()); - } - "__traceback__" => { - // `interp_exceptions.py:202-206 descr_settraceback` — - // accept None or PyTraceback only. Now that real - // PyTraceback exists, narrow the type check to the - // exact pair PyPy accepts; reject everything else as - // TypeError per PyPy. - let accept = unsafe { - pyre_object::is_none(value) || crate::pytraceback::is_pytraceback(value) - }; - if !accept { - return Err(PyError::type_error( - "__traceback__ must be a traceback or None", - )); - } - let stored = if unsafe { pyre_object::is_none(value) } { - pyre_object::PY_NULL - } else { - value - }; - unsafe { pyre_object::interp_exceptions::w_exception_set_traceback(obj, stored) }; - return Ok(w_none()); - } - "__suppress_context__" => { - // `interp_exceptions.py:215-216 descr_setsuppresscontext` - // — `space.bool_w(w_value)` coerces via `__bool__`. - let b = is_true(value)?; - unsafe { pyre_object::interp_exceptions::w_exception_set_suppress_context(obj, b) }; - return Ok(w_none()); - } - // `interp_exceptions.py:468-471` - // `readwrite_attrproperty_w('w_object', W_UnicodeTranslateError)` - // and `:1081-1083` / `:1201-1203` for Decode / Encode. - // PyPy's `attrproperty_w` writer stores the raw `w_value` - // into the slot with no type coercion — that matches the - // direct slot write here. Gated on the three Unicode*Error - // kinds because PyPy installs these descriptors only on - // those typedefs. - "object" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError - | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError - | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError - ) { - unsafe { pyre_object::interp_exceptions::w_exception_set_object(obj, value) }; - return Ok(w_none()); - } - } - "start" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError - | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError - | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError - ) { - unsafe { pyre_object::interp_exceptions::w_exception_set_start(obj, value) }; - return Ok(w_none()); - } - } - "end" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError - | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError - | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError - ) { - unsafe { pyre_object::interp_exceptions::w_exception_set_end(obj, value) }; - return Ok(w_none()); - } - } - "reason" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError - | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError - | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError - ) { - unsafe { pyre_object::interp_exceptions::w_exception_set_reason(obj, value) }; - return Ok(w_none()); - } - } - "encoding" => { - // `interp_exceptions.py:1080 W_UnicodeDecodeError.encoding` - // / `:1200 W_UnicodeEncodeError.encoding`. Translate has - // no encoding attrproperty per `:461-471` typedef. - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError - | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError - ) { - unsafe { pyre_object::interp_exceptions::w_exception_set_encoding(obj, value) }; - return Ok(w_none()); - } - } - // `interp_exceptions.py:739-742` — - // `readwrite_attrproperty_w('w_errno' / 'w_strerror' / - // 'w_filename' / 'w_filename2', W_OSError)`. The - // `attrproperty_w` writer stores the raw `w_value` into the - // slot; the matching getattr arm reads it back ahead of the - // `args_w`-derived fallback. Gated on the OSError family - // (OSError / FileNotFoundError) because PyPy installs these - // descriptors only on `W_OSError.typedef`. - "errno" | "strerror" | "filename" | "filename2" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::OSError - | pyre_object::interp_exceptions::ExcKind::FileNotFoundError - ) { - unsafe { - match name { - "errno" => { - pyre_object::interp_exceptions::w_exception_set_errno(obj, value) - } - "strerror" => { - pyre_object::interp_exceptions::w_exception_set_strerror(obj, value) - } - "filename" => { - pyre_object::interp_exceptions::w_exception_set_filename(obj, value) - } - _ => pyre_object::interp_exceptions::w_exception_set_filename2( - obj, value, - ), - } - }; - return Ok(w_none()); - } - } - // `interp_exceptions.py:1006 - // readwrite_attrproperty_w('w_code', W_SystemExit)` — the - // writer stores the raw `w_value` into the slot; the matching - // getattr arm reads it back ahead of the `args_w`-derived - // fallback. Gated on SystemExit because PyPy installs the - // descriptor only on `W_SystemExit.typedef`. - "code" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if kind == pyre_object::interp_exceptions::ExcKind::SystemExit { - unsafe { pyre_object::interp_exceptions::w_exception_set_code(obj, value) }; - return Ok(w_none()); - } - } - // `interp_exceptions.py:679-681 W_ImportError` writable - // `msg` / `name` / `path` (plus `name_from`) slots; the - // matching getattr arm reads them back. Gated on the - // ImportError-family kind (ImportError / ModuleNotFoundError). - // `name` is handled by the shared arm below. - "msg" | "path" | "name_from" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::ImportError - | pyre_object::interp_exceptions::ExcKind::ModuleNotFoundError - ) { - unsafe { - match name { - "msg" => pyre_object::interp_exceptions::w_exception_set_import_msg( - obj, value, - ), - "path" => pyre_object::interp_exceptions::w_exception_set_import_path( - obj, value, - ), - _ => pyre_object::interp_exceptions::w_exception_set_import_name_from( - obj, value, - ), - } - }; - return Ok(w_none()); - } - } - // Shared writable `name` slot for ImportError / ModuleNotFoundError - // / NameError (and its UnboundLocalError subclass) / AttributeError; - // the matching getattr arm reads it back. - "name" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if matches!( - kind, - pyre_object::interp_exceptions::ExcKind::ImportError - | pyre_object::interp_exceptions::ExcKind::ModuleNotFoundError - | pyre_object::interp_exceptions::ExcKind::NameError - | pyre_object::interp_exceptions::ExcKind::UnboundLocalError - | pyre_object::interp_exceptions::ExcKind::AttributeError - ) { - unsafe { pyre_object::interp_exceptions::w_exception_set_name(obj, value) }; - return Ok(w_none()); - } - } - // Writable `obj` slot (W_AttributeError). - "obj" => { - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - if kind == pyre_object::interp_exceptions::ExcKind::AttributeError { - unsafe { pyre_object::interp_exceptions::w_exception_set_attr_obj(obj, value) }; - return Ok(w_none()); - } - } - _ => {} + let handled = exception_attr_set(obj, name, value)?; + if !handled.is_null() { + return Ok(handled); } } // descroperation.py:121-122 `if w_obj.setdictvalue(space, name, w_value): @@ -10834,7 +10901,7 @@ pub fn object_setattr(obj: PyObjectRef, name: &str, value: PyObjectRef) -> PyRes if setdictvalue(obj, name, value)? { return Ok(w_none()); } - Err(raiseattrerror(obj, name, w_descr)) + Err(raiseattrerror(obj, name, w_descr, true)) } /// A direct `W_BaseException` reference slot whose hard-coded getattr/setattr @@ -10859,6 +10926,18 @@ pub enum ExceptionAttrSlot { UnicodeEncoding, } +/// True for a `GetSetProperty` `make_exc_type` installed from the class's +/// `interp_exceptions.py` typedef. Every one shares a single `fget` +/// function object, so identity on that slot separates them from a user +/// override of the same name on a heap subclass. +fn is_exception_typedef_getset(descr: PyObjectRef) -> bool { + if descr.is_null() || !unsafe { pyre_object::typedef::is_getset_property(descr) } { + return false; + } + let fget = unsafe { pyre_object::typedef::w_getset_get_fget(descr) }; + !fget.is_null() && std::ptr::eq(fget, crate::builtins::exception_getset_fget_obj()) +} + /// Ingredients for the full-body walker's mirror of the typed exception-slot /// attribute arms. This helper deliberately lives beside `getattr_str_impl` /// and `object_setattr`, whose branch order it audits. @@ -10999,13 +11078,14 @@ pub unsafe fn exception_attr_slot_fold( { ExceptionAttrSlot::UnicodeReason } - // `encoding` is absent on `UnicodeTranslateError`, so its getattr arm - // excludes that kind. + // 3.14 declares `encoding` on `UnicodeError`, so the Translate kind + // takes the slot too even though it never stamps one. "encoding" if matches!( kind, pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError ) => { ExceptionAttrSlot::UnicodeEncoding @@ -11037,9 +11117,14 @@ pub unsafe fn exception_attr_slot_fold( return None; } let w_type = crate::typedef::r#type(obj)?; - // Any hit precedes the hard-coded exception arm. The version-tag guard - // below pins this miss across later heap-subclass mutations. - if unsafe { lookup_in_type_where(w_type.as_ptr(), name) }.is_some() { + // Any hit other than the class's own `interp_exceptions.py` typedef + // getset precedes the slot; that one *is* the slot, reached through + // `exception_attr_get`, so folding past it keeps the same value. The + // version-tag guard below pins this decision across later heap-subclass + // mutations. + if let Some(descr) = unsafe { lookup_in_type_where(w_type.as_ptr(), name) } + && !is_exception_typedef_getset(descr) + { return None; } let version_tag = unsafe { w_type_version_tag(w_type.as_ptr()) }; @@ -11163,12 +11248,18 @@ pub(crate) fn setdictvalue( /// raise oefmt(space.w_AttributeError, /// "'%T' object attribute '%s' is read-only", w_obj, name) /// ``` +/// +/// `store` marks the `__setattr__` / `__delattr__` terminals. A store that +/// misses on a receiver with no instance dict cannot ever succeed, so +/// `Objects/object.c _PyObject_GenericSetAttrWithDict` names that reason in the +/// message; a read miss on the same receiver does not. // dont_look_inside: attribute-miss / read-only AttributeError construction; slow path. #[majit_macros::dont_look_inside] pub(crate) fn raiseattrerror( obj: PyObjectRef, name: &str, w_descr: Option, + store: bool, ) -> PyError { // descroperation.py:58-67 — with a descriptor in hand, the attribute // exists on the type but has no reachable `__set__`/`__delete__` and the @@ -11203,8 +11294,17 @@ pub(crate) fn raiseattrerror( format!("'{}' object", tp_name) } }; + // `object.c _PyObject_GenericSetAttrWithDict` appends the suffix when the + // receiver has no dict *slot*. A raising `getdict` says nothing about + // whether the object could hold a dict, so the suffix is only added on a + // plainly absent one. + let no_dict_suffix = if store && getdict_backing(obj).is_ok_and(|dict| dict.is_null()) { + " and no __dict__ for setting new attributes" + } else { + "" + }; PyError::attribute_error_with_context( - format!("{} has no attribute '{}'", subject, name), + format!("{subject} has no attribute '{name}'{no_dict_suffix}"), obj, name, ) @@ -11246,6 +11346,76 @@ pub fn delattr_str(obj: PyObjectRef, name: &str) -> PyResult { object_delattr(obj, name) } +/// True for an exception attribute whose class registers a `GetSetProperty` +/// with a reset-to-`None` deleter. Each name is gated on the kinds whose +/// typedef installs the descriptor, the same gate the setattr arms apply, so +/// `del ValueError("v").name` still reports a plain missing attribute. +unsafe fn exception_deletable_slot(obj: PyObjectRef, name: &str) -> bool { + use pyre_object::interp_exceptions::ExcKind as K; + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + match name { + "errno" | "strerror" | "filename2" => matches!(kind, K::OSError | K::FileNotFoundError), + "filename" => matches!(kind, K::OSError | K::FileNotFoundError | K::SyntaxError), + "code" => kind == K::SystemExit, + "value" => kind == K::StopIteration, + "msg" => matches!( + kind, + K::ImportError | K::ModuleNotFoundError | K::SyntaxError + ), + "path" | "name_from" => matches!(kind, K::ImportError | K::ModuleNotFoundError), + "name" => matches!( + kind, + K::ImportError + | K::ModuleNotFoundError + | K::NameError + | K::UnboundLocalError + | K::AttributeError + ), + "obj" => kind == K::AttributeError, + "object" | "reason" => matches!( + kind, + K::UnicodeTranslateError | K::UnicodeDecodeError | K::UnicodeEncodeError + ), + "encoding" => matches!( + kind, + K::UnicodeDecodeError | K::UnicodeEncodeError | K::UnicodeTranslateError + ), + "lineno" | "offset" | "text" | "end_lineno" | "end_offset" | "print_file_and_line" => { + kind == K::SyntaxError + } + _ => false, + } +} + +/// The `W_BaseException` typedef's attribute deletes, shared by the per-class +/// `GetSetProperty` descriptors and the instance-attribute delete path. +/// `PY_NULL` means the name is not one this exception kind declares. +pub(crate) fn exception_attr_delete(obj: PyObjectRef, name: &str) -> PyResult { + match name { + "args" | "__cause__" | "__context__" | "__traceback__" => { + return Err(PyError::type_error(format!("{name} may not be deleted"))); + } + "__suppress_context__" => { + return Err(PyError::type_error("can't delete numeric/char attribute")); + } + "start" | "end" + if matches!( + unsafe { pyre_object::w_exception_get_kind(obj) }, + pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError + | pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError + | pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError + ) => + { + return Err(PyError::type_error("can't delete numeric/char attribute")); + } + _ if unsafe { exception_deletable_slot(obj, name) } => { + return object_setattr(obj, name, w_none()); + } + _ => {} + } + Ok(pyre_object::PY_NULL) +} + /// Terminal `object.__delattr__` — bypasses user override. pub fn object_delattr(obj: PyObjectRef, name: &str) -> PyResult { let obj = crate::module::_weakref::interp__weakref::force(obj)?; @@ -11309,7 +11479,7 @@ pub fn object_delattr(obj: PyObjectRef, name: &str) -> PyResult { Err(err) if err.kind == crate::PyErrorKind::KeyError => { // descroperation.py descr__delattr__: deldictvalue // returning False raises AttributeError immediately. - return Err(raiseattrerror(obj, name, None)); + return Err(raiseattrerror(obj, name, None, true)); } Err(err) => return Err(err), } @@ -11357,17 +11527,25 @@ pub fn object_delattr(obj: PyObjectRef, name: &str) -> PyResult { mutated(obj, Some(name)); return Ok(w_none()); } - return Err(raiseattrerror(obj, name, None)); + return Err(raiseattrerror(obj, name, None, true)); } } } - // `pypy/module/exceptions/interp_exceptions.py:159-161 - // W_BaseException.descr_delargs` → unconditional TypeError - // ("args may not be deleted"). Reject `del e.args` before the - // generic instance-dict removal path, which would otherwise - // succeed silently when an entry existed there. - if unsafe { pyre_object::is_exception(obj) } && name == "args" { - return Err(PyError::type_error("args may not be deleted")); + // `pypy/module/exceptions/interp_exceptions.py` registers a deleter beside + // every exception `GetSetProperty`. The `W_BaseException` slots refuse + // deletion (`:159-161 descr_delargs` and the `descr_delcause` / + // `descr_delcontext` / `descr_deltraceback` siblings raise, and + // `__suppress_context__` and the Unicode `start` / `end` offsets are + // plain non-reference fields), while every per-class slot resets to + // `None` — exactly what the matching setattr arm does for an explicit + // `None` store. All of this runs before the generic instance-dict + // removal below, which would otherwise silently succeed on a name that + // happens to have an entry there. + if unsafe { pyre_object::is_exception(obj) } { + let handled = exception_attr_delete(obj, name)?; + if !handled.is_null() { + return Ok(handled); + } } // Instance/general: remove from the instance dict. let w_dict = getdict_backing(obj)?; @@ -11384,7 +11562,7 @@ pub fn object_delattr(obj: PyObjectRef, name: &str) -> PyResult { // `w_descr` carries a found-but-non-data descriptor so the miss is read-only. // `raiseattrerror` resolves the type name via the tag-safe `typedef::type`, // so a tagged immediate never reaches a raw `ob_type` deref here. - Err(raiseattrerror(obj, name, w_descr)) + Err(raiseattrerror(obj, name, w_descr, true)) } /// PyPy: baseobjspace.py `call`. @@ -12374,6 +12552,14 @@ pub fn pick_builtin_obj_checked( if !space_builtin.is_null() && std::ptr::eq(w_builtin, space_builtin) { return Ok(w_builtin); } + // `exec`/`eval` plant the builtins *dict* rather than the + // module (3.14 `PyEval_GetBuiltins`), so the identity test + // has to cover that spelling too — otherwise every frame + // created under an exec'd namespace would allocate a fresh + // aliasing module below. + if std::ptr::eq(w_builtin, unsafe { (*exec_ctx).get_builtin_dict() }) { + return Ok(space_builtin); + } } if unsafe { pyre_object::is_module(w_builtin) } { return Ok(w_builtin); @@ -12977,12 +13163,14 @@ unsafe fn obj_type_name(obj: PyObjectRef) -> &'static str { /// Type name for the "not iterable" TypeError. A tagged immediate is an /// exact `int`; name it without derefing its (non-pointer) tagged bits as /// `ob_type`. Gated on `CAN_BE_TAGGED`; folds to the raw deref at flag-false. -unsafe fn not_iterable_type_name(obj: PyObjectRef) -> &'static str { +unsafe fn not_iterable_type_name(obj: PyObjectRef) -> String { if pyre_object::tagged_int::CAN_BE_TAGGED && pyre_object::tagged_int::is_tagged_int(obj) { - "int" - } else { - (*(*obj).ob_type).name + return "int".to_string(); } + // `%T` resolves `space.type(w_obj)`, so a user instance is named by its + // class and not by the shared `object` instance layout its `ob_type` + // vtable carries. + object_functionstr_type_name(obj) } unsafe fn iter_check_is_iterator(w_iterator: PyObjectRef) -> PyResult { @@ -13397,7 +13585,7 @@ pub fn iter(obj: PyObjectRef) -> PyResult { if is_none(method) { return Err(PyError::type_error(format!( "'{}' object is not iterable", - (*(*obj).ob_type).name + not_iterable_type_name(obj) ))); } let w_iter = crate::call::call_function_impl_result(method, &[obj])?; @@ -16964,14 +17152,21 @@ mod tests { } /// `bound_method_attr_fast_path` must admit every descriptor kind the - /// `get()` it reproduces binds through `w_method_new` — otherwise the - /// `LOAD_ATTR`-method fold declines and the walker emits an opaque - /// may-force `getattr` residual per iteration instead. + /// `get()` it reproduces binds — otherwise the `LOAD_ATTR`-method fold + /// declines and the walker emits an opaque may-force `getattr` residual per + /// iteration instead. /// /// `TypeDef` methods are retagged `method_descriptor` /// (`function_retag_method_descriptor`), not `function`, so a predicate /// testing `FUNCTION_TYPE` alone silently declines `lst.append` and every /// sibling. Both types take the SAME arm in `get()`. + /// + /// What that arm *produces* differs by descriptor kind: `descrobject.c + /// method_get` returns `PyCMethod_New(descr->d_method, obj, NULL, d_type)`, + /// so binding a `tp_methods` descriptor yields a + /// `builtin_function_or_method` carrying the receiver — `type([].append) is + /// type(len)` — where a Python `def` binds to a `method`. The predicate has + /// to admit both, so this pins the builtin arm. #[test] fn bound_method_fast_path_admits_the_same_kinds_get_binds() { crate::typedef::init_typeobjects(); @@ -16991,14 +17186,22 @@ mod tests { ); // `get()` is the behaviour the fold reproduces: it binds this descriptor - // into a Method carrying (w_function = descr, w_self = the receiver). + // into a builtin carrier holding the receiver. let bound = unsafe { get(w_descr, w_list, w_type) } .expect("get() must not raise") .expect("get() binds a method_descriptor"); - assert!(unsafe { pyre_object::function::is_method(bound) }); + assert!( + std::ptr::eq( + crate::typedef::r#type(bound) + .expect("a bound carrier has a type") + .as_ptr(), + crate::typedef::gettypeobject(&crate::function::BUILTIN_FUNCTION_TYPE), + ), + "binding a tp_methods descriptor yields a builtin_function_or_method", + ); assert!(std::ptr::eq( - unsafe { pyre_object::function::w_method_get_func(bound) }, - w_descr, + unsafe { pyre_object::w_method_get_self(bound) }, + w_list, )); // So the predicate must admit it, naming the very same descriptor. diff --git a/pyre/pyre-interpreter/src/builtins.rs b/pyre/pyre-interpreter/src/builtins.rs index 73b4b2e3b44..7606be3fc49 100644 --- a/pyre/pyre-interpreter/src/builtins.rs +++ b/pyre/pyre-interpreter/src/builtins.rs @@ -1019,10 +1019,13 @@ unsafe fn memoryview_get_offset( } } -/// An index key — `getindex_w` accepts any object with `__index__`, not only -/// an exact int, so a scalar key or a multi-index tuple element counts as an -/// index when it is an int or exposes `__index__`. -unsafe fn memoryview_is_index(w: PyObjectRef) -> bool { +/// `_PyIndex_Check(v)` — whether the type exposes `nb_index` at all. +/// +/// This is a *type* test and never a conversion, which is what lets a caller +/// substitute its own "not an index" message for a miss while anything +/// `__index__` itself raises still propagates unchanged. Converting here +/// instead would both run a user slot twice and relabel its exception. +pub(crate) unsafe fn index_check(w: PyObjectRef) -> bool { unsafe { pyre_object::is_int(w) || crate::baseobjspace::lookup(w, "__index__").is_some() } } @@ -1037,7 +1040,7 @@ unsafe fn memoryview_start_from_tuple( let mut start = 0; for dim in 0..n { let w = pyre_object::w_tuple_getitem(index, dim).unwrap_or(w_none()); - if !memoryview_is_index(w) { + if !index_check(w) { return Err(crate::PyError::type_error("memoryview: invalid slice key")); } let index = getindex_w(w)?; @@ -1060,7 +1063,7 @@ unsafe fn memoryview_tuple_kind(index: PyObjectRef) -> (bool, bool) { let mut all_slice = n > 0; for i in 0..n { let w = pyre_object::w_tuple_getitem(index, i as i64).unwrap_or(w_none()); - if !memoryview_is_index(w) { + if !index_check(w) { all_index = false; } if !pyre_object::is_slice(w) { @@ -1084,7 +1087,7 @@ fn memoryview_getitem(args: &[PyObjectRef]) -> Result Result Result { .copied() .unwrap_or_else(|| pyre_object::w_str_new("")); let Some(sys) = crate::importing::get_sys_module("sys") else { - return Err(crate::PyError::runtime_error("input: lost sys.stdin")); + return Err(crate::PyError::runtime_error("lost sys.stdin")); }; let _roots = pyre_object::gc_roots::push_roots(); @@ -3303,13 +3311,15 @@ fn builtin_input(args: &[PyObjectRef]) -> Result { pyre_object::gc_roots::pin_root(sys); pyre_object::gc_roots::pin_root(prompt); + // `bltinmodule.c builtin_input_impl`: an absent — or `None` — standard + // stream is a `RuntimeError`, not an attribute error from the stream call. let stream = |name: &str| -> Result { let sys = pyre_object::gc_roots::shadow_stack_get(root); + let lost = || crate::PyError::runtime_error(format!("lost sys.{name}")); match crate::baseobjspace::getattr_str(sys, name) { + Ok(value) if unsafe { pyre_object::is_none(value) } => Err(lost()), Ok(value) => Ok(value), - Err(error) if error.kind == crate::PyErrorKind::AttributeError => Err( - crate::PyError::runtime_error(format!("input: lost sys.{name}")), - ), + Err(error) if error.kind == crate::PyErrorKind::AttributeError => Err(lost()), Err(error) => Err(error), } }; @@ -3752,7 +3762,7 @@ pub fn builtin_abs(args: &[PyObjectRef]) -> Result } } Err(crate::PyError::type_error(format!( - "unsupported operand type for unary abs: '{}'", + "bad operand type for abs(): '{}'", crate::baseobjspace::object_functionstr_type_name(obj) ))) } @@ -3940,6 +3950,62 @@ pub(crate) fn resolve_pos_or_kw( } } +/// `_PyArg_UnpackKeywords`' two argument-count checks for a callable +/// declaring `maxpos` positional-or-keyword slots followed by `kwonly` +/// keyword-only ones, of which `minargs` are required. +/// +/// A call whose positionals and keywords together exceed the declared total +/// is reported against that total ("takes at most 2 arguments (3 given)"), +/// and only a call that stays within it but oversupplies the positional part +/// is reported against `maxpos`. The order matters: `list.sort` declares no +/// positional slot and two keyword-only ones, so one stray positional is a +/// positional error while three arguments are a total-count error. +pub(crate) fn clinic_arity( + fn_name: &str, + npos: usize, + nkw: usize, + minargs: usize, + maxpos: usize, + kwonly: usize, +) -> Result<(), crate::PyError> { + let maxargs = maxpos + kwonly; + if npos + nkw > maxargs { + return Err(crate::PyError::type_error(format!( + "{fn_name}() takes {} {maxargs} {}argument{} ({} given)", + if minargs < maxargs { + "at most" + } else { + "exactly" + }, + // bpo-31229: a call that passed only keywords names them, so + // "takes exactly 1 argument (2 given)" cannot read as a claim + // about positional arguments that were never supplied. + if npos == 0 { "keyword " } else { "" }, + if maxargs == 1 { "" } else { "s" }, + npos + nkw, + ))); + } + if npos > maxpos { + let limit = if maxpos == 0 { + "no positional arguments".to_string() + } else { + format!( + "{} {maxpos} positional argument{} ({npos} given)", + if minargs < maxpos { + "at most" + } else { + "exactly" + }, + if maxpos == 1 { "" } else { "s" }, + ) + }; + return Err(crate::PyError::type_error(format!( + "{fn_name}() takes {limit}" + ))); + } + Ok(()) +} + /// Bind positional + `__pyre_kw__` keyword arguments into a resolved /// scope of length `names.len()`, mirroring the gateway's /// `Arguments._match_signature` (`pypy/interpreter/argument.py`). Each @@ -3960,16 +4026,17 @@ pub(crate) fn bind_builtin_kwargs( fn_name: &str, ) -> Result, crate::PyError> { let (positional, kwargs) = split_builtin_kwargs(args); - if positional.len() > names.len() { - return Err(crate::PyError::type_error(format!( - "{fn_name}() takes at most {} positional argument{} ({} given)", - names.len(), - if names.len() == 1 { "" } else { "s" }, - positional.len(), - ))); - } + clinic_arity( + fn_name, + positional.len(), + real_kwarg_count(kwargs), + required.iter().filter(|r| **r).count(), + names.len(), + 0, + )?; let mut scope: Vec = vec![PY_NULL; names.len()]; let mut filled: Vec = vec![false; names.len()]; + let mut unknown: Option = None; for (i, &v) in positional.iter().enumerate() { scope[i] = v; filled[i] = true; @@ -3984,28 +4051,36 @@ pub(crate) fn bind_builtin_kwargs( Some(idx) => { if filled[idx] { return Err(crate::PyError::type_error(format!( - "{fn_name}() got multiple values for argument '{key}'" + "argument for {fn_name}() given by name ('{key}') and position ({})", + idx + 1, ))); } scope[idx] = *val; filled[idx] = true; } - None => { - return Err(crate::PyError::type_error(format!( - "{fn_name}() got an unexpected keyword argument '{key}'" - ))); - } + // `_PyArg_UnpackKeywords` collects the unrecognized names and + // only reports them once every declared slot has been filled, + // so a call that misses a required argument is reported + // against that argument even when it also passed a keyword + // the function does not know. + None => unknown = Some(key.to_string_lossy().into_owned()), } } } for i in 0..names.len() { if !filled[i] && required[i] { return Err(crate::PyError::type_error(format!( - "{fn_name}() missing required argument: '{}'", - names[i] + "{fn_name}() missing required argument '{}' (pos {})", + names[i], + i + 1, ))); } } + if let Some(key) = unknown { + return Err(crate::PyError::type_error(format!( + "{fn_name}() got an unexpected keyword argument '{key}'" + ))); + } Ok(scope) } @@ -4161,16 +4236,17 @@ fn min_max_dispatch( fn_name: &str, ) -> Result { let (positional, kwargs) = split_builtin_kwargs(args); - // functional.py:198-201 — only `key` and `default` are accepted. - kwarg_reject_unknown(kwargs, &["key", "default"], fn_name)?; - let key_fn = kwarg_get(kwargs, "key").filter(|k| unsafe { !pyre_object::is_none(*k) }); - let default = kwarg_get(kwargs, "default"); - // functional.py:216-218 — empty positional → TypeError, not panic. + // `min_max` unpacks its positionals before it looks at the keywords, so a + // keywords-only call is reported against the missing operand. if positional.is_empty() { return Err(crate::PyError::type_error(format!( "{fn_name} expected at least 1 argument, got 0" ))); } + // functional.py:198-201 — only `key` and `default` are accepted. + kwarg_reject_unknown(kwargs, &["key", "default"], fn_name)?; + let key_fn = kwarg_get(kwargs, "key").filter(|k| unsafe { !pyre_object::is_none(*k) }); + let default = kwarg_get(kwargs, "default"); // functional.py:206-210 — `default=` is only meaningful for the // single-iterable form; combining it with multiple positional args // is a user error. @@ -5529,6 +5605,19 @@ fn base_exception_reduce(args: &[PyObjectRef]) -> Result Result { + if unsafe { pyre_object::is_none(state) } { + return Ok(false); + } + if !unsafe { pyre_object::is_dict(state) } { + return Err(crate::PyError::type_error("state is not a dictionary")); + } + Ok(true) +} + /// `interp_exceptions.py:239-241 BaseException.descr_setstate` — /// `self.getdict(space).update(state)`. fn base_exception_setstate(args: &[PyObjectRef]) -> Result { @@ -5538,6 +5627,9 @@ fn base_exception_setstate(args: &[PyObjectRef]) -> Result Result bool { + if method.is_null() || !crate::function::is_function(method) { + return false; + } + let code = crate::function::getcode(method) as PyObjectRef; + if code.is_null() || !unsafe { crate::gateway::is_builtin_code(code) } { + return false; + } + let f = unsafe { crate::gateway::builtin_code_get(code) }; + [ + base_exception_str_method as crate::gateway::BuiltinCodeFn, + exception_str_method as crate::gateway::BuiltinCodeFn, + exception_repr_method as crate::gateway::BuiltinCodeFn, + ] + .iter() + .any(|&target| std::ptr::fn_addr_eq(f, target)) +} + +/// `interp_exceptions.py:993-998 W_SystemExit.descr_init` — a lone argument +/// becomes `code` verbatim, several become the args tuple, and none leaves +/// the `None` class default; `W_BaseException.descr_init` then stamps `args`. +/// It runs first here so its keyword rejection precedes the `code` write. +fn exc_system_exit_init(args: &[PyObjectRef]) -> crate::PyResult { + let w_self = *args.first().ok_or_else(|| { + crate::PyError::type_error("__init__() missing 1 required positional argument: 'self'") + })?; + exc_base_exception_init(args)?; + let (positional, _) = split_builtin_kwargs(&args[1..]); + let code = match positional.len() { + 0 => return Ok(pyre_object::w_none()), + 1 => positional[0], + _ => pyre_object::w_tuple_new(positional.to_vec()), + }; + unsafe { pyre_object::interp_exceptions::w_exception_set_code(w_self, code) }; + Ok(pyre_object::w_none()) +} + +/// `interp_exceptions.py:126-133 W_BaseException.descr_str` — the base rule, +/// which reports the args alone. It is what `BaseException.__str__(exc)` +/// runs even when `exc`'s own class registers a `descr_str` override. +fn base_exception_str_method(args: &[PyObjectRef]) -> crate::PyResult { + let obj = args[0]; + Ok(pyre_object::w_str_new(&unsafe { + crate::display::base_exception_str(obj)? + })) +} + +/// The `descr_str` of the class that registers one, falling back to +/// `W_BaseException.descr_str` for an arg shape it does not special-case +/// (`KeyError('a', 'b')`, an `OSError` with neither errno nor strerror). +fn exception_str_method(args: &[PyObjectRef]) -> crate::PyResult { + let obj = args[0]; + let text = unsafe { + match crate::display::exception_kind_str(obj)? { + Some(s) => s, + None => crate::display::base_exception_str(obj)?, + } + }; + Ok(pyre_object::w_str_new(&text)) +} + +/// `interp_exceptions.py:135-151 W_BaseException.descr_repr` — every builtin +/// exception class inherits this one, so it is registered on `BaseException` +/// alone and reads the receiver's own class name. +fn exception_repr_method(args: &[PyObjectRef]) -> crate::PyResult { + let obj = args[0]; + Ok(pyre_object::w_str_new(&unsafe { + crate::display::py_repr(obj)? + })) +} + +/// `interp_exceptions.py` typedef `GetSetProperty` entries, per class. +/// +/// Each class declares only the attributes its own `TypeDef` adds; a +/// subclass reaches the rest through the MRO the way +/// `ModuleNotFoundError` reaches `ImportError.msg`. +fn exception_typedef_attrs(class_name: &str) -> &'static [&'static str] { + match class_name { + "BaseException" => &[ + "args", + "__cause__", + "__context__", + "__suppress_context__", + "__traceback__", + ], + "SystemExit" => &["code"], + "StopIteration" => &["value"], + "OSError" => &[ + "characters_written", + "errno", + "filename", + "filename2", + "strerror", + ], + "ImportError" => &["msg", "name", "name_from", "path"], + "NameError" => &["name"], + "AttributeError" => &["name", "obj"], + "SyntaxError" => &[ + "end_lineno", + "end_offset", + "filename", + "lineno", + "msg", + "offset", + "print_file_and_line", + "text", + ], + "UnicodeDecodeError" | "UnicodeEncodeError" | "UnicodeTranslateError" => { + &["encoding", "end", "object", "reason", "start"] + } + "BaseExceptionGroup" => &["exceptions", "message"], + _ => &[], + } +} + +/// The attribute name a descriptor built by [`make_exception_getset`] carries. +fn exception_getset_name(w_descr: PyObjectRef) -> String { + let w_name = unsafe { pyre_object::typedef::w_getset_get_name(w_descr) }; + if w_name.is_null() || !unsafe { pyre_object::is_str(w_name) } { + return String::new(); + } + unsafe { pyre_object::w_str_get_value(w_name) }.to_string() +} + +/// A name the receiving exception kind does not declare — `OSError`'s +/// `characters_written` on anything but a `BlockingIOError`, say. The +/// descriptor is inherited but reads back as absent. +fn exception_getset_absent(w_obj: PyObjectRef, name: &str) -> crate::PyError { + crate::PyError::attribute_error_with_context( + format!( + "'{}' object has no attribute '{name}'", + crate::baseobjspace::object_functionstr_type_name(w_obj) + ), + w_obj, + name, + ) +} + +fn exception_getset_fget(args: &[PyObjectRef]) -> crate::PyResult { + let (w_descr, w_obj) = (args[0], args[1]); + let name = exception_getset_name(w_descr); + let found = crate::baseobjspace::exception_attr_get(w_obj, &name)?; + if found.is_null() { + return Err(exception_getset_absent(w_obj, &name)); + } + Ok(found) +} + +fn exception_getset_fset(args: &[PyObjectRef]) -> crate::PyResult { + let (w_descr, w_obj, w_value) = (args[0], args[1], args[2]); + let name = exception_getset_name(w_descr); + let handled = crate::baseobjspace::exception_attr_set(w_obj, &name, w_value)?; + if handled.is_null() { + return Err(exception_getset_absent(w_obj, &name)); + } + Ok(handled) +} + +fn exception_getset_fdel(args: &[PyObjectRef]) -> crate::PyResult { + let (w_descr, w_obj) = (args[0], args[1]); + let name = exception_getset_name(w_descr); + let handled = crate::baseobjspace::exception_attr_delete(w_obj, &name)?; + if handled.is_null() { + return Err(exception_getset_absent(w_obj, &name)); + } + Ok(handled) +} + +/// The three ends of every exception `GetSetProperty`. One function object +/// backs all of them, so `exception_attr_slot_fold` recognises a descriptor +/// it may look through by comparing the `fget` it found against this one. +fn exception_getset_ends() -> (PyObjectRef, PyObjectRef, PyObjectRef) { + static ENDS: std::sync::OnceLock<(usize, usize, usize)> = std::sync::OnceLock::new(); + let (fget, fset, fdel) = *ENDS.get_or_init(|| { + ( + make_builtin_function_with_arity("__get__", exception_getset_fget, 2) as usize, + make_builtin_function_with_arity("__set__", exception_getset_fset, 3) as usize, + make_builtin_function_with_arity("__delete__", exception_getset_fdel, 2) as usize, + ) + }); + ( + fget as PyObjectRef, + fset as PyObjectRef, + fdel as PyObjectRef, + ) +} + +/// The shared `fget` every exception `GetSetProperty` carries. +pub(crate) fn exception_getset_fget_obj() -> PyObjectRef { + exception_getset_ends().0 +} + +/// Install the class's `interp_exceptions.py` typedef getsets into `ns`. +fn install_exception_getsets(ns: PyObjectRef, class_name: &str) { + let (fget, fset, fdel) = exception_getset_ends(); + for attr in exception_typedef_attrs(class_name) { + unsafe { + pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( + ns, + attr, + crate::typedef::make_getset_property_named(fget, fset, fdel, attr), + ) + }; + } +} + /// Build a builtin exception type with the given name, base, and __new__ wrapper. pub(crate) fn make_exc_type( name: &'static str, @@ -6163,7 +6469,7 @@ fn make_exc_type_with_init( pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__new__", - make_builtin_function("__new__", new_fn), + crate::typedef::make_new_descr(new_fn), ) }; if let Some(init_fn) = init_fn { @@ -6175,6 +6481,46 @@ fn make_exc_type_with_init( ) }; } + // `interp_exceptions.py` declares each class's typed attributes + // as `GetSetProperty` entries on its own `TypeDef`. + install_exception_getsets(ns, name); + // `interp_exceptions.py:291-292` registers `__str__` / + // `__repr__` on `BaseException`'s typedef, and each of the + // classes below registers a `descr_str` of its own on top. A + // class that inherits both stays out of this list, so + // `LookupError.__str__` resolves up the MRO to + // `BaseException`'s the way `KeyError.__str__` does not. + if name == "BaseException" { + unsafe { + pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( + ns, + "__repr__", + make_builtin_function_with_arity("__repr__", exception_repr_method, 1), + ); + pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( + ns, + "__str__", + make_builtin_function_with_arity("__str__", base_exception_str_method, 1), + ); + }; + } else if matches!( + name, + "KeyError" + | "OSError" + | "ImportError" + | "SyntaxError" + | "UnicodeDecodeError" + | "UnicodeEncodeError" + | "UnicodeTranslateError" + ) { + unsafe { + pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( + ns, + "__str__", + make_builtin_function_with_arity("__str__", exception_str_method, 1), + ) + }; + } // `pypy/module/exceptions/interp_exceptions.py:225-235` // `BaseException.with_traceback` — installed on every // builtin exception class so MRO lookup from a subclass @@ -6253,13 +6599,19 @@ fn make_exc_type_with_init( })?; // `interp_exceptions.py:257-260` — accept // `str` and any `str` subclass - // (`isinstance_w(w_note, space.w_unicode)`); - // otherwise `oefmt("note must be a str, not %T")`. + // (`isinstance_w(w_note, space.w_unicode)`). + // The rejection wording is the argument-clinic + // one (`_PyArg_BadArgument`), which names the + // method and renders `None` as `None` rather + // than as its type. if !unsafe { crate::baseobjspace::isinstance_str_w(w_note) } { - let tp_name = - crate::baseobjspace::object_functionstr_type_name(w_note); + let got = if w_note == pyre_object::w_none() { + "None".to_string() + } else { + crate::baseobjspace::object_functionstr_type_name(w_note) + }; return Err(crate::PyError::type_error(format!( - "note must be a str, not {tp_name}" + "add_note() argument must be str, not {got}" ))); } // `interp_exceptions.py:240-254` — lazy @@ -6382,7 +6734,7 @@ pub(crate) fn make_exc_type_multi( pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__new__", - make_builtin_function("__new__", new_fn), + crate::typedef::make_new_descr(new_fn), ) }; }, @@ -6924,7 +7276,7 @@ fn make_exception_group_type(name: &'static str, bases: &[PyObjectRef]) -> PyObj pyre_object::w_dict_setitem_str_no_proxy( ns, "__new__", - make_builtin_function("__new__", exception_group_new), + crate::typedef::make_new_descr(exception_group_new), ); pyre_object::w_dict_setitem_str_no_proxy( ns, @@ -7608,6 +7960,14 @@ pub(crate) fn parse_int_from_str( Ok(w_long_new(value)) } +/// The error every decimal `int` conversion raises once the result would +/// exceed `sys.get_int_max_str_digits()` digits. +pub(crate) fn int_max_str_digits_error(maxdigits: i32) -> crate::PyError { + crate::PyError::value_error(format!( + "Exceeds the limit ({maxdigits} digits) for integer string conversion; use sys.set_int_max_str_digits() to increase the limit" + )) +} + /// PyPy `W_AbstractLongObject.descr_str` / Python 3.14 integer-to-decimal /// conversion guard. The bit-length lower bound rejects enormous values /// before the quadratic decimal conversion; the resulting string supplies @@ -7625,6 +7985,7 @@ pub(crate) unsafe fn int_to_decimal_string(obj: PyObjectRef) -> Result Result maxdigits as u64 { - return Err(crate::PyError::value_error(format!( - "Exceeds the limit ({maxdigits}) for integer string conversion; use sys.set_int_max_str_digits() to increase the limit" - ))); + return Err(too_long(maxdigits)); } } // longobject.py:109 calls `self.asbigint().str(max_str_digits=...)`. @@ -7643,9 +8002,7 @@ pub(crate) unsafe fn int_to_decimal_string(obj: PyObjectRef) -> Result crate::PyError::value_error(format!( - "Exceeds the limit ({maxdigits}) for integer string conversion; use sys.set_int_max_str_digits() to increase the limit" - )), + pyre_object::rbigint::RBigIntError::MaxStrDigits => too_long(maxdigits), pyre_object::rbigint::RBigIntError::Memory => crate::PyError::memory_error(""), _ => unreachable!("rbigint.str returned an unrelated error"), }) @@ -8063,6 +8420,27 @@ pub fn collect_iterable(obj: PyObjectRef) -> Result, crate::PyE collect_iterator(it) } +/// `PySequence_Fast(seq, msg)` — materialise `seq`, replacing only the +/// `TypeError` raised while obtaining the iterator with `msg`. A `TypeError` +/// the iterator raises later keeps its own message, and every other error +/// propagates unchanged. +pub(crate) fn sequence_fast( + obj: PyObjectRef, + msg: &str, +) -> Result, crate::PyError> { + let _roots = pyre_object::gc_roots::push_roots(); + let obj_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(obj); + let it = match crate::baseobjspace::iter(pyre_object::gc_roots::shadow_stack_get(obj_slot)) { + Ok(it) => it, + Err(e) if e.kind == crate::PyErrorKind::TypeError => { + return Err(crate::PyError::type_error(msg)); + } + Err(e) => return Err(e), + }; + collect_iterator(it) +} + /// Consume an iterator that has already been obtained. Kept separate from /// [`collect_iterable`] for CPython `PySequence_Fast` parity: callers such as /// dict sequence-pair conversion must distinguish an error from `iter(obj)` @@ -8230,6 +8608,16 @@ pub(crate) fn builtin_super(args: &[PyObjectRef]) -> Result Result { } match positional.len() { 0 => Err(crate::PyError::type_error( - "iter() requires at least one argument", + "iter expected at least 1 argument, got 0", )), 1 => crate::baseobjspace::iter(positional[0]), 2 => { @@ -8391,7 +8779,7 @@ fn builtin_next(args: &[PyObjectRef]) -> Result { } if args.is_empty() { return Err(crate::PyError::type_error( - "next() requires at least one argument", + "next expected at least 1 argument, got 0", )); } if args.len() > 2 { @@ -8447,7 +8835,11 @@ pub fn compile_err_to_syntax_error( e: crate::compile::CompileError, source: &str, ) -> crate::PyError { - let msg = e.to_string(); + let subclass = syntax_error_subclass(&e, source); + let msg = match subclass { + Some((_, Some(replacement))) => replacement.to_string(), + _ => e.to_string(), + }; let (lineno, offset) = e.python_location(); if lineno == 0 { return crate::PyError::syntax_error(msg); @@ -8456,7 +8848,7 @@ pub fn compile_err_to_syntax_error( let filename = e.source_path().to_string(); // The offending source line, keeping its trailing newline like `e.text`. let text = source.split_inclusive('\n').nth(lineno - 1); - crate::PyError::syntax_error_located( + let mut err = crate::PyError::syntax_error_located( msg, &filename, lineno as i64, @@ -8464,7 +8856,198 @@ pub fn compile_err_to_syntax_error( end_lineno as i64, end_offset as i64, text, - ) + ); + if let Some((name, _)) = subclass { + err.retag_exception_class(name); + } + err +} + +/// Which of `tokenizer.c tok_get_normal_mode`'s two indentation rejections a +/// line hits first. +enum IndentFault { + /// `TabError`: the line's indentation measures differently with tabs + /// expanded to 8 columns than with tabs counted as 1, so which block it + /// belongs to depends on the tab width. + TabsAndSpaces, + /// `IndentationError`: a dedent that lands between two enclosing levels. + UnmatchedDedent, +} + +/// The first indentation the tokenizer would reject, and why. +/// +/// `tokenizer.c tok_get_normal_mode` keeps two indent stacks — `indstack` +/// measured with tabs expanded to the next multiple of 8, `altindstack` with +/// each tab counted as 1 — and compares the incoming line against both. Equal, +/// deeper and shallower each have an `altcol` companion check, and it is the +/// disagreement between the two measures that is `TabError`; a dedent matching +/// no `indstack` entry is the plain indentation error. +/// +/// A file-wide census cannot stand in for this. Tabs in one block and spaces in +/// another is legal as long as no single comparison disagrees, so counting both +/// characters anywhere in the source retags an unrelated failure. +/// +/// Answers `None` for anything this cannot decide from lines alone — an +/// unterminated string, or a line still inside brackets when the source runs +/// out — because the tokenizer processes indentation only outside both, and +/// `TabError` should be claimed only on positive evidence. +fn first_indent_fault(source: &str) -> Option { + let mut indstack = vec![0usize]; + let mut altindstack = vec![0usize]; + let mut depth = 0usize; + let mut in_triple: Option = None; + for line in source.lines() { + let bytes = line.as_bytes(); + // Only a logical line's first physical line carries indentation; a + // continuation inside brackets or a triple-quoted string does not. + let measures = (depth == 0 && in_triple.is_none()).then(|| { + let mut col = 0usize; + let mut altcol = 0usize; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b' ' => { + col += 1; + altcol += 1; + } + b'\t' => { + col = col / 8 * 8 + 8; + altcol += 1; + } + _ => break, + } + i += 1; + } + (col, altcol, i) + }); + scan_line_nesting(bytes, &mut depth, &mut in_triple)?; + let Some((col, altcol, indent_len)) = measures else { + continue; + }; + // `tok->blankline`: a blank or comment-only line is not indentation. + match bytes.get(indent_len) { + None | Some(b'#') | Some(b'\r') => continue, + _ => {} + } + let (top, alttop) = ( + indstack[indstack.len() - 1], + altindstack[altindstack.len() - 1], + ); + if col == top { + if altcol != alttop { + return Some(IndentFault::TabsAndSpaces); + } + } else if col > top { + if altcol <= alttop { + return Some(IndentFault::TabsAndSpaces); + } + indstack.push(col); + altindstack.push(altcol); + } else { + while indstack.len() > 1 && col < indstack[indstack.len() - 1] { + indstack.pop(); + altindstack.pop(); + } + if col != indstack[indstack.len() - 1] { + return Some(IndentFault::UnmatchedDedent); + } + if altcol != altindstack[altindstack.len() - 1] { + return Some(IndentFault::TabsAndSpaces); + } + } + } + None +} + +/// Advance `depth` (bracket nesting) and `in_triple` (the open triple quote's +/// character) across one physical line, skipping what a quote or a `#` hides. +/// `None` when the line ends inside a single-quoted string that is not a +/// continuation, which means this scan has lost track. +fn scan_line_nesting(bytes: &[u8], depth: &mut usize, in_triple: &mut Option) -> Option<()> { + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if let Some(quote) = *in_triple { + if c == b'\\' { + i += 2; + continue; + } + if c == quote && bytes[i + 1..].starts_with(&[quote, quote]) { + *in_triple = None; + i += 3; + continue; + } + i += 1; + continue; + } + match c { + b'#' => return Some(()), + b'(' | b'[' | b'{' => *depth += 1, + b')' | b']' | b'}' => *depth = depth.saturating_sub(1), + b'"' | b'\'' => { + if bytes[i + 1..].starts_with(&[c, c]) { + *in_triple = Some(c); + i += 3; + continue; + } + // A single-quoted string closes on this line or the source is + // malformed in a way this scan cannot follow. + let mut j = i + 1; + loop { + match bytes.get(j) { + None => return None, + Some(b'\\') => j += 2, + Some(&b) if b == c => break, + Some(_) => j += 1, + } + } + i = j + 1; + continue; + } + _ => {} + } + i += 1; + } + Some(()) +} + +/// The `SyntaxError` subclass a compile failure belongs to. 3.14 raises +/// `IndentationError` for every indentation-shaped tokenizer failure, +/// `TabError` when that failure comes from mixing tabs and spaces, and plain +/// `SyntaxError` for the rest. +fn syntax_error_subclass( + e: &crate::compile::CompileError, + source: &str, +) -> Option<(&'static str, Option<&'static str>)> { + use rustpython_compiler::parser::{LexicalErrorType, ParseErrorType}; + let crate::compile::CompileError::Parse(parse_err) = e else { + return None; + }; + // Every indentation-shaped failure is one of the two the tokenizer + // distinguishes, and only the source says which — the parser reports a + // single column, having already collapsed the two measures. `TabError` + // carries the tokenizer's own message rather than the parser's, which + // describes the shape it saw instead of the tab/space clash behind it. + let indentation = |plain: Option<&'static str>| match first_indent_fault(source) { + Some(IndentFault::TabsAndSpaces) => ( + "TabError", + Some("inconsistent use of tabs and spaces in indentation"), + ), + _ => ("IndentationError", plain), + }; + match &parse_err.error { + // The parser spells this one "Unexpected indentation"; the tokenizer + // raises `unexpected indent`. The dedent message below already reads + // as the tokenizer writes it, so it keeps the parser's. + ParseErrorType::UnexpectedIndentation => Some(indentation(Some("unexpected indent"))), + ParseErrorType::Lexical(LexicalErrorType::IndentationError) => Some(indentation(None)), + // `pegen`'s "expected an indented block after on line N", + // which the compiler reconstructs as a plain message. + ParseErrorType::OtherError(msg) if msg.starts_with("expected an indented block") => { + Some(("IndentationError", None)) + } + _ => None, + } } /// `pypy/interpreter/astcompiler/consts.py` compilation flag bits. @@ -8534,6 +9117,18 @@ fn builtin_compile(args: &[PyObjectRef]) -> Result // flags/dont_inherit/optimize are positional-or-keyword; _feature_version // is keyword-only. PyCF_ONLY_AST follows PyPy's compile_to_ast boundary. let (pos, kwargs) = split_builtin_kwargs(args); + let source = bind_pos_or_kw(pos, kwargs, 0, "source", "compile", 1)?.ok_or_else(|| { + crate::PyError::type_error("compile() missing required argument 'source' (pos 1)") + })?; + let filename_obj = + bind_pos_or_kw(pos, kwargs, 1, "filename", "compile", 2)?.ok_or_else(|| { + crate::PyError::type_error("compile() missing required argument 'filename' (pos 2)") + })?; + let mode_obj = bind_pos_or_kw(pos, kwargs, 2, "mode", "compile", 3)?.ok_or_else(|| { + crate::PyError::type_error("compile() missing required argument 'mode' (pos 3)") + })?; + // Every declared slot binds before the unrecognized keywords are + // reported, so a call missing a required argument names that argument. kwarg_reject_unknown( kwargs, &[ @@ -8547,16 +9142,6 @@ fn builtin_compile(args: &[PyObjectRef]) -> Result ], "compile", )?; - let source = bind_pos_or_kw(pos, kwargs, 0, "source", "compile", 1)?.ok_or_else(|| { - crate::PyError::type_error("compile() missing required argument 'source' (pos 1)") - })?; - let filename_obj = - bind_pos_or_kw(pos, kwargs, 1, "filename", "compile", 2)?.ok_or_else(|| { - crate::PyError::type_error("compile() missing required argument 'filename' (pos 2)") - })?; - let mode_obj = bind_pos_or_kw(pos, kwargs, 2, "mode", "compile", 3)?.ok_or_else(|| { - crate::PyError::type_error("compile() missing required argument 'mode' (pos 3)") - })?; let filename = if unsafe { pyre_object::is_str(filename_obj) } { crate::baseobjspace::str_utf8_w(filename_obj)?.to_string() } else { @@ -8617,10 +9202,10 @@ fn builtin_compile(args: &[PyObjectRef]) -> Result "exec" => crate::compile::Mode::Exec, "eval" => crate::compile::Mode::Eval, "single" => crate::compile::Mode::Single, - other => { + _ => { return Err(crate::PyError::new( crate::PyErrorKind::ValueError, - format!("compile() mode must be 'exec', 'eval' or 'single', not {other:?}"), + "compile() mode must be 'exec', 'eval' or 'single'", )); } }; @@ -8710,19 +9295,21 @@ fn builtin_compile(args: &[PyObjectRef]) -> Result /// the supplied namespaces. When the namespaces are dicts, pyre converts /// them into `DictStorage`s before invocation and copies the post-run /// namespace contents back so that callers see the new bindings. -fn builtin_exec(args: &[PyObjectRef]) -> Result { +pub(crate) fn builtin_exec(args: &[PyObjectRef]) -> Result { // `exec(source, /, globals=None, locals=None, *, closure=None)`: source is // positional-only; globals/locals are positional-or-keyword; `closure` is // keyword-only. `closure` supplies the cell objects that bind a code // object's free variables (bltinmodule.c builtin_exec_impl); a None // closure normalises to "absent" (PY_NULL). let (pos, kwargs) = split_builtin_kwargs(args); - kwarg_reject_unknown(kwargs, &["globals", "locals", "closure"], "exec")?; + // The positional-only `source` is bound before the unrecognized keywords + // are reported, so a keywords-only call is reported against `source`. if pos.is_empty() { return Err(crate::PyError::type_error( "exec() takes at least 1 positional argument (0 given)", )); } + kwarg_reject_unknown(kwargs, &["globals", "locals", "closure"], "exec")?; let source = pos[0]; let globals_arg = bind_pos_or_kw(pos, kwargs, 1, "globals", "exec", 2)?.unwrap_or(pyre_object::PY_NULL); @@ -8742,12 +9329,14 @@ fn builtin_eval(args: &[PyObjectRef]) -> Result { // `eval(source, /, globals=None, locals=None)`: source is positional-only; // globals/locals are positional-or-keyword. let (pos, kwargs) = split_builtin_kwargs(args); - kwarg_reject_unknown(kwargs, &["globals", "locals"], "eval")?; + // The positional-only `source` is bound before the unrecognized keywords + // are reported, so a keywords-only call is reported against `source`. if pos.is_empty() { return Err(crate::PyError::type_error( "eval() takes at least 1 positional argument (0 given)", )); } + kwarg_reject_unknown(kwargs, &["globals", "locals"], "eval")?; let source = pos[0]; let globals_arg = bind_pos_or_kw(pos, kwargs, 1, "globals", "eval", 2)?.unwrap_or(pyre_object::PY_NULL); @@ -8831,6 +9420,18 @@ fn exec_or_eval( } } + /// `PyEval_GetBuiltins()` — the value `exec`/`eval` plant under + /// `__builtins__` in a namespace that lacks it. PyPy stores the picked + /// `Module` (`compiling.py:110 space.builtin`); 3.14 stores that module's + /// *dict*, which is what `isinstance(__builtins__, dict)` inside + /// `exec(src, {})` observes. + fn planted_builtins(w_builtin: pyre_object::PyObjectRef) -> pyre_object::PyObjectRef { + if !w_builtin.is_null() && unsafe { pyre_object::is_module(w_builtin) } { + return unsafe { pyre_object::w_module_get_w_dict(w_builtin) }; + } + w_builtin + } + fn ensure_eval_builtins( w_globals: pyre_object::PyObjectRef, exec_ctx: *const crate::PyExecutionContext, @@ -8845,7 +9446,7 @@ fn exec_or_eval( // not fire for eval() in PyPy. Dispatch on the dict object so // the str-keyed write fans into the storage proxy. let w_builtin = if !exec_ctx.is_null() { - unsafe { (*exec_ctx).get_builtin() } + planted_builtins(unsafe { (*exec_ctx).get_builtin() }) } else { pyre_object::PY_NULL }; @@ -8875,9 +9476,9 @@ fn exec_or_eval( // `w_globals` object so a dict-subclass `setdefault` override // fires. let w_builtin = if !caller_frame.is_null() { - unsafe { (*caller_frame).get_builtin() } + planted_builtins(unsafe { (*caller_frame).get_builtin() }) } else if !exec_ctx.is_null() { - unsafe { (*exec_ctx).get_builtin() } + planted_builtins(unsafe { (*exec_ctx).get_builtin() }) } else { pyre_object::PY_NULL }; @@ -8894,20 +9495,36 @@ fn exec_or_eval( // globals: not None ⇒ isinstance_w(w_dict) else TypeError // locals : not None ⇒ space.lookup(__getitem__) is not None // else TypeError "must be a mapping or None" - let funcname = if is_eval { "eval" } else { "exec" }; if !is_none_or_null(globals_arg) && !is_dict_w(globals_arg) { - return Err(crate::PyError::type_error(format!( - "{funcname}() arg 2 must be a dict, not {}", - type_name_of(globals_arg) - ))); + // `builtin_eval_impl` splits on `PyMapping_Check`, so a mapping that + // is merely not a dict is told how to pass it as the locals instead; + // `builtin_exec_impl` names the type it got. + let message = if is_eval { + if unsafe { crate::baseobjspace::lookup(globals_arg, "__getitem__").is_some() } { + "globals must be a real dict; try eval(expr, {}, mapping)".to_string() + } else { + "globals must be a dict".to_string() + } + } else { + format!( + "exec() globals must be a dict, not {}", + type_name_of(globals_arg) + ) + }; + return Err(crate::PyError::type_error(message)); } if !is_none_or_null(locals_arg) && unsafe { crate::baseobjspace::lookup(locals_arg, "__getitem__").is_none() } { - return Err(crate::PyError::type_error(format!( - "{funcname}() arg 3 must be a mapping or None, not {}", - type_name_of(locals_arg) - ))); + let message = if is_eval { + "locals must be a mapping".to_string() + } else { + format!( + "locals must be a mapping or None, not {}", + type_name_of(locals_arg) + ) + }; + return Err(crate::PyError::type_error(message)); } // bltinmodule.c builtin_exec_impl — validate the closure against the @@ -9012,10 +9629,11 @@ fn exec_or_eval( // locals=globals and pyre's existing same-dict path handles it. let mut implicit_caller_locals: pyre_object::PyObjectRef = std::ptr::null_mut(); if is_none_or_null(globals_arg) && is_none_or_null(locals_arg) && !caller_frame.is_null() { - // pyframe.py:540 getdictscope returns the caller's - // w_locals (PyObjectRef) — same dict-or-mapping the - // interpreter sees inside the calling function body. - implicit_caller_locals = unsafe { (*caller_frame).getdictscope()? }; + // The caller's locals as `locals()` reports them: its real namespace + // for a module or class frame, an independent snapshot for an + // optimized one. The snapshot is what keeps `exec("y = 1")` inside a + // function from adding `y` to that function's locals. + implicit_caller_locals = unsafe { (*caller_frame).frame_locals_snapshot()? }; } let mut locals_object_arg: pyre_object::PyObjectRef = std::ptr::null_mut(); if !is_none_or_null(locals_arg) { @@ -9166,31 +9784,43 @@ fn builtin_globals(args: &[PyObjectRef]) -> Result }) } +/// The frame whose locals `locals()` / `vars()` / `dir()` report on +/// (`interp_inspect.py:7-11 locals` — `ec.gettopframe_nohidden()`). +/// +/// Going through `gettopframe_nohidden` is what makes the frame's fastlocals +/// readable: it runs `force_frame` on every frame it walks +/// (`executioncontext.rs:409-421`), and `fast2locals` reads +/// `locals_cells_stack_w` directly, so an unforced virtualizable hands back an +/// array of nulls — which `fast2locals` renders as an EMPTY mapping rather +/// than a stale one. Reading `CURRENT_FRAME` instead skips that force, and +/// under the JIT the caller then sees no locals at all. +fn topframe_for_locals() -> *mut crate::PyFrame { + let ec = crate::call::getexecutioncontext() as *mut crate::PyExecutionContext; + if ec.is_null() { + return std::ptr::null_mut(); + } + unsafe { (*ec).gettopframe_nohidden() } +} + fn builtin_locals(args: &[PyObjectRef]) -> Result { if !args.is_empty() { return Err(crate::PyError::type_error("locals() takes no arguments")); } - crate::eval::CURRENT_FRAME.with(|current| { - let frame = current.get(); - if frame.is_null() { - return Err(crate::PyError::runtime_error( - "locals() requires an active frame", - )); - } - // `interp_inspect.py:7-11 locals` returns - // `ec.gettopframe_nohidden().getdictscope()` unconditionally. - // `getdictscope` (`pyframe.py:525-530`) always runs `fast2locals()` - // before returning `debugdata.w_locals`, so a second `locals()` - // re-syncs the mapping with the current fast locals — - // `x = 1; locals(); x = 2; locals()["x"]` reads `2`. `fast2locals` - // lazily allocates and caches the mapping on first call, so identity - // holds (`locals() is locals()`, and `locals() is globals()` at - // module scope where `debugdata.w_locals is w_globals`); for a - // non-dict exec/eval mapping it returns that live object and writes - // through its `__setitem__`. - let frame_mut = unsafe { &mut *frame }; - frame_mut.getdictscope() - }) + // `frame_locals_snapshot` (`_PyEval_GetFrameLocals`) hands an optimized + // frame an independent copy, so a snapshot neither tracks later stores nor + // writes back — `x = 1; d = locals(); x = 2; d["x"]` still reads `1`, and + // `locals() is locals()` is false. Module and class frames keep returning + // their real namespace, so `locals() is globals()` still holds at module + // scope and a non-dict exec/eval mapping is still the live object written + // through its `__setitem__`. + let frame = topframe_for_locals(); + if frame.is_null() { + return Err(crate::PyError::runtime_error( + "locals() requires an active frame", + )); + } + let frame_mut = unsafe { &mut *frame }; + frame_mut.frame_locals_snapshot() } fn builtin_vars(args: &[PyObjectRef]) -> Result { @@ -9204,9 +9834,10 @@ fn builtin_vars(args: &[PyObjectRef]) -> Result { return builtin_locals(args); } if args.len() != 1 { - return Err(crate::PyError::type_error( - "vars() takes at most 1 argument.", - )); + return Err(crate::PyError::type_error(format!( + "vars expected at most 1 argument, got {}", + args.len() + ))); } let obj = args[0]; let has_dict = unsafe { @@ -9321,27 +9952,34 @@ pub(crate) fn builtin_dir(args: &[PyObjectRef]) -> Result 1 { return Err(crate::PyError::type_error(format!( @@ -10342,6 +10980,10 @@ pub(crate) fn builtin_zip(args: &[PyObjectRef]) -> Result Result Result { let (positional, kwargs) = split_builtin_kwargs(args); - if positional.is_empty() { - return Err(crate::PyError::type_error( - "sorted() requires at least one argument", - )); - } - if positional.len() > 1 { + // `builtin_sorted` unpacks its one positional itself and leaves every + // keyword to the `list.sort` it delegates to, so an unknown keyword is + // reported by `sort`, not by `sorted`. + if positional.len() != 1 { return Err(crate::PyError::type_error(format!( - "sorted() takes at most 1 positional argument ({} given)", + "sorted expected 1 argument, got {}", positional.len() ))); } - kwarg_reject_unknown(kwargs, &["key", "reverse"], "sorted")?; + kwarg_reject_unknown(kwargs, &["key", "reverse"], "sort")?; let iterable = positional[0]; let key_fn = kwarg_get(kwargs, "key").filter(|k| unsafe { !pyre_object::is_none(*k) }); let reverse = kwarg_get(kwargs, "reverse") .map(|v| crate::baseobjspace::is_true(v)) .transpose()? .unwrap_or(false); - let items = collect_iterable(iterable)?; + // `app_functional.py:7` `sorted` is `list(iterable)` followed by + // `sorted_lst.sort(key=key, reverse=reverse)`, so the built list picks its + // storage strategy first and the sort runs through the same body + // `list.sort` uses. + let w_list = w_list_new(collect_iterable(iterable)?); let _roots = pyre_object::gc_roots::push_roots(); - let item_base = pyre_object::gc_roots::shadow_stack_len(); - for item in items { - pyre_object::gc_roots::pin_root(item); + let list_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(w_list); + sort_list_in_place(list_slot, key_fn, reverse)?; + Ok(pyre_object::gc_roots::shadow_stack_get(list_slot)) +} + +/// `listobject.py:809 descr_sort` — the shared body of `list.sort` and +/// `sorted`. `list_slot` is a shadow-stack slot holding the list, because a +/// key call or a comparison dunder can collect and move it. +pub(crate) fn sort_list_in_place( + list_slot: usize, + key_fn: Option, + reverse: bool, +) -> Result<(), crate::PyError> { + // `descr_sort` sorts through the strategy's own `sort` whenever the list + // is not object-strategy and no key was given: the storage is already + // unwrapped, so there is no boxing, no comparison dunder, and no need to + // empty the receiver first — no user code can run to observe it. + if key_fn.is_none() { + let list = pyre_object::gc_roots::shadow_stack_get(list_slot); + unsafe { + if let Some((items, len)) = pyre_object::listobject::w_list_int_items_raw(list) { + sort_scalars(std::slice::from_raw_parts_mut(items, len), reverse)?; + return Ok(()); + } + if let Some((items, len)) = pyre_object::listobject::w_list_float_items_raw(list) { + sort_scalars(std::slice::from_raw_parts_mut(items, len), reverse)?; + return Ok(()); + } + } + } + unsafe { + // Hold the detached values in shadow-stack slots, not a bare Rust Vec, + // while key and comparison calls can collect. The receiver is empty + // for the whole operation, so user code cannot alter this sorting + // slice through the visible list. + let list = pyre_object::gc_roots::shadow_stack_get(list_slot); + let saved = pyre_object::listobject::w_list_items_copy_as_vec(list); + let _roots = pyre_object::gc_roots::push_roots(); + let item_base = pyre_object::gc_roots::shadow_stack_len(); + for item in saved { + pyre_object::gc_roots::pin_root(item); + } + let saved_len = pyre_object::gc_roots::shadow_stack_len() - item_base; + pyre_object::listobject::w_list_clear(list); + + let (order, sorted) = sort_rooted_items(item_base, saved_len, key_fn, reverse); + let list = pyre_object::gc_roots::shadow_stack_get(list_slot); + // Whether the user mucked with the list during the sort: any mutation + // switches the emptied receiver away from the Empty strategy, and a + // list never switches back, so a net-zero append+pop is caught too. + let mucked = !pyre_object::listobject::w_list_is_empty_strategy(list); + + // `descr_sort`'s `finally` puts the items back unconditionally, + // discarding whatever the user stored into the receiver meanwhile. + let restored = order + .into_iter() + .map(|index| pyre_object::gc_roots::shadow_stack_get(item_base + index)) + .collect(); + pyre_object::listobject::w_list_init_items(list, restored); + sorted?; + if mucked { + return Err(crate::PyError::new( + crate::PyErrorKind::ValueError, + "list modified during sort", + )); + } + } + Ok(()) +} + +/// `IntegerListStrategy.sort` (`listobject.py:1963`) / `FloatListStrategy.sort` +/// (`:2067`) on the unwrapped storage. +/// +/// `descr_sort`'s reverse handling — reverse, stable ascending sort, reverse +/// again — is kept here rather than the strategies' single trailing +/// `l.reverse()`: for `float` the difference is observable, since `-0.0` and +/// `0.0` compare equal but are distinct values whose relative order a stable +/// sort must preserve. +fn sort_scalars(items: &mut [T], reverse: bool) -> Result<(), crate::PyError> +where + ScalarLt: crate::listsort::SortLt, +{ + if reverse { + items.reverse(); + } + crate::listsort::sort_with(items, &mut ScalarLt)?; + if reverse { + items.reverse(); + } + Ok(()) +} + +/// `listobject.py:2429 IntSort.lt` / `:2434 FloatSort.lt` — a direct scalar +/// comparison, never a dunder. +struct ScalarLt; + +impl crate::listsort::SortLt for ScalarLt { + fn lt(&mut self, a: i64, b: i64) -> Result { + Ok(a < b) + } +} + +impl crate::listsort::SortLt for ScalarLt { + fn lt(&mut self, a: f64, b: f64) -> Result { + Ok(a < b) } - let item_len = pyre_object::gc_roots::shadow_stack_len() - item_base; - let order = sort_rooted_items(item_base, item_len, key_fn, reverse)?; - let result = order - .into_iter() - .map(|index| pyre_object::gc_roots::shadow_stack_get(item_base + index)) - .collect(); - Ok(w_list_new(result)) } /// Sort rooted item slots and return the resulting permutation. All object /// references that survive a Python call live in the shadow stack; the sort /// itself only moves integer indices. +/// +/// The permutation comes back even when a key call or a comparison raises, +/// because `descr_sort` puts `sorter.list` back from a `finally` — an +/// interrupted sort leaves the list holding its elements in whatever order the +/// merge had reached, not the order it started in. pub(crate) fn sort_rooted_items( item_base: usize, item_len: usize, key_fn: Option, reverse: bool, -) -> Result, crate::PyError> { +) -> (Vec, Result<(), crate::PyError>) { + let mut order: Vec = (0..item_len).collect(); let _key_roots = pyre_object::gc_roots::push_roots(); let key_base = pyre_object::gc_roots::shadow_stack_len(); let key_fn_slot = key_fn.map(|key| { @@ -10670,105 +11416,131 @@ pub(crate) fn sort_rooted_items( let key_base = key_base + usize::from(key_fn_slot.is_some()); if let Some(key_fn_slot) = key_fn_slot { for index in 0..item_len { - let key = crate::call::call_function_impl_result( + // `_compute_keys_for_sorting` (listobject.py:894) runs before the + // `reverse` flip, so a raising key leaves the input order. + match crate::call::call_function_impl_result( pyre_object::gc_roots::shadow_stack_get(key_fn_slot), &[pyre_object::gc_roots::shadow_stack_get(item_base + index)], - )?; - pyre_object::gc_roots::pin_root(key); + ) { + Ok(key) => pyre_object::gc_roots::pin_root(key), + Err(err) => return (order, Err(err)), + } } } - let mut order: Vec = (0..item_len).collect(); - // `rpython/rlib/listsort.py listsort.lt` defers to - // `space.lt(a, b)` and propagates exceptions; if the user's - // `__lt__` raises, sort halts with that error. Rust's - // `sort_by` closure cannot return Result, so capture the first - // error via a Cell and surface it after the sort completes. - // `pypy/objspace/std/listobject.py descr_sort` reverses before and after a stable - // ascending sort for `reverse=True`, so equal elements keep their - // original relative order (a stable descending sort). A single - // post-sort reverse would instead flip ties. + // `descr_sort` reverses before and after a stable ascending sort for + // `reverse=True`, so equal elements keep their original relative order (a + // stable descending sort). A single post-sort reverse would instead flip + // ties. if reverse { order.reverse(); } - let sort_error: std::cell::Cell> = std::cell::Cell::new(None); - let sort_lt = |left: usize, right: usize| -> bool { - if sort_error - .take() - .map(|e| { - sort_error.set(Some(e)); - true - }) - .unwrap_or(false) - { - return false; - } - let left = pyre_object::gc_roots::shadow_stack_get(if key_fn_slot.is_some() { - key_base + left - } else { - item_base + left - }); - let right = pyre_object::gc_roots::shadow_stack_get(if key_fn_slot.is_some() { - key_base + right - } else { - item_base + right - }); - match crate::baseobjspace::compare(left, right, crate::baseobjspace::CompareOp::Lt) { - Ok(r) => crate::baseobjspace::is_true(r).unwrap_or_else(|e| { - sort_error.set(Some(e)); - false - }), - Err(e) => { - sort_error.set(Some(e)); - false - } - } - }; - order.sort_by(|left, right| { - let ab = sort_lt(*left, *right); - if ab { - return std::cmp::Ordering::Less; - } - let ba = sort_lt(*right, *left); - if ba { - return std::cmp::Ordering::Greater; - } - // Fast-path tail kept for the cases where `compare` returns - // `False` for both directions (legacy unhashable / unorderable - // pairs that pyre still has) — preserves prior behaviour. + let base = if key_fn_slot.is_some() { + key_base + } else { + item_base + }; + let mut compare = sort_compare_for(base, item_len, key_fn_slot.is_some()); + let result = crate::listsort::sort_with(&mut order, &mut compare); + // Second half of the double-reverse (see above), which runs on the raising + // path too — an interrupted `reverse=True` sort must not leave the list in + // the opposite orientation from the one it was handed. `descr_sort` puts + // this reverse inside its `try` and so skips it; `list_sort_impl` does not, + // and that is the behaviour to match. + if reverse { + order.reverse(); + } + (order, result) +} + +/// The comparison primitive `listsort.py`'s TimSort drives, holding the +/// shadow-stack base of the values being compared (the keys under `key=`, +/// otherwise the items themselves). +/// +/// `descr_sort` (`listobject.py:809`) resolves the sorter class from the +/// list's storage strategy, so an integer-strategy list sorts through +/// `IntSort` (`listobject.py:2429`) and never dispatches a comparison dunder +/// at all; `FloatSort` (`:2434`) is the same for floats, and `SimpleSort` +/// (`:2423`) / `CustomKeySort` (`:2446`) are the generic `space.lt` path. +enum SortCompare { + Int(usize), + Float(usize), + Str(usize), + Generic(usize), +} + +impl crate::listsort::SortLt for SortCompare { + fn lt(&mut self, a: usize, b: usize) -> Result { + let (Self::Int(base) | Self::Float(base) | Self::Str(base) | Self::Generic(base)) = *self; + let left = pyre_object::gc_roots::shadow_stack_get(base + a); + let right = pyre_object::gc_roots::shadow_stack_get(base + b); + // Each arm is what `compare_slot` reaches for that exact pair, taken + // directly: `int_lt`'s `int_value`, `float_lt`'s unwrapped `<`, and the + // WTF-8 byte order the str arm compares on. unsafe { - let left = pyre_object::gc_roots::shadow_stack_get(if key_fn_slot.is_some() { - key_base + *left - } else { - item_base + *left - }); - let right = pyre_object::gc_roots::shadow_stack_get(if key_fn_slot.is_some() { - key_base + *right - } else { - item_base + *right - }); - if is_int(left) && is_int(right) { - return w_int_get_value(left).cmp(&w_int_get_value(right)); - } - if is_str(left) && is_str(right) { - return w_str_get_value(left).cmp(w_str_get_value(right)); - } - if is_float(left) && is_float(right) { - return pyre_object::w_float_get_value(left) - .partial_cmp(&pyre_object::w_float_get_value(right)) - .unwrap_or(std::cmp::Ordering::Equal); + match self { + Self::Int(_) => Ok(crate::objspace::descroperation::int_value(left) + < crate::objspace::descroperation::int_value(right)), + Self::Float(_) => Ok( + pyre_object::w_float_get_value(left) < pyre_object::w_float_get_value(right) + ), + Self::Str(_) => { + Ok(w_str_get_wtf8(left).as_bytes() < w_str_get_wtf8(right).as_bytes()) + } + Self::Generic(_) => { + let result = crate::baseobjspace::compare( + left, + right, + crate::baseobjspace::CompareOp::Lt, + )?; + crate::baseobjspace::is_true(result) + } } - std::cmp::Ordering::Equal } - }); - if let Some(err) = sort_error.take() { - return Err(err); } - // Second half of the `reverse=True` double-reverse (see above). - if reverse { - order.reverse(); +} + +/// Pick the sorter the way `descr_sort` picks it from the list's strategy. +/// +/// pyre's list stores plain object references with no strategy to read, so the +/// same decision is one pass over the values. What makes the specialization +/// equivalent is that a subclass carrying its own `__lt__` must fail the test +/// and take the generic path, exactly as it would keep an object-strategy list +/// upstream — so the test has to be `is_exact_type` (pyobject.rs:179), which +/// compares the instance's `w_class` against the builtin's type object and so +/// rejects a subclass, which retags `w_class` to its own. `is_int` / `is_str` +/// / `is_float` are NOT usable here: they are `py_type_check`, an `ob_type` +/// layout test a subclass instance also passes because it shares the builtin +/// vtable. A `key=` sort is `CustomKeySort`, generic upstream as well. +fn sort_compare_for(base: usize, len: usize, keyed: bool) -> SortCompare { + if keyed { + return SortCompare::Generic(base); + } + let (mut all_int, mut all_float, mut all_str) = (true, true, true); + for index in 0..len { + let item = pyre_object::gc_roots::shadow_stack_get(base + index); + unsafe { + // `bool` is admitted alongside `int` because it cannot be + // subclassed, so it can never carry an overriding `__lt__`, and + // `int_value` reads it the same way. + all_int &= pyre_object::is_exact_type(item, &pyre_object::INT_TYPE) + || pyre_object::is_exact_type(item, &pyre_object::BOOL_TYPE); + all_float &= pyre_object::is_exact_type(item, &pyre_object::FLOAT_TYPE); + all_str &= pyre_object::is_exact_type(item, &pyre_object::STR_TYPE); + } + if !(all_int || all_float || all_str) { + return SortCompare::Generic(base); + } + } + if all_int { + SortCompare::Int(base) + } else if all_float { + SortCompare::Float(base) + } else if all_str { + SortCompare::Str(base) + } else { + SortCompare::Generic(base) } - Ok(order) } /// `any(iterable)` — PyPy: operation.py any @@ -12322,6 +13094,11 @@ fn fileio_close_owned_fd(fd: i32) { /// wrapper. In particular, binary unbuffered I/O returns the raw `FileIO`. pub fn builtin_open(args: &[PyObjectRef]) -> Result { let (positional, kwargs) = split_builtin_kwargs(args); + // Every declared slot binds before the unrecognized keywords are + // reported, so a call missing `file` names `file`. + let file = bind_pos_or_kw(positional, kwargs, 0, "file", "open", 1)?.ok_or_else(|| { + crate::PyError::type_error("open() missing required argument 'file' (pos 1)") + })?; kwarg_reject_unknown( kwargs, &[ @@ -12336,8 +13113,6 @@ pub fn builtin_open(args: &[PyObjectRef]) -> Result ], "open", )?; - let file = bind_pos_or_kw(positional, kwargs, 0, "file", "open", 1)? - .ok_or_else(|| crate::PyError::type_error("open() missing 'file' argument"))?; let w_mode = bind_pos_or_kw(positional, kwargs, 1, "mode", "open", 2)?.unwrap_or_else(|| w_str_new("r")); if unsafe { !pyre_object::is_str(w_mode) } { @@ -12549,7 +13324,9 @@ pub fn builtin_open(args: &[PyObjectRef]) -> Result /// has constructed the real `_io.FileIO` object. fn open_raw_file(args: &[PyObjectRef]) -> Result { if args.is_empty() { - return Err(crate::PyError::type_error("open() missing 'file' argument")); + return Err(crate::PyError::type_error( + "open() missing required argument 'file' (pos 1)", + )); } let (open_pos, open_kwargs) = split_builtin_kwargs(args); kwarg_reject_unknown( @@ -12567,7 +13344,9 @@ fn open_raw_file(args: &[PyObjectRef]) -> Result { "open", )?; let path_obj = resolve_pos_or_kw(open_pos.first().copied(), open_kwargs, "file", "open", 1)? - .ok_or_else(|| crate::PyError::type_error("open() missing 'file' argument"))?; + .ok_or_else(|| { + crate::PyError::type_error("open() missing required argument 'file' (pos 1)") + })?; let mode_obj = resolve_pos_or_kw(open_pos.get(1).copied(), open_kwargs, "mode", "open", 2)?; let encoding_obj = resolve_pos_or_kw(open_pos.get(3).copied(), open_kwargs, "encoding", "open", 4)?; @@ -12922,11 +13701,12 @@ fn builtin_all(args: &[PyObjectRef]) -> Result { /// `sum(sequence, start=0)` — PyPy `__builtin__/app_functional.py sum`. /// -/// A plain left-fold through `space.add` (`_regular_sum`'s -/// `last = last + x`). No Kahan/Neumaier compensation: float operands -/// accumulate with ordinary left-to-right IEEE rounding, exactly as PyPy -/// does (`sum([0.1, 0.2, 0.3])` is `0.6000000000000001`, not `0.6`). A -/// `str`/`bytes`/`bytearray` `start` is rejected up front. +/// A left-fold through `space.add` (`_regular_sum`'s `last = last + x`) while +/// the running total is an exact int, then the improved Kahan-Babuška +/// (Neumaier) compensated float accumulator `builtin_sum_impl` uses — so +/// `sum([0.1] * 10)` is exactly `1.0` rather than the naive partial sum +/// `functional.py:_sum` produces. A `str`/`bytes`/`bytearray` `start` is +/// rejected up front. fn builtin_sum(args: &[PyObjectRef]) -> Result { // `sum(iterable, /, start=0)`: iterable is positional-only, start is // positional-or-keyword; at most two arguments total. @@ -12964,13 +13744,81 @@ fn builtin_sum(args: &[PyObjectRef]) -> Result { // (so generators, ranges, sets, dict views, ... all work). Very // intentionally `last + x`, not `+=` — preserving a mutable `start` // (e.g. a list) matches PyPy's app-level definition. + let items = crate::builtins::collect_iterable(iterable)?; let mut last = start; - for item in crate::builtins::collect_iterable(iterable)? { + let mut i = 0; + // `builtin_sum_impl` runs an exact-int accumulator until the running + // total turns into an exact float, then a float accumulator that carries + // the improved Kahan-Babuška (Neumaier) compensation term — so + // `sum([0.1] * 10)` is exactly `1.0` rather than the naive partial sum + // `functional.py:_sum` produces. The int phase is the generic `last + x` + // loop, which already keeps exact ints exact and promotes to a float on + // the first float item, so only the float phase needs its own arithmetic. + while i < items.len() + && unsafe { is_exact_int_operand(last) } + && !unsafe { is_exact_float_operand(last) } + { + last = crate::baseobjspace::add(last, items[i])?; + i += 1; + } + if unsafe { is_exact_float_operand(last) } { + let mut total = unsafe { pyre_object::w_float_get_value(last) }; + let mut compensation = 0.0f64; + while i < items.len() { + let item = items[i]; + // A float *subclass* leaves the fast path — its `__add__` may be + // overridden — while `bool` and an `int` subclass stay in it, + // matching the `PyFloat_CheckExact` / `PyLong_Check` asymmetry of + // the C loop. An int too wide for a machine word leaves it too, + // the way `PyLong_AsLongAndOverflow` signals overflow. + let x = unsafe { + if pyre_object::is_float(item) && pyre_object::is_exact_builtin_instance(item) { + pyre_object::w_float_get_value(item) + } else if pyre_object::pyobject::is_int(item) { + pyre_object::w_int_get_value(item) as f64 + } else { + break; + } + }; + let t = total + x; + compensation += if total.abs() >= x.abs() { + (total - t) + x + } else { + (x - t) + total + }; + total = t; + i += 1; + } + // The residual is folded back only while the total is still finite. + // Once it reaches an infinity the term degenerates — `(total - t)` is + // `inf - inf` — and adding that NaN in would report `sum([inf, 1.0])` + // as NaN instead of `inf`. + last = pyre_object::w_float_new(if total.is_finite() { + total + compensation + } else { + total + }); + } + for &item in &items[i..] { last = crate::baseobjspace::add(last, item)?; } Ok(last) } +/// `PyLong_CheckExact` — an exact `int`, excluding `bool` and any subclass. +unsafe fn is_exact_int_operand(obj: PyObjectRef) -> bool { + unsafe { + pyre_object::pyobject::is_int_or_long(obj) + && !pyre_object::pyobject::is_bool(obj) + && pyre_object::is_exact_builtin_instance(obj) + } +} + +/// `PyFloat_CheckExact` — an exact `float`, excluding any subclass. +unsafe fn is_exact_float_operand(obj: PyObjectRef) -> bool { + unsafe { pyre_object::is_float(obj) && pyre_object::is_exact_builtin_instance(obj) } +} + /// `round(number, ndigits=None)` — PyPy: operation.py round /// Round half to even (banker's rounding), matching Python 3 semantics. fn round_half_even(v: f64) -> f64 { @@ -13025,10 +13873,10 @@ pub(crate) fn builtin_round(args: &[PyObjectRef]) -> Result Result { )); } if args.len() != 2 { + // `_PyArg_CheckPositional` names the function bare and parenthesis-free + // once it declares two or more arguments. return Err(crate::PyError::type_error(format!( - "divmod() takes exactly two arguments ({} given)", + "divmod expected 2 arguments, got {}", args.len() ))); } @@ -13194,13 +14044,13 @@ fn builtin_pow(args: &[PyObjectRef]) -> Result { "pow() takes at most 3 arguments ({total} given)" ))); } - kwarg_reject_unknown(kwargs, &["base", "exp", "mod"], "pow")?; let base = bind_pos_or_kw(pos, kwargs, 0, "base", "pow", 1)?.ok_or_else(|| { crate::PyError::type_error("pow() missing required argument 'base' (pos 1)") })?; let exp = bind_pos_or_kw(pos, kwargs, 1, "exp", "pow", 2)?.ok_or_else(|| { crate::PyError::type_error("pow() missing required argument 'exp' (pos 2)") })?; + kwarg_reject_unknown(kwargs, &["base", "exp", "mod"], "pow")?; let modulus = bind_pos_or_kw(pos, kwargs, 2, "mod", "pow", 3)?; match modulus { Some(m) if !unsafe { pyre_object::is_none(m) } => crate::baseobjspace::pow3(base, exp, m), @@ -13515,10 +14365,9 @@ fn builtin_format(args: &[PyObjectRef]) -> Result { )); } if args.is_empty() { - return Err(crate::PyError::type_error(format!( - "format() takes at least one argument ({} given)", - args.len() - ))); + return Err(crate::PyError::type_error( + "format expected at least 1 argument, got 0", + )); } if args.len() > 2 { return Err(crate::PyError::type_error(format!( diff --git a/pyre/pyre-interpreter/src/call.rs b/pyre/pyre-interpreter/src/call.rs index 8976819d256..65bb1321c11 100644 --- a/pyre/pyre-interpreter/src/call.rs +++ b/pyre/pyre-interpreter/src/call.rs @@ -169,14 +169,26 @@ type DepthBumpFn = fn() -> Option>; static DEPTH_BUMP_OVERRIDE: OnceLock = OnceLock::new(); thread_local! { - /// Call depth counter — incremented on every user function call, - /// decremented on return. Replaces the Box depth bump - /// callback with a zero-allocation TLS increment. - static CALL_DEPTH: Cell = const { Cell::new(0) }; + /// Python recursion depth — the number of user Python frames currently + /// executing bytecode on this thread. Bumped once at every `eval_loop` / + /// `eval_loop_jit` entry and dropped when that activation returns, so the + /// module-level frame, an `exec`ed body and a resumed generator each cost + /// one unit exactly like a called function does. `stack_check()` compares + /// it against `sys.getrecursionlimit()`. + static PY_RECURSION_DEPTH: Cell = const { Cell::new(0) }; + + /// The innermost frame whose activation has already spent its + /// [`PY_RECURSION_DEPTH`] unit. A frame is executed through nested entry + /// points — the JIT wrapper may run it as compiled code, hand it to the + /// JIT eval loop, or decline and re-enter the plain evaluator for the very + /// same frame — and only the outermost of those pays. Any frame reached + /// from here is a different, simultaneously-live frame, so its address + /// cannot collide with the one recorded. + static ACCOUNTED_ACTIVATION: Cell = const { Cell::new(0) }; /// Monotonic count of Python frame eval-loop entries — bumped once per /// `eval_loop` / `eval_loop_jit` entry (every user-level bytecode frame - /// that begins running), NEVER decremented. Unlike [`CALL_DEPTH`] (net + /// that begins running), NEVER decremented. Unlike [`PY_RECURSION_DEPTH`] (net /// zero after a balanced call returns), this is a cumulative odometer, so /// a snapshot taken before a residual call and re-read after it reveals /// whether ANY user Python frame ran during the call regardless of how @@ -189,15 +201,16 @@ thread_local! { static FRAME_ENTRY_COUNT: Cell = const { Cell::new(0) }; } -/// Get current call depth. Used by pyre-jit for JIT_CALL_DEPTH parity. +/// Number of user Python frames currently executing bytecode on this thread. +/// Used by pyre-jit for JIT_CALL_DEPTH parity. /// /// The counter is runtime-mutable execution-context state. Like /// [`frame_entry_count`], its TLS read has no source-translatable graph and /// must remain a residual read rather than exposing `LocalKey::with` to the /// annotator. #[majit_macros::dont_look_inside] -pub fn call_depth() -> u32 { - CALL_DEPTH.with(|d| d.get()) +pub fn py_recursion_depth() -> u32 { + PY_RECURSION_DEPTH.with(|d| d.get()) } /// Snapshot of the monotonic Python frame eval-loop entry odometer @@ -222,20 +235,58 @@ pub fn bump_frame_entry_count() { FRAME_ENTRY_COUNT.with(|c| c.set(c.get().wrapping_add(1))); } -/// Increment call depth and return an RAII guard that decrements on drop. -/// Used by _flat_pycall to match call_user_function's depth tracking. -#[inline(always)] -pub fn increment_call_depth() -> CallDepthGuardPublic { - CALL_DEPTH.with(|d| d.set(d.get() + 1)); - CallDepthGuardPublic +/// Spend one unit of the recursion budget on `frame`'s activation, returning a +/// guard that gives it back when the activation finishes. Re-entering for a +/// frame that is already accounted spends nothing, so a frame costs exactly one +/// unit whether it runs as compiled code, through the JIT eval loop, or in the +/// plain evaluator. `pyframe.py:360` (`execute_frame.insert_stack_check_here`) +/// puts the matching stack check at the same seam. +#[inline] +pub fn enter_recursive_frame(frame: *const PyFrame) -> RecursionDepthGuard { + let key = frame as usize; + if ACCOUNTED_ACTIVATION.with(|c| c.get()) == key { + return RecursionDepthGuard { + prev: key, + spent: false, + }; + } + PY_RECURSION_DEPTH.with(|d| d.set(d.get() + 1)); + RecursionDepthGuard { + prev: ACCOUNTED_ACTIVATION.with(|c| c.replace(key)), + spent: true, + } } -/// RAII guard that decrements CALL_DEPTH on drop. -pub struct CallDepthGuardPublic; -impl Drop for CallDepthGuardPublic { - #[inline(always)] +/// Spend one unit of the recursion budget on a dispatch level that pushes no +/// Python frame, returning the same guard [`enter_recursive_frame`] returns. +/// +/// The self-referential `A.__call__ = A()` chain recurses through +/// `user_call_slot` natively and never reaches a frame activation, so there is +/// no activation to key on: the unit is spent unconditionally and +/// `ACCOUNTED_ACTIVATION` is carried through unchanged, leaving the next real +/// activation to account for itself. +#[inline] +pub fn enter_native_dispatch() -> RecursionDepthGuard { + PY_RECURSION_DEPTH.with(|d| d.set(d.get() + 1)); + RecursionDepthGuard { + prev: ACCOUNTED_ACTIVATION.with(|c| c.get()), + spent: true, + } +} + +/// RAII guard that releases the [`PY_RECURSION_DEPTH`] unit on drop. +pub struct RecursionDepthGuard { + prev: usize, + spent: bool, +} + +impl Drop for RecursionDepthGuard { + #[inline] fn drop(&mut self) { - CALL_DEPTH.with(|d| d.set(d.get().saturating_sub(1))); + if self.spent { + PY_RECURSION_DEPTH.with(|d| d.set(d.get().saturating_sub(1))); + ACCOUNTED_ACTIVATION.with(|c| c.set(self.prev)); + } } } @@ -707,8 +758,6 @@ pub fn call_user_function_resolved( callable: PyObjectRef, args: &[PyObjectRef], ) -> PyResult { - let _depth_guard = increment_call_depth(); - let w_code = unsafe { crate::getcode(callable) }; let w_globals = unsafe { function_get_globals_obj(callable) }; let closure = unsafe { function_get_closure(callable) }; @@ -1358,7 +1407,7 @@ fn call_callable_with_mode( // natively. The call-depth guard counts this dispatch level and, by // dropping only after the call returns, keeps it off the tail so LLVM // cannot rewrite the self-call into a loop that never grows the stack. - let _depth_guard = increment_call_depth(); + let _depth_guard = enter_native_dispatch(); return call_callable_with_mode(frame, target, args, mode); } @@ -1412,7 +1461,6 @@ pub fn call_user_function( callable: PyObjectRef, args: &[PyObjectRef], ) -> PyResult { - let _depth_guard = increment_call_depth(); let eval_fn = get_eval_fn(); call_user_function_with_eval(frame, callable, args, eval_fn) } @@ -1661,12 +1709,10 @@ pub(crate) fn resolve_kwargs( let Some(kw_name_obj) = kw_name else { continue }; let kw_value = args[n_pos + ki]; - // argument.py:630 — keywords must be strings (check before access) + // argument.py:630 — keywords must be strings (check before access). + // `_PyStack_UnpackDict` names neither the callable nor the key's type. if !unsafe { pyre_object::is_str(kw_name_obj) } { - return Err(crate::PyError::type_error(format!( - "{}() keywords must be strings", - fname - ))); + return Err(crate::PyError::type_error("keywords must be strings")); } // A lone-surrogate keyword name (not valid UTF-8) never equals a // source-level parameter name, so it falls straight to **kwargs or @@ -2164,7 +2210,10 @@ pub fn call_with_kwargs( }; if arity <= 4 && !kwargs.is_empty() { return Err(unsafe { - crate::builtin_code_no_keyword_arguments(code as pyre_object::PyObjectRef) + crate::builtin_code_no_keyword_arguments( + code as pyre_object::PyObjectRef, + pos_args.first().copied(), + ) }); } let mut full_args = pos_args.to_vec(); @@ -2601,7 +2650,7 @@ pub fn call_with_kwargs( // Depth guard: count this dispatch level and, dropping after the call, // keep it off the tail so a self-referential `A.__call__ = A()` // recurses natively for stack_check (see call_callable_with_mode). - let _depth_guard = increment_call_depth(); + let _depth_guard = enter_native_dispatch(); return call_with_kwargs(frame, target, pos_args, kwargs); } @@ -2802,7 +2851,7 @@ pub fn call_function_impl_result( // Depth guard: count this dispatch level and, dropping after the // call, keep it off the tail so a self-referential // `A.__call__ = A()` recurses natively for stack_check. - let _depth_guard = increment_call_depth(); + let _depth_guard = enter_native_dispatch(); return call_function_impl_result(target, args); } } diff --git a/pyre/pyre-interpreter/src/display.rs b/pyre/pyre-interpreter/src/display.rs index 00b9f179118..2620877449a 100644 --- a/pyre/pyre-interpreter/src/display.rs +++ b/pyre/pyre-interpreter/src/display.rs @@ -524,9 +524,18 @@ pub(crate) unsafe fn exc_user_dunder_obj( let Some((src, method)) = crate::baseobjspace::lookup_where_pair(w_class, name) else { return Ok(None); }; + // `object`'s and `BaseException`'s registrations are the two the + // native formatting stands in for, and so are the `descr_str` + // builtins the exception classes install on top of them — calling + // any of those back from here would recurse. A builtin that the + // native path does *not* implement, such as + // `BaseExceptionGroup.__str__`, still has to be dispatched. if method.is_null() || std::ptr::eq(src, crate::typedef::w_object()) { return Ok(None); } + if crate::builtins::is_native_exception_dunder(method) { + return Ok(None); + } if let Some(base) = crate::builtins::lookup_exc_class("BaseException") { if std::ptr::eq(src, base) { return Ok(None); @@ -754,14 +763,19 @@ pub unsafe fn py_repr(obj: PyObjectRef) -> Result { let owner_name = pyre_object::w_type_get_name(owner); format!("") } else if std::ptr::eq(tp, &BUILTIN_FUNCTION_TYPE as *const PyType) { - // function.py:721 BuiltinFunction.descr_function_repr + // function.py:721 BuiltinFunction.descr_function_repr. Same text + // the `__repr__` this type registers in `typedef.rs` produces; + // this native arm is the one `repr()` actually reaches. let name = function_get_name(obj); - format!("") + let w_self = crate::function::function_get_self_or_none(obj); + crate::function::builtin_function_repr_text(name, w_self) } else if std::ptr::eq(tp, &FUNCTION_TYPE as *const PyType) { - // CPython 3.14 func_repr, selected by `init_function_type`. - // Exact builtin values take this fast path instead of dispatching - // through that type-dict descriptor, so it must preserve the same - // address-bearing representation. + // function.py:283 Function.descr_function_repr — + // `self.getrepr(space, 'function %s' % self.qualname)`, and + // `baseobjspace.py:115 getrepr` appends ` at 0x`. Exact + // builtin values take this fast path instead of dispatching + // through the `__repr__` the type registers in `typedef.rs`, so it + // must produce the same address-bearing text. let name = function_get_qualname(obj); format!("") } else if unsafe { pyre_object::is_exception(obj) } { @@ -1008,135 +1022,9 @@ pub unsafe fn py_str(obj: PyObjectRef) -> Result { return Ok(s); } } - // `pypy/module/exceptions/interp_exceptions.py:126-133 - // W_BaseException.descr_str`: - // - // ```python - // def descr_str(self, space): - // lgt = len(self.args_w) - // if lgt == 0: - // return space.newtext('') - // elif lgt == 1: - // return space.str(self.args_w[0]) - // else: - // return space.str(space.newtuple(self.args_w)) - // ``` - // - // PyPy reads `self.args_w` on every call so `e.args = (...)` - // mutations are reflected by subsequent `str(e)` reads. Pyre - // previously returned the constructor-time `message` snapshot, - // which split repr/str apart after the user mutated args. if unsafe { pyre_object::is_exception(obj) } { - // `pypy/module/exceptions/interp_exceptions.py:447-459` - // `W_UnicodeTranslateError.descr_str`, - // `:1061-1071` `W_UnicodeDecodeError.descr_str`, - // `:1175-1191` `W_UnicodeEncodeError.descr_str` — each - // typedef registers `__str__ = interp2app(descr_str)`, - // overriding the inherited `W_BaseException.descr_str`. - // Dispatched on `ExcKind` because Pyre flattens the three - // PyPy subclasses into the single `W_BaseException` - // struct. - let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; - match kind { - pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError => { - return Ok(unicode_translate_error_str(obj)); - } - pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError => { - return Ok(unicode_decode_error_str(obj)); - } - pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError => { - return Ok(unicode_encode_error_str(obj)); - } - // `interp_exceptions.py:540-548 W_KeyError.descr_str` — - // a single-argument KeyError stringifies as `repr(args[0])` - // so `str(KeyError('k'))` is `"'k'"`; with any other arg - // count it falls back to `W_BaseException.descr_str` below. - pyre_object::interp_exceptions::ExcKind::KeyError => { - let args = pyre_object::interp_exceptions::w_exception_get_args(obj); - if !args.is_null() - && pyre_object::is_tuple(args) - && pyre_object::w_tuple_len(args) == 1 - { - let first = pyre_object::w_tuple_getitem(args, 0).unwrap_or(args); - return py_repr(first); - } - } - // `interp_exceptions.py:667-703 W_OSError.descr_str` reads - // the `errno`/`strerror`/`filename`/`filename2` slots: - // the 2-argument form renders as `"[Errno N] strerror"`, - // extended with `": 'filename'"` and `" -> 'filename2'"` - // when those are present. `_init_error` drops filename - // from `args`, so prefer the slot and fall back to the - // positional arg (same 2..=5 gate as the getters) for the - // internal-constructor path that leaves the slots `PY_NULL`. - // Both errno and strerror absent falls back to - // `W_BaseException.descr_str` below. - pyre_object::interp_exceptions::ExcKind::OSError - | pyre_object::interp_exceptions::ExcKind::FileNotFoundError => { - let args = pyre_object::interp_exceptions::w_exception_get_args(obj); - let n = if !args.is_null() && pyre_object::is_tuple(args) { - pyre_object::w_tuple_len(args) - } else { - 0 - }; - let slot_or_arg = |slot: pyre_object::PyObjectRef, - idx: usize| - -> Option { - if !slot.is_null() { - return Some(slot); - } - if (2..=5).contains(&n) && idx < n { - unsafe { pyre_object::w_tuple_getitem(args, idx as i64) } - } else { - None - } - }; - let w_errno = slot_or_arg( - pyre_object::interp_exceptions::w_exception_get_errno(obj), - 0, - ); - let w_strerror = slot_or_arg( - pyre_object::interp_exceptions::w_exception_get_strerror(obj), - 1, - ); - if let (Some(w_errno), Some(w_strerror)) = (w_errno, w_strerror) { - let errno = py_str(w_errno)?; - let strerror = py_str(w_strerror)?; - let w_filename = slot_or_arg( - pyre_object::interp_exceptions::w_exception_get_filename(obj), - 2, - ) - .filter(|&f| !pyre_object::is_none(f)); - if let Some(fname) = w_filename { - let w_filename2 = slot_or_arg( - pyre_object::interp_exceptions::w_exception_get_filename2(obj), - 4, - ) - .filter(|&f| !pyre_object::is_none(f)); - if let Some(fname2) = w_filename2 { - return Ok(format!( - "[Errno {errno}] {strerror}: {} -> {}", - py_repr(fname)?, - py_repr(fname2)? - )); - } - return Ok(format!("[Errno {errno}] {strerror}: {}", py_repr(fname)?)); - } - return Ok(format!("[Errno {errno}] {strerror}")); - } - } - // `interp_exceptions.py:859-883 W_SyntaxError.descr_str` — - // a non-str `msg` stringifies plainly; otherwise the message - // is suffixed with the `basename(filename)` and `line N` / - // `lines N-M` derived from the location attributes. The - // WTF-8 path already implements this; reuse it and drop any - // lone surrogates for the plain-`String` caller. - pyre_object::interp_exceptions::ExcKind::SyntaxError => { - if let Some(w) = exception_descr_str_wtf8(obj)? { - return Ok(w.to_string_lossy().into_owned()); - } - } - _ => {} + if let Some(s) = exception_kind_str(obj)? { + return Ok(s); } // A user subclass that overrides `__str__` shadows the builtin // `W_BaseException.descr_str`; dispatch it before the generic @@ -1147,22 +1035,7 @@ pub unsafe fn py_str(obj: PyObjectRef) -> Result { if let Some(s) = exc_user_dunder(obj, "__str__")? { return Ok(s); } - let args = pyre_object::interp_exceptions::w_exception_get_args(obj); - if args.is_null() { - return Ok(String::new()); - } - if !pyre_object::is_tuple(args) { - return py_str(args); - } - let n: usize = pyre_object::w_tuple_len(args); - if n == 0 { - return Ok(String::new()); - } - if n == 1 { - let first = pyre_object::w_tuple_getitem(args, 0).unwrap_or(args); - return py_str(first); - } - return py_str(args); + return base_exception_str(obj); } // `int`/`float`/... define no `tp_str`, so `str()` falls back to // `repr()` (a `__str__` override wins, otherwise the `__repr__` @@ -1183,6 +1056,175 @@ pub unsafe fn py_str(obj: PyObjectRef) -> Result { } } +/// `pypy/module/exceptions/interp_exceptions.py:126-133 +/// W_BaseException.descr_str`: +/// +/// ```python +/// def descr_str(self, space): +/// lgt = len(self.args_w) +/// if lgt == 0: +/// return space.newtext('') +/// elif lgt == 1: +/// return space.str(self.args_w[0]) +/// else: +/// return space.str(space.newtuple(self.args_w)) +/// ``` +/// +/// PyPy reads `self.args_w` on every call so `e.args = (...)` mutations are +/// reflected by subsequent `str(e)` reads. +/// +/// # Safety +/// `obj` must be a live `W_BaseException`. +pub(crate) unsafe fn base_exception_str(obj: PyObjectRef) -> Result { + unsafe { + let args = pyre_object::interp_exceptions::w_exception_get_args(obj); + if args.is_null() { + return Ok(String::new()); + } + if !pyre_object::is_tuple(args) { + return py_str(args); + } + let n: usize = pyre_object::w_tuple_len(args); + if n == 0 { + return Ok(String::new()); + } + if n == 1 { + let first = pyre_object::w_tuple_getitem(args, 0).unwrap_or(args); + return py_str(first); + } + py_str(args) + } +} + +/// The `descr_str` overrides the builtin exception classes register on top of +/// `W_BaseException.descr_str`, dispatched on the instance's `ExcKind` because +/// pyre flattens PyPy's subclasses into the single `W_BaseException` struct. +/// `None` means the instance's class inherits the base `descr_str`. +/// +/// # Safety +/// `obj` must be a live `W_BaseException`. +pub(crate) unsafe fn exception_kind_str( + obj: PyObjectRef, +) -> Result, crate::PyError> { + unsafe { + // `pypy/module/exceptions/interp_exceptions.py:447-459` + // `W_UnicodeTranslateError.descr_str`, + // `:1061-1071` `W_UnicodeDecodeError.descr_str`, + // `:1175-1191` `W_UnicodeEncodeError.descr_str` — each + // typedef registers `__str__ = interp2app(descr_str)`, + // overriding the inherited `W_BaseException.descr_str`. + // Dispatched on `ExcKind` because Pyre flattens the three + // PyPy subclasses into the single `W_BaseException` + // struct. + let kind = unsafe { pyre_object::w_exception_get_kind(obj) }; + match kind { + pyre_object::interp_exceptions::ExcKind::UnicodeTranslateError => { + return Ok(Some(unicode_translate_error_str(obj))); + } + pyre_object::interp_exceptions::ExcKind::UnicodeDecodeError => { + return Ok(Some(unicode_decode_error_str(obj))); + } + pyre_object::interp_exceptions::ExcKind::UnicodeEncodeError => { + return Ok(Some(unicode_encode_error_str(obj))); + } + // `interp_exceptions.py:540-548 W_KeyError.descr_str` — + // a single-argument KeyError stringifies as `repr(args[0])` + // so `str(KeyError('k'))` is `"'k'"`; with any other arg + // count it falls back to `W_BaseException.descr_str` below. + pyre_object::interp_exceptions::ExcKind::KeyError => { + let args = pyre_object::interp_exceptions::w_exception_get_args(obj); + if !args.is_null() + && pyre_object::is_tuple(args) + && pyre_object::w_tuple_len(args) == 1 + { + let first = pyre_object::w_tuple_getitem(args, 0).unwrap_or(args); + return Ok(Some(py_repr(first)?)); + } + } + // `interp_exceptions.py:667-703 W_OSError.descr_str` reads + // the `errno`/`strerror`/`filename`/`filename2` slots: + // the 2-argument form renders as `"[Errno N] strerror"`, + // extended with `": 'filename'"` and `" -> 'filename2'"` + // when those are present. `_init_error` drops filename + // from `args`, so prefer the slot and fall back to the + // positional arg (same 2..=5 gate as the getters) for the + // internal-constructor path that leaves the slots `PY_NULL`. + // Both errno and strerror absent falls back to + // `W_BaseException.descr_str` below. + pyre_object::interp_exceptions::ExcKind::OSError + | pyre_object::interp_exceptions::ExcKind::FileNotFoundError => { + let args = pyre_object::interp_exceptions::w_exception_get_args(obj); + let n = if !args.is_null() && pyre_object::is_tuple(args) { + pyre_object::w_tuple_len(args) + } else { + 0 + }; + let slot_or_arg = |slot: pyre_object::PyObjectRef, + idx: usize| + -> Option { + if !slot.is_null() { + return Some(slot); + } + if (2..=5).contains(&n) && idx < n { + unsafe { pyre_object::w_tuple_getitem(args, idx as i64) } + } else { + None + } + }; + let w_errno = slot_or_arg( + pyre_object::interp_exceptions::w_exception_get_errno(obj), + 0, + ); + let w_strerror = slot_or_arg( + pyre_object::interp_exceptions::w_exception_get_strerror(obj), + 1, + ); + if let (Some(w_errno), Some(w_strerror)) = (w_errno, w_strerror) { + let errno = py_str(w_errno)?; + let strerror = py_str(w_strerror)?; + let w_filename = slot_or_arg( + pyre_object::interp_exceptions::w_exception_get_filename(obj), + 2, + ) + .filter(|&f| !pyre_object::is_none(f)); + if let Some(fname) = w_filename { + let w_filename2 = slot_or_arg( + pyre_object::interp_exceptions::w_exception_get_filename2(obj), + 4, + ) + .filter(|&f| !pyre_object::is_none(f)); + if let Some(fname2) = w_filename2 { + return Ok(Some(format!( + "[Errno {errno}] {strerror}: {} -> {}", + py_repr(fname)?, + py_repr(fname2)? + ))); + } + return Ok(Some(format!( + "[Errno {errno}] {strerror}: {}", + py_repr(fname)? + ))); + } + return Ok(Some(format!("[Errno {errno}] {strerror}"))); + } + } + // `interp_exceptions.py:859-883 W_SyntaxError.descr_str` — + // a non-str `msg` stringifies plainly; otherwise the message + // is suffixed with the `basename(filename)` and `line N` / + // `lines N-M` derived from the location attributes. The + // WTF-8 path already implements this; reuse it and drop any + // lone surrogates for the plain-`String` caller. + pyre_object::interp_exceptions::ExcKind::SyntaxError => { + if let Some(w) = exception_descr_str_wtf8(obj)? { + return Ok(Some(w.to_string_lossy().into_owned())); + } + } + _ => {} + } + Ok(None) + } +} + /// WTF-8 preserving variant of `py_str` for the `str(x)` path. /// /// Mirrors `py_str` but returns a `Wtf8Buf`, preserving lone surrogates @@ -1339,21 +1381,19 @@ unsafe fn exception_descr_str_wtf8(obj: PyObjectRef) -> Result, } else { None }; - // `have_filename` → `os.path.basename(self.filename or "???")`. + // `have_filename` → `my_basename(self.filename)`. + // `interp_exceptions.py:875` substitutes `"???"` for a falsy + // filename; 3.14 only tests `PyUnicode_Check`, so an *empty* + // filename basenames to the empty string. let w_filename = crate::baseobjspace::syntax_error_attr(obj, "filename"); if pyre_object::pyobject::is_exact_type(w_filename, &STR_TYPE) { let fbuf = pyre_object::w_str_get_wtf8(w_filename).to_wtf8_buf(); - let fname = if fbuf.as_bytes().is_empty() { - Wtf8Buf::from_string("???".to_string()) - } else { - let start = fbuf - .as_bytes() - .iter() - .rposition(|&b| b == b'/') - .map_or(0, |i| i + 1); - fbuf[start..].to_wtf8_buf() - }; - let mut inner = fname; + let start = fbuf + .as_bytes() + .iter() + .rposition(|&b| b == b'/') + .map_or(0, |i| i + 1); + let mut inner = fbuf[start..].to_wtf8_buf(); if let Some(l) = lineno_str { inner.push_str(", "); inner.push_wtf8(&l); diff --git a/pyre/pyre-interpreter/src/error.rs b/pyre/pyre-interpreter/src/error.rs index 9d0b51ac346..e4073078338 100644 --- a/pyre/pyre-interpreter/src/error.rs +++ b/pyre/pyre-interpreter/src/error.rs @@ -188,17 +188,7 @@ impl OperationError { ) -> Result { let w_type = crate::baseobjspace::exception_getclass(w_inst); if w_type.is_null() || !unsafe { crate::baseobjspace::exception_is_valid_class_w(w_type) } { - let constructor = unsafe { crate::display::py_repr(w_constructor) } - .unwrap_or_else(|_| "".to_string()); - let returned_type = if w_type.is_null() { - "".to_string() - } else { - unsafe { crate::display::py_repr(w_type) } - .unwrap_or_else(|_| "".to_string()) - }; - return Err(PyError::type_error(format!( - "calling {constructor} should have returned an instance of BaseException, not {returned_type}" - ))); + return Err(exception_from_call_type_error(w_constructor, w_inst)); } Ok(w_type) } @@ -247,6 +237,25 @@ impl OperationError { } } +/// The TypeError raised when instantiating a raised exception class yields +/// something that is not a `BaseException` instance: +/// "calling C should have returned an instance of BaseException, not T". +/// A direct `raise non_exception`, which never calls a constructor, keeps +/// the ordinary "exceptions must derive from BaseException" wording. +pub fn exception_from_call_type_error(w_constructor: PyObjectRef, w_inst: PyObjectRef) -> PyError { + let constructor = unsafe { crate::display::py_repr(w_constructor) } + .unwrap_or_else(|_| "".to_string()); + let w_type = crate::baseobjspace::exception_getclass(w_inst); + let returned_type = if w_type.is_null() { + "".to_string() + } else { + unsafe { crate::display::py_repr(w_type) }.unwrap_or_else(|_| "".to_string()) + }; + PyError::type_error(format!( + "calling {constructor} should have returned an instance of BaseException, not {returned_type}" + )) +} + /// `pypy/interpreter/error.py:478-509 _break_context_cycle` parity — /// Floyd cycle-detection over the `__context__` chain, breaking the /// loop by writing `None` into the offending link before the new @@ -562,6 +571,22 @@ impl PyError { Self::new(PyErrorKind::ValueError, msg) } + /// Retag the materialised exception to a subclass that shares its + /// layout, the way `os_error_family_new` picks the errno subclass: + /// the `ExcKind` (and so the storage) stays put and only `w_class` + /// moves. Used for `IndentationError` / `TabError`, which are + /// `SyntaxError` subclasses with no storage of their own. + pub fn retag_exception_class(&mut self, class_name: &str) { + if self.exc_object.is_null() { + return; + } + if let Some(w_target) = crate::builtins::lookup_exc_class(class_name) { + unsafe { + (*(self.exc_object as *mut pyre_object::PyObject)).w_class = w_target; + } + } + } + pub fn syntax_error(msg: impl Into) -> Self { Self::new(PyErrorKind::SyntaxError, msg) } @@ -890,6 +915,41 @@ impl PyError { Self::os_error_syscall(errno, pyre_object::PY_NULL) } + /// `PyErr_SetFromErrno(exc)` for an exception class outside the OSError + /// family: `args` becomes the same `(errno, strerror)` pair, but with no + /// `__str__` override to fold it into `"[Errno N] strerror"` the message + /// is the args tuple's own repr — `OverflowError: (34, 'Result too + /// large')`. A libm `ERANGE` out of `float.__pow__` is reported this way; + /// `floatobject.py:941` instead raises a plain `"float power"` message, + /// which 3.14 does not produce. + pub fn errno_pair(kind: PyErrorKind, exc_kind: ExcKind, errno: i32) -> Self { + let strerror = Self::clean_strerror(errno); + // Root the fresh exception across the args allocations: `exc` lives + // only in this Rust local while `w_int_new` / `w_str_new` / + // `w_list_new` run, so a collection there could sweep the unrooted + // exception before `w_exception_set_args` writes through it. + let _roots = pyre_object::gc_roots::push_roots(); + let exc = w_exception_new(exc_kind, &strerror); + pyre_object::gc_roots::pin_root(exc); + let args_list = pyre_object::w_list_new(vec![ + pyre_object::w_int_new(errno as i64), + pyre_object::w_str_new(&strerror), + ]); + unsafe { pyre_object::interp_exceptions::w_exception_set_args(exc, args_list) }; + PyError { + kind, + // Leave the display message empty so `message_text` derives it + // from `exc_object`, whose `descr_str` renders the two-element + // `args` as a tuple repr rather than as a bare string. + message: String::new(), + exc_object: exc, + attach_tb: true, + reraise_lasti: -1, + w_name_context: std::ptr::null_mut(), + w_obj_context: std::ptr::null_mut(), + } + } + /// Raise an OSError carrying the C-level `(errno, strerror)` pair, /// matching `OSError.__init__`'s 2-argument form: `args` becomes /// `(errno, strerror)`, `str(e)` is `"[Errno N] strerror"`, and @@ -1503,20 +1563,26 @@ pub fn write_exception( err: &PyError, include_traceback: bool, ) -> std::io::Result<()> { - if include_traceback { - // `traceback.py:171-194` __cause__ / __context__ chain - // printing. Recurse into the older exception first, emit - // the bridging banner, then print the current exception. - if !err.exc_object.is_null() { - write_chained_context(writer, err.exc_object)?; - } - writeln!(writer, "Traceback (most recent call last):")?; - write_traceback_chain(writer, err)?; - writeln!(writer, "{}", err.render_exception())?; - write_exception_notes(writer, err.exc_object) - } else { - writeln!(writer, "{}", err.render_exception()) + if !include_traceback { + return writeln!(writer, "{}", err.render_exception()); } + if !err.exc_object.is_null() && unsafe { pyre_object::is_exception(err.exc_object) } { + // The instance carries the whole report: the cause/context chain, the + // group tree, the notes and the suggestion suffix all hang off it, so + // render it through the same structured walk `_PyErr_Display` uses + // rather than re-deriving a flat header here. + let _roots = pyre_object::gc_roots::push_roots(); + let exc_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(err.exc_object); + let exc = pyre_object::gc_roots::shadow_stack_get(exc_slot); + let mut context = ExceptionPrintContext::new(exc); + return write_exception_object_recursive(writer, exc, &mut context); + } + // A Rust-side error that never materialised an instance carries no chain, + // no group and no frame list — only the header. + writeln!(writer, "Traceback (most recent call last):")?; + write_traceback_chain(writer, err)?; + writeln!(writer, "{}", err.render_exception()) } /// CPython 3.14 `_PyErr_Display(file, exc_type, exc_value, exc_tb)` shape used @@ -2459,11 +2525,35 @@ fn write_traceback_chain_from_exc( write_traceback_chain_from_tb(writer, tb) } +/// `traceback.py:_RECURSIVE_CUTOFF` — a frame repeating the same +/// `(filename, lineno, name)` is printed at most this many times before the +/// rest collapse into a single `[Previous line repeated N more times]`. +const RECURSIVE_CUTOFF: usize = 3; + +/// `traceback.py:StackSummary.format` collapse line, emitted when a run of +/// identical frames just ended (or the walk finished on one). A run at or +/// below the cutoff was printed in full and needs no marker. +fn write_repeated_frames(writer: &mut W, count: usize) -> std::io::Result<()> { + if count <= RECURSIVE_CUTOFF { + return Ok(()); + } + let count = count - RECURSIVE_CUTOFF; + let plural = if count > 1 { "s" } else { "" }; + writeln!( + writer, + " [Previous line repeated {count} more time{plural}]" + ) +} + fn write_traceback_chain_from_tb( writer: &mut W, mut tb: PyObjectRef, ) -> std::io::Result<()> { let _roots = pyre_object::gc_roots::push_roots(); + // `StackSummary.format` dedup state: the previous frame's identity and how + // many consecutive frames have carried it. + let mut last: Option<(String, i64, String)> = None; + let mut repeats: usize = 0; while !tb.is_null() { let tb_slot = pyre_object::gc_roots::shadow_stack_len(); pyre_object::gc_roots::pin_root(tb); @@ -2503,12 +2593,44 @@ fn write_traceback_chain_from_tb( ) } }; + let key = (filename, lineno, funcname); + if last.as_ref() != Some(&key) { + write_repeated_frames(writer, repeats)?; + last = Some(key.clone()); + repeats = 0; + } + repeats += 1; + if repeats > RECURSIVE_CUTOFF { + let current_tb = pyre_object::gc_roots::shadow_stack_get(tb_slot); + tb = unsafe { crate::pytraceback::w_pytraceback_get_w_next(current_tb) }; + continue; + } + let (filename, lineno, funcname) = key; writeln!( writer, " File \"{}\", line {}, in {}", filename, lineno, funcname )?; - if let Some(line) = read_source_line(&filename, lineno) { + // `FrameSummary._set_lines` collects every line the failing + // instruction spans, so a statement written across several lines (a + // class body, a multi-line call) shows all of them, dedented by the + // indentation they share. + if let Some((start_line, end_line, _, _)) = location + && usize::try_from(lineno).ok() == Some(start_line) + && end_line > start_line + { + let span: Vec = (start_line..=end_line) + .map(|n| { + read_source_line(&filename, n as i64) + .map_or(String::new(), |l| l.trim_end().to_string()) + }) + .collect(); + if !span.iter().all(|line| line.trim().is_empty()) { + for line in dedent_lines(&span) { + writeln!(writer, " {line}")?; + } + } + } else if let Some(line) = read_source_line(&filename, lineno) { let raw_line = line.trim_end_matches(['\n', '\r']); let shown_line = raw_line.trim_start(); writeln!(writer, " {shown_line}")?; @@ -2559,7 +2681,41 @@ fn write_traceback_chain_from_tb( let current_tb = pyre_object::gc_roots::shadow_stack_get(tb_slot); tb = unsafe { crate::pytraceback::w_pytraceback_get_w_next(current_tb) }; } - Ok(()) + write_repeated_frames(writer, repeats) +} + +/// `textwrap.dedent` — drop the longest leading-whitespace prefix shared by +/// every non-blank line; whitespace-only lines normalise to empty. +fn dedent_lines(lines: &[String]) -> Vec { + let mut prefix: Option<&str> = None; + for line in lines { + if line.trim().is_empty() { + continue; + } + let indent = &line[..line.len() - line.trim_start().len()]; + prefix = Some(match prefix { + None => indent, + Some(common) => { + let shared = common + .bytes() + .zip(indent.bytes()) + .take_while(|(a, b)| a == b) + .count(); + &common[..shared] + } + }); + } + let prefix = prefix.unwrap_or(""); + lines + .iter() + .map(|line| { + if line.trim().is_empty() { + String::new() + } else { + line[prefix.len()..].to_string() + } + }) + .collect() } /// `traceback.py:_should_show_carets` special case: a ` = (…)` or @@ -2660,11 +2816,14 @@ fn read_source_line(filename: &str, lineno: i64) -> Option { if lineno <= 0 || filename.is_empty() || filename.starts_with('<') { return None; } - #[cfg(all(feature = "host_env", not(target_arch = "wasm32")))] + #[cfg(feature = "host_env")] { // Read through the import machinery's source provider, not std::fs: // under sandbox that routes the read through the seam to the controller // VFS, so a guest-controlled traceback path cannot leak a host file. + // On wasm32 the provider is the embedder's — the host-FS bridge for the + // native-host build, `NullSourceProvider` in a browser — so the same + // call renders the offending line wherever one is actually reachable. let content = crate::importing::read_source_to_string(std::path::Path::new(filename)).ok()?; content @@ -2672,7 +2831,7 @@ fn read_source_line(filename: &str, lineno: i64) -> Option { .nth((lineno - 1) as usize) .map(|s| s.to_string()) } - #[cfg(any(not(feature = "host_env"), target_arch = "wasm32"))] + #[cfg(not(feature = "host_env"))] { // Sandbox-intentional: PyPy's `error.py:150 linecache.getline` // also returns silently when the source can't be read; with @@ -2693,6 +2852,44 @@ pub fn eprint_exception(err: &PyError, include_traceback: bool) { crate::host_seam::emit_stderr(&buf); } +/// `app_main.py:114-129 handle_sys_exit` — `exitcode = e.code`; `None` exits +/// 0; otherwise `int(exitcode)`, and a value `int()` rejects is printed to +/// stderr with exit status 1. `e.code` itself is `args[0]` for a 1-arg raise +/// and the whole args tuple otherwise (`interp_exceptions.py:993-998 +/// W_SystemExit.descr_init`). A `SystemExit` with no object behind it has no +/// `code` attribute beyond the class default `None`, i.e. a success exit. +/// +/// Lives here rather than in a launcher because pyre has two of them — +/// `pyrex` and the wasm `run_python` entry — and both terminate on the same +/// rule. +pub fn system_exit_code(err: &PyError) -> i32 { + let exc = err.exc_object; + if exc.is_null() { + return 0; + } + let code = match crate::getattr(exc, pyre_object::w_str_new("code")) { + Ok(c) => c, + Err(_) => return 1, + }; + if unsafe { pyre_object::is_none(code) } { + return 0; + } + // `pylifecycle.c _Py_HandleSystemExit` tests `PyLong_Check(exc)` — there is + // no `int()` coercion, so a float or any other non-integer code is printed + // rather than converted. + if unsafe { pyre_object::is_int_or_long(code) } { + // `exitcode = (int)PyLong_AsLong(exc)`: a value too wide for a machine + // word leaves the -1 `PyLong_AsLong` returns on overflow, and the + // narrowing to `int` is a plain truncation. So `SystemExit(10**100)` + // and `SystemExit(-1)` both exit 255. + return crate::baseobjspace::int_w(code).unwrap_or(-1) as i32; + } + let text = + unsafe { crate::display::py_str(code) }.unwrap_or_else(|_| "".to_string()); + crate::host_seam::emit_stderr(format!("{text}\n").as_bytes()); + 1 +} + pub fn get_cleared_operation_error(_space: PyObjectRef) -> OperationError { let _ = _space; OperationError::new(std::ptr::null_mut(), std::ptr::null_mut()) diff --git a/pyre/pyre-interpreter/src/eval.rs b/pyre/pyre-interpreter/src/eval.rs index 0fea46c513a..c8f36fe0bb0 100644 --- a/pyre/pyre-interpreter/src/eval.rs +++ b/pyre/pyre-interpreter/src/eval.rs @@ -1384,6 +1384,27 @@ pub fn normalize_raise_value(value: PyObjectRef) -> PyObjectRef { value } +/// `pyopcode.py:764-766` — `raise Class` instantiates the class, and +/// `normalize_exception` then validates the result. `space.call_function` +/// propagates the constructor's own error in RPython; pyre's returns +/// `PY_NULL` with the error parked in the pending-call slot, so an unchecked +/// null would both lose that error and report the raise as +/// "exceptions must derive from BaseException". +/// +/// # Safety +/// `w_type` must be a live exception class (`exception_is_valid_obj_as_class_w`). +unsafe fn instantiate_raised_class(w_type: PyObjectRef) -> Result { + let result = unsafe { crate::call_function(w_type, &[]) }; + if result.is_null() { + return Err(crate::call::take_call_error() + .unwrap_or_else(|| PyError::type_error("exceptions must derive from BaseException"))); + } + if !unsafe { pyre_object::is_exception(result) } { + return Err(crate::error::exception_from_call_type_error(w_type, result)); + } + Ok(result) +} + /// Normalize the `from` cause of a `raise X from Y` statement: instantiate /// the cause if it is an exception class, validate that the result is /// `None` / a `BaseException` instance, and return a `PyError::type_error` @@ -1824,6 +1845,11 @@ pub(crate) fn eval_frame_plain_with_resume( operr: Option, throw_args: Option<([PyObjectRef; 3], usize)>, ) -> PyResult { + // Spend one unit of the recursion budget on this frame's activation and + // give it back when it returns. Every Python frame costs the same unit — + // module body, called function, `exec`ed code, resumed generator — so the + // depth `stack_check()` reads is the number of live Python frames. + let _recursion_depth = crate::call::enter_recursive_frame(frame); frame.fix_array_ptrs(); if frame.execution_context.is_null() { match prepare_frame_resume(frame, w_inputvalue, operr, throw_args)? { @@ -3403,15 +3429,9 @@ impl OpcodeStepExecutor for PyFrame { unsafe { if crate::baseobjspace::exception_is_valid_obj_as_class_w(w_value) { // pyopcode.py:711-713 — class raise: call the type. - let result = crate::call_function(w_value, &[]); - if pyre_object::is_exception(result) { - attach_raise_cause(result, None)?; - Err(PyError::from_exc_object(result)) - } else { - Err(PyError::type_error( - "exceptions must derive from BaseException", - )) - } + let result = instantiate_raised_class(w_value)?; + attach_raise_cause(result, None)?; + Err(PyError::from_exc_object(result)) } else if pyre_object::is_exception(w_value) { attach_raise_cause(w_value, None)?; Err(PyError::from_exc_object(w_value)) @@ -3443,15 +3463,9 @@ impl OpcodeStepExecutor for PyFrame { pyre_object::gc_roots::pin_root(c); } // pyopcode.py:711-713 — class raise: call the type. - let result = crate::call_function(w_value, &[]); - if pyre_object::is_exception(result) { - attach_raise_cause(result, cause)?; - Err(PyError::from_exc_object(result)) - } else { - Err(PyError::type_error( - "exceptions must derive from BaseException", - )) - } + let result = instantiate_raised_class(w_value)?; + attach_raise_cause(result, cause)?; + Err(PyError::from_exc_object(result)) } else if pyre_object::is_exception(w_value) { attach_raise_cause(w_value, cause)?; Err(PyError::from_exc_object(w_value)) diff --git a/pyre/pyre-interpreter/src/executioncontext.rs b/pyre/pyre-interpreter/src/executioncontext.rs index e54bd6dc352..ed003857d15 100644 --- a/pyre/pyre-interpreter/src/executioncontext.rs +++ b/pyre/pyre-interpreter/src/executioncontext.rs @@ -31,6 +31,25 @@ pub fn force_frame(frame: *mut PyFrame) { } } +/// Force a frame whose fastlocals application code is about to read. +/// +/// RPython needs no such call: storing the frame pointer anywhere the JIT +/// cannot see forces the virtualizable by escape analysis, so +/// `pyframe.py:539 fast2locals` always finds a materialized +/// `locals_cells_stack_w`. Pyre forces through explicit hooks instead, and +/// [`PyExecutionContext::gettopframe_nohidden`] only covers the frames IT +/// walks — a frame handed out some other way (a traceback's `tb_frame`) +/// reaches `fast2locals` unforced, whose null slots render as an EMPTY +/// mapping rather than a stale one. +/// +/// # Safety +/// `frame` must be a live `PyFrame` (or null). +pub fn force_frame_before_locals_read(frame: *mut PyFrame) { + if !frame.is_null() { + force_frame(frame); + } +} + /// `_jit_vref.py:48-52` `vref()` = `jit_force_virtual`. /// /// `topframeref` and every frame's `f_backref` hold a `jit.virtual_ref` — at @@ -1317,6 +1336,14 @@ impl ExecutionContext { /// the Module identity so identity-sensitive callers (PyPy /// `pick_builtin` `if w_builtin is space.builtin: return space.builtin`) /// observe the same object every call. + /// The dict half of [`Self::get_builtin`] — `interp->builtins`, the + /// object 3.14 plants as `__builtins__` in a fresh `exec`/`eval` + /// namespace and in every imported module (only `__main__` gets the + /// module itself). + pub fn get_builtin_dict(&self) -> PyObjectRef { + self.builtins_module + } + pub fn get_builtin(&self) -> PyObjectRef { let cached = execution_context_builtin_cache_get(self); if !cached.is_null() { @@ -1349,15 +1376,12 @@ impl ExecutionContext { } } self.builtin_dict_cache.set(module); - // `pypy/interpreter/baseobjspace.py:647` — - // `self.setitem(self.builtin.w_dict, 'builtins', w_builtin)`. - // After the builtins module exists, install the self-reference - // so `__builtins__.__builtins__ is __builtins__` and - // user-level `import builtins; builtins.__builtins__` round-trips - // through `space.builtin.w_dict[__builtins__]`. - unsafe { - pyre_object::w_dict_setitem_str(self.builtins_module, "__builtins__", module); - } + // `baseobjspace.py:650` installs a self-reference here + // (`self.setitem(self.builtin.w_dict, '__builtins__', w_builtin)`). + // 3.14 does not: `__builtins__` is planted by the frame/import + // machinery into *other* modules' globals, so `builtins` itself has + // no such key and `import builtins; builtins.__builtins__` raises + // AttributeError. module } diff --git a/pyre/pyre-interpreter/src/function.rs b/pyre/pyre-interpreter/src/function.rs index 3392382c7c5..ca66f65d452 100644 --- a/pyre/pyre-interpreter/src/function.rs +++ b/pyre/pyre-interpreter/src/function.rs @@ -760,6 +760,26 @@ pub unsafe fn function_get_self_or_none(obj: PyObjectRef) -> PyObjectRef { } } +/// `meth_repr` — the receiver decides the wording, not how the carrier is +/// spelled: a null or module `m_self` reports as a plain function, anything +/// else as a method of that receiver. A `__new__` entry carries its owning +/// type, so it reads ``. +/// +/// # Safety +/// `w_self` must be null or a valid object pointer. +pub unsafe fn builtin_function_repr_text(name: &str, w_self: PyObjectRef) -> String { + let bound = !w_self.is_null() + && !unsafe { pyre_object::is_none(w_self) } + && !unsafe { pyre_object::is_module(w_self) }; + if !bound { + return format!(""); + } + 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!("") +} + /// CPython 3.14 `meth_reduce`: type-bound builtins reconstruct through /// `getattr(__self__, __name__)`; module-level builtins reduce by qualname. pub unsafe fn descr_builtin_function_reduce(obj: PyObjectRef) -> crate::PyResult { @@ -2813,8 +2833,6 @@ fn _flat_pycall( frame: &mut crate::pyframe::PyFrame, dropvalues: usize, ) -> PyObjectRef { - // call.rs:423-424 parity — increment call depth for JIT depth tracking. - let _depth_guard = crate::call::increment_call_depth(); let w_globals = unsafe { function_get_globals_obj(func) }; let closure = unsafe { function_get_closure(func) }; @@ -2844,6 +2862,14 @@ fn _flat_pycall( for i in 0..nargs { new_frame.set_locals_w(i, frame.peekvalue(nargs - 1 - i)); } + // The callee's locals array is old-gen (`OldGenGc`) and the arguments + // just written into it are young. RPython's GC transform emits the + // old-to-young `write_barrier` (minimark.py:1065) after such a store; + // pyre has no transform pass, so the batch barrier runs here. Until the + // callee frame is installed on the `f_backref` chain nothing else exposes + // these slots, so a minor collection before then would leave every + // argument stale. + crate::pyframe::remember_frame_locals_array(new_frame.locals_cells_stack_w); frame.dropvalues(dropvalues); new_frame.fix_array_ptrs(); @@ -2888,7 +2914,6 @@ fn _flat_pycall_defaults( defs_to_load: usize, dropvalues: usize, ) -> PyObjectRef { - let _depth_guard = crate::call::increment_call_depth(); let w_globals = unsafe { function_get_globals_obj(func) }; let closure = unsafe { function_get_closure(func) }; @@ -2928,6 +2953,9 @@ fn _flat_pycall_defaults( } } + // Same old-to-young barrier as `_flat_pycall`: positional arguments and + // defaults were written into the callee's old-gen locals array. + crate::pyframe::remember_frame_locals_array(new_frame.locals_cells_stack_w); frame.dropvalues(dropvalues); new_frame.fix_array_ptrs(); diff --git a/pyre/pyre-interpreter/src/gateway.rs b/pyre/pyre-interpreter/src/gateway.rs index 5871e4d60c0..34563f7dea9 100644 --- a/pyre/pyre-interpreter/src/gateway.rs +++ b/pyre/pyre-interpreter/src/gateway.rs @@ -625,6 +625,12 @@ pub struct BuiltinCode { /// The pointee is `'static`, so it carries no Drop obligation and is not /// a GC pointer. pub owner: *const MethodOwner, + /// The module this function is defined in — `PyCFunctionObject.m_module`. + /// Empty for a builtin that is not a module-level function, and for + /// `builtins` itself, which is the module `_PyObject_FunctionStr` leaves + /// off (`len()`, not `builtins.len()`). `'static` like `name`, so it is + /// neither a Drop obligation nor a GC pointer. + pub module: &'static str, } /// Fixed payload size used by `gct_fv_gc_malloc`'s `c_size` @@ -725,9 +731,36 @@ fn builtin_code_new_full( fast_natural_arity, sig, owner: std::ptr::null(), + module: "", }) as PyObjectRef } +/// Record the module a function object was defined in and hand the object +/// back, so a registration table can wrap its constructor in place. A +/// non-`BuiltinCode` callable (an app-level def installed by the same table) +/// passes through untouched. +/// +/// The first writer wins: a module registered under two names, or one whose +/// namespace is swept after the table already stamped it, keeps the module +/// that defined the function rather than the one that re-exported it. +pub fn with_module(module: &'static str, func: PyObjectRef) -> PyObjectRef { + unsafe { + // A module namespace holds every kind of value, so the callable check + // has to come before `getcode` reads a `Function` field off it. + if func.is_null() || !crate::function::is_function(func) { + return func; + } + let code = crate::function::getcode(func) as PyObjectRef; + if !code.is_null() && is_builtin_code(code) { + let code = code as *mut BuiltinCode; + if std::ptr::read(&raw const (*code).module).is_empty() { + (*code).module = module; + } + } + } + func +} + /// Allocate a `BuiltinCode` carrying an argument `Signature`. The /// signature is leaked to `'static` so the raw pointer stored on the /// object has no Drop obligation, matching the `func`/`name` convention. @@ -798,13 +831,22 @@ pub unsafe fn builtin_code_call( args: &[PyObjectRef], ) -> Result { let code = obj as *const BuiltinCode; + // The trailing marker dict is not an argument, so every check below reads + // the positional slice: counting it as the receiver would report the + // keyword dict as the object the descriptor was called on. + let (positional, kwargs) = crate::builtins::split_builtin_kwargs(args); + let receiver = positional.first().copied(); let owner = unsafe { (*code).owner }; if !owner.is_null() { let owner = unsafe { &*owner }; - let accepted = matches!(args.first(), Some(&receiver) + let accepted = matches!(receiver, Some(receiver) if owner.is_instance.is_none_or(|is_instance| is_instance(receiver))); if !accepted { - return Err(receiver_mismatch(owner, unsafe { (*code).name }, args)); + return Err(receiver_mismatch( + owner, + unsafe { (*code).name }, + positional, + )); } } // eval.py:16-23 — a `fast_natural_arity` of 0..=4 is the exact positional @@ -812,92 +854,142 @@ pub unsafe fn builtin_code_call( // `PASSTHROUGHARGS1` and the `FLATPYCALL` bit all exceed 4. A body with a // fixed arity indexes its slice directly, so a call that supplies a // different number of arguments is rejected before the body runs. The - // slice length is the positional count on every call but a keyword one, so - // fixed-count bodies reject a trailing keyword marker before it can be read - // as an ordinary argument. + // trailing marker dict occupies a slot of its own, so the keyword one + // argument short of the arity leaves the slice at exactly the declared + // length — the split has to happen before the count is read, not only once + // a length already mismatched. let arity = unsafe { (*code).fast_natural_arity } as usize; if arity <= 4 { - let (positional, kwargs) = crate::builtins::split_builtin_kwargs(args); - if crate::builtins::has_real_kwargs(kwargs) { - return Err(no_keyword_arguments(unsafe { &*code })); + // A builtin registered by arity alone declares no parameter names + // (`sig` is null), so it is positional-only and a keyword cannot name + // anything it accepts. Checked ahead of the count, which is the order + // the messages come in (`','.join('a', 'b', x=1)` reports the keyword). + if unsafe { (*code).sig.is_null() } && crate::builtins::has_real_kwargs(kwargs) { + return Err(no_keyword_arguments(unsafe { &*code }, receiver)); } if positional.len() != arity { - return Err(arity_mismatch(unsafe { &*code }, arity, positional.len())); + return Err(arity_mismatch( + unsafe { &*code }, + receiver, + arity, + positional.len(), + )); } } unsafe { ((*code).func)(args) } } -/// The TypeError raised for a call whose positional count does not match the -/// implementation's. Python 3.14 words this from the entry point's calling -/// convention rather than from a signature, so a builtin registered by arity -/// alone can reproduce it exactly: -/// -/// - one argument (`METH_O`) — `range.count() takes exactly one argument (0 given)` -/// - no arguments (`METH_NOARGS`) — `object.__dir__() takes no arguments (1 given)` -/// - a fixed count — `insert expected 2 arguments, got 1` -/// - a slot wrapper — `expected 1 argument, got 0`, with no qualified name, -/// except the two-argument wrappers (`__setitem__`, `__setattr__`, `__set__`) -/// which keep the bare method name. -/// -/// A descriptor's receiver is not part of the reported count, so the declared -/// arity and the supplied count both drop it. The qualified name follows -/// `func.__qualname__`: a method descriptor reports `type.name`, a -/// module-level builtin its bare name. +/// Wording for a keyword passed to a positional-only builtin. A slot wrapper +/// reports itself as `wrapper NAME()`; everything else uses its qualified +/// name, the same split [`arity_mismatch`] draws. #[cold] #[inline(never)] -fn arity_mismatch(code: &BuiltinCode, expected: usize, given: usize) -> crate::PyError { +fn no_keyword_arguments(code: &BuiltinCode, receiver: Option) -> crate::PyError { + let (owner, qualname) = builtin_names(code, receiver); let name = code.name; + let subject = match owner { + Some(owner) if is_slot_wrapper(owner.type_name, name) => format!("wrapper {name}"), + _ => qualname, + }; + crate::PyError::type_error(format!("{subject}() takes no keyword arguments")) +} + +/// The name a builtin reports itself under (`_PyObject_FunctionStr` of the +/// `__module__` and `__qualname__` a `builtin_function_or_method` carries): a +/// module-level builtin `module.name` — bare for `builtins`, whose module +/// prefix is left off — and a descriptor `TYPE.name`. +/// +/// TYPE is the receiver itself when the receiver IS a type +/// (`int.__subclasses__`) and otherwise the receiver's type — the rule a +/// bound `builtin_function_or_method` follows (`type(__self__).__qualname__`), +/// which is the callable kind pyre hands out for every builtin method. A +/// `method_descriptor` reports the class that declares it instead, a +/// distinction pyre cannot draw with one callable kind; the descriptors whose +/// declaring class is fixed (`object.__sizeof__`) name it themselves. +fn builtin_names( + code: &BuiltinCode, + receiver: Option, +) -> (Option<&'static MethodOwner>, String) { let owner = unsafe { code.owner.as_ref() }; - let qualname = builtin_code_qualname(code); - // A descriptor's receiver is not part of the reported count. - let (wanted, got) = match owner { + let qualname = match owner { + None if code.module.is_empty() || code.module == "builtins" => code.name.to_string(), + None => format!("{}.{}", code.module, code.name), + Some(owner) => { + let ty = match receiver { + Some(r) if unsafe { pyre_object::typeobject::is_type(r) } => { + unsafe { pyre_object::w_type_get_name(r) }.to_string() + } + Some(r) => crate::baseobjspace::object_functionstr_type_name(r), + None => owner.type_name.to_string(), + }; + format!("{ty}.{}", code.name) + } + }; + (owner, qualname) +} + +/// Wording for a call whose positional count does not match the +/// implementation's. A builtin is reported under the convention its C +/// counterpart is written in, which for a fixed-arity implementation follows +/// from the argument count alone once the receiver is discounted: +/// +/// - two or more arguments is argument-clinic's `_PyArg_CheckPositional`: +/// `NAME expected N arguments, got M`, under the BARE name — this is the +/// form for slot wrappers too (`__setitem__ expected 2 arguments, got 0`); +/// - one argument on a slot wrapper drops the name entirely +/// (`expected 1 argument, got 2`), because the wrapper stands in front of +/// every type's slot; +/// - otherwise no arguments is `METH_NOARGS` +/// (`NAME() takes no arguments (M given)`) and exactly one is `METH_O` +/// (`NAME() takes exactly one argument (M given)`). +/// +/// The `at least` / `at most` variants belong to builtins with optional +/// arguments; those carry no fixed arity, so they check their own counts and +/// never reach here. +#[cold] +#[inline(never)] +fn arity_mismatch( + code: &BuiltinCode, + receiver: Option, + expected: usize, + given: usize, +) -> crate::PyError { + let (owner, qualname) = builtin_names(code, receiver); + // A descriptor's slice leads with the receiver; every wording below counts + // the arguments after it. + let (declared, supplied) = match owner { Some(_) => (expected.saturating_sub(1), given.saturating_sub(1)), None => (expected, given), }; - let message = if owner.is_some_and(|owner| is_slot_wrapper(owner.type_name, name)) { - if wanted == 2 { - format!("{name} expected 2 arguments, got {got}") - } else { - format!( - "expected {wanted} argument{}, got {got}", - if wanted == 1 { "" } else { "s" }, - ) - } + let name = code.name; + let slot = owner.is_some_and(|owner| is_slot_wrapper(owner.type_name, name)); + let message = if declared >= 2 { + format!("{name} expected {declared} arguments, got {supplied}") + } else if slot { + format!( + "expected {declared} argument{}, got {supplied}", + if declared == 1 { "" } else { "s" } + ) + } else if declared == 0 { + format!("{qualname}() takes no arguments ({supplied} given)") } else { - match wanted { - 0 => format!("{qualname}() takes no arguments ({got} given)"), - 1 => format!("{qualname}() takes exactly one argument ({got} given)"), - _ => format!("{name} expected {wanted} arguments, got {got}"), - } + format!("{qualname}() takes exactly one argument ({supplied} given)") }; crate::PyError::type_error(message) } -fn builtin_code_qualname(code: &BuiltinCode) -> String { - match unsafe { code.owner.as_ref() } { - Some(owner) => format!("{}.{}", owner.type_name, code.name), - None => code.name.to_string(), - } -} - -fn no_keyword_arguments(code: &BuiltinCode) -> crate::PyError { - // A method carries its owning type, so it names itself `list.append`. - // A module-level builtin has no owner to qualify it with and reports the - // bare name. - crate::PyError::type_error(format!( - "{}() takes no keyword arguments", - builtin_code_qualname(code) - )) -} - -/// Build the keyword-rejection error for a fixed-count BuiltinCode. +/// Build the keyword-rejection error for a fixed-count BuiltinCode, for a +/// caller that rejects the keyword before it reaches [`builtin_code_call`]. +/// `receiver` is the call's first positional argument, if any. /// /// # Safety /// `obj` must point to a valid `BuiltinCode`. #[inline] -pub unsafe fn builtin_code_no_keyword_arguments(obj: PyObjectRef) -> crate::PyError { - unsafe { no_keyword_arguments(&*(obj as *const BuiltinCode)) } +pub unsafe fn builtin_code_no_keyword_arguments( + obj: PyObjectRef, + receiver: Option, +) -> crate::PyError { + unsafe { no_keyword_arguments(&*(obj as *const BuiltinCode), receiver) } } /// Python 3.14 fills a type's slots with `wrapper_descriptor`s and its @@ -905,18 +997,22 @@ pub unsafe fn builtin_code_no_keyword_arguments(obj: PyObjectRef) -> crate::PyEr /// receiver errors differently, so only the slot names need listing — every /// other name is an ordinary method. `__contains__` and `__getitem__` are /// slots everywhere except on the types that expose them through `tp_methods`. -fn is_slot_wrapper(type_name: &str, name: &str) -> bool { +pub(crate) fn is_slot_wrapper(type_name: &str, name: &str) -> bool { match name { - "__contains__" => !matches!(type_name, "dict" | "set" | "frozenset"), - "__getitem__" => !matches!(type_name, "dict" | "list"), + "__contains__" => !matches!(type_name, "dict" | "set" | "frozenset" | "FrameLocalsProxy"), + "__getitem__" => !matches!(type_name, "dict" | "list" | "FrameLocalsProxy"), _ => matches!( name, "__abs__" | "__add__" + | "__aiter__" | "__and__" + | "__anext__" + | "__await__" | "__bool__" | "__buffer__" | "__call__" + | "__del__" | "__delattr__" | "__delete__" | "__delitem__" @@ -931,19 +1027,27 @@ fn is_slot_wrapper(type_name: &str, name: &str) -> bool { | "__hash__" | "__iadd__" | "__iand__" + | "__ifloordiv__" + | "__ilshift__" + | "__imatmul__" + | "__imod__" | "__imul__" | "__index__" | "__init__" | "__int__" | "__invert__" | "__ior__" + | "__ipow__" + | "__irshift__" | "__isub__" | "__iter__" + | "__itruediv__" | "__ixor__" | "__le__" | "__len__" | "__lshift__" | "__lt__" + | "__matmul__" | "__mod__" | "__mul__" | "__ne__" @@ -959,6 +1063,7 @@ fn is_slot_wrapper(type_name: &str, name: &str) -> bool { | "__repr__" | "__rfloordiv__" | "__rlshift__" + | "__rmatmul__" | "__rmod__" | "__rmul__" | "__ror__" @@ -1376,18 +1481,40 @@ mod tests { // (gateway.py visit_fsencode line 365) and by posix call sites that // previously inlined the same extraction. pub fn fsencode_w(obj: pyre_object::PyObjectRef) -> Result { - let data = fsencode_bytes_w(obj)?; - Ok(String::from_utf8_lossy(&data).into_owned()) + Ok(fsencode_w_with_kind(obj)?.0) +} + +/// [`fsencode_w`] paired with the discriminator `posixmodule.c +/// path_converter` derives from the same resolution: `true` when the path +/// resolved to `bytes`, which makes the names the call reports back `bytes` +/// too. Callers that need both must use this rather than re-resolving, +/// because `path_converter` calls `__fspath__` exactly **once** — a second +/// call would let a stateful implementation observe duplicate side effects, or +/// answer `str` first and `bytes` second and leave the reported names +/// describing a different path than the one that was listed. +pub fn fsencode_w_with_kind( + obj: pyre_object::PyObjectRef, +) -> Result<(String, bool), crate::PyError> { + let (data, is_bytes) = fsencode_bytes_w_with_kind(obj)?; + Ok((String::from_utf8_lossy(&data).into_owned(), is_bytes)) } pub fn fsencode_bytes_w(obj: pyre_object::PyObjectRef) -> Result, crate::PyError> { + Ok(fsencode_bytes_w_with_kind(obj)?.0) +} + +/// [`fsencode_bytes_w`] paired with the `bytes`-ness of the resolved object; +/// see [`fsencode_w_with_kind`] for why the two are produced together. +pub fn fsencode_bytes_w_with_kind( + obj: pyre_object::PyObjectRef, +) -> Result<(Vec, bool), crate::PyError> { unsafe { if pyre_object::is_str(obj) { - return fsencode_str_bytes(obj); + return Ok((fsencode_str_bytes(obj)?, false)); } if pyre_object::bytesobject::is_bytes_like(obj) { let data = pyre_object::bytesobject::bytes_like_data(obj); - return Ok(data.to_vec()); + return Ok((data.to_vec(), true)); } } // `type(path).__fspath__(path)` — the descriptor read off the type is @@ -1398,11 +1525,11 @@ pub fn fsencode_bytes_w(obj: pyre_object::PyObjectRef) -> Result, crate: let result = crate::call::call_function_impl_result(fspath_fn, &[obj])?; unsafe { if pyre_object::is_str(result) { - return fsencode_str_bytes(result); + return Ok((fsencode_str_bytes(result)?, false)); } if pyre_object::bytesobject::is_bytes_like(result) { let data = pyre_object::bytesobject::bytes_like_data(result); - return Ok(data.to_vec()); + return Ok((data.to_vec(), true)); } } } diff --git a/pyre/pyre-interpreter/src/host_seam.rs b/pyre/pyre-interpreter/src/host_seam.rs index a1de66f3b49..44af2d25e2d 100644 --- a/pyre/pyre-interpreter/src/host_seam.rs +++ b/pyre/pyre-interpreter/src/host_seam.rs @@ -63,7 +63,7 @@ pub mod sys { pub use ::libc::winsize; // Constants (added as sandbox-reachable modules need them). pub use ::libc::{ - CODESET, EBADF, EINTR, EINVAL, F_OK, LC_ALL, LC_COLLATE, LC_CTYPE, LC_MESSAGES, + AT_FDCWD, CODESET, EBADF, EINTR, EINVAL, F_OK, LC_ALL, LC_COLLATE, LC_CTYPE, LC_MESSAGES, LC_MONETARY, LC_NUMERIC, LC_TIME, O_APPEND, O_CREAT, O_DSYNC, O_EXCL, O_NONBLOCK, O_RDONLY, O_RDWR, O_SYNC, O_TRUNC, O_WRONLY, PRIO_PGRP, PRIO_PROCESS, PRIO_USER, R_OK, RUSAGE_SELF, S_IFDIR, S_IFMT, S_IFREG, SEEK_CUR, SEEK_END, SEEK_SET, TIOCGWINSZ, W_OK, WCONTINUED, @@ -95,6 +95,16 @@ pub mod sys { _SC_XOPEN_REALTIME_THREADS, _SC_XOPEN_SHM, _SC_XOPEN_UNIX, _SC_XOPEN_VERSION, _SC_XOPEN_XCU_VERSION, }; + // `posix.pathconf_names`' `_PC_*` table. `libc` exports these on the BSD + // family only, so the table names them there and spells the glibc values + // out for Linux — this re-export carries the same gate. + #[cfg(any(target_os = "macos", target_os = "ios"))] + pub use ::libc::{ + _PC_ALLOC_SIZE_MIN, _PC_ASYNC_IO, _PC_CHOWN_RESTRICTED, _PC_FILESIZEBITS, _PC_LINK_MAX, + _PC_MAX_CANON, _PC_MAX_INPUT, _PC_MIN_HOLE_SIZE, _PC_NAME_MAX, _PC_NO_TRUNC, _PC_PATH_MAX, + _PC_PIPE_BUF, _PC_PRIO_IO, _PC_REC_INCR_XFER_SIZE, _PC_REC_MAX_XFER_SIZE, + _PC_REC_MIN_XFER_SIZE, _PC_REC_XFER_ALIGN, _PC_SYMLINK_MAX, _PC_SYNC_IO, _PC_VDISABLE, + }; } /// An error from an OS seam operation. Self-contained in the interpreter so the @@ -465,6 +475,9 @@ pub fn getenv(name: &[u8]) -> SeamResult>> { /// caller above it. #[majit_macros::dont_look_inside] pub fn emit_stdout(bytes: &[u8]) { + if crate::print_hook_emit_bytes(bytes) { + return; + } #[cfg(not(feature = "sandbox"))] { use std::io::Write; @@ -484,6 +497,9 @@ pub fn emit_stdout(bytes: &[u8]) { /// `warn_category_w` liftable. #[majit_macros::dont_look_inside] pub fn emit_stderr(bytes: &[u8]) { + if crate::stderr_hook_emit(bytes) { + return; + } #[cfg(not(feature = "sandbox"))] { use std::io::Write; diff --git a/pyre/pyre-interpreter/src/importing.rs b/pyre/pyre-interpreter/src/importing.rs index 0e0bb7a198c..268c538f420 100644 --- a/pyre/pyre-interpreter/src/importing.rs +++ b/pyre/pyre-interpreter/src/importing.rs @@ -482,7 +482,7 @@ pub fn builtin_module_names() -> Vec<&'static str> { /// `default_modules` / `working_modules`) is likewise an explicit set of /// string literals with platform conditionals, not filesystem discovery. /// Automatic discovery is intentionally not done: it could not express -/// the alias arms (`"_operator"` → `operator`), explicit-path arms +/// the alias arms (`"builtins"` → `__builtin__`), explicit-path arms /// (`importlib.machinery` → a non-default init fn), or the /// `#[cfg(unix)]` gating that `resource` / `fcntl` / `syslog` require. pub fn install_builtin_modules() { @@ -901,7 +901,13 @@ fn init_sysconfigdata_empty(ns: PyObjectRef) { /// `allocate_and_init_instance(module=True)`. Pyre mirrors that here: /// the initializer writes directly into a rooted, non-moving module dict. pub(crate) fn load_builtin_module(name: &str) -> Option { - let module_def = BUILTIN_MODULES.lock().unwrap().get(name).copied()?; + // The registry key outlives the module, which is what lets the sweep below + // hand the name to `BuiltinCode.module` without copying it. + let (static_name, module_def) = { + let table = BUILTIN_MODULES.lock().unwrap(); + let (static_name, def) = table.get_key_value(name)?; + (*static_name, *def) + }; let w_dict = pyre_object::dictmultiobject::w_module_dict_new(); let _roots = pyre_object::gc_roots::push_roots(); let save_point = pyre_object::gc_roots::shadow_stack_len(); @@ -938,6 +944,11 @@ pub(crate) fn load_builtin_module(name: &str) -> Option { pyre_object::gc_roots::shadow_stack_get(save_point + 1), ); } + // The same name on the code object, where the error wordings read + // it (`math.sqrt() takes exactly one argument`). A module built by + // a registration table already stamped its own functions, so this + // only reaches the hand-built namespaces. + crate::gateway::with_module(static_name, value); } } let module = pyre_object::w_module_new_aliasing_dict(name, w_dict); diff --git a/pyre/pyre-interpreter/src/jit_fnaddr.rs b/pyre/pyre-interpreter/src/jit_fnaddr.rs index 375920dd73a..f0d3193c1e6 100644 --- a/pyre/pyre-interpreter/src/jit_fnaddr.rs +++ b/pyre/pyre-interpreter/src/jit_fnaddr.rs @@ -1231,8 +1231,8 @@ pub fn jit_trace_fnaddrs() -> Vec<(&'static str, i64)> { ); push_fnaddr( &mut entries, - "pyre_interpreter::call::call_depth", - crate::call::call_depth as *const (), + "pyre_interpreter::call::py_recursion_depth", + crate::call::py_recursion_depth as *const (), ); push_fnaddr( &mut entries, @@ -2983,6 +2983,18 @@ pub fn jit_static_pytype_addrs() -> Vec<(&'static str, i64)> { pytype_addr!("functional::RANGE_ITER_TYPE", functional::RANGE_ITER_TYPE), pytype_addr!("memoryview::MEMORYVIEW_TYPE", memoryview::MEMORYVIEW_TYPE), pytype_addr!("iterobject::SEQ_ITER_TYPE", iterobject::SEQ_ITER_TYPE), + pytype_addr!( + "iterobject::STR_ASCII_ITER_TYPE", + iterobject::STR_ASCII_ITER_TYPE + ), + pytype_addr!("iterobject::STR_ITER_TYPE", iterobject::STR_ITER_TYPE), + pytype_addr!("iterobject::BYTES_ITER_TYPE", iterobject::BYTES_ITER_TYPE), + pytype_addr!( + "iterobject::BYTEARRAY_ITER_TYPE", + iterobject::BYTEARRAY_ITER_TYPE + ), + pytype_addr!("iterobject::MEMORY_ITER_TYPE", iterobject::MEMORY_ITER_TYPE), + pytype_addr!("iterobject::ARRAY_ITER_TYPE", iterobject::ARRAY_ITER_TYPE), pytype_addr!("iterobject::LIST_ITER_TYPE", iterobject::LIST_ITER_TYPE), pytype_addr!( "iterobject::LIST_REVERSE_ITER_TYPE", @@ -3535,8 +3547,11 @@ mod tests { bump ); - let call_depth = crate::call::call_depth as *const () as usize as i64; - assert_eq!(bindings["pyre_interpreter::call::call_depth"], call_depth); + let py_recursion_depth = crate::call::py_recursion_depth as *const () as usize as i64; + assert_eq!( + bindings["pyre_interpreter::call::py_recursion_depth"], + py_recursion_depth + ); let recursion_limit = crate::module::sys::state::recursion_limit as *const () as usize as i64; diff --git a/pyre/pyre-interpreter/src/lib.rs b/pyre/pyre-interpreter/src/lib.rs index 329dadcf047..4075d586aa7 100644 --- a/pyre/pyre-interpreter/src/lib.rs +++ b/pyre/pyre-interpreter/src/lib.rs @@ -66,6 +66,9 @@ pub mod host_seam { /// only this one. #[majit_macros::dont_look_inside] pub fn emit_stdout(bytes: &[u8]) { + if super::print_hook_emit_bytes(bytes) { + return; + } use std::io::Write; let _ = std::io::stdout().write_all(bytes); } @@ -73,6 +76,9 @@ pub mod host_seam { /// Emit bytes to the interpreter's stderr (fd 2). #[majit_macros::dont_look_inside] pub fn emit_stderr(bytes: &[u8]) { + if super::stderr_hook_emit(bytes) { + return; + } use std::io::Write; let _ = std::io::stderr().write_all(bytes); } @@ -86,6 +92,7 @@ pub mod host_seam { pub mod async_operation; pub mod jit_fnaddr; pub mod listobject; +pub mod listsort; pub mod opcode_ops; pub mod pycode; pub mod pyopcode; @@ -306,11 +313,14 @@ macro_rules! py_module { $crate::module_ns_store( ns, stringify!($ifn_name), - $crate::make_module_builtin_function_with_arity_and_maybe_sig( - stringify!($ifn_name), - $ifn_name, - ::paste::paste! { [<$ifn_name _pyre_arity>]() }, - ::paste::paste! { [<$ifn_name _pyre_sig>]() }, + $crate::gateway::with_module( + $name, + $crate::make_module_builtin_function_with_arity_and_maybe_sig( + stringify!($ifn_name), + $ifn_name, + ::paste::paste! { [<$ifn_name _pyre_arity>]() }, + ::paste::paste! { [<$ifn_name _pyre_sig>]() }, + ), ), ); } @@ -318,13 +328,19 @@ macro_rules! py_module { $($( $crate::module_ns_store( ns, $fn_key, - $crate::py_module_fn!($fn_key, $fn_arity, $fn_path), + $crate::gateway::with_module( + $name, + $crate::py_module_fn!($fn_key, $fn_arity, $fn_path), + ), ); )*)? $($( $crate::module_ns_store( ns, $mfn_key, - $crate::py_module_module_fn!($mfn_key, $mfn_arity, $mfn_path), + $crate::gateway::with_module( + $name, + $crate::py_module_module_fn!($mfn_key, $mfn_arity, $mfn_path), + ), ); )*)? $( @@ -950,6 +966,42 @@ pub fn all_foreign_pytypes() -> &'static [( PYTYPES } +/// Managed `#[pyre_class]` types whose only inline GC edge is the header's +/// `w_class`, in the order `build_gc` must register them. `allocate_stable` +/// puts their instances in the old generation, so the marker needs a type id +/// carrying that offset — otherwise a `class L(_thread.LockType)` instance +/// leaves its heap type unreachable. The tail order pins the type ids the +/// alias census below and `SUBCLASS_RANGE_HIERARCHY` assert. +pub fn all_w_class_only_descriptors() -> Vec<&'static pyre_object::lltype::PyreClassDescriptor> { + use pyre_object::lltype::PyreClassPyTypeOf; + vec![ + ::DESCRIPTOR, + ::DESCRIPTOR, + ::DESCRIPTOR, + ] +} + +/// The same edge for `#[pyre_class]` types allocated through the immortal +/// `allocate`: the collector never walks them, so their `w_class` is reached +/// by the interpreter's immortal-root walker instead of a type id, and they +/// take no place in the type-id censuses. Each is behind a target/feature +/// gate this crate spells exactly. +pub fn all_immortal_w_class_only_descriptors() +-> Vec<&'static pyre_object::lltype::PyreClassDescriptor> { + #[allow(unused_imports)] + use pyre_object::lltype::PyreClassPyTypeOf; + vec![ + // `select` is compiled out of a sandbox build (`module/mod.rs:93`), so + // its descriptors carry that gate too. + #[cfg(all(unix, feature = "host_env", not(feature = "sandbox")))] + ::DESCRIPTOR, + #[cfg(all(target_os = "macos", feature = "host_env", not(feature = "sandbox")))] + ::DESCRIPTOR, + #[cfg(all(target_os = "macos", feature = "host_env", not(feature = "sandbox")))] + ::DESCRIPTOR, + ] +} + /// Interpreter-owned PyType aliases in the shared GC inheritance census. /// `pyre-object::pyobject::all_subclass_range_aliases` supplies the object /// layer; `init_typeobjects` passes both slices to the common numbering @@ -1004,35 +1056,101 @@ pub fn all_subclass_range_aliases() -> Vec()), subclass_range_alias(139, typed::()), subclass_range_alias(140, typed::()), + // `all_w_class_only_descriptors` order, registered at the absolute + // tail of `build_gc` after `W_DequeBlock` and `W_BufferWrapper`. + subclass_range_alias(153, typed::()), + subclass_range_alias(154, typed::()), + subclass_range_alias(155, typed::()), ] } -// ── Print hook for wasm (stdout capture) ── -use std::cell::RefCell; -thread_local! { - static PRINT_HOOK: RefCell> = RefCell::new(None); +// ── Print / stderr hooks for wasm (fd-1 / fd-2 capture) ── +// +// An embedder installs these to receive everything the interpreter writes to +// fd 1 and fd 2. The sink belongs to the *process*, not to whichever thread +// installed it: a traceback or warning raised on any interpreter thread has to +// reach the same embedder, and on wasm32 `std::io::stderr().write_all` +// discards the bytes outright, so a thread that saw no hook would lose them. +// Both are plain `fn` pointers, so one atomic word each holds them and the +// write path takes no lock. +use std::sync::atomic::{AtomicUsize, Ordering}; + +static PRINT_HOOK: AtomicUsize = AtomicUsize::new(0); +static STDERR_HOOK: AtomicUsize = AtomicUsize::new(0); + +fn store_hook(slot: &AtomicUsize, hook: fn(&[u8])) { + slot.store(hook as usize, Ordering::Release); +} + +fn load_hook(slot: &AtomicUsize) -> Option { + match slot.load(Ordering::Acquire) { + 0 => None, + // SAFETY: the slot only ever holds a `fn(&[u8])` written by + // `store_hook`, and a function pointer is pointer-sized. + raw => Some(unsafe { std::mem::transmute::(raw) }), + } } -/// Set a hook that receives all `print()` output instead of stdout. -pub fn set_print_hook(hook: fn(&str)) { - PRINT_HOOK.with(|h| *h.borrow_mut() = Some(hook)); +/// Set a hook that receives all fd-1 output instead of stdout. +/// +/// The hook takes bytes rather than `&str` so a write the filesystem or a +/// `sys.stdout.buffer` caller made is handed over unmodified; decoding it is +/// the embedder's decision, not a lossy conversion applied on the way out. +pub fn set_print_hook(hook: fn(&[u8])) { + store_hook(&PRINT_HOOK, hook); +} + +/// Offer already-encoded `bytes` to the print hook. Returns whether a hook +/// consumed them; `false` leaves the caller on its own descriptor path. +pub fn print_hook_emit_bytes(bytes: &[u8]) -> bool { + match load_hook(&PRINT_HOOK) { + Some(hook) => { + hook(bytes); + true + } + None => false, + } +} + +/// [`print_hook_emit_bytes`] for callers holding a `str`. +pub fn print_hook_emit(s: &str) -> bool { + print_hook_emit_bytes(s.as_bytes()) } /// Write a string through the print hook (if set) or stdout. pub fn print_output(s: &str) { - PRINT_HOOK.with(|h| { - if let Some(hook) = *h.borrow() { - hook(s); - } else { - // Under sandbox fd 1 is the marshalling pipe, so route program - // output through ll_os_write(1,…) for the controller to relay; a - // raw `print!` would corrupt the protocol stream. - #[cfg(all(unix, feature = "sandbox"))] - let _ = crate::host_seam::ops::write(1, s.as_bytes()); - #[cfg(not(all(unix, feature = "sandbox")))] - print!("{s}"); + if print_hook_emit(s) { + return; + } + // Under sandbox fd 1 is the marshalling pipe, so route program + // output through ll_os_write(1,…) for the controller to relay; a + // raw `print!` would corrupt the protocol stream. + #[cfg(all(unix, feature = "sandbox"))] + let _ = crate::host_seam::ops::write(1, s.as_bytes()); + #[cfg(not(all(unix, feature = "sandbox")))] + print!("{s}"); +} + +/// Set a hook that receives everything the interpreter writes to fd 2 — +/// `sys.stderr.write`, tracebacks, warnings — instead of the real descriptor. +/// +/// The wasm32 target has no descriptors: `std::io::stderr().write_all` there +/// discards the bytes, so without a hook a traceback simply vanishes. The +/// stdout twin is [`set_print_hook`]. +pub fn set_stderr_hook(hook: fn(&[u8])) { + store_hook(&STDERR_HOOK, hook); +} + +/// Offer `bytes` to the stderr hook. Returns whether a hook consumed them; +/// `false` leaves the caller on its own descriptor path. +pub fn stderr_hook_emit(bytes: &[u8]) -> bool { + match load_hook(&STDERR_HOOK) { + Some(hook) => { + hook(bytes); + true } - }); + None => false, + } } // baseobjspace call helpers are re-exported from `baseobjspace`. diff --git a/pyre/pyre-interpreter/src/listsort.rs b/pyre/pyre-interpreter/src/listsort.rs new file mode 100644 index 00000000000..2f4fe17406d --- /dev/null +++ b/pyre/pyre-interpreter/src/listsort.rs @@ -0,0 +1,699 @@ +//! `rpython/rlib/listsort.py` — the adaptive, stable, natural mergesort +//! (TimSort with the powersort merge policy) behind `list.sort` and `sorted`. +//! +//! Two properties of the upstream algorithm are the reason for the port: +//! +//! * `lt` is THE comparison primitive (`listsort.py:97`: `le` is +//! `not self.lt(b, a)`), so a user `__lt__` runs **exactly once** per +//! comparison. A three-way `Ordering` comparator cannot express that — it +//! needs both directions — so the sort driver itself has to be the one that +//! only ever asks "is a < b". +//! * The merge steps hold elements in a scratch copy and reinstate them in a +//! `finally` (`listsort.py:388`, `:495`), so a comparison that raises still +//! leaves a permutation of the input behind rather than duplicates. +//! +//! `make_timsort_class`'s `getitem`/`setitem`/`length`/`lt` parameters become +//! the [`SortLt`] trait plus a `&mut [usize]` permutation: the array being +//! sorted holds indices into a caller-owned rooted area, so a moving +//! collection during a comparison cannot invalidate anything the sorter holds. + +use crate::PyError; + +/// `listsort.py:93-97` — `lt` is the single comparison primitive and `le` is +/// derived from it. Implementors must not override `le`. +/// +/// `T` is what `make_timsort_class`'s `lt` receives: the list's element type. +/// For an object list that is an index into a rooted area; for the unwrapped +/// strategies (`listobject.py:1963` `IntegerListStrategy.sort`, `:2067` +/// `FloatListStrategy.sort`) it is the scalar itself. +pub(crate) trait SortLt { + fn lt(&mut self, a: T, b: T) -> Result; + + fn le(&mut self, a: T, b: T) -> Result { + Ok(!self.lt(b, a)?) + } +} + +/// `listsort.py:12` `merge_compute_minrun`. +fn merge_compute_minrun(mut n: usize) -> usize { + // Becomes 1 if any 1 bits are shifted off. + let mut r = 0; + while n >= 64 { + r |= n & 1; + n >>= 1; + } + n + r +} + +/// `listsort.py:28` `powerloop` — the "power" (depth in the conceptual binary +/// merge tree) of the run of length `n1` starting at `s1`, followed by a run of +/// length `n2`, in a list of length `n`. +fn powerloop(s1: usize, n1: usize, n2: usize, n: usize) -> usize { + // a = 2 * (s1 + n1/2), b = 2 * (s1 + n1 + n2/2), kept doubled so both + // midpoints stay integral. + let mut a = 2 * s1 + n1; + let mut b = a + n1 + n2; + let mut result = 0; + loop { + result += 1; + if a >= n { + a -= n; + b -= n; + } else if b >= n { + break; + } + a <<= 1; + b <<= 1; + } + result +} + +/// `listsort.py:604` `ListSlice` — a sublist of the array being sorted. +/// +/// `ListSlice.list` is either the array itself or, after `copyitems`, the +/// sorter's scratch copy; `scratch` is that discriminator. +#[derive(Clone, Copy)] +struct ListSlice { + scratch: bool, + base: usize, + len: usize, + power: usize, +} + +impl ListSlice { + fn of_list(base: usize, len: usize) -> Self { + Self { + scratch: false, + base, + len, + power: 0, + } + } +} + +/// `listsort.py:263` — galloping mode is entered after this many consecutive +/// wins by the same run. +const MIN_GALLOP: usize = 7; + +struct TimSort<'a, T: Copy, L: SortLt> { + list: &'a mut [T], + listlength: usize, + scratch_list: Vec, + min_gallop: usize, + pending: Vec, + cmp: &'a mut L, +} + +impl> TimSort<'_, T, L> { + fn slice_get(&self, slice: &ListSlice, index: usize) -> T { + if slice.scratch { + self.scratch_list[index] + } else { + self.list[index] + } + } + + /// `listsort.py:198-201` — `gallop`'s `lower`: `le` searches for the + /// largest `k` with `a[k] <= key`, `lt` for the largest with `a[k] < key`. + fn lower(&mut self, a: T, b: T, rightmost: bool) -> Result { + if rightmost { + self.cmp.le(a, b) + } else { + self.cmp.lt(a, b) + } + } + + /// `listsort.py:669` `ListSlice.reverse`. + fn reverse_slice(&mut self, slice: &ListSlice) { + debug_assert!(!slice.scratch); + if slice.len == 0 { + return; + } + let mut lo = slice.base; + let mut hi = lo + slice.len - 1; + while lo < hi { + self.list.swap(lo, hi); + lo += 1; + hi -= 1; + } + } + + /// `listsort.py:651` `ListSlice.popleft`. + fn popleft(&mut self, slice: &mut ListSlice) -> T { + let result = self.slice_get(slice, slice.base); + slice.base += 1; + slice.len -= 1; + result + } + + /// `listsort.py:657` `ListSlice.popright`. + fn popright(&mut self, slice: &mut ListSlice) -> T { + slice.len -= 1; + self.slice_get(slice, slice.base + slice.len) + } + + /// `listsort.py:622` `ListSlice.copyitems` — move the slice into the + /// sorter's scratch store, growing it when the run does not fit. + fn copyitems(&mut self, slice: &mut ListSlice) { + debug_assert!(!slice.scratch); + if slice.len > self.scratch_list.len() { + let listlength = self.list.len(); + let mut scratchsize = std::cmp::min(listlength.div_ceil(2), 256); + if slice.len > scratchsize { + scratchsize = slice.len; + } + let start = slice.base; + let stop = std::cmp::min(slice.base + scratchsize, listlength); + self.scratch_list = self.list[start..stop].to_vec(); + } else { + let base = slice.base; + for index in 0..slice.len { + self.scratch_list[index] = self.list[base + index]; + } + } + slice.scratch = true; + slice.base = 0; + } + + /// `listsort.py:104` `binarysort` — binary insertion sort of the slice, + /// whose first `sorted` elements are already in order. Stable. + fn binarysort(&mut self, a: &ListSlice, sorted: usize) -> Result<(), PyError> { + debug_assert!(!a.scratch); + let abase = a.base; + for start in (a.base + sorted)..(a.base + a.len) { + let mut l = abase; + let mut r = start; + let pivot = self.list[r]; + // pivot >= all in [base, l) and pivot < all in [r, start). + while l < r { + let p = l + ((r - l) >> 1); + let candidate = self.list[p]; + if self.cmp.lt(pivot, candidate)? { + r = p; + } else { + l = p + 1; + } + } + // Elements equal to the pivot leave `l` past them, which is what + // makes the insertion stable. Slide over to make room. + let mut p = start; + while p > l { + self.list[p] = self.list[p - 1]; + p -= 1; + } + self.list[l] = pivot; + } + Ok(()) + } + + /// `listsort.py:151` `count_run` — length of the longest ascending + /// (`a[0] <= a[1] <= …`) or strictly descending (`a[0] > a[1] > …`) prefix + /// of `a`, written to `run.len`. Returns whether it was descending; the + /// strictness of "descending" is what lets the caller reverse it without + /// breaking stability. + fn count_run(&mut self, a: &ListSlice, run: &mut ListSlice) -> Result { + let descending; + let mut n; + if a.len <= 1 { + n = a.len; + descending = false; + } else { + n = 2; + let first = self.slice_get(a, a.base + 1); + let zeroth = self.slice_get(a, a.base); + if self.cmp.lt(first, zeroth)? { + descending = true; + for p in (a.base + 2)..(a.base + a.len) { + let this = self.slice_get(a, p); + let prev = self.slice_get(a, p - 1); + if self.cmp.lt(this, prev)? { + n += 1; + } else { + break; + } + } + } else { + descending = false; + for p in (a.base + 2)..(a.base + a.len) { + let this = self.slice_get(a, p); + let prev = self.slice_get(a, p - 1); + if self.cmp.lt(this, prev)? { + break; + } + n += 1; + } + } + } + run.len = n; + Ok(descending) + } + + /// `listsort.py:186` `gallop` — locate where `key` belongs in the sorted + /// slice `a`, starting the search at `hint`. With `rightmost`, return the + /// index right of the rightmost equal element, otherwise left of the + /// leftmost one. + /// + /// The offsets are signed because the left-gallop's `hint - ofs` reaches + /// `-1` before the closing binary search bumps it back to `0` + /// (`listsort.py:305` asserts `-1 <= lastofs`). + fn gallop( + &mut self, + key: T, + a: &ListSlice, + hint: usize, + rightmost: bool, + ) -> Result { + debug_assert!(hint < a.len); + let p = a.base + hint; + let hint = hint as isize; + let mut lastofs: isize = 0; + let mut ofs: isize = 1; + let at_hint = self.slice_get(a, p); + if self.lower(at_hint, key, rightmost)? { + // a[hint] < key — gallop right until a[hint+lastofs] < key <= a[hint+ofs]. + let maxofs = a.len as isize - hint; // a[a.len-1] is highest + while ofs < maxofs { + let value = self.slice_get(a, p + ofs as usize); + if self.lower(value, key, rightmost)? { + lastofs = ofs; + ofs = ofs + .checked_mul(2) + .and_then(|doubled| doubled.checked_add(1)) + .unwrap_or(maxofs); + } else { + break; + } + } + if ofs > maxofs { + ofs = maxofs; + } + lastofs += hint; + ofs += hint; + } else { + // key <= a[hint] — gallop left until a[hint-ofs] < key <= a[hint-lastofs]. + let maxofs = hint + 1; // a[0] is lowest + while ofs < maxofs { + let value = self.slice_get(a, p - ofs as usize); + if self.lower(value, key, rightmost)? { + break; + } + lastofs = ofs; + ofs = ofs + .checked_mul(2) + .and_then(|doubled| doubled.checked_add(1)) + .unwrap_or(maxofs); + } + if ofs > maxofs { + ofs = maxofs; + } + (lastofs, ofs) = (hint - ofs, hint - lastofs); + } + + // a[lastofs] < key <= a[ofs]; binary-search the gap with the invariant + // a[lastofs-1] < key <= a[ofs]. + lastofs += 1; + while lastofs < ofs { + let m = lastofs + ((ofs - lastofs) >> 1); + let value = self.slice_get(a, a.base + m as usize); + if self.lower(value, key, rightmost)? { + lastofs = m + 1; + } else { + ofs = m; + } + } + Ok(ofs as usize) + } + + /// `listsort.py:290` `merge_lo` — stable in-place merge of adjacent slices + /// with `a.len <= b.len`, holding `a` in the scratch store. + fn merge_lo(&mut self, a: &mut ListSlice, b: &mut ListSlice) -> Result<(), PyError> { + debug_assert!(a.len > 0 && b.len > 0 && a.base + a.len == b.base); + let mut dest = a.base; + self.copyitems(a); + let result = self.merge_lo_inner(a, b, &mut dest); + // `listsort.py:388` finally: the last element of `a` belongs at the end + // of the merge, so what remains of `b` is reinstated before what + // remains of `a` — on the error path too, keeping the array a + // permutation of its input. + for p in b.base..(b.base + b.len) { + let value = self.slice_get(b, p); + self.list[dest] = value; + dest += 1; + } + for p in a.base..(a.base + a.len) { + let value = self.slice_get(a, p); + self.list[dest] = value; + dest += 1; + } + result + } + + fn merge_lo_inner( + &mut self, + a: &mut ListSlice, + b: &mut ListSlice, + dest: &mut usize, + ) -> Result<(), PyError> { + let mut min_gallop = self.min_gallop; + let value = self.popleft(b); + self.list[*dest] = value; + *dest += 1; + if a.len == 1 || b.len == 0 { + return Ok(()); + } + loop { + let mut acount = 0; // number of times A won in a row + let mut bcount = 0; // number of times B won in a row + + // Do the straightforward thing until (if ever) one run appears to + // win consistently. + loop { + let bhead = self.slice_get(b, b.base); + let ahead = self.slice_get(a, a.base); + if self.cmp.lt(bhead, ahead)? { + let value = self.popleft(b); + self.list[*dest] = value; + *dest += 1; + if b.len == 0 { + return Ok(()); + } + bcount += 1; + acount = 0; + if bcount >= min_gallop { + break; + } + } else { + let value = self.popleft(a); + self.list[*dest] = value; + *dest += 1; + if a.len == 1 { + return Ok(()); + } + acount += 1; + bcount = 0; + if acount >= min_gallop { + break; + } + } + } + + // One run is winning so consistently that galloping may be a huge + // win. Stay there until neither run wins consistently anymore. + min_gallop += 1; + loop { + if min_gallop > 1 { + min_gallop -= 1; + } + self.min_gallop = min_gallop; + + let bhead = self.slice_get(b, b.base); + acount = self.gallop(bhead, a, 0, true)?; + for p in a.base..(a.base + acount) { + let value = self.slice_get(a, p); + self.list[*dest] = value; + *dest += 1; + } + a.base += acount; + a.len -= acount; + // a.len == 0 is impossible here for a consistent comparison + // function, which cannot be assumed. + if a.len <= 1 { + return Ok(()); + } + + let value = self.popleft(b); + self.list[*dest] = value; + *dest += 1; + if b.len == 0 { + return Ok(()); + } + + let ahead = self.slice_get(a, a.base); + bcount = self.gallop(ahead, b, 0, false)?; + for p in b.base..(b.base + bcount) { + let value = self.slice_get(b, p); + self.list[*dest] = value; + *dest += 1; + } + b.base += bcount; + b.len -= bcount; + if b.len == 0 { + return Ok(()); + } + + let value = self.popleft(a); + self.list[*dest] = value; + *dest += 1; + if a.len == 1 { + return Ok(()); + } + + if acount < MIN_GALLOP && bcount < MIN_GALLOP { + break; + } + } + min_gallop += 1; // penalize it for leaving galloping mode + self.min_gallop = min_gallop; + } + } + + /// `listsort.py:400` `merge_hi` — the `a.len >= b.len` mirror of + /// [`Self::merge_lo`], holding `b` in the scratch store and filling from + /// the right. + fn merge_hi(&mut self, a: &mut ListSlice, b: &mut ListSlice) -> Result<(), PyError> { + debug_assert!(a.len > 0 && b.len > 0 && a.base + a.len == b.base); + let mut dest = b.base + b.len; + self.copyitems(b); + let result = self.merge_hi_inner(a, b, &mut dest); + // `listsort.py:495` finally, mirrored: what remains of `a` first. + for p in (a.base..(a.base + a.len)).rev() { + let value = self.slice_get(a, p); + dest -= 1; + self.list[dest] = value; + } + for p in (b.base..(b.base + b.len)).rev() { + let value = self.slice_get(b, p); + dest -= 1; + self.list[dest] = value; + } + result + } + + fn merge_hi_inner( + &mut self, + a: &mut ListSlice, + b: &mut ListSlice, + dest: &mut usize, + ) -> Result<(), PyError> { + let mut min_gallop = self.min_gallop; + *dest -= 1; + let value = self.popright(a); + self.list[*dest] = value; + if a.len == 0 || b.len == 1 { + return Ok(()); + } + loop { + let mut acount = 0; + let mut bcount = 0; + loop { + let nexta = self.slice_get(a, a.base + a.len - 1); + let nextb = self.slice_get(b, b.base + b.len - 1); + if self.cmp.lt(nextb, nexta)? { + *dest -= 1; + self.list[*dest] = nexta; + a.len -= 1; + if a.len == 0 { + return Ok(()); + } + acount += 1; + bcount = 0; + if acount >= min_gallop { + break; + } + } else { + *dest -= 1; + self.list[*dest] = nextb; + b.len -= 1; + if b.len == 1 { + return Ok(()); + } + bcount += 1; + acount = 0; + if bcount >= min_gallop { + break; + } + } + } + + min_gallop += 1; + loop { + if min_gallop > 1 { + min_gallop -= 1; + } + self.min_gallop = min_gallop; + + let nextb = self.slice_get(b, b.base + b.len - 1); + let k = self.gallop(nextb, a, a.len - 1, true)?; + acount = a.len - k; + for p in ((a.base + k)..(a.base + a.len)).rev() { + let value = self.slice_get(a, p); + *dest -= 1; + self.list[*dest] = value; + } + a.len -= acount; + if a.len == 0 { + return Ok(()); + } + + *dest -= 1; + let value = self.popright(b); + self.list[*dest] = value; + if b.len == 1 { + return Ok(()); + } + + let nexta = self.slice_get(a, a.base + a.len - 1); + let k = self.gallop(nexta, b, b.len - 1, false)?; + bcount = b.len - k; + for p in ((b.base + k)..(b.base + b.len)).rev() { + let value = self.slice_get(b, p); + *dest -= 1; + self.list[*dest] = value; + } + b.len -= bcount; + // b.len == 0 is impossible here for a consistent comparison + // function, which cannot be assumed. + if b.len <= 1 { + return Ok(()); + } + + *dest -= 1; + let value = self.popright(a); + self.list[*dest] = value; + if a.len == 0 { + return Ok(()); + } + + if acount < MIN_GALLOP && bcount < MIN_GALLOP { + break; + } + } + min_gallop += 1; + self.min_gallop = min_gallop; + } + } + + /// `listsort.py:504` `merge_at` — merge the two runs at pending indices + /// `i` and `i+1`. + fn merge_at(&mut self, i: usize) -> Result<(), PyError> { + let mut a = self.pending[i]; + let mut b = self.pending[i + 1]; + debug_assert!(a.len > 0 && b.len > 0 && a.base + a.len == b.base); + + // Record the length of the combined runs and remove run b. + self.pending[i] = ListSlice::of_list(a.base, a.len + b.len); + self.pending.remove(i + 1); + + // Where does b start in a? Elements of a before that are in place. + let bhead = self.slice_get(&b, b.base); + let k = self.gallop(bhead, &a, 0, true)?; + a.base += k; + a.len -= k; + if a.len == 0 { + return Ok(()); + } + + // Where does a end in b? Elements of b after that are in place. + let atail = self.slice_get(&a, a.base + a.len - 1); + b.len = self.gallop(atail, &b, b.len - 1, false)?; + if b.len == 0 { + return Ok(()); + } + + // Direction chosen to minimize the scratch storage needed. + if a.len <= b.len { + self.merge_lo(&mut a, &mut b) + } else { + self.merge_hi(&mut a, &mut b) + } + } + + /// `listsort.py:534` `found_new_run` — the powersort merge policy: merge + /// the pending runs whose power exceeds the newly identified run's. + fn found_new_run(&mut self, run: &ListSlice) -> Result<(), PyError> { + let Some(last) = self.pending.last() else { + return Ok(()); + }; + let power = powerloop(last.base, last.len, run.len, self.listlength); + while self.pending.len() > 1 && self.pending[self.pending.len() - 2].power > power { + self.merge_at(self.pending.len() - 2)?; + } + let top = self.pending.len() - 1; + self.pending[top].power = power; + Ok(()) + } + + /// `listsort.py:553` `merge_force_collapse`. + fn merge_force_collapse(&mut self) -> Result<(), PyError> { + while self.pending.len() > 1 { + let n = self.pending.len(); + let at = if n >= 3 && self.pending[n - 3].len < self.pending[n - 1].len { + n - 3 + } else { + n - 2 + }; + self.merge_at(at)?; + } + Ok(()) + } + + /// `listsort.py:566` `sort` — march over the array once left to right + /// finding natural runs, extending short ones to `minrun`, merging as the + /// powersort policy dictates. + fn sort(&mut self) -> Result<(), PyError> { + if self.listlength < 2 { + return Ok(()); + } + let mut remaining = ListSlice::of_list(0, self.listlength); + self.min_gallop = MIN_GALLOP; + self.pending.clear(); + let minrun = merge_compute_minrun(remaining.len); + + while remaining.len > 0 { + let mut run = ListSlice::of_list(remaining.base, remaining.len); + if self.count_run(&remaining, &mut run)? { + self.reverse_slice(&run); + } + if run.len < minrun { + let sorted = run.len; + run.len = std::cmp::min(minrun, remaining.len); + self.binarysort(&run, sorted)?; + } + // Maybe merge, but never the newest run. + self.found_new_run(&run)?; + self.pending.push(run); + remaining.base += run.len; + remaining.len -= run.len; + } + debug_assert_eq!(remaining.base, self.listlength); + + self.merge_force_collapse() + } +} + +/// Sort `list` in place with `cmp` as the only comparison primitive. +/// +/// On a raising comparison the error propagates and `list` is left holding +/// some permutation of its input (`listsort.py`'s merge `finally` blocks). +pub(crate) fn sort_with>(list: &mut [T], cmp: &mut L) -> Result<(), PyError> { + let listlength = list.len(); + let mut sorter = TimSort { + list, + listlength, + scratch_list: Vec::new(), + min_gallop: MIN_GALLOP, + pending: Vec::new(), + cmp, + }; + sorter.sort() +} diff --git a/pyre/pyre-interpreter/src/module/_abc/mod.rs b/pyre/pyre-interpreter/src/module/_abc/mod.rs index 06a9eb3aeb9..7355b557c2a 100644 --- a/pyre/pyre-interpreter/src/module/_abc/mod.rs +++ b/pyre/pyre-interpreter/src/module/_abc/mod.rs @@ -120,9 +120,47 @@ fn register(args: &[PyObjectRef]) -> Result { } // Invalidate any outstanding cache token. INVALIDATION_COUNTER.fetch_add(1, Ordering::Relaxed); + // `app_abc.py:102-105` — an ABC that carries a structural-match marker + // hands it to the registered class and its descendants + // (`_internal_set_collection_flag_recursive`). Registering with + // `Mapping` / `Sequence` is what makes `case {...}` / `case [...]` accept + // a class that inherits from neither, so the marker has to travel with + // the registration, not only with `__abc_tpflags__` at class creation. + let flag = unsafe { typeobject::w_type_get_flag_map_or_seq(cls) }; + if flag != b'?' && unsafe { is_type(subclass) } { + set_collection_flag_recursive(subclass, flag); + } Ok(subclass) } +// `interp_abc.py:15-20 set_collection_flag_recursive` — stamp the marker on +// `w_type` and every class already deriving from it. +fn set_collection_flag_recursive(w_type: PyObjectRef, flag: u8) { + unsafe { + // A non-heap type's marker is fixed at registration + // (`objspace.py:104-108` marks exactly dict / dictproxy / list / + // tuple), and `Py_TPFLAGS_IMMUTABLETYPE` stops the recursion there. + // `_collections_abc` runs `Sequence.register(str)` and + // `ByteString.register(bytes)`, so without this stop `str` / `bytes` / + // `bytearray` would start matching `case [...]` — the one thing a + // sequence pattern must never accept. + // + // A class already carrying the marker passed it to its descendants at + // creation (`inherit_flag_map_or_seq`), so that subtree is done. + if !typeobject::w_type_is_heaptype(w_type) + || typeobject::w_type_get_flag_map_or_seq(w_type) == flag + { + return; + } + typeobject::w_type_set_flag_map_or_seq(w_type, flag); + // `only_real_subclasses` is False for every walk but + // `descr___subclasses__` (typeobject.py:677-680). + for child in typeobject::w_type_get_subclasses(w_type, false) { + set_collection_flag_recursive(child, flag); + } + } +} + // `_py_abc.ABCMeta.__subclasscheck__` (`_py_abc.py:108-147`): the subclass // hook first, then a direct `__mro__` test, then the recursive registry and // subclass walks. The positive/negative caches are a pure optimisation and diff --git a/pyre/pyre-interpreter/src/module/_ctypes/cdata.rs b/pyre/pyre-interpreter/src/module/_ctypes/cdata.rs index c63a9af452f..19e551aae63 100644 --- a/pyre/pyre-interpreter/src/module/_ctypes/cdata.rs +++ b/pyre/pyre-interpreter/src/module/_ctypes/cdata.rs @@ -263,7 +263,7 @@ fn init_simplecdata_type(ns: PyObjectRef) { type_ns_store( ns, "__new__", - crate::make_builtin_function("__new__", simplecdata_new), + crate::typedef::make_new_descr(simplecdata_new), ); type_ns_store( ns, diff --git a/pyre/pyre-interpreter/src/module/_ctypes/funcptr.rs b/pyre/pyre-interpreter/src/module/_ctypes/funcptr.rs index e85aa53ff74..d78beb5c82b 100644 --- a/pyre/pyre-interpreter/src/module/_ctypes/funcptr.rs +++ b/pyre/pyre-interpreter/src/module/_ctypes/funcptr.rs @@ -52,11 +52,7 @@ pub(super) fn cfuncptr_type() -> PyObjectRef { } fn init_cfuncptr_type(ns: PyObjectRef) { - type_ns_store( - ns, - "__new__", - crate::make_builtin_function("__new__", cfuncptr_new), - ); + type_ns_store(ns, "__new__", crate::typedef::make_new_descr(cfuncptr_new)); type_ns_store( ns, "__call__", diff --git a/pyre/pyre-interpreter/src/module/_ctypes/metaclass.rs b/pyre/pyre-interpreter/src/module/_ctypes/metaclass.rs index ae8e19b96bb..5b3e6dfd3be 100644 --- a/pyre/pyre-interpreter/src/module/_ctypes/metaclass.rs +++ b/pyre/pyre-interpreter/src/module/_ctypes/metaclass.rs @@ -100,7 +100,7 @@ cached_type!(CFIELD, cfield_type, || { type_ns_store( ns, "__new__", - crate::make_builtin_function("__new__", cfield_new_internal), + crate::typedef::make_new_descr(cfield_new_internal), ); type_ns_store( ns, @@ -180,7 +180,7 @@ cached_type!(POINTER_BASE, pointer_base_type, || { }); fn install_new(ns: PyObjectRef, f: crate::gateway::BuiltinCodeFn) { - type_ns_store(ns, "__new__", crate::make_builtin_function("__new__", f)); + type_ns_store(ns, "__new__", crate::typedef::make_new_descr(f)); } fn install_init(ns: PyObjectRef, f: crate::gateway::BuiltinCodeFn) { @@ -235,11 +235,7 @@ fn install_fields_getset(ns: PyObjectRef) { } fn init_aggregate_base(ns: PyObjectRef) { - type_ns_store( - ns, - "__new__", - crate::make_builtin_function("__new__", structure_new), - ); + type_ns_store(ns, "__new__", crate::typedef::make_new_descr(structure_new)); type_ns_store( ns, "__init__", diff --git a/pyre/pyre-interpreter/src/module/_io/textio.rs b/pyre/pyre-interpreter/src/module/_io/textio.rs index 9f0d657a139..a1254d4bc39 100644 --- a/pyre/pyre-interpreter/src/module/_io/textio.rs +++ b/pyre/pyre-interpreter/src/module/_io/textio.rs @@ -956,6 +956,11 @@ impl W_TextIOWrapper { /// reports itself unreadable however readable its buffer is, and only the /// methods `make_std_stream` installs as instance overrides work. /// + /// The standard streams are constructed while the `sys` module itself is + /// being created, and the codec lookup imports `encodings` — an import that + /// cannot run before `importlib._bootstrap` registers itself, which is why + /// the bootstrap tail is what calls this. + /// /// Skips a stream this did not build — a replaced `sys.stdout`, one that /// already has a codec, one carrying no encoding string. A codec the /// registry refuses, or an incremental encoder/decoder that will not @@ -973,7 +978,14 @@ impl W_TextIOWrapper { return Ok(()); } let payload = unsafe { &mut *(stream as *mut Self) }; - if !payload.w_encoder.is_null() || !payload.w_decoder.is_null() { + // The buffer is read below for the probes `descr_init` runs against it, + // so a wrapper without one has nothing to attach rather than a null to + // dereference. + if !payload.w_encoder.is_null() + || !payload.w_decoder.is_null() + || payload.w_buffer.is_null() + || unsafe { pyre_object::is_none(payload.w_buffer) } + { return Ok(()); } let Some(encoding) = @@ -982,7 +994,19 @@ impl W_TextIOWrapper { return Ok(()); }; let codec = Self::lookup_text_codec(&encoding)?; - payload.set_encoder_decoder(codec) + payload.set_encoder_decoder(codec)?; + // The rest of the `descr_init` tail `allocate_stdio` also had to defer: + // the seekable/telling pair and the `read1` probe both call into the + // buffer, which is why they wait for the same moment the codec does. + // `interp_textio.py:601-610` runs them in this order, after the codec. + if let Ok(w_seekable) = super::call_method_result(payload.w_buffer, "seekable", &[]) + && let Ok(seekable) = crate::baseobjspace::is_true(w_seekable) + { + payload.seekable_flag = seekable; + payload.telling = seekable; + } + payload.has_read1 = crate::baseobjspace::getattr_str(payload.w_buffer, "read1").is_ok(); + Ok(()) } } diff --git a/pyre/pyre-interpreter/src/module/_socket/interp_socket.rs b/pyre/pyre-interpreter/src/module/_socket/interp_socket.rs index 2c73900ef5c..ccb76a4e698 100644 --- a/pyre/pyre-interpreter/src/module/_socket/interp_socket.rs +++ b/pyre/pyre-interpreter/src/module/_socket/interp_socket.rs @@ -142,8 +142,8 @@ fn socket_converted_error( ) -> crate::PyError { let cls = match applevelerrcls { "timeout" => crate::builtins::lookup_exc_class("TimeoutError"), - "gaierror" => crate::builtins::lookup_exc_class("_socket.gaierror"), - "herror" => crate::builtins::lookup_exc_class("_socket.herror"), + "gaierror" => crate::builtins::lookup_exc_class("socket.gaierror"), + "herror" => crate::builtins::lookup_exc_class("socket.herror"), _ => crate::builtins::lookup_exc_class("OSError"), } .or_else(|| crate::builtins::lookup_exc_class("OSError")) @@ -1052,6 +1052,9 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { // herror = new_exception_class("_socket.herror", w_OSError) // gaierror = new_exception_class("_socket.gaierror", w_OSError) // timeout = new_exception_class("_socket.timeout", w_OSError) + // `socketmodule.c` names them `socket.herror` / `socket.gaierror` + // instead, and `type.__module__` reads the qualified prefix back, so + // `socket.gaierror.__module__` is `"socket"` rather than `"_socket"`. let w_os_error = crate::builtins::lookup_exc_class("OSError") .expect("OSError must be installed before _socket init"); crate::module_ns_store(ns, "error", w_os_error); @@ -1059,7 +1062,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { ns, "herror", crate::builtins::make_exc_type( - "_socket.herror", + "socket.herror", crate::builtins::exc_exception_new, w_os_error, ), @@ -1068,7 +1071,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { ns, "gaierror", crate::builtins::make_exc_type( - "_socket.gaierror", + "socket.gaierror", crate::builtins::exc_exception_new, w_os_error, ), @@ -1077,7 +1080,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { ns, "timeout", crate::builtins::make_exc_type( - "_socket.timeout", + "socket.timeout", crate::builtins::exc_exception_new, w_os_error, ), @@ -2123,10 +2126,17 @@ fn pack_inet_addr( "AF_INET address must be a (host, port) tuple", )); } + // `getsockaddrarg` parses the AF_INET form with `"O&i"` — exactly two + // items — and the AF_INET6 form with `"O&i|II"` — two to four. let len = unsafe { pyre_object::w_tuple_len(addr) }; - if family == libc::AF_INET && len < 2 { + if family == libc::AF_INET && len != 2 { return Err(crate::PyError::type_error( - "AF_INET address must be a (host, port) tuple", + "AF_INET address must be a pair (host, port)", + )); + } + if family == libc::AF_INET6 && !(2..=4).contains(&len) { + return Err(crate::PyError::type_error( + "AF_INET6 address must be a tuple (host, port[, flowinfo[, scopeid]])", )); } let host_obj = unsafe { pyre_object::w_tuple_getitem(addr, 0) } @@ -2313,7 +2323,7 @@ fn init_socket_type(ns: pyre_object::PyObjectRef) { unsafe { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__new__", - crate::make_builtin_function("__new__", |args| { + crate::typedef::make_new_descr(|args| { let cls = args .first() .copied() diff --git a/pyre/pyre-interpreter/src/module/_warnings/mod.rs b/pyre/pyre-interpreter/src/module/_warnings/mod.rs index 51b47d404a7..5082054e198 100644 --- a/pyre/pyre-interpreter/src/module/_warnings/mod.rs +++ b/pyre/pyre-interpreter/src/module/_warnings/mod.rs @@ -847,6 +847,8 @@ crate::py_module! { message: PyObjectRef, #[default(pyre_object::PY_NULL)] category: PyObjectRef, #[default(1i64)] stacklevel: i64, + // `source` is positional-or-keyword; only `skip_file_prefixes` + // sits behind the clinic's `*`. #[default(pyre_object::PY_NULL)] source: PyObjectRef, #[kwonly] #[default(pyre_object::PY_NULL)] skip_file_prefixes: PyObjectRef, ) -> Result { @@ -905,7 +907,7 @@ crate::py_module! { #[default(pyre_object::PY_NULL)] module: PyObjectRef, #[default(pyre_object::PY_NULL)] registry: PyObjectRef, #[default(pyre_object::PY_NULL)] module_globals: PyObjectRef, - #[kwonly] #[default(pyre_object::PY_NULL)] source: PyObjectRef, + #[default(pyre_object::PY_NULL)] source: PyObjectRef, ) -> Result { let source_line = get_source_line(module_globals, lineno)?; let _roots = pyre_object::gc_roots::push_roots(); diff --git a/pyre/pyre-interpreter/src/module/_weakref/interp__weakref.rs b/pyre/pyre-interpreter/src/module/_weakref/interp__weakref.rs index 3e2b378ff83..627197350f0 100644 --- a/pyre/pyre-interpreter/src/module/_weakref/interp__weakref.rs +++ b/pyre/pyre-interpreter/src/module/_weakref/interp__weakref.rs @@ -147,7 +147,7 @@ fn init_weakref_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__new__", - make_builtin_function("__new__", descr__new__weakref_typecall), + crate::typedef::make_new_descr(descr__new__weakref_typecall), ) }; unsafe { @@ -244,7 +244,7 @@ fn init_proxy_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__new__", - make_builtin_function("__new__", descr__new__proxy), + crate::typedef::make_new_descr(descr__new__proxy), ) }; unsafe { @@ -296,7 +296,7 @@ fn init_callable_proxy_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__new__", - make_builtin_function("__new__", descr__new__callableproxy), + crate::typedef::make_new_descr(descr__new__callableproxy), ) }; unsafe { diff --git a/pyre/pyre-interpreter/src/module/binascii/mod.rs b/pyre/pyre-interpreter/src/module/binascii/mod.rs index f9c80ed4c59..d44fa067e81 100644 --- a/pyre/pyre-interpreter/src/module/binascii/mod.rs +++ b/pyre/pyre-interpreter/src/module/binascii/mod.rs @@ -13,6 +13,96 @@ mod transforms; use pyre_object::*; +/// `PyArg_UnpackTuple` for the entries that declare no keyword at all +/// (`crc32(data, crc=0, /)`, `crc_hqx(data, crc, /)`). A keyword is refused +/// under the module-qualified name; the arity message names the function bare +/// and is the only one in this module that carries no `()`. +fn unpack_positional<'a>( + args: &'a [PyObjectRef], + fn_name: &str, + min: usize, + max: usize, +) -> Result<&'a [PyObjectRef], crate::PyError> { + let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); + if crate::builtins::real_kwarg_count(kwargs) != 0 { + return Err(crate::PyError::type_error(format!( + "binascii.{fn_name}() takes no keyword arguments" + ))); + } + if pos.len() < min { + return Err(crate::PyError::type_error(format!( + "{fn_name} expected {}{min} argument{}, got {}", + if min == max { "" } else { "at least " }, + if min == 1 { "" } else { "s" }, + pos.len(), + ))); + } + if pos.len() > max { + return Err(crate::PyError::type_error(format!( + "{fn_name} expected {}{max} arguments, got {}", + if min == max { "" } else { "at most " }, + pos.len(), + ))); + } + Ok(pos) +} + +/// The entries whose `data` is positional-only and whose remaining slots are +/// keyword-only (`a2b_base64`, `b2a_base64`, `b2a_uu`). A positional-only +/// parameter is never reported by name, so both an omitted and a surplus +/// positional read as the one positional slot; `_PyArg_UnpackKeywords` holds +/// an unrecognized keyword back until then, which is why `f(data=…)` is a +/// missing positional rather than an unexpected keyword. +fn arg_data_posonly( + args: &[PyObjectRef], + fn_name: &str, + kwonly: &[&str], +) -> Result<(PyObjectRef, Option), crate::PyError> { + let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); + crate::builtins::clinic_arity( + fn_name, + pos.len(), + crate::builtins::real_kwarg_count(kwargs), + 1, + 1, + kwonly.len(), + )?; + if pos.is_empty() { + return Err(crate::PyError::type_error(format!( + "{fn_name}() takes exactly 1 positional argument (0 given)" + ))); + } + if let Some(dict) = kwargs { + for (key, _) in unsafe { pyre_object::w_dict_str_entries_wtf8(dict) }.iter() { + let name = key.to_string_lossy(); + if name != "__pyre_kw__" && !kwonly.contains(&name.as_ref()) { + return Err(crate::PyError::type_error(format!( + "{fn_name}() got an unexpected keyword argument '{name}'" + ))); + } + } + } + Ok((pos[0], kwargs)) +} + +/// A keyword-only flag: absent or `None` keeps `default`. +fn kwonly_bool(kwargs: Option, name: &str, default: bool) -> bool { + slot_bool( + crate::builtins::kwarg_get(kwargs, name).unwrap_or(PY_NULL), + default, + ) +} + +/// A slot [`crate::builtins::bind_builtin_kwargs`] resolved: `PY_NULL` for an +/// omitted optional argument, and `None` for one passed explicitly, both keep +/// `default`. +fn slot_bool(w: PyObjectRef, default: bool) -> bool { + if w.is_null() || unsafe { is_none(w) } { + return default; + } + crate::baseobjspace::is_true(w).unwrap_or(default) +} + /// `ascii_buffer_converter` — accept a str (ASCII) or any bytes-like and /// surface the raw bytes. Only the `a2b_*` decoders take a str source. fn as_bytes(obj: PyObjectRef) -> Result, crate::PyError> { @@ -105,62 +195,32 @@ fn transform_error(e: transforms::Error) -> crate::PyError { // ── argument helpers ──────────────────────────────────────────────────── -/// Optional flag argument, read from `name=` or the positional slot `index`. -/// A missing or `None` value yields `default`; anything else is truth-tested. -fn arg_bool( - pos: &[PyObjectRef], - kwargs: Option, - name: &str, - index: usize, - default: bool, -) -> bool { - match crate::builtins::kwarg_get(kwargs, name).or_else(|| pos.get(index).copied()) { - Some(o) if unsafe { is_none(o) } => default, - Some(o) => crate::baseobjspace::is_true(o).unwrap_or(default), - None => default, - } -} - -/// The `sep` / `bytes_per_sep` separator for `hexlify` / `b2a_hex`, validated -/// exactly as the C accelerator: length-1, ASCII. -fn arg_sep( - pos: &[PyObjectRef], - kwargs: Option, +/// The `sep` / `bytes_per_sep` separator slots of `hexlify` / `b2a_hex`, +/// validated exactly as the C accelerator: length-1, ASCII. +fn sep_args( + w_sep: PyObjectRef, + w_bytes_per_sep: PyObjectRef, ) -> Result<(Option, isize), crate::PyError> { - let sep = match crate::builtins::kwarg_get(kwargs, "sep").or_else(|| pos.get(1).copied()) { - Some(o) if !unsafe { is_none(o) } => { - let bytes = as_bytes(o)?; - if bytes.len() != 1 { - return Err(crate::PyError::value_error("sep must be length 1.")); - } - if !bytes[0].is_ascii() { - return Err(crate::PyError::value_error("sep must be ASCII.")); - } - Some(bytes[0]) + let sep = if w_sep.is_null() || unsafe { is_none(w_sep) } { + None + } else { + let bytes = as_bytes(w_sep)?; + if bytes.len() != 1 { + return Err(crate::PyError::value_error("sep must be length 1.")); + } + if !bytes[0].is_ascii() { + return Err(crate::PyError::value_error("sep must be ASCII.")); } - _ => None, + Some(bytes[0]) + }; + let bytes_per_sep = if w_bytes_per_sep.is_null() || unsafe { is_none(w_bytes_per_sep) } { + 1 + } else { + crate::builtins::space_index_w(w_bytes_per_sep)? as isize }; - let bytes_per_sep = - match crate::builtins::kwarg_get(kwargs, "bytes_per_sep").or_else(|| pos.get(2).copied()) { - Some(o) if !unsafe { is_none(o) } => crate::baseobjspace::int_w(o)? as isize, - _ => 1, - }; Ok((sep, bytes_per_sep)) } -fn arg_u32( - pos: &[PyObjectRef], - kwargs: Option, - name: &str, - index: usize, - default: u32, -) -> Result { - match crate::builtins::kwarg_get(kwargs, name).or_else(|| pos.get(index).copied()) { - Some(o) if !unsafe { is_none(o) } => Ok(crate::baseobjspace::int_w(o)? as u32), - _ => Ok(default), - } -} - crate::py_module! { "binascii", exceptions: { @@ -170,16 +230,20 @@ crate::py_module! { "Incomplete" => crate::builtins::lookup_exc_class("Exception").expect("Exception installed"), }, functions: { + // `(data, sep=, bytes_per_sep=1)` — three + // positional-or-keyword slots. "b2a_hex" / * = |args| { - let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); - let data = as_buffer_bytes(pos.first().copied().unwrap_or(w_none()))?; - let (sep, bytes_per_sep) = arg_sep(pos, kwargs)?; + let scope = crate::builtins::bind_builtin_kwargs( + args, &["data", "sep", "bytes_per_sep"], &[true, false, false], "b2a_hex")?; + let data = as_buffer_bytes(scope[0])?; + let (sep, bytes_per_sep) = sep_args(scope[1], scope[2])?; Ok(w_bytes_from_bytes(&transforms::hexlify(&data, sep, bytes_per_sep))) }, "hexlify" / * = |args| { - let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); - let data = as_buffer_bytes(pos.first().copied().unwrap_or(w_none()))?; - let (sep, bytes_per_sep) = arg_sep(pos, kwargs)?; + let scope = crate::builtins::bind_builtin_kwargs( + args, &["data", "sep", "bytes_per_sep"], &[true, false, false], "hexlify")?; + let data = as_buffer_bytes(scope[0])?; + let (sep, bytes_per_sep) = sep_args(scope[1], scope[2])?; Ok(w_bytes_from_bytes(&transforms::hexlify(&data, sep, bytes_per_sep))) }, "a2b_hex" / 1 = |args| { @@ -192,48 +256,57 @@ crate::py_module! { let out = transforms::unhexlify(&data).map_err(transform_error)?; Ok(w_bytes_from_bytes(&out)) }, + // `(data, crc=0, /)` and `(data, crc, /)` — positional-only throughout. "crc32" / * = |args| { - let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); - let data = as_buffer_bytes(pos.first().copied().unwrap_or(w_none()))?; - let init = arg_u32(pos, kwargs, "crc", 1, 0)?; + let pos = unpack_positional(args, "crc32", 1, 2)?; + let data = as_buffer_bytes(pos[0])?; + let init = match pos.get(1) { + Some(&o) => crate::baseobjspace::int_w(o)? as u32, + None => 0, + }; Ok(w_int_new(transforms::crc32(&data, init) as i64)) }, "crc_hqx" / * = |args| { - let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); - let data = as_buffer_bytes(pos.first().copied().unwrap_or(w_none()))?; - let init = match crate::builtins::kwarg_get(kwargs, "crc").or_else(|| pos.get(1).copied()) { - Some(o) => crate::baseobjspace::int_w(o)? as u32, - None => return Err(crate::PyError::type_error( - "crc_hqx() missing required argument 'crc' (pos 2)", - )), - }; + let pos = unpack_positional(args, "crc_hqx", 2, 2)?; + let data = as_buffer_bytes(pos[0])?; + let init = crate::baseobjspace::int_w(pos[1])? as u32; Ok(w_int_new(transforms::crc_hqx(&data, init) as i64)) }, + // `(data, /, *, flag=…)` — one positional-only slot, the rest + // keyword-only. "a2b_base64" / * = |args| { - let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); - let data = as_bytes(pos.first().copied().unwrap_or(w_none()))?; - let strict_mode = arg_bool(pos, kwargs, "strict_mode", 1, false); + let (w_data, kwargs) = arg_data_posonly(args, "a2b_base64", &["strict_mode"])?; + let data = as_bytes(w_data)?; + let strict_mode = kwonly_bool(kwargs, "strict_mode", false); let out = transforms::a2b_base64(&data, strict_mode).map_err(transform_error)?; Ok(w_bytes_from_bytes(&out)) }, "b2a_base64" / * = |args| { - let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); - let data = as_buffer_bytes(pos.first().copied().unwrap_or(w_none()))?; - let newline = arg_bool(pos, kwargs, "newline", 1, true); + let (w_data, kwargs) = arg_data_posonly(args, "b2a_base64", &["newline"])?; + let data = as_buffer_bytes(w_data)?; + let newline = kwonly_bool(kwargs, "newline", true); Ok(w_bytes_from_bytes(&transforms::b2a_base64(&data, newline))) }, + // `(data, header=False)` / `(data, quotetabs, istext, header)` — every + // slot positional-or-keyword. "a2b_qp" / * = |args| { - let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); - let data = as_bytes(pos.first().copied().unwrap_or(w_none()))?; - let header = arg_bool(pos, kwargs, "header", 1, false); + let scope = crate::builtins::bind_builtin_kwargs( + args, &["data", "header"], &[true, false], "a2b_qp")?; + let data = as_bytes(scope[0])?; + let header = slot_bool(scope[1], false); Ok(w_bytes_from_bytes(&transforms::a2b_qp(&data, header))) }, "b2a_qp" / * = |args| { - let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); - let data = as_buffer_bytes(pos.first().copied().unwrap_or(w_none()))?; - let quotetabs = arg_bool(pos, kwargs, "quotetabs", 1, false); - let istext = arg_bool(pos, kwargs, "istext", 2, true); - let header = arg_bool(pos, kwargs, "header", 3, false); + let scope = crate::builtins::bind_builtin_kwargs( + args, + &["data", "quotetabs", "istext", "header"], + &[true, false, false, false], + "b2a_qp", + )?; + let data = as_buffer_bytes(scope[0])?; + let quotetabs = slot_bool(scope[1], false); + let istext = slot_bool(scope[2], true); + let header = slot_bool(scope[3], false); Ok(w_bytes_from_bytes(&transforms::b2a_qp(&data, quotetabs, istext, header))) }, "a2b_uu" / 1 = |args| { @@ -242,9 +315,9 @@ crate::py_module! { Ok(w_bytes_from_bytes(&out)) }, "b2a_uu" / * = |args| { - let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); - let data = as_buffer_bytes(pos.first().copied().unwrap_or(w_none()))?; - let backtick = arg_bool(pos, kwargs, "backtick", 1, false); + let (w_data, kwargs) = arg_data_posonly(args, "b2a_uu", &["backtick"])?; + let data = as_buffer_bytes(w_data)?; + let backtick = kwonly_bool(kwargs, "backtick", false); let out = transforms::b2a_uu(&data, backtick).map_err(transform_error)?; Ok(w_bytes_from_bytes(&out)) }, diff --git a/pyre/pyre-interpreter/src/module/imp/interp_imp.rs b/pyre/pyre-interpreter/src/module/imp/interp_imp.rs index f9044e4b7a3..eb89451091e 100644 --- a/pyre/pyre-interpreter/src/module/imp/interp_imp.rs +++ b/pyre/pyre-interpreter/src/module/imp/interp_imp.rs @@ -538,14 +538,17 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { return Ok(pyre_object::w_int_new(0)); } }; - // `interp_imp.is_builtin`: 0 = not a builtin, 1 = a builtin - // not yet imported, -1 = a builtin already in sys.modules and - // thus not re-initializable (sys/builtins and any other - // already-imported builtin). + // `import.c is_builtin`: 0 = not a builtin, -1 = an inittab + // entry whose `initfunc` is NULL and so cannot be + // (re)initialized, 1 = every other builtin. `sys` and + // `builtins` are the two NULL-initfunc entries. + // `interp_imp.py is_builtin` instead answers -1 for *any* + // builtin already in `sys.modules`, which makes an ordinary + // imported builtin such as `time` report -1. let is_builtin = BUILTIN_MODULES.lock().unwrap().contains_key(name); let result = if !is_builtin { 0 - } else if crate::importing::check_sys_modules(name).is_some() { + } else if matches!(name, "sys" | "builtins") { -1 } else { 1 @@ -588,11 +591,75 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { "init_frozen", crate::make_builtin_function_with_arity( "init_frozen", - // interp_imp.py:74 — frozen modules are served through the meta - // path, never re-initialized by this legacy entry point. + // `import.c _imp_init_frozen_impl` — run the frozen module's code + // in a fresh namespace registered under its name and hand the + // module back, or None when the name is not frozen. A name + // already in sys.modules keeps its module. + // `interp_imp.py:74 init_frozen` instead always answers None, + // leaving frozen modules to the meta path. |args| { - let _ = frozen_name(args, "init_frozen")?; - Ok(pyre_object::w_none()) + let name = frozen_name(args, "init_frozen")?; + let Some(entry) = served_frozen_module(&name) else { + return Ok(pyre_object::w_none()); + }; + // A frozen name is ASCII by construction (the table's keys), + // so the lossy view is the name itself. + let name = name.to_string_lossy().into_owned(); + if let Some(module) = crate::importing::get_sys_module(&name) { + return Ok(module); + } + let code = frozen_code(entry)?; + let _roots = pyre_object::gc_roots::push_roots(); + let code_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(code); + let ec = crate::call::getexecutioncontext(); + let w_globals = unsafe { &*ec }.fresh_module_globals(); + let globals_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(w_globals); + // The name string is allocated before the store so the mapping + // it writes into is read after that allocation, not before it. + let w_name = pyre_object::w_str_new(&name); + unsafe { + pyre_object::w_dict_setitem_str( + pyre_object::gc_roots::shadow_stack_get(globals_slot), + "__name__", + w_name, + ); + }; + // `PyImport_ImportFrozenModuleObject`: a frozen *package* gets + // `__path__` set to the empty list before its code runs, which + // is what makes `import __phello__.spam` resolve through it + // instead of reporting that `__phello__` is not a package. + if entry.is_package { + let w_path = pyre_object::w_list_new(Vec::new()); + unsafe { + pyre_object::w_dict_setitem_str( + pyre_object::gc_roots::shadow_stack_get(globals_slot), + "__path__", + w_path, + ); + }; + } + let module_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(pyre_object::w_module_new_aliasing_dict( + &name, + pyre_object::gc_roots::shadow_stack_get(globals_slot), + )); + // `set_sys_module` inserts into `sys.modules` and so allocates: + // publish the module from its rooted slot rather than from a + // pointer captured before the insert. + crate::importing::set_sys_module( + &name, + pyre_object::gc_roots::shadow_stack_get(module_slot), + ); + if let Err(error) = crate::builtins::builtin_exec(&[ + pyre_object::gc_roots::shadow_stack_get(code_slot), + pyre_object::gc_roots::shadow_stack_get(globals_slot), + ]) { + crate::importing::remove_sys_module(&name); + return Err(error); + } + Ok(pyre_object::gc_roots::shadow_stack_get(module_slot)) }, 1, ), diff --git a/pyre/pyre-interpreter/src/module/math/interp_math.rs b/pyre/pyre-interpreter/src/module/math/interp_math.rs index 9fdbf8ba7b4..209b4eb0841 100644 --- a/pyre/pyre-interpreter/src/module/math/interp_math.rs +++ b/pyre/pyre-interpreter/src/module/math/interp_math.rs @@ -97,7 +97,10 @@ pub fn try_get_double(obj: PyObjectRef) -> Result { Err(err) if err.kind != crate::PyErrorKind::AttributeError => return Err(err), Err(_) => {} } - Err(crate::PyError::type_error("must be real number")) + Err(crate::PyError::type_error(format!( + "must be real number, not {}", + crate::type_methods::arg_type_name(obj) + ))) } type PyResult = Result; @@ -400,6 +403,7 @@ pub fn atan2(args: &[PyObjectRef]) -> PyResult { } pub fn hypot(args: &[PyObjectRef]) -> PyResult { + let args = no_keywords(args, "hypot")?; let coords: Vec = args .iter() .map(|&a| try_get_double(a)) @@ -466,14 +470,16 @@ fn math_unary_int( } if !fallback_float { return Err(crate::PyError::type_error(format!( - "type {} doesn't define {fname}() method", + "type {} doesn't define {dunder} method", crate::baseobjspace::object_functionstr_type_name(args[0]) ))); } - // Fall back to `__float__` coercion — `try_get_double` raises TypeError - // when the operand has no numeric interpretation. - let v = try_get_double(args[0]) - .map_err(|_| crate::PyError::type_error(format!("type has no {fname}() method")))?; + // Fall back to `__float__` coercion. `try_get_double` already reports + // "must be real number, not X" for an operand with no numeric + // interpretation, and its contract is that everything else — a raising + // `__float__`, or the `OverflowError` for an int too wide for an f64 — + // propagates; relabelling here would swallow exactly those. + let v = try_get_double(args[0])?; Ok(w_int_new(match dunder { "__ceil__" => v.ceil() as i64, "__floor__" => v.floor() as i64, @@ -555,9 +561,34 @@ fn log_any(w_x: PyObjectRef, base: f64) -> PyResult { } } +/// A `math` entry point declared `METH_VARARGS` takes no keywords at all, so +/// one is rejected before the arguments are read — otherwise the trailing +/// marker dict reaches the body as one more operand. +pub(crate) fn no_keywords<'a>( + args: &'a [PyObjectRef], + name: &str, +) -> Result<&'a [PyObjectRef], crate::PyError> { + let (positional, kwargs) = crate::builtins::split_builtin_kwargs(args); + if crate::builtins::has_real_kwargs(kwargs) { + return Err(crate::PyError::type_error(format!( + "math.{name}() takes no keyword arguments" + ))); + } + Ok(positional) +} + pub fn log(args: &[PyObjectRef]) -> PyResult { - if args.is_empty() || args.len() > 2 { - return Err(crate::PyError::type_error("log() takes 1 or 2 arguments")); + let args = no_keywords(args, "log")?; + if args.is_empty() { + return Err(crate::PyError::type_error( + "log expected at least 1 argument, got 0", + )); + } + if args.len() > 2 { + return Err(crate::PyError::type_error(format!( + "log expected at most 2 arguments, got {}", + args.len() + ))); } let base = if args.len() >= 2 { // The base is validated before the argument, so log(x, base) with a @@ -650,10 +681,23 @@ pub fn isfinite(args: &[PyObjectRef]) -> PyResult { pub fn isclose(args: &[PyObjectRef]) -> PyResult { let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); - if pos.len() != 2 { - return Err(crate::PyError::type_error( - "isclose() takes exactly 2 positional arguments", - )); + if pos.len() < 2 { + // `_PyArg_ParseStackAndKeywords` names the first slot it could not + // fill; both are positional-only, so a keyword never fills one. + let missing = if pos.is_empty() { + "a' (pos 1" + } else { + "b' (pos 2" + }; + return Err(crate::PyError::type_error(format!( + "isclose() missing required argument '{missing})" + ))); + } + if pos.len() > 2 { + return Err(crate::PyError::type_error(format!( + "isclose() takes exactly 2 positional arguments ({} given)", + pos.len() + ))); } // `rel_tol` and `abs_tol` are the only (keyword-only) parameters. crate::builtins::kwarg_reject_unknown(kwargs, &["rel_tol", "abs_tol"], "isclose")?; @@ -682,14 +726,17 @@ pub fn factorial(args: &[PyObjectRef]) -> PyResult { "factorial() takes exactly 1 argument", )); } - // PyPy: pypy/module/math/app_math.py factorial — reject floats that aren't - // exact integers, and negative x. - unsafe { - if pyre_object::is_float(args[0]) { - return Err(crate::PyError::type_error( - "factorial() only accepts integral values", - )); - } + // pypy/module/math/app_math.py:factorial — + // if '__index__' not in dir(n): + // raise TypeError("'%s' object cannot be interpreted as an integer" + // % type(n).__name__) + // The check is on `__index__` alone, so floats are rejected for the same + // reason strings are rather than by a numeric-value test. + if unsafe { crate::baseobjspace::lookup_special(args[0], "__index__") }?.is_none() { + return Err(crate::PyError::type_error(format!( + "'{}' object cannot be interpreted as an integer", + crate::baseobjspace::object_functionstr_type_name(args[0]) + ))); } let n_big = get_bigint(args[0])?; if n_big.int_lt(0) { @@ -700,9 +747,10 @@ pub fn factorial(args: &[PyObjectRef]) -> PyResult { let n = if jit_bigint_to_i64_fits(&n_big) != 0 { jit_bigint_to_i64_value(&n_big) } else { - return Err(crate::PyError::overflow_error( - "factorial() argument should not exceed i64::MAX", - )); + return Err(crate::PyError::overflow_error(format!( + "factorial() argument should not exceed {}", + i64::MAX + ))); }; // pypy/module/math/app_math.py:factorial — balanced odd-product tree. @@ -800,6 +848,7 @@ fn get_bigint(obj: PyObjectRef) -> Result { } pub fn gcd(args: &[PyObjectRef]) -> PyResult { + let args = no_keywords(args, "gcd")?; // RPython's GC transform roots this running rbigint across the next // argument's potentially user-defined `__index__` call. let mut result = RBigIntGcRoot::new(BigInt::zero()); @@ -810,6 +859,7 @@ pub fn gcd(args: &[PyObjectRef]) -> PyResult { } pub fn lcm(args: &[PyObjectRef]) -> PyResult { + let args = no_keywords(args, "lcm")?; if args.is_empty() { return Ok(w_int_new(1)); } @@ -905,6 +955,7 @@ pub fn comb(args: &[PyObjectRef]) -> PyResult { } pub fn perm(args: &[PyObjectRef]) -> PyResult { + let args = no_keywords(args, "perm")?; if args.is_empty() { return Err(crate::PyError::type_error( "perm() takes at least 1 argument", diff --git a/pyre/pyre-interpreter/src/module/mmap/interp_mmap.rs b/pyre/pyre-interpreter/src/module/mmap/interp_mmap.rs index 121a4ea2494..29cacb1f1f2 100644 --- a/pyre/pyre-interpreter/src/module/mmap/interp_mmap.rs +++ b/pyre/pyre-interpreter/src/module/mmap/interp_mmap.rs @@ -269,7 +269,7 @@ fn init_mmap_type(ns: pyre_object::PyObjectRef) { unsafe { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__new__", - crate::make_builtin_function("__new__", |args| { + crate::typedef::make_new_descr(|args| { if args.is_empty() { return Err(crate::PyError::type_error( "mmap() requires fileno + length", diff --git a/pyre/pyre-interpreter/src/module/operator/mod.rs b/pyre/pyre-interpreter/src/module/operator/mod.rs index cebc45c686f..2891a66253b 100644 --- a/pyre/pyre-interpreter/src/module/operator/mod.rs +++ b/pyre/pyre-interpreter/src/module/operator/mod.rs @@ -133,7 +133,10 @@ use crate::baseobjspace::{ }; crate::py_module! { - "operator", + // `Modules/_operator.c` — the accelerator module. `operator` itself is + // the pure-Python `lib-python/3/operator.py`, which imports from here. + // `moduledef.py:5` names it `_operator` too (`applevel_name`). + "_operator", // `moduledef.py` `app_names` — `countOf` (a plain `app_operator.py` loop) // and the `attrgetter`/`itemgetter`/`methodcaller` factory classes (which // cannot be plain interp-level functions) — are the only app-level names. diff --git a/pyre/pyre-interpreter/src/module/posix/interp_posix.rs b/pyre/pyre-interpreter/src/module/posix/interp_posix.rs index 895e4c753c4..077e0c47ebd 100644 --- a/pyre/pyre-interpreter/src/module/posix/interp_posix.rs +++ b/pyre/pyre-interpreter/src/module/posix/interp_posix.rs @@ -963,9 +963,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { "chmod", "fchmod", "lchmod", - "chown", "fchown", - "lchown", "access", "faccessat", "chflags", @@ -1028,7 +1026,6 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { "confstr_names", "sysconf", "sysconf_names", - "pathconf_names", "setenv", // putenv/unsetenv are implemented above unless the host environment is // out of reach. @@ -1188,6 +1185,21 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { errno_err(crate::builtins::io_error_posix_errno(&e, 0), path) } + /// A filesystem name reported back to the caller: `bytes` when the path + /// argument was `bytes` (`posixmodule.c path_converter`), else `str`. + /// + /// The name arrives as raw bytes because bytes mode exists precisely for + /// entries the filesystem encoding cannot round-trip: `readdir`'s `d_name` + /// is handed back unchanged, so a name like `b"bad_\xff"` stays openable + /// instead of decoding to U+FFFD first. + fn fs_name_obj(bytes_mode: bool, name: &[u8]) -> PyObjectRef { + if bytes_mode { + pyre_object::bytesobject::w_bytes_from_bytes(name) + } else { + pyre_object::w_str_new(&String::from_utf8_lossy(name)) + } + } + // ── posix.open(path, flags, mode=0o777) → fd ── crate::module_ns_store( ns, @@ -1825,10 +1837,12 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { ns, "listdir", crate::make_builtin_function("listdir", |args| { - let path = if args.is_empty() || unsafe { pyre_object::is_none(args[0]) } { - ".".to_string() + // One resolution yields both the path and its bytes-ness, so + // `__fspath__` runs exactly once (`fsencode_w_with_kind`). + let (path, bytes_mode) = if args.is_empty() || unsafe { pyre_object::is_none(args[0]) } { + (".".to_string(), false) } else { - extract_path(args[0])? + crate::gateway::fsencode_w_with_kind(args[0])? }; #[cfg(feature = "sandbox")] { @@ -1836,7 +1850,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { .map_err(|e| crate::host_seam::seam_os_err(e, &path))?; let items = names .into_iter() - .map(|n| pyre_object::w_str_new(&String::from_utf8_lossy(&n))) + .map(|n| fs_name_obj(bytes_mode, &n)) .collect(); return Ok(pyre_object::w_list_new(items)); } @@ -1847,7 +1861,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { for entry in entries { let entry = entry.map_err(|e| io_err(e, &path))?; let name = entry.file_name(); - items.push(pyre_object::w_str_new(&name.to_string_lossy())); + items.push(fs_name_obj(bytes_mode, name.as_encoded_bytes())); } Ok(pyre_object::w_list_new(items)) } @@ -2244,6 +2258,51 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { extras.push(("st_flags", pyre_object::w_int_new(st.st_flags as i64))); crate::_structseq::new_instance_with_extra(stat_result_seq_type(), seq, extras) } + /// `os.stat(path, *, dir_fd=None, follow_symlinks=True)` / + /// `os.lstat(path, *, dir_fd=None)` — `follow_symlinks` is keyword-only, + /// so `stat` cannot take the fixed-arity carrier that rejects keywords. + /// `dir_fd` stays unimplemented (the `*at` family is absent from + /// `_have_functions`), so only `None` is accepted. + fn stat_entry( + args: &[pyre_object::PyObjectRef], + default_follow: bool, + ) -> Result { + let name = if default_follow { "stat" } else { "lstat" }; + let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); + let allowed: &[&str] = if default_follow { + &["path", "dir_fd", "follow_symlinks"] + } else { + &["path", "dir_fd"] + }; + crate::builtins::kwarg_reject_unknown(kwargs, allowed, name)?; + if pos.len() > 1 { + return Err(crate::PyError::type_error(format!( + "{name}() takes at most 1 positional argument ({} given)", + pos.len() + ))); + } + let path = match crate::builtins::bind_pos_or_kw(pos, kwargs, 0, "path", name, 1)? { + Some(path) => path, + None => { + return Err(crate::PyError::type_error(format!( + "{name}() missing required argument 'path' (pos 1)" + ))); + } + }; + if let Some(dir_fd) = crate::builtins::kwarg_get(kwargs, "dir_fd") + && !unsafe { pyre_object::is_none(dir_fd) } + { + return Err(crate::PyError::not_implemented(format!( + "{name}: dir_fd unavailable on this platform" + ))); + } + let follow_symlinks = match crate::builtins::kwarg_get(kwargs, "follow_symlinks") { + Some(v) => crate::baseobjspace::is_true(v)?, + None => default_follow, + }; + stat_impl(&[path], follow_symlinks) + } + fn stat_impl( args: &[pyre_object::PyObjectRef], follow_symlinks: bool, @@ -2478,20 +2537,30 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { } fn scandir_fn(args: &[PyObjectRef]) -> Result { - let path = if args.is_empty() || unsafe { pyre_object::is_none(args[0]) } { - ".".to_string() + // One resolution yields both the path and its bytes-ness, so + // `__fspath__` runs exactly once (`fsencode_w_with_kind`). + let (path, bytes_mode) = if args.is_empty() || unsafe { pyre_object::is_none(args[0]) } { + (".".to_string(), false) } else { - crate::gateway::fsencode_w(args[0])? + crate::gateway::fsencode_w_with_kind(args[0])? }; let entries = host_fs::read_dir(&path).map_err(|e| io_err(e, &path))?; let list = pyre_object::w_list_new(Vec::new()); for entry in entries { let entry = entry.map_err(|e| io_err(e, &path))?; - let name = entry.file_name().to_string_lossy().to_string(); - let full = entry.path().to_string_lossy().to_string(); + let name = entry.file_name(); + let full = entry.path().into_os_string(); let de = pyre_object::w_instance_new(dir_entry_type()); - let _ = crate::baseobjspace::setattr_str(de, "name", pyre_object::w_str_new(&name)); - let _ = crate::baseobjspace::setattr_str(de, "path", pyre_object::w_str_new(&full)); + let _ = crate::baseobjspace::setattr_str( + de, + "name", + fs_name_obj(bytes_mode, name.as_encoded_bytes()), + ); + let _ = crate::baseobjspace::setattr_str( + de, + "path", + fs_name_obj(bytes_mode, full.as_encoded_bytes()), + ); unsafe { pyre_object::w_list_append(list, de) }; } let it = pyre_object::w_instance_new(scandir_iter_type()); @@ -2568,12 +2637,12 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { crate::module_ns_store( ns, "stat", - crate::make_builtin_function_with_arity("stat", |args| stat_impl(args, true), 1), + crate::make_builtin_function("stat", |args| stat_entry(args, true)), ); crate::module_ns_store( ns, "lstat", - crate::make_builtin_function_with_arity("lstat", |args| stat_impl(args, false), 1), + crate::make_builtin_function("lstat", |args| stat_entry(args, false)), ); crate::module_ns_store( ns, @@ -3804,6 +3873,126 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { ), ); + // os.chown(path, uid, gid, *, dir_fd=None, follow_symlinks=True) -> None + // os.lchown(path, uid, gid) -> None + // `uid`/`gid` of -1 means "leave unchanged", as for fchown. + fn chown_entry( + args: &[pyre_object::PyObjectRef], + name: &str, + default_follow: bool, + ) -> Result { + use std::os::fd::BorrowedFd; + let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); + let allowed: &[&str] = if default_follow { + &["path", "uid", "gid", "dir_fd", "follow_symlinks"] + } else { + &["path", "uid", "gid"] + }; + crate::builtins::kwarg_reject_unknown(kwargs, allowed, name)?; + if pos.len() > 3 { + // `chown` declares keyword-only `dir_fd`/`follow_symlinks`, so + // its surplus-positional report is the "positional arguments" + // form; `lchown` takes no keywords at all and reports the + // plain "arguments" form. + let surplus = if default_follow { + format!( + "{name}() takes exactly 3 positional arguments ({} given)", + pos.len() + ) + } else { + format!("{name}() takes at most 3 arguments ({} given)", pos.len()) + }; + return Err(crate::PyError::type_error(surplus)); + } + // Every parameter is bound duplicate-aware, so a call that supplies + // one both ways raises before the ownership syscall runs. + let arg = |index: usize, key: &'static str| -> Result { + match crate::builtins::bind_pos_or_kw(pos, kwargs, index, key, name, index + 1)? { + Some(value) => Ok(value), + None => Err(crate::PyError::type_error(format!( + "{name}() missing required argument '{key}' (pos {})", + index + 1 + ))), + } + }; + let (path_obj, uid_obj, gid_obj) = + (arg(0, "path")?, arg(1, "uid")?, arg(2, "gid")?); + // `posixmodule.c path_converter` calls `__fspath__` and lets what it + // raises out: a `RuntimeError` from a user `__fspath__` is that + // object's error, not a statement that the argument was the wrong + // type. Rewriting every failure into a `TypeError` here would also + // swallow the `UnicodeEncodeError` a lone surrogate produces. + let path = extract_path(path_obj)?; + // `_Py_Uid_Converter` / `_Py_Gid_Converter`: `uid_t` is unsigned, yet + // -1 is always accepted as the "leave unchanged" sentinel. Only + // that one value means unchanged; every other id is judged by + // round-tripping through `uid_t`, so nothing is silently wrapped — + // 2**32 truncates to 0 and would otherwise request uid 0. + // + // The two range reports follow the C converter's own split: a value + // that still fits a C long but fails the round trip is "less than + // minimum" (including 2**32, whose truncation reads as underflow), + // while one too wide for a long is "greater than maximum". + let id_of = + |w: pyre_object::PyObjectRef, what: &str| -> Result, crate::PyError> { + if !unsafe { crate::builtins::index_check(w) } { + return Err(crate::PyError::type_error(format!( + "{what} should be integer, not {}", + crate::type_methods::arg_type_name(w) + ))); + } + let w_index = crate::baseobjspace::space_index(w)?; + let raw = crate::baseobjspace::int_w(w_index).map_err(|_| { + crate::PyError::overflow_error(format!("{what} is greater than maximum")) + })?; + if raw == -1 { + return Ok(None); + } + let narrowed = raw as u32; + if i64::from(narrowed) != raw { + return Err(crate::PyError::overflow_error(format!( + "{what} is less than minimum" + ))); + } + Ok(Some(narrowed)) + }; + let (uid, gid) = (id_of(uid_obj, "uid")?, id_of(gid_obj, "gid")?); + if let Some(dir_fd) = crate::builtins::kwarg_get(kwargs, "dir_fd") + && !unsafe { pyre_object::is_none(dir_fd) } + { + return Err(crate::PyError::not_implemented(format!( + "{name}: dir_fd unavailable on this platform" + ))); + } + let follow_symlinks = match crate::builtins::kwarg_get(kwargs, "follow_symlinks") { + Some(v) => crate::baseobjspace::is_true(v)?, + None => default_follow, + }; + // `fchownat` with `AT_FDCWD` is the `chown` / `lchown` pair: + // the flagless call follows the final symlink, `AT_SYMLINK_NOFOLLOW` + // does not. + let cwd = unsafe { BorrowedFd::borrow_raw(libc::AT_FDCWD) }; + host_posix::fchownat( + cwd, + std::ffi::OsStr::new(path.as_str()), + uid, + gid, + follow_symlinks, + ) + .map_err(|e| io_err(e, &path))?; + Ok(pyre_object::w_none()) + } + crate::module_ns_store( + ns, + "chown", + crate::make_builtin_function("chown", |args| chown_entry(args, "chown", true)), + ); + crate::module_ns_store( + ns, + "lchown", + crate::make_builtin_function("lchown", |args| chown_entry(args, "lchown", false)), + ); + // os.fchown(fd, uid, gid) -> None (uid/gid of -1 means "leave unchanged") crate::module_ns_store( ns, @@ -4520,6 +4709,113 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { pyre_object::w_int_new(libc::PRIO_USER as i64), ); + // `posixmodule.c` `pathconf_names` — the `_PC_*` table + // `conv_path_confname` resolves a string `name` argument through. + // `libc` exports the constants only on the BSD family, so the glibc + // values (`bits/confname.h`) are spelled out for Linux. + #[cfg(any(target_os = "macos", target_os = "ios"))] + const PATHCONF_NAMES: &[(&str, i32)] = &[ + ("PC_ALLOC_SIZE_MIN", libc::_PC_ALLOC_SIZE_MIN), + ("PC_ASYNC_IO", libc::_PC_ASYNC_IO), + ("PC_CHOWN_RESTRICTED", libc::_PC_CHOWN_RESTRICTED), + ("PC_FILESIZEBITS", libc::_PC_FILESIZEBITS), + ("PC_LINK_MAX", libc::_PC_LINK_MAX), + ("PC_MAX_CANON", libc::_PC_MAX_CANON), + ("PC_MAX_INPUT", libc::_PC_MAX_INPUT), + ("PC_MIN_HOLE_SIZE", libc::_PC_MIN_HOLE_SIZE), + ("PC_NAME_MAX", libc::_PC_NAME_MAX), + ("PC_NO_TRUNC", libc::_PC_NO_TRUNC), + ("PC_PATH_MAX", libc::_PC_PATH_MAX), + ("PC_PIPE_BUF", libc::_PC_PIPE_BUF), + ("PC_PRIO_IO", libc::_PC_PRIO_IO), + ("PC_REC_INCR_XFER_SIZE", libc::_PC_REC_INCR_XFER_SIZE), + ("PC_REC_MAX_XFER_SIZE", libc::_PC_REC_MAX_XFER_SIZE), + ("PC_REC_MIN_XFER_SIZE", libc::_PC_REC_MIN_XFER_SIZE), + ("PC_REC_XFER_ALIGN", libc::_PC_REC_XFER_ALIGN), + ("PC_SYMLINK_MAX", libc::_PC_SYMLINK_MAX), + ("PC_SYNC_IO", libc::_PC_SYNC_IO), + ("PC_VDISABLE", libc::_PC_VDISABLE), + ]; + #[cfg(target_os = "linux")] + const PATHCONF_NAMES: &[(&str, i32)] = &[ + ("PC_2_SYMLINKS", 20), + ("PC_ALLOC_SIZE_MIN", 18), + ("PC_ASYNC_IO", 10), + ("PC_CHOWN_RESTRICTED", 6), + ("PC_FILESIZEBITS", 13), + ("PC_LINK_MAX", 0), + ("PC_MAX_CANON", 1), + ("PC_MAX_INPUT", 2), + ("PC_NAME_MAX", 3), + ("PC_NO_TRUNC", 7), + ("PC_PATH_MAX", 4), + ("PC_PIPE_BUF", 5), + ("PC_PRIO_IO", 11), + ("PC_REC_INCR_XFER_SIZE", 14), + ("PC_REC_MAX_XFER_SIZE", 15), + ("PC_REC_MIN_XFER_SIZE", 16), + ("PC_REC_XFER_ALIGN", 17), + ("PC_SOCK_MAXBUF", 12), + ("PC_SYMLINK_MAX", 19), + ("PC_SYNC_IO", 9), + ("PC_VDISABLE", 8), + ]; + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "linux")))] + const PATHCONF_NAMES: &[(&str, i32)] = &[]; + let _pathconf_roots = pyre_object::gc_roots::push_roots(); + let names_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(pyre_object::w_dict_new()); + for (name, value) in PATHCONF_NAMES { + // The value is allocated before the store, and the dict is reloaded + // from its root slot every iteration because the insert itself can + // grow — and so relocate — the dict. + let w_value = pyre_object::w_int_new(*value as i64); + unsafe { + pyre_object::w_dict_setitem_str( + pyre_object::gc_roots::shadow_stack_get(names_slot), + name, + w_value, + ) + }; + } + crate::module_ns_store( + ns, + "pathconf_names", + pyre_object::gc_roots::shadow_stack_get(names_slot), + ); + + /// `posixmodule.c conv_path_confname`: an `int` passes through, a + /// `str` is resolved through `pathconf_names`. + fn confname_arg(w: PyObjectRef) -> Result { + if unsafe { pyre_object::is_str(w) } { + // A str carrying a lone surrogate has no `&str` view. It simply + // matches no known name, which is the ValueError below — not an + // interpreter abort, which is what reading the value unchecked + // would produce. + let name = unsafe { pyre_object::w_str_get_value_opt(w) }; + return name + .and_then(|name| { + PATHCONF_NAMES + .iter() + .find(|(known, _)| *known == name) + .map(|(_, value)| *value) + }) + .ok_or_else(|| { + crate::PyError::value_error("unrecognized configuration name") + }); + } + // `conv_confname` gates on `PyIndex_Check` before converting, so an + // object that is neither a str nor index-able is this TypeError, + // while an `__index__` that raises propagates its own exception. + if !unsafe { crate::builtins::index_check(w) } { + return Err(crate::PyError::type_error( + "configuration names must be strings or integers", + )); + } + let value = crate::baseobjspace::int_w(crate::baseobjspace::space_index(w)?)?; + Ok(value as i32) + } + // os.pathconf(path, name) -> int | None crate::module_ns_store( ns, @@ -4534,7 +4830,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { let cpath = std::ffi::CString::new(path.as_bytes()).map_err(|_| { crate::PyError::value_error("pathconf: embedded null in path") })?; - let name = (unsafe { pyre_object::w_int_get_value(args[1]) }) as i32; + let name = confname_arg(args[1])?; match host_posix::pathconf(&cpath, name).map_err(|e| io_err(e, ""))? { Some(v) => Ok(pyre_object::w_int_new(v as i64)), None => Ok(pyre_object::w_none()), @@ -4555,7 +4851,7 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { return Err(crate::PyError::type_error("fpathconf() requires fd, name")); } let fd = (unsafe { pyre_object::w_int_get_value(args[0]) }) as i32; - let name = (unsafe { pyre_object::w_int_get_value(args[1]) }) as i32; + let name = confname_arg(args[1])?; match host_posix::fpathconf(fd, name).map_err(|e| io_err(e, ""))? { Some(v) => Ok(pyre_object::w_int_new(v as i64)), None => Ok(pyre_object::w_none()), diff --git a/pyre/pyre-interpreter/src/module/struct/mod.rs b/pyre/pyre-interpreter/src/module/struct/mod.rs index 01c693ca42b..fcc1bf11aee 100644 --- a/pyre/pyre-interpreter/src/module/struct/mod.rs +++ b/pyre/pyre-interpreter/src/module/struct/mod.rs @@ -1462,11 +1462,15 @@ crate::py_module! { // through the args slice (typed varargs are not supported by // inline_functions arity inference). "pack" / * = |args| { - if args.is_empty() { + let (args, kwargs) = crate::builtins::split_builtin_kwargs(args); + if crate::builtins::has_real_kwargs(kwargs) { return Err(crate::PyError::type_error( - "pack() missing 1 required positional argument: 'fmt'", + "_struct.pack() takes no keyword arguments", )); } + if args.is_empty() { + return Err(crate::PyError::type_error("missing format argument")); + } let fmt = format_to_string(args[0])?; do_pack(&fmt, &args[1..]) }, diff --git a/pyre/pyre-interpreter/src/module/sys/vm.rs b/pyre/pyre-interpreter/src/module/sys/vm.rs index 31771524848..013f0dc1172 100644 --- a/pyre/pyre-interpreter/src/module/sys/vm.rs +++ b/pyre/pyre-interpreter/src/module/sys/vm.rs @@ -1055,6 +1055,78 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { Ok(current as pyre_object::PyObjectRef) }), ); + // `sys._getframemodulename(depth=0)` — the module name of the frame + // `depth` levels up, or None when the walk runs off the stack. Used by + // `warnings` and `functools` to attribute a call to its module without + // materialising the frame. + // + // `sys__getframemodulename_impl` reads `PyFunction_GetModule(f->f_funcobj)`. + // A pyre frame carries `pycode` and `w_globals` but no link back to the + // function object (the JIT virtualizable layout is the hot path, and + // `debugdata` is allocated lazily), so the module name comes from + // `globals["__name__"]` — the value `__module__` is initialised from. A + // function whose `__module__` was reassigned after definition therefore + // still reports its defining module. + module_ns_store( + ns, + "_getframemodulename", + crate::make_builtin_function("_getframemodulename", |args| { + let (positional, kwargs) = crate::builtins::split_builtin_kwargs(args); + crate::builtins::kwarg_reject_unknown(kwargs, &["depth"], "_getframemodulename")?; + // The arity is judged on the total argument count, so supplying + // `depth` both ways is "takes at most 1 argument (2 given)" rather + // than the duplicate-binding report. + let supplied = positional.len() + crate::builtins::real_kwarg_count(kwargs); + if supplied > 1 { + return Err(crate::PyError::type_error(format!( + "_getframemodulename() takes at most 1 argument ({supplied} given)" + ))); + } + let depth = match crate::builtins::bind_pos_or_kw( + positional, + kwargs, + 0, + "depth", + "_getframemodulename", + 1, + )? { + Some(v) => crate::baseobjspace::int_w(crate::baseobjspace::space_index(v)?)?, + None => 0, + }; + let ec = current_execution_context(); + if ec.is_null() { + return Ok(pyre_object::w_none()); + } + // Force the frame `topframeref` names before walking, for the same + // reason `sys._getframe` above does: a JIT-inlined callee has no + // frame until the force materialises one, so an unforced walk would + // start at the caller and report the caller's module. + let mut current = unsafe { + (*ec).gettopframe(); + (*ec).gettopframe_nohidden() + }; + // `while (f && (_PyFrame_IsIncomplete(f) || depth-- > 0))` — the + // post-decrement test fails immediately for a negative depth, so a + // negative walks zero frames and reports the current module rather + // than `None`. + let mut remaining = depth.max(0); + while !current.is_null() && remaining > 0 { + current = crate::executioncontext::ExecutionContext::getnextframe_nohidden(current); + remaining -= 1; + } + if current.is_null() { + return Ok(pyre_object::w_none()); + } + let w_globals = unsafe { (*current).w_globals }; + if w_globals.is_null() { + return Ok(pyre_object::w_none()); + } + match crate::baseobjspace::finditem_str(w_globals, "__name__")? { + Some(name) if !name.is_null() => Ok(name), + _ => Ok(pyre_object::w_none()), + } + }), + ); // sys.exc_info() → (type, value, traceback) // // Tuple construction is shared with `exc_info_direct` (the JIT fast-path @@ -1177,7 +1249,17 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { "setrecursionlimit() takes exactly one argument", )); } - let new_limit = crate::baseobjspace::c_int_w(args[0])?; + // `sys_setrecursionlimit_impl`'s clinic `int new_limit` + // converter reads `__index__`, so a non-index argument is + // named in the error rather than reported as a bare + // "expected integer". + let new_limit = crate::builtins::space_index_w(args[0])?; + if !(i32::MIN as i64..=i32::MAX as i64).contains(&new_limit) { + return Err(crate::PyError::overflow_error( + "expected a 32-bit integer", + )); + } + let new_limit = new_limit as i32; crate::stack_check::set_recursion_limit(new_limit)?; Ok(w_none()) }, @@ -1967,39 +2049,58 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { make_builtin_function( "getsizeof", |args| { - if args.len() > 2 { - return Err(crate::PyError::type_error(format!( - "getsizeof() takes at most 2 arguments ({} given)", - args.len() - ))); + // `vm.py:355 getsizeof(space, w_object, w_default=None)` — both + // parameters are positional-or-keyword. + let scope = crate::builtins::bind_builtin_kwargs( + args, + &["object", "default"], + &[true, false], + "getsizeof", + )?; + let w_obj = scope[0]; + let w_default = Some(scope[1]).filter(|d| !d.is_null()); + // `sys_getsizeof` looks `__sizeof__` up on the type and calls + // it. The `default` covers every TypeError along that route — + // a missing slot, an uncallable one, and a result that is not + // an integer — but not the negative-result ValueError. + // `vm.py:355 getsizeof` instead returns the default + // unconditionally ("not implemented on PyPy"); 3.14 reports the + // real size, and pyre's types all carry a working + // `__sizeof__`. The GC header CPython adds for tracked + // objects has no fixed pyre counterpart, so the reported size + // is the object's own. + let sized = match unsafe { + crate::baseobjspace::lookup_special(w_obj, "__sizeof__") + } { + Ok(Some(method)) => crate::call::call_function_impl_result(method, &[]), + Ok(None) => Err(crate::PyError::type_error(format!( + "Type {} doesn't define __sizeof__", + crate::baseobjspace::object_functionstr_type_name(w_obj) + ))), + Err(e) => Err(e), } - let Some(&w_obj) = args.first() else { - return Err(crate::PyError::type_error( - "getsizeof() takes at least 1 argument (0 given)", - )); - }; - if let Some(w_type) = crate::typedef::r#type(w_obj) { - if let Some(w_sizeof) = unsafe { - crate::baseobjspace::lookup_in_type(w_type.as_ptr(), "__sizeof__") - } { - let w_size = unsafe { - crate::baseobjspace::get_and_call_function( - w_sizeof, - w_obj, - w_type.as_ptr(), - &[], - ) - }?; - // getsizeof must yield a non-negative integer: a - // non-int result is rejected like a failed index - // coercion, and a negative one (including a bignum) - // raises ValueError. - if unsafe { !pyre_object::is_int(w_size) } { - return Err(crate::PyError::type_error(format!( - "'{}' object cannot be interpreted as an integer", - crate::type_methods::arg_type_name(w_size) - ))); - } + .and_then(|w_size| { + // `PyLong_AsSsize_t(res)` — no `__index__` coercion, so a + // non-integer result is the TypeError the default covers, + // while a result too wide for the word is an OverflowError + // it does not. Both precede the `size < 0` test below, so + // `__sizeof__` returning `-(1 << 100)` overflows rather + // than reporting the negative. + let is_integer = unsafe { + pyre_object::is_int(w_size) || pyre_object::pyobject::is_long(w_size) + }; + if !is_integer { + return Err(crate::PyError::type_error("an integer is required")); + } + crate::baseobjspace::int_w(w_size).map_err(|_| { + crate::PyError::overflow_error( + "Python int too large to convert to C ssize_t", + ) + })?; + Ok(w_size) + }); + match (sized, w_default) { + (Ok(w_size), _) => { let negative = crate::baseobjspace::is_true( crate::objspace::descroperation::compare( w_size, @@ -2012,14 +2113,12 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { "__sizeof__() should return >= 0", )); } - return Ok(w_size); + Ok(w_size) } - } - match args.get(1).copied() { - Some(w_default) => Ok(w_default), - None => Err(crate::PyError::type_error( - "getsizeof(object, default) -> int: object size is not tracked; supply a default", - )), + (Err(e), Some(w_default)) if e.kind == crate::PyErrorKind::TypeError => { + Ok(w_default) + } + (Err(e), _) => Err(e), } }, ), @@ -2089,10 +2188,11 @@ pub fn register_module(ns: pyre_object::PyObjectRef) { module_ns_store( ns, "set_coroutine_origin_tracking_depth", - make_builtin_function_with_arity( + // `depth` is positional-or-keyword, so this cannot take the + // fixed-arity carrier (which rejects keywords before the body runs). + crate::make_builtin_function( "set_coroutine_origin_tracking_depth", sys_set_coroutine_origin_tracking_depth, - 1, ), ); module_ns_store( @@ -2355,6 +2455,14 @@ fn make_std_stream(name: &'static str, fd: i32) -> PyObjectRef { let _roots = pyre_object::gc_roots::push_roots(); pyre_object::gc_roots::pin_root(buffer); let buffer_slot = pyre_object::gc_roots::shadow_stack_len() - 1; + // `app_main.py:484 create_stdio` names the raw descriptor object rather + // than the wrapper, so `repr(sys.stdin.buffer)` reads `name=''` + // instead of the bare descriptor number. + if let Ok(raw) = + crate::baseobjspace::getattr_str(pyre_object::gc_roots::shadow_stack_get(buffer_slot), "raw") + { + let _ = crate::baseobjspace::setattr_str(raw, "name", w_str_new(name)); + } let (encoding, configured_errors) = stdio_encoding_and_errors(); let errors = if to_stderr { "backslashreplace" @@ -2404,17 +2512,21 @@ fn make_std_stream(name: &'static str, fd: i32) -> PyObjectRef { let (encoding, _) = live_stdio_encoding_errors("stderr", "backslashreplace"); let bytes = crate::type_methods::encode_object(s_obj, &encoding, "backslashreplace")?; - // Under sandbox fd 1 is the marshalling pipe, so a raw write - // would corrupt the protocol: route through ll_os_write(2,…) - // and let the controller relay it to its own stderr. - #[cfg(not(feature = "sandbox"))] - { - use std::io::Write; - let _ = std::io::stderr().write_all(&bytes); + // An embedder with no fd 2 (wasm32) takes the bytes through + // its hook; otherwise fall through to the descriptor. + if !crate::stderr_hook_emit(&bytes) { + // Under sandbox fd 1 is the marshalling pipe, so a raw write + // would corrupt the protocol: route through ll_os_write(2,…) + // and let the controller relay it to its own stderr. + #[cfg(not(feature = "sandbox"))] + { + use std::io::Write; + let _ = std::io::stderr().write_all(&bytes); + } + #[cfg(feature = "sandbox")] + crate::host_seam::ops::write(2, &bytes) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; } - #[cfg(feature = "sandbox")] - crate::host_seam::ops::write(2, &bytes) - .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; return Ok(w_int_new(unsafe { w_str_len(s_obj) } as i64)); } Ok(w_int_new(0)) @@ -2424,14 +2536,19 @@ fn make_std_stream(name: &'static str, fd: i32) -> PyObjectRef { if let Some(s_obj) = pick_str(args) { let (encoding, errors) = live_stdio_encoding_errors("stdout", "strict"); let bytes = crate::type_methods::encode_object(s_obj, &encoding, &errors)?; - #[cfg(not(feature = "sandbox"))] - { - use std::io::Write; - let _ = std::io::stdout().write_all(&bytes); + // Same seam `print` rides, so an embedder that captures stdout + // (wasm32, which has no fd 1) sees `sys.stdout.write` too and + // the two stay in order. + if !crate::print_hook_emit_bytes(&bytes) { + #[cfg(not(feature = "sandbox"))] + { + use std::io::Write; + let _ = std::io::stdout().write_all(&bytes); + } + #[cfg(feature = "sandbox")] + crate::host_seam::ops::write(1, &bytes) + .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; } - #[cfg(feature = "sandbox")] - crate::host_seam::ops::write(1, &bytes) - .map_err(|e| crate::host_seam::seam_os_err(e, ""))?; return Ok(w_int_new(unsafe { w_str_len(s_obj) } as i64)); } Ok(w_int_new(0)) diff --git a/pyre/pyre-interpreter/src/module/thread/mod.rs b/pyre/pyre-interpreter/src/module/thread/mod.rs index 878284be5f5..878dea8887e 100644 --- a/pyre/pyre-interpreter/src/module/thread/mod.rs +++ b/pyre/pyre-interpreter/src/module/thread/mod.rs @@ -345,6 +345,7 @@ pub(crate) fn after_fork_child() { pyre_object::listobject::list_locks_after_fork_child(); pyre_object::setobject::set_locks_after_fork_child(); pyre_object::interp_itertools::count_locks_after_fork_child(); + pyre_object::typeobject::subclasses_locks_after_fork_child(); crate::objspace::std::mapdict::after_fork_child(); pyre_object::dictmultiobject::module_dict_locks_after_fork_child(); crate::module::_collections::deque_locks_after_fork_child(); @@ -616,7 +617,7 @@ mod lock_class { } } } -use lock_class::W_Lock; +pub use lock_class::W_Lock; mod rlock_class { use super::*; @@ -844,7 +845,7 @@ mod rlock_class { } } } -use rlock_class::W_RLock; +pub use rlock_class::W_RLock; mod handle_class { use super::*; @@ -966,7 +967,7 @@ mod handle_class { } } } -use handle_class::W_ThreadHandle; +pub use handle_class::W_ThreadHandle; impl W_ThreadHandle { fn start(&self, ident: i64) -> Result<(), crate::PyError> { @@ -1486,6 +1487,18 @@ fn spawn_thread( let stack_size = STACK_SIZE.load(Ordering::Relaxed); if stack_size != 0 { builder = builder.stack_size(stack_size); + } else { + // `stack_check`'s byte budget is `MAX_STACK_SIZE`, and the clamp that + // keeps it inside the real stack reads `RLIMIT_STACK` — which describes + // the *main* thread. A spawned thread takes the host default instead + // (2 MiB), narrower than the budget, so deep recursion here would reach + // the guard page before `stack_check` ever reported overflow. Give the + // default thread room for the budget plus the same quarter margin the + // clamp reserves. An explicit `_thread.stack_size(n)` is still honored + // as requested. + builder = builder.stack_size( + crate::stack_check::MAX_STACK_SIZE + (crate::stack_check::MAX_STACK_SIZE >> 2), + ); } builder .spawn(move || { diff --git a/pyre/pyre-interpreter/src/module/time/interp_time.rs b/pyre/pyre-interpreter/src/module/time/interp_time.rs index 196cad58335..d60d91224a7 100644 --- a/pyre/pyre-interpreter/src/module/time/interp_time.rs +++ b/pyre/pyre-interpreter/src/module/time/interp_time.rs @@ -141,8 +141,10 @@ pub fn sleep(args: &[PyObjectRef]) -> Result { let has_int = crate::baseobjspace::lookup(args[0], "__int__").is_some() || crate::baseobjspace::lookup(args[0], "__index__").is_some(); if !has_int { + // `_PyTime_FromSecondsObject` accepts either domain, so + // an argument that is neither names both. return Err(crate::PyError::type_error(format!( - "'{}' object is not an integer or float", + "'{}' object cannot be interpreted as an integer or float", crate::type_methods::arg_type_name(args[0]) ))); } diff --git a/pyre/pyre-interpreter/src/module/unicodedata/mod.rs b/pyre/pyre-interpreter/src/module/unicodedata/mod.rs index 8b235db7e2a..5118e7ae6c9 100644 --- a/pyre/pyre-interpreter/src/module/unicodedata/mod.rs +++ b/pyre/pyre-interpreter/src/module/unicodedata/mod.rs @@ -67,6 +67,12 @@ fn extract_char(func: &str, argno: Option, obj: PyObjectRef) -> Result Result { + let (args, kwargs) = crate::builtins::split_builtin_kwargs(args); + if crate::builtins::has_real_kwargs(kwargs) { + return Err(PyError::type_error(format!( + "unicodedata.{func}() takes no keyword arguments" + ))); + } if args.len() != 1 { return Err(PyError::type_error(format!( "unicodedata.{func}() takes exactly one argument ({} given)", diff --git a/pyre/pyre-interpreter/src/module/zlib/mod.rs b/pyre/pyre-interpreter/src/module/zlib/mod.rs index 94aed754286..0170fdf42b9 100644 --- a/pyre/pyre-interpreter/src/module/zlib/mod.rs +++ b/pyre/pyre-interpreter/src/module/zlib/mod.rs @@ -367,7 +367,7 @@ fn init_zdecompress_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__new__", - crate::make_builtin_function("__new__", |args| { + crate::typedef::make_new_descr(|args| { // args[0] is the type; the rest are the constructor arguments. let (pos, kwargs) = crate::builtins::split_builtin_kwargs(&args[1..]); let wbits = to_wbits(arg_int(pos, kwargs, "wbits", 0, backend::MAX_WBITS as i64)?); diff --git a/pyre/pyre-interpreter/src/objspace/descroperation.rs b/pyre/pyre-interpreter/src/objspace/descroperation.rs index d665c4a89a1..eb118e08931 100644 --- a/pyre/pyre-interpreter/src/objspace/descroperation.rs +++ b/pyre/pyre-interpreter/src/objspace/descroperation.rs @@ -20,6 +20,14 @@ use crate::baseobjspace::{ }; pub use crate::{PyError, PyErrorKind, PyResult}; +/// Every zero-divisor `ZeroDivisionError` carries this one message, whatever +/// the operator and whatever the operand types — int, long, float or complex, +/// `/`, `//`, `%` or `divmod`. The per-operator and per-type wordings +/// ("integer division or modulo by zero", "float modulo", …) were unified in +/// 3.12. `0 ** -1` is the one ZeroDivisionError that keeps its own message +/// ("zero to a negative power"), because it is not a division. +const ZERO_DIVISION_MSG: &str = "division by zero"; + // ── BigInt helpers ────────────────────────────────────────────────── /// Box a BigInt result, demoting to W_IntObject if it fits in i64. @@ -774,7 +782,7 @@ fn bigint_mod(a: BigInt, b: BigInt) -> BigInt { fn bigint_truediv(a: &BigInt, b: &BigInt) -> Result { a.truediv(b).map_err(|error| match error { pyre_object::rbigint::RBigIntError::DivisionByZero => { - PyError::zero_division("division by zero") + PyError::zero_division(ZERO_DIVISION_MSG) } pyre_object::rbigint::RBigIntError::FloatDivisionOverflow => { PyError::overflow_error("integer division result too large for a float") @@ -827,7 +835,7 @@ unsafe fn int_floordiv(a: PyObjectRef, b: PyObjectRef) -> PyResult { let va = int_value(a); let vb = int_value(b); if vb == 0 { - return Err(PyError::zero_division("integer division or modulo by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } // intobject.py `_floordiv`: `ovfcheck(x // y)` has exactly one // non-zero-divisor overflow on a signed machine word. @@ -847,9 +855,7 @@ unsafe fn int_mod(a: PyObjectRef, b: PyObjectRef) -> PyResult { let va = int_value(a); let vb = int_value(b); if vb == 0 { - // `%` alone reports "integer modulo by zero"; only `//`/divmod say - // "integer division or modulo by zero". - return Err(PyError::zero_division("integer modulo by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } // intobject.py `_mod`: the matching machine-word overflow is // `MIN % -1`; bounce that one case to rbigint like `ovfcheck`. @@ -1004,13 +1010,12 @@ unsafe fn long_mul(a: PyObjectRef, b: PyObjectRef) -> PyResult { unsafe fn long_floordiv(a: PyObjectRef, b: PyObjectRef) -> PyResult { // longobject.py:424 `_make_descr_binop(_floordiv, _int_floordiv)`: a // machine-int divisor takes the dedicated `rbigint.int_floordiv` leg. - // 3.x reports "integer division or modulo by zero" for every int; the - // int path raises the same. PyPy's `_floordiv` still carries the 2.x - // "long ..." wording (longobject.py:409), which a 3.x runtime does not. + // PyPy's `_floordiv` still carries the 2.x "long ..." wording + // (longobject.py:409), which a 3.x runtime does not. if is_int_like(b) { let vb = int_value(b); if vb == 0 { - return Err(PyError::zero_division("integer division or modulo by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } debug_assert!(is_long(a)); if w_long_get_value(a).get_sign() == 1 && vb == 1 { @@ -1026,7 +1031,7 @@ unsafe fn long_floordiv(a: PyObjectRef, b: PyObjectRef) -> PyResult { debug_assert!(is_long(b)); let vb = w_long_get_value(b); if !vb.tobool() { - return Err(PyError::zero_division("integer division or modulo by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } let owned_a; let va = if is_long(a) { @@ -1045,12 +1050,10 @@ unsafe fn long_mod(a: PyObjectRef, b: PyObjectRef) -> PyResult { // (machine-int RHS) computes through `rbigint.int_mod_int_result` and // returns `space.newint` — the remainder of a long by a machine int always // fits — while `_mod` (long RHS) returns `newlong`. - // `%` alone reports "integer modulo by zero" (not the floordiv/divmod - // "division or modulo" wording). if is_int_like(b) { let vb = int_value(b); if vb == 0 { - return Err(PyError::zero_division("integer modulo by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } debug_assert!(is_long(a)); return Ok(w_int_new(bigint_int_modulo_int_result_nonzero( @@ -1061,7 +1064,7 @@ unsafe fn long_mod(a: PyObjectRef, b: PyObjectRef) -> PyResult { debug_assert!(is_long(b)); let vb = w_long_get_value(b); if !vb.tobool() { - return Err(PyError::zero_division("integer modulo by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } let owned_a; let va = if is_long(a) { @@ -1243,7 +1246,7 @@ fn alloc_result_bigint(value: BigInt, collecting: bool) -> i64 { fn bigint_floordiv_core(a: &BigInt, b: &BigInt, collecting: bool) -> i64 { if pyre_object::longobject::jit_bigint_sign_i64(b) == 0 { crate::runtime_ops::jit_publish_exception( - PyError::zero_division("integer division or modulo by zero").to_exc_object(), + PyError::zero_division(ZERO_DIVISION_MSG).to_exc_object(), ); return 0; } @@ -1256,9 +1259,8 @@ fn bigint_floordiv_core(a: &BigInt, b: &BigInt, collecting: bool) -> i64 { fn bigint_mod_core(a: &BigInt, b: &BigInt, collecting: bool) -> i64 { if pyre_object::longobject::jit_bigint_sign_i64(b) == 0 { - // `%` alone reports "integer modulo by zero". crate::runtime_ops::jit_publish_exception( - PyError::zero_division("integer modulo by zero").to_exc_object(), + PyError::zero_division(ZERO_DIVISION_MSG).to_exc_object(), ); return 0; } @@ -1478,7 +1480,7 @@ unsafe fn float_truediv(a: PyObjectRef, b: PyObjectRef) -> PyResult { let vb = as_float(b); reject_float_coercion_overflow(b, vb)?; if vb == 0.0 { - return Err(PyError::zero_division("float division by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } let va = as_float(a); reject_float_coercion_overflow(a, va)?; @@ -1503,7 +1505,7 @@ unsafe fn float_mod(a: PyObjectRef, b: PyObjectRef) -> PyResult { reject_float_coercion_overflow(b, y)?; if y == 0.0 { // floatobject.py:526 - return Err(PyError::zero_division("float modulo")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } let mut m = jit_float_fmod(x, y); if m != 0.0 { @@ -1522,7 +1524,7 @@ unsafe fn float_mod(a: PyObjectRef, b: PyObjectRef) -> PyResult { fn float_divmod_w(x: f64, y: f64) -> Result<(f64, f64), PyError> { if y == 0.0 { // floatobject.py:761 - return Err(PyError::zero_division("float modulo")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } let mut m = jit_float_fmod(x, y); // floatobject.py:767: div = (x - mod) / y @@ -1936,6 +1938,51 @@ unsafe fn repeat_count(n: PyObjectRef) -> Result { } } +/// tupleobject.py descr_mul +pub(crate) unsafe fn tuple_repeat(t: PyObjectRef, n: PyObjectRef) -> PyResult { + let n = repeat_count(n)?; + // tupleobject.py: `if times == 1 and space.type(self) == space.w_tuple: + // return self`. Subclasses must still be copied to a base tuple. + if n == 1 && is_exact_tuple(t) { + return Ok(t); + } + let len = w_tuple_len(t); + let cap = len + .checked_mul(n) + .ok_or_else(|| PyError::new(PyErrorKind::OverflowError, "tuple is too large"))?; + let mut items: Vec = Vec::new(); + items + .try_reserve_exact(cap) + .map_err(|_| PyError::new(PyErrorKind::MemoryError, ""))?; + for _ in 0..n { + for i in 0..len { + if let Some(item) = w_tuple_getitem(t, i as i64) { + items.push(item); + } + } + } + Ok(w_tuple_new(items)) +} + +/// The builtin sequences repeat through `sq_repeat`, never `nb_multiply`. +pub(crate) unsafe fn is_repeat_sequence(obj: PyObjectRef) -> bool { + is_str(obj) || is_list(obj) || is_tuple(obj) || pyre_object::bytesobject::is_bytes_like(obj) +} + +/// `sequence_repeat` for a receiver [`is_repeat_sequence`] accepted, with the +/// count already reduced through `__index__`. +unsafe fn sequence_repeat(seq: PyObjectRef, count: PyObjectRef) -> PyResult { + if is_str(seq) { + str_repeat(seq, count) + } else if is_list(seq) { + list_repeat(seq, count) + } else if is_tuple(seq) { + tuple_repeat(seq, count) + } else { + bytes_repeat(seq, count) + } +} + /// unicodeobject.py:619-621 descr_mul pub(crate) unsafe fn str_repeat(s: PyObjectRef, n: PyObjectRef) -> PyResult { // Repeat at the WTF-8 byte level — a repetition of valid WTF-8 is valid @@ -2496,7 +2543,7 @@ unsafe fn complex_truediv(a: PyObjectRef, b: PyObjectRef) -> PyResult { reject_float_coercion_overflow(a, ar)?; reject_float_coercion_overflow(b, br)?; if br == 0.0 && bi == 0.0 { - return Err(PyError::zero_division("complex division by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } if is_complex(a) && is_complex(b) { Ok(complex_quot(ar, ai, br, bi)) @@ -2554,7 +2601,9 @@ unsafe fn complex_pow(a: PyObjectRef, b: PyObjectRef) -> PyResult { (1.0, 0.0) } else if ar == 0.0 && ai == 0.0 { if bi != 0.0 || br < 0.0 { - return Err(PyError::zero_division("0.0 to a negative or complex power")); + return Err(PyError::zero_division( + "zero to a negative or complex power", + )); } (0.0, 0.0) } else if bi == 0.0 && (-100.0..=100.0).contains(&br) && br == br.trunc() { @@ -2857,6 +2906,49 @@ unsafe fn bytes_operand_overrides(obj: PyObjectRef, fwd: &str, rev: &str) -> boo dunder_overridden(obj, fwd, t) || dunder_overridden(obj, rev, t) } +/// `needs_seq_binop_dispatch` for the `sq_repeat` branches of [`mul`], where +/// only one operand is the sequence: a subclass that overrides the multiply +/// specials relative to its own builtin base has to run that override instead +/// of repeating, exactly as the concat branches of [`add`] are gated. +/// +/// Judged one operand at a time rather than as a pair, because a repeat's other +/// operand is the multiplier — an `int`, whose own `__mul__` lives on `int` and +/// would read as an override against any sequence base. A *non*-overriding +/// subclass keeps the repeat path, which is what makes `L([1]) * 2` still a +/// plain list repetition. +/// +/// `dont_look_inside`: the builtin-base type statics are loaded here, so a +/// traced caller emits a residual call rather than an unresolvable +/// `LoadStatic`. +#[majit_macros::dont_look_inside] +pub(crate) unsafe fn seq_repeat_override(obj: PyObjectRef, dunders: &[&str]) -> bool { + if pyre_object::is_exact_builtin_instance(obj) { + return false; + } + let tp: *const pyre_object::PyType = if is_str(obj) { + &pyre_object::STR_TYPE + } else if is_list(obj) { + &pyre_object::LIST_TYPE + } else if is_tuple(obj) { + &pyre_object::TUPLE_TYPE + } else if pyre_object::bytesobject::is_bytes_like(obj) { + if pyre_object::bytesobject::is_bytes(obj) { + &pyre_object::bytesobject::BYTES_TYPE + } else { + &pyre_object::bytearrayobject::BYTEARRAY_TYPE + } + } else { + return false; + }; + let Some(t) = crate::typedef::gettypefor(tp) else { + return false; + }; + let t = t.as_ptr(); + dunders + .iter() + .any(|dunder| dunder_overridden(obj, dunder, t)) +} + /// True when `obj` is an exact builtin numeric instance /// (`int`/`long`/`float`/`complex`/`bool`, not a subclass). These types /// define no in-place special method (`__iadd__` etc.), so @@ -3146,6 +3238,16 @@ pub fn mul(a: PyObjectRef, b: PyObjectRef) -> PyResult { if is_complex_pair(a, b) { return complex_mul(a, b); } + // The `sq_repeat` fast paths below are valid for exact builtin + // sequences. A sequence subclass that overrides `__mul__`/`__rmul__` + // must reach its override first — `LM([1]) * 2` is `LM.__mul__`, not a + // list repetition — the same gate the concat branches of `add` apply. + const MUL_SPECIALS: &[&str] = &["__mul__", "__rmul__"]; + if seq_repeat_override(a, MUL_SPECIALS) || seq_repeat_override(b, MUL_SPECIALS) { + if let Some(result) = try_dispatch_binary_special(a, b, "__mul__", "__rmul__")? { + return Ok(result); + } + } if is_str(a) && is_int_or_long(b) { return str_repeat(a, b); } @@ -3160,32 +3262,10 @@ pub fn mul(a: PyObjectRef, b: PyObjectRef) -> PyResult { } // tupleobject.py descr_mul if is_tuple(a) && is_int_or_long(b) { - let n = repeat_count(b)?; - // tupleobject.py: `if times == 1 and space.type(self) == - // space.w_tuple: return self`. Subclasses must still be copied to - // a base tuple. - if n == 1 && is_exact_tuple(a) { - return Ok(a); - } - let len = w_tuple_len(a); - let cap = len - .checked_mul(n) - .ok_or_else(|| PyError::new(PyErrorKind::OverflowError, "tuple is too large"))?; - let mut items: Vec = Vec::new(); - items - .try_reserve_exact(cap) - .map_err(|_| PyError::new(PyErrorKind::MemoryError, ""))?; - for _ in 0..n { - for i in 0..len { - if let Some(item) = w_tuple_getitem(a, i as i64) { - items.push(item); - } - } - } - return Ok(w_tuple_new(items)); + return tuple_repeat(a, b); } if is_int_or_long(a) && is_tuple(b) { - return mul(b, a); + return tuple_repeat(b, a); } // bytesobject.py descr_mul / bytearrayobject.py descr_mul if pyre_object::bytesobject::is_bytes_like(a) && is_int_or_long(b) { @@ -3194,25 +3274,43 @@ pub fn mul(a: PyObjectRef, b: PyObjectRef) -> PyResult { if is_int_or_long(a) && pyre_object::bytesobject::is_bytes_like(b) { return mul(b, a); } - if let Some(result) = try_dispatch_binary_special(a, b, "__mul__", "__rmul__")? { + // `PyNumber_Multiply`: none of the builtin sequences implements + // `nb_multiply`, so their `__mul__` / `__rmul__` slot wrappers take no + // part in the operator dispatch — only the other operand can supply a + // numeric implementation. + let a_seq = is_repeat_sequence(a); + let b_seq = is_repeat_sequence(b); + let dispatched = match (a_seq, b_seq) { + (true, true) => None, + (true, false) => match lookup_type_special(b, "__rmul__") { + Some(method) => try_call_special(method, &[b, a])?, + None => None, + }, + (false, true) => match lookup_type_special(a, "__mul__") { + Some(method) => try_call_special(method, &[a, b])?, + None => None, + }, + (false, false) => try_dispatch_binary_special(a, b, "__mul__", "__rmul__")?, + }; + if let Some(result) = dispatched { return Ok(result); } let a_name = crate::baseobjspace::object_functionstr_type_name(a); let b_name = crate::baseobjspace::object_functionstr_type_name(b); - // Sequence repetition slot (sq_repeat): a sequence on either side - // with a non-int multiplier reports the non-int's type. - let a_seq = - is_str(a) || is_list(a) || is_tuple(a) || pyre_object::bytesobject::is_bytes_like(a); - let b_seq = - is_str(b) || is_list(b) || is_tuple(b) || pyre_object::bytesobject::is_bytes_like(b); - if a_seq { - return Err(PyError::type_error(format!( - "can't multiply sequence by non-int of type '{b_name}'" - ))); - } - if b_seq { + // `sequence_repeat`: the count goes through `__index__`, and an + // operand that has none is reported by its own type — the sequence is + // never the one named. + if a_seq || b_seq { + let (seq, other, other_name) = if a_seq { + (a, b, b_name) + } else { + (b, a, a_name) + }; + if !(a_seq && b_seq) && crate::baseobjspace::lookup(other, "__index__").is_some() { + return sequence_repeat(seq, crate::baseobjspace::getindex_repeat(other)?); + } return Err(PyError::type_error(format!( - "can't multiply sequence by non-int of type '{a_name}'" + "can't multiply sequence by non-int of type '{other_name}'" ))); } Err(PyError::type_error(format!( @@ -3326,7 +3424,7 @@ pub fn truediv(a: PyObjectRef, b: PyObjectRef) -> PyResult { return float_truediv(a, b); } if !is_long(b) && as_float(b) == 0.0 { - return Err(PyError::zero_division("division by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } // intobject.py:332 `_truediv`: machine ints wider than the // binary64 mantissa deliberately overflow into the rbigint path @@ -3497,7 +3595,7 @@ pub(crate) fn truediv_builtin(a: PyObjectRef, b: PyObjectRef) -> PyResult { return float_truediv(a, b); } if !is_long(b) && as_float(b) == 0.0 { - return Err(PyError::zero_division("division by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } // Match `_truediv`'s overflow-to-rbigint leg for i64 values that // are not exactly representable in a binary64 mantissa. @@ -3591,7 +3689,7 @@ pub(crate) fn divmod_builtin(a: PyObjectRef, b: PyObjectRef) -> PyResult { // every numeric zero divisor. This intentionally differs from // PyPy 3.11's int/float-specific divmod and modulo messages. if !is_true(b)? { - return Err(PyError::zero_division("division by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } if is_float_pair(a, b) { let x = as_float(a); @@ -4043,7 +4141,12 @@ pub fn pow3(base: PyObjectRef, exp: PyObjectRef, modulus: PyObjectRef) -> PyResu if let Some(result) = try_dispatch_ternary_pow_special(base, exp, modulus)? { return Ok(result); } - Err(ternary_builtin_type_error("pow()", base, exp, modulus)) + Err(ternary_builtin_type_error( + "** or pow()", + base, + exp, + modulus, + )) } /// `divmod(a, b)` dispatch — pypy/interpreter/baseobjspace.py @@ -4063,7 +4166,7 @@ pub fn divmod(a: PyObjectRef, b: PyObjectRef) -> PyResult { // Python 3.14 target-version spelling; see `divmod_builtin` above // for the PyPy 3.11 difference. if !is_true(b)? { - return Err(PyError::zero_division("division by zero")); + return Err(PyError::zero_division(ZERO_DIVISION_MSG)); } if is_float_pair(a, b) { let x = as_float(a); @@ -4108,11 +4211,37 @@ pub fn jit_float_fmod(x: f64, y: f64) -> f64 { x % y } +/// `float_pow`: libm sets `ERANGE` when a finite base produces an +/// out-of-range result. An infinite base is excluded because `pow(±inf, y)` +/// is answered by the special cases above rather than by libm, so its infinity +/// is the exact result and not a range error. +fn float_pow_range_check(z: f64, base: f64) -> Result { + if z.is_infinite() && !base.is_infinite() { + return Err(FloatPowError::Overflow); + } + Ok(z) +} + +/// 3.14 surfaces the `ERANGE` libm sets for `float_pow` through +/// `PyErr_SetFromErrno` as the `(errno, strerror)` pair. +/// `floatobject.py:937-943` instead lets its own `math.pow` OverflowError +/// through as the message `"float power"`. +fn float_pow_overflow_error() -> PyError { + // 34 on every platform pyre targets; spelled out rather than taken from + // `libc`, which does not export the errno constants for `wasm32`. + const ERANGE: i32 = 34; + PyError::errno_pair( + crate::PyErrorKind::OverflowError, + pyre_object::interp_exceptions::ExcKind::OverflowError, + ERANGE, + ) +} + /// floatobject.py:865 `_pow`. fn float_pow_inner(x: f64, y: f64) -> Result { // floatobject.py:800-801 if y == 2.0 { - return Ok(x * x); + return float_pow_range_check(x * x, x); } // floatobject.py:803-804 if y == 0.0 { @@ -4168,10 +4297,7 @@ fn float_pow_inner(x: f64, y: f64) -> Result { return Ok(if negate_result { -1.0 } else { 1.0 }); } // floatobject.py:871-877 - let z = bx.powf(y); - if z.is_infinite() && !bx.is_infinite() { - return Err(FloatPowError::Overflow); - } + let z = float_pow_range_check(bx.powf(y), bx)?; // floatobject.py:879-881 Ok(if negate_result { -z } else { z }) } @@ -4186,7 +4312,7 @@ pub fn float_pow_raw(x: f64, y: f64) -> Result { "negative number cannot be raised to a fractional power", )), Err(FloatPowError::ZeroDivision) => Err(PyError::zero_division("zero to a negative power")), - Err(FloatPowError::Overflow) => Err(PyError::overflow_error("float power")), + Err(FloatPowError::Overflow) => Err(float_pow_overflow_error()), } } @@ -4199,7 +4325,7 @@ fn float_pow_impl(x: f64, y: f64) -> PyResult { complex_pow(w_complex_new(x, 0.0), w_complex_new(y, 0.0)) }, Err(FloatPowError::ZeroDivision) => Err(PyError::zero_division("zero to a negative power")), - Err(FloatPowError::Overflow) => Err(PyError::overflow_error("float power")), + Err(FloatPowError::Overflow) => Err(float_pow_overflow_error()), } } diff --git a/pyre/pyre-interpreter/src/objspace/std/formatting.rs b/pyre/pyre-interpreter/src/objspace/std/formatting.rs index d57089c9c50..f6cc53aca86 100644 --- a/pyre/pyre-interpreter/src/objspace/std/formatting.rs +++ b/pyre/pyre-interpreter/src/objspace/std/formatting.rs @@ -264,10 +264,22 @@ fn cformat_rbigint(spec: &CFormatSpec, num: &BigInt) -> Result _ => unreachable!("percent integer formats use radix 8, 10, or 16"), }; let negative = num.int_lt(0); - let mut magnitude = num.format(digits, "", "", 0).map_err(|error| match error { - pyre_object::rbigint::RBigIntError::Memory => PyError::memory_error(""), - _ => unreachable!("validated radix formatting returned an unrelated error"), - })?; + // Only the decimal conversion is quadratic, so only it carries the + // `sys.set_int_max_str_digits` limit; `%o`/`%x`/`%X` are exempt. + let maxdigits = if radix == 10 { + crate::module::sys::state::int_max_str_digits() + } else { + 0 + }; + let mut magnitude = + num.format(digits, "", "", maxdigits as i64) + .map_err(|error| match error { + pyre_object::rbigint::RBigIntError::Memory => PyError::memory_error(""), + pyre_object::rbigint::RBigIntError::MaxStrDigits => { + crate::builtins::int_max_str_digits_error(maxdigits) + } + _ => unreachable!("validated radix formatting returned an unrelated error"), + })?; if negative { let Some(unsigned) = magnitude.strip_prefix('-') else { return Err(PyError::system_error( diff --git a/pyre/pyre-interpreter/src/opcode_ops.rs b/pyre/pyre-interpreter/src/opcode_ops.rs index ab1e3bf2712..6db1fda0735 100644 --- a/pyre/pyre-interpreter/src/opcode_ops.rs +++ b/pyre/pyre-interpreter/src/opcode_ops.rs @@ -28,6 +28,21 @@ fn inplace_dunder_name(op: BinaryOperator) -> Option<&'static str> { }) } +/// `sq_inplace_repeat` is not a numeric slot, so a builtin sequence times an +/// operand with no `__index__` never reaches `__imul__`: the in-place multiply +/// falls through to `sequence_repeat`, which reports the multiplier rather +/// than running the slot's own converter. +/// +/// A sequence *subclass* that defines its own `__imul__` is exempt — that +/// override is a real numeric slot and runs whatever the multiplier is, so +/// `x = LI([1]); x *= "s"` is `LI.__imul__`, not the multiplier TypeError. +fn skips_inplace_special(a: PyObjectRef, b: PyObjectRef, op: BinaryOperator) -> bool { + matches!(op, BinaryOperator::InplaceMultiply) + && unsafe { crate::objspace::descroperation::is_repeat_sequence(a) } + && !unsafe { crate::objspace::descroperation::seq_repeat_override(a, &["__imul__"]) } + && unsafe { crate::baseobjspace::lookup(b, "__index__").is_none() } +} + pub fn binary_value( a: PyObjectRef, b: PyObjectRef, @@ -39,7 +54,7 @@ pub fn binary_value( // descroperation.py:825 `inplace_impl` — consult the in-place // special first; fall through to the binary op below when absent or // `NotImplemented`. - if let Some(idunder) = inplace_dunder_name(op) { + if let Some(idunder) = inplace_dunder_name(op).filter(|_| !skips_inplace_special(a, b, op)) { // `seq_bug_compat` applies only to `+=` / `*=`; pass the reflected // name so the builtin-sequence rhs-first branch can fire. let (rdunder, seq_bug_compat) = match op { @@ -491,12 +506,12 @@ pub fn list_extend_value(list: PyObjectRef, iterable: PyObjectRef) -> Result<(), match crate::type_methods::list_method_extend(&[list, iterable]) { Ok(_) => Ok(()), Err(error) if crate::baseobjspace::is_iterable(iterable) => Err(error), - Err(_) => unsafe { - let type_name = pyre_object::type_name_of(iterable); + Err(_) => { + let type_name = crate::baseobjspace::object_functionstr_type_name(iterable); Err(PyError::type_error(format!( "Value after * must be an iterable, not {type_name}" ))) - }, + } } } diff --git a/pyre/pyre-interpreter/src/pyframe.rs b/pyre/pyre-interpreter/src/pyframe.rs index ce6f598b8fb..b74ad8fce82 100644 --- a/pyre/pyre-interpreter/src/pyframe.rs +++ b/pyre/pyre-interpreter/src/pyframe.rs @@ -2326,6 +2326,34 @@ impl PyFrame { Ok(self.get_w_locals()) } + /// The mapping `locals()` hands back, and the implicit locals `exec` / + /// `eval` run against (`_PyEval_GetFrameLocals`). + /// + /// A frame whose `f_locals` is a `FrameLocalsProxy` — every optimized + /// frame — yields an INDEPENDENT dict copy, so `locals() is locals()` is + /// false, a snapshot does not track later stores, and writing into one + /// reaches neither the fast locals nor the next snapshot (PEP 667). + /// Module and class frames keep handing back their real namespace, which + /// is what makes a module-level `locals() is globals()` still hold. + pub fn frame_locals_snapshot(&mut self) -> Result { + let w_locals = self.getdictscope()?; + if w_locals.is_null() || !self.code().flags.contains(crate::CodeFlags::OPTIMIZED) { + return Ok(w_locals); + } + let _roots = pyre_object::gc_roots::push_roots(); + let locals_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(w_locals); + let snapshot_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(pyre_object::w_dict_new()); + // `dict_update_value` walks a mapping's `keys()`, so both sides are + // reloaded across it as well as across the `w_dict_new` above. + crate::opcode_ops::dict_update_value( + pyre_object::gc_roots::shadow_stack_get(snapshot_slot), + pyre_object::gc_roots::shadow_stack_get(locals_slot), + )?; + Ok(pyre_object::gc_roots::shadow_stack_get(snapshot_slot)) + } + /// Test-helper constructor — creates a frame with a fresh execution /// context. /// diff --git a/pyre/pyre-interpreter/src/runtime_ops.rs b/pyre/pyre-interpreter/src/runtime_ops.rs index 066e5c7c0f2..b122f395153 100644 --- a/pyre/pyre-interpreter/src/runtime_ops.rs +++ b/pyre/pyre-interpreter/src/runtime_ops.rs @@ -1227,25 +1227,31 @@ pub extern "C" fn jit_sequence_getitem(seq: i64, index: i64) -> i64 { pub fn unpack_sequence_exact(seq: PyObjectRef, count: usize) -> Result, PyError> { // Fast path only for exact built-in sequence types. Subclasses and other // instances may define custom `__iter__` that must be honored. - let exact_sequence_len = unsafe { + // `ceval.c UNPACK_SEQUENCE` takes its length-aware fast path for an exact + // tuple or list only, and only there does a "too many" error name the + // source's total. A str is unpacked by the generic iterator loop, which + // stops one item past `count` and so has no total to report. + let (exact_sequence_len, reports_total) = unsafe { if pyre_object::is_exact_tuple(seq) { - Some(w_tuple_len(seq)) + (Some(w_tuple_len(seq)), true) } else if pyre_object::is_exact_list(seq) { - Some(w_list_len(seq)) + (Some(w_list_len(seq)), true) } else if pyre_object::is_exact_type(seq, &pyre_object::STR_TYPE) { - Some(w_str_len(seq)) + (Some(w_str_len(seq)), false) } else { - None + (None, false) } }; if let Some(len) = exact_sequence_len { if len != count { // `baseobjspace.py:1041-1053 _unpackiterable_known_length_jitlook` // raises ValueError on length mismatch. - let msg = if len > count { - format!("too many values to unpack (expected {count})") - } else { + let msg = if len < count { format!("not enough values to unpack (expected {count}, got {len})") + } else if reports_total { + format!("too many values to unpack (expected {count}, got {len})") + } else { + format!("too many values to unpack (expected {count})") }; return Err(PyError::value_error(msg)); } @@ -1256,9 +1262,10 @@ pub fn unpack_sequence_exact(seq: PyObjectRef, count: usize) -> Result it, @@ -1317,7 +1324,7 @@ pub fn unpack_ex_slots( Err(e) if e.kind == PyErrorKind::TypeError => { return Err(PyError::type_error(format!( "cannot unpack non-iterable {} object", - pyre_object::type_name_of(value) + crate::baseobjspace::object_functionstr_type_name(value) ))); } Err(e) => return Err(e), @@ -1363,7 +1370,7 @@ pub fn ensure_range_iter(iter: PyObjectRef) -> Result<(), PyError> { } Err(PyError::type_error(format!( "'{}' object is not iterable", - unsafe { pyre_object::type_name_of(iter) } + crate::baseobjspace::object_functionstr_type_name(iter) ))) } diff --git a/pyre/pyre-interpreter/src/sliceobject.rs b/pyre/pyre-interpreter/src/sliceobject.rs index 1791e96eb5b..3ef5f2d702c 100644 --- a/pyre/pyre-interpreter/src/sliceobject.rs +++ b/pyre/pyre-interpreter/src/sliceobject.rs @@ -33,19 +33,54 @@ pub(crate) fn eval_slice_index(w_int: PyObjectRef) -> Result Result { - let mut index = eval_slice_index(w_index)?; - if index < 0 { - // `eval_slice_index` clamps an out-of-word index to `i64::MIN`, so fold - // by `size` without overflowing before flooring at 0. - index = index.saturating_add(size); - if index < 0 { - index = 0; - } - } + let index = adapt_bound(size, eval_slice_index(w_index)?); debug_assert!(index >= 0); Ok(index) } +/// `eval_slice_index` for a bound that may not be `None` +/// (`_PyEval_SliceIndexNotNone`), which is how a sequence `index` method +/// converts its optional start/stop: `[1, 2].index(2, None)` is a TypeError +/// even though `[1, 2][None:]` is not. The message drops the `or None` the +/// slicing form offers. +pub(crate) fn eval_slice_index_not_none(w_int: PyObjectRef) -> Result { + // `_PyIndex_Check` gates the substituted message and is a type test, so a + // bound that has `__index__` is converted exactly once, by + // `eval_slice_index`. Probing with a conversion instead would run a user + // slot twice and relabel everything it raises — an `OverflowError` for a + // result too wide for a word, or the slot's own exception — as this + // TypeError, which upstream reserves for a type with no `__index__` at all. + if unsafe { is_none(w_int) || !crate::builtins::index_check(w_int) } { + return Err(crate::PyError::type_error( + "slice indices must be integers or have an __index__ method", + )); + } + eval_slice_index(w_int) +} + +/// `unwrap_start_stop` for the bounds of a sequence `index` method, which +/// rejects `None` rather than reading it as "this side unbounded". +pub fn unwrap_start_stop_not_none( + size: i64, + w_start: PyObjectRef, + w_end: PyObjectRef, +) -> Result<(i64, i64), crate::PyError> { + let start = adapt_bound(size, eval_slice_index_not_none(w_start)?); + let end = adapt_bound(size, eval_slice_index_not_none(w_end)?); + Ok((start, end)) +} + +/// The negative-index normalization `adapt_lower_bound` applies once the +/// bound has already been converted to an `i64`. +fn adapt_bound(size: i64, index: i64) -> i64 { + if index >= 0 { + return index; + } + // An out-of-word index arrives clamped to `i64::MIN`, so fold by `size` + // without overflowing before flooring at 0. + index.saturating_add(size).max(0) +} + /// sliceobject.py:242 `unwrap_start_stop(space, size, w_start, w_end)`. /// /// Returns `(start, end)` after negative-index normalization. `None` diff --git a/pyre/pyre-interpreter/src/stack_check.rs b/pyre/pyre-interpreter/src/stack_check.rs index 6c8affcd374..c439f919272 100644 --- a/pyre/pyre-interpreter/src/stack_check.rs +++ b/pyre/pyre-interpreter/src/stack_check.rs @@ -9,7 +9,8 @@ //! grow downward). Raw read, returns 0 when never captured. //! * [`pyre_stack_get_length`] — effective stack budget in bytes. //! * [`pyre_stack_set_length_fraction`] — multiply `MAX_STACK_SIZE` by -//! `frac` and store the result as the new effective length. +//! `frac`, clamp to the OS stack, and store the result as the new +//! effective length. //! * [`pyre_stack_too_big_slowpath`] — four-case slow path: first-time //! capture, thread-switch cache refresh, stack-underflow base //! revision, real overflow. @@ -35,7 +36,7 @@ //! `rpython/jit/backend/x86/assembler.py:1080 //! _call_header_with_stack_check`). -use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicI32, AtomicU8, AtomicUsize, Ordering}; use pyre_object::interp_exceptions::{ExcKind, w_exception_new}; @@ -56,25 +57,25 @@ pub use crate::module::sys::state::{DEFAULT_RECURSION_LIMIT, MAX_RECURSION_LIMIT /// `_stack_set_length_fraction(frac) * MAX_STACK_SIZE` produces a /// byte budget identical to an equivalent PyPy build. /// -/// Note that Rust interpreter frames are larger than RPython's -/// translated-C frames, so the default recursion budget will exhaust -/// sooner (in terms of Python-level call depth) than CPython or -/// translated PyPy at the same `sys.setrecursionlimit()`. User -/// programs that expect CPython-style depth should raise the -/// recursion limit accordingly; the budget formula itself now -/// matches PyPy byte-for-byte. -#[cfg(any( - target_arch = "powerpc", - target_arch = "powerpc64", - target_arch = "s390x" -))] -pub const MAX_STACK_SIZE: usize = 11 << 18; -#[cfg(not(any( - target_arch = "powerpc", - target_arch = "powerpc64", - target_arch = "s390x" -)))] +/// stack.h picks the constant per architecture for exactly one reason: +/// 768 KB "is only enough for 406 levels on ppc64", so platforms whose +/// frames are bigger get the larger budget instead. Rust interpreter +/// frames are bigger than RPython's translated-C frames in the same way +/// — 768 KB bottoms out around 476 Python call levels here — so pyre +/// applies the same rule and takes `11 << 18` on the platforms that +/// have room for it. That keeps the byte budget above the recursion +/// limit's own cutoff, so `sys.setrecursionlimit(N)` is what bounds +/// Python call depth and the byte budget stays the hard-stack guard it +/// is upstream. +/// +/// `wasm32` keeps `3 << 18`: its linear-memory stack is sized at link +/// time (1 MB by default) with no guard page and no `getrlimit` for +/// [`pyre_stack_set_length_fraction`] to clamp against, so a budget +/// wider than the real stack would corrupt memory instead of raising. +#[cfg(target_arch = "wasm32")] pub const MAX_STACK_SIZE: usize = 3 << 18; +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_STACK_SIZE: usize = 11 << 18; /// rpython/translator/c/src/stack.h:23-27 `rpy_stacktoobig_t` parity. /// @@ -258,16 +259,65 @@ pub extern "C" fn pyre_stack_get_length_adr() -> usize { &raw const PYRE_STACKTOOBIG.stack_length as usize } -/// rpython/translator/c/src/stack.c:20-23 `LL_stack_set_length_fraction`. +/// rpython/translator/c/src/stack.c:23-36 `_ll_stack_os_limit`. Size in +/// bytes of this thread's C stack as the OS sees it, or 0 when +/// unknown/unlimited. Computed once and cached: this is not on a hot +/// path, but `test.support.infinite_recursion()` toggles the recursion +/// limit in a loop, so caching keeps repeated calls free. +#[cfg(not(any(windows, target_arch = "wasm32")))] +fn stack_os_limit() -> usize { + static CACHED: AtomicUsize = AtomicUsize::new(usize::MAX); + let cached = CACHED.load(Ordering::Relaxed); + if cached != usize::MAX { + return cached; + } + let mut rl: libc::rlimit = unsafe { std::mem::zeroed() }; + let limit = match unsafe { libc::getrlimit(libc::RLIMIT_STACK, &mut rl) } { + 0 if rl.rlim_cur != libc::RLIM_INFINITY && rl.rlim_cur != 0 => rl.rlim_cur as usize, + _ => 0, + }; + CACHED.store(limit, Ordering::Relaxed); + limit +} + +/// Windows before `GetCurrentThreadStackLimits` (Windows 8) and wasm32 +/// have no runtime query, so no clamp applies — matching the +/// `#ifdef` fallthrough at stack.c:36-45. +#[cfg(any(windows, target_arch = "wasm32"))] +fn stack_os_limit() -> usize { + 0 +} + +/// rpython/translator/c/src/stack.c:58-77 `LL_stack_set_length_fraction`. /// /// ```c /// void LL_stack_set_length_fraction(double fraction) { -/// rpy_stacktoobig.stack_length = (Signed)(MAX_STACK_SIZE * fraction); +/// Signed length = (Signed)(MAX_STACK_SIZE * fraction); +/// Signed os_limit = _ll_stack_os_limit(); +/// if (os_limit > 0) { +/// Signed cap = os_limit - (os_limit >> 2); +/// if (cap > 0 && length > cap) +/// length = cap; +/// } +/// rpy_stacktoobig.stack_length = length; /// } /// ``` +/// +/// `sys.setrecursionlimit()` scales `length` linearly, so a high limit +/// can push it past the real OS stack and segfault before the check +/// ever reports an overflow. Clamping to the stack the OS actually +/// gives this thread, minus a 25% margin for the frames between the +/// check and the guard page, keeps the check firing first. #[unsafe(no_mangle)] pub extern "C" fn pyre_stack_set_length_fraction(frac: f64) { - let length = (MAX_STACK_SIZE as f64 * frac) as usize; + let mut length = (MAX_STACK_SIZE as f64 * frac) as usize; + let os_limit = stack_os_limit(); + if os_limit > 0 { + let cap = os_limit - (os_limit >> 2); + if cap > 0 && length > cap { + length = cap; + } + } PYRE_STACKTOOBIG .stack_length .store(length, Ordering::Relaxed); @@ -492,33 +542,43 @@ pub extern "C" fn pyre_stack_criticalcode_stop() { TL_REPORT_ERROR.with(|c| c.set(1)); } +/// Largest recursion limit ever requested on this process. The native byte +/// budget is reserved from it, never from a lowered limit. +static NATIVE_STACK_RESERVE: AtomicI32 = AtomicI32::new(DEFAULT_RECURSION_LIMIT); + /// pypy/module/sys/vm.py:63 `setrecursionlimit`, with Python 3.14's /// logical-depth rejection before PyPy's byte-budget update. The check must -/// use Python call depth: native Rust frame size is unrelated to CPython's -/// `py_recursion_remaining` and cannot decide whether a Python limit is below -/// the current interpreter depth. +/// use Python frame depth: native Rust frame size is unrelated to the Python +/// recursion budget and cannot decide whether a limit is below the current +/// interpreter depth. pub fn set_recursion_limit(new_limit: i32) -> Result<(), PyError> { if new_limit <= 0 { return Err(PyError::value_error("recursion limit must be positive")); } // pypy/module/sys/vm.py:86-87 silent upper bound. let limit = new_limit.min(MAX_RECURSION_LIMIT); - let old_limit = get_recursion_limit(); - // CPython 3.14 Python/sysmodule.c `sys_setrecursionlimit_impl`: reject + // Python/sysmodule.c `sys_setrecursionlimit_impl`: reject // when the current Python recursion depth has already reached the limit. - let depth = crate::call::call_depth(); + let depth = crate::call::py_recursion_depth(); if depth >= limit as u32 { return Err(PyError::recursion_error(format!( "cannot set the recursion limit to {limit} at the recursion depth {depth}: the limit is too low" ))); } - // CPython 3.14 keeps Python recursion accounting separate from native - // stack protection. Preserve PyPy's native byte guard as a high-water - // allocation: lowering the Python limit must not shrink it underneath - // the already-running Rust interpreter stack. Logical depth below is - // what enforces the newly lowered Python limit. - pyre_stack_set_length_fraction(old_limit.max(limit) as f64 * 0.001); + // Python 3.14 keeps Python recursion accounting separate from native stack + // protection. Preserve PyPy's native byte guard as a high-water + // allocation: lowering the Python limit must not shrink it underneath the + // already-running Rust interpreter stack. Logical depth below is what + // enforces the newly lowered Python limit. The mark is the largest limit + // ever requested, not the previous one — `setrecursionlimit(n)` called + // twice would otherwise collapse the reservation from the startup 5000 to + // `n` on the second call, and the native guard would then cut a recursion + // short far above the Python limit. + let reserved = NATIVE_STACK_RESERVE + .fetch_max(limit, Ordering::Relaxed) + .max(limit); + pyre_stack_set_length_fraction(reserved as f64 * 0.001); crate::module::sys::state::set_recursion_limit(limit); majit_gc::shadow_stack::increase_root_stack_depth((limit as f64 * 0.001 * 163840.0) as usize); Ok(()) @@ -548,7 +608,7 @@ pub fn stack_check() -> Result<(), PyError> { // CPython 3.14 checks `py_recursion_remaining`, i.e. Python interpreter // depth, independently of native stack protection. pyre's matching // counter is bumped around every user-function call. - if crate::call::call_depth() >= get_recursion_limit() as u32 { + if crate::call::py_recursion_depth() >= get_recursion_limit() as u32 { return Err(PyError::recursion_error("maximum recursion depth exceeded")); } let current = current_sp(); @@ -629,6 +689,7 @@ mod tests { PYRE_STACKTOOBIG.report_error.store(1, Ordering::Relaxed); TL_REPORT_ERROR.with(|c| c.set(1)); crate::module::sys::state::reset_recursion_limit_for_tests(); + NATIVE_STACK_RESERVE.store(DEFAULT_RECURSION_LIMIT, Ordering::Relaxed); clear_jit_pending_exception(); } diff --git a/pyre/pyre-interpreter/src/type_methods.rs b/pyre/pyre-interpreter/src/type_methods.rs index 6c249f81d06..470da5c1084 100644 --- a/pyre/pyre-interpreter/src/type_methods.rs +++ b/pyre/pyre-interpreter/src/type_methods.rs @@ -56,13 +56,95 @@ pub(crate) fn arg_or_none(args: &[PyObjectRef], i: usize) -> PyObjectRef { if i < args.len() { args[i] } else { w_none() } } +/// `TYPE.NAME`, the name a bound builtin method reports itself under. TYPE +/// comes from the receiver rather than from the type the method is declared +/// on, so a call on a subclass instance names the subclass +/// (`class L(list)` — `L().copy` reports `L.copy`); a receiver that IS a type +/// reports under its own name, which is what a class method binds to +/// (`dict.fromkeys`, not `type.fromkeys`). With no receiver at all the bare +/// name is used; `require_receiver` normally rejects that first. +fn method_qualname(args: &[PyObjectRef], name: &str) -> String { + match args.first() { + Some(&receiver) => { + let ty = if unsafe { pyre_object::typeobject::is_type(receiver) } { + unsafe { pyre_object::w_type_get_name(receiver) }.to_string() + } else { + crate::baseobjspace::object_functionstr_type_name(receiver) + }; + format!("{ty}.{name}") + } + None => name.to_string(), + } +} + +/// The builtin type a method reached through `receiver` is declared on — the +/// first non-heap type in the receiver's MRO. Whether a name is filled by a +/// `wrapper_descriptor` or a `method_descriptor` is a property of the +/// declaring type, not of the subclass the call came in on, so the wording +/// split reads this while the qualified name reads the receiver's own type. +fn declaring_builtin_type_name(receiver: PyObjectRef) -> String { + unsafe { + let Some(w_type) = crate::typedef::r#type(receiver) else { + return String::new(); + }; + let mro = pyre_object::w_type_get_mro(w_type.as_ptr()); + if !mro.is_null() { + for &entry in (*mro).as_slice() { + if !pyre_object::typeobject::w_type_is_heaptype(entry) { + return pyre_object::w_type_get_name(entry).to_string(); + } + } + } + pyre_object::w_type_get_name(w_type.as_ptr()).to_string() + } +} + +/// TypeError for a keyword handed to a builtin method that takes a fixed +/// number of positional arguments. +/// +/// Every arity helper here counts the raw slice length, so the body it guards +/// has no parameter a keyword could name — a keyword-aware builtin reads the +/// trailing marker dict itself (`split_builtin_kwargs`) and never validates by +/// slice length. Without this the marker would be counted as a positional +/// value and reach the implementation as one. A slot wrapper reports itself +/// as `wrapper NAME()`, everything else under its qualified name. +pub(crate) fn reject_kwargs(args: &[PyObjectRef], name: &str) -> Result<(), crate::PyError> { + reject_kwargs_of(None, args, name) +} + +/// [`reject_kwargs`] for a method whose declaring class is fixed rather than +/// read off the receiver — see [`arity_no_args_of`]. +fn reject_kwargs_of( + owner: Option<&str>, + args: &[PyObjectRef], + name: &str, +) -> Result<(), crate::PyError> { + let (positional, kwargs) = crate::builtins::split_builtin_kwargs(args); + if !crate::builtins::has_real_kwargs(kwargs) { + return Ok(()); + } + let slot = positional.first().is_some_and(|&receiver| { + crate::gateway::is_slot_wrapper(&declaring_builtin_type_name(receiver), name) + }); + let subject = match (slot, owner) { + (true, _) => format!("wrapper {name}"), + (false, Some(owner)) => format!("{owner}.{name}"), + (false, None) => method_qualname(positional, name), + }; + Err(crate::PyError::type_error(format!( + "{subject}() takes no keyword arguments" + ))) +} + /// TypeError for a method requiring exactly `n` positional arguments after -/// the receiver, called with a different count. +/// the receiver, called with a different count. `name` is the bare method +/// name; the message qualifies it with the receiver's type. pub(crate) fn arity_exact( args: &[PyObjectRef], name: &str, n: usize, ) -> Result<(), crate::PyError> { + reject_kwargs(args, name)?; if args.len() != n + 1 { let expected = match n { 0 => "no arguments".to_string(), @@ -70,7 +152,8 @@ pub(crate) fn arity_exact( k => format!("exactly {k} arguments"), }; return Err(crate::PyError::type_error(format!( - "{name}() takes {expected} ({} given)", + "{}() takes {expected} ({} given)", + method_qualname(args, name), args_given(args), ))); } @@ -85,6 +168,7 @@ pub(crate) fn arity_at_least( name: &str, min: usize, ) -> Result<(), crate::PyError> { + reject_kwargs(args, name)?; if args.len() < min + 1 { return Err(crate::PyError::type_error(format!( "{name} expected at least {min} argument{}, got {}", @@ -95,25 +179,6 @@ pub(crate) fn arity_at_least( Ok(()) } -/// TypeError for a method requiring at least `min` positional arguments -/// after the receiver, called with fewer — the METH_FASTCALL -/// "X() takes at least N positional arguments (M given)" form -/// (`str.replace`, `bytes.translate`). -pub(crate) fn arity_at_least_positional( - args: &[PyObjectRef], - name: &str, - min: usize, -) -> Result<(), crate::PyError> { - if args.len() < min + 1 { - return Err(crate::PyError::type_error(format!( - "{name}() takes at least {min} positional argument{} ({} given)", - if min == 1 { "" } else { "s" }, - args_given(args), - ))); - } - Ok(()) -} - /// Validate that a str search method's substring (`args[1]`) is a `str`, /// else raise `TypeError("{method}() argument 1 must be str, not {type}")`. /// The search path reads `args[1]` as a `W_UnicodeObject`; a non-str would @@ -137,6 +202,7 @@ pub(crate) fn arity_at_most( name: &str, max: usize, ) -> Result<(), crate::PyError> { + reject_kwargs(args, name)?; if args.len() > max + 1 { return Err(crate::PyError::type_error(format!( "{name} expected at most {max} argument{}, got {}", @@ -158,6 +224,7 @@ pub(crate) fn arity_exact_unpack( name: &str, n: usize, ) -> Result<(), crate::PyError> { + reject_kwargs(args, name)?; if args.len() != n + 1 { return Err(crate::PyError::type_error(format!( "{name} expected {n} argument{}, got {}", @@ -184,11 +251,29 @@ pub(crate) fn arity_slot(args: &[PyObjectRef], n: usize) -> Result<(), crate::Py } /// TypeError for a METH_NOARGS method called with positional arguments — -/// the "X() takes no arguments (M given)" form (`list.__reversed__`). +/// the "X() takes no arguments (M given)" form (`list.__reversed__`). `name` +/// is the bare method name; the message qualifies it with the receiver's type. pub(crate) fn arity_no_args(args: &[PyObjectRef], name: &str) -> Result<(), crate::PyError> { + arity_no_args_of(None, args, name) +} + +/// [`arity_no_args`] for a method whose declaring class cannot be recovered +/// from the receiver: `object.__sizeof__` is shadowed on the MRO of every +/// receiver whose own type declares one, yet the descriptor reached through +/// `object` still reports `object`. +pub(crate) fn arity_no_args_of( + owner: Option<&str>, + args: &[PyObjectRef], + name: &str, +) -> Result<(), crate::PyError> { + reject_kwargs_of(owner, args, name)?; if args.len() != 1 { + let qualname = match owner { + Some(owner) => format!("{owner}.{name}"), + None => method_qualname(args, name), + }; return Err(crate::PyError::type_error(format!( - "{name}() takes no arguments ({} given)", + "{qualname}() takes no arguments ({} given)", args_given(args), ))); } @@ -197,8 +282,10 @@ pub(crate) fn arity_no_args(args: &[PyObjectRef], name: &str) -> Result<(), crat /// TypeError for the ternary-power slot (`__pow__` / `__rpow__`), which /// accepts one or two positional arguments after the receiver — the -/// "expected 1 or 2 arguments, got M" form with no method name. -pub(crate) fn arity_pow(args: &[PyObjectRef]) -> Result<(), crate::PyError> { +/// "expected 1 or 2 arguments, got M" form with no method name. `name` is +/// carried only for the keyword rejection, which does name the wrapper. +pub(crate) fn arity_pow(args: &[PyObjectRef], name: &str) -> Result<(), crate::PyError> { + reject_kwargs(args, name)?; let extra = args_given(args); if !(1..=2).contains(&extra) { return Err(crate::PyError::type_error(format!( @@ -208,6 +295,16 @@ pub(crate) fn arity_pow(args: &[PyObjectRef]) -> Result<(), crate::PyError> { Ok(()) } +/// `int.__round__` / `float.__round__`, which share `round()`'s body but are +/// methods: the receiver is the number, `ndigits` the one optional argument, +/// and the count in a mismatch excludes the receiver +/// (`__round__ expected at most 1 argument, got 2`). +pub fn number_dunder_round(args: &[PyObjectRef]) -> Result { + require_receiver(args, "__round__")?; + arity_at_most(args, "__round__", 1)?; + crate::builtins::builtin_round(args) +} + /// TypeError for an unbound method descriptor invoked with no receiver /// (`list.append()` with zero arguments) — `args` is empty. pub(crate) fn require_receiver(args: &[PyObjectRef], name: &str) -> Result<(), crate::PyError> { @@ -439,14 +536,14 @@ pub(crate) fn require_no_args(args: &[PyObjectRef], name: &str) -> Result<(), cr pub fn list_method_append(args: &[PyObjectRef]) -> Result { require_list_receiver(args, "append", true)?; - arity_exact(args, "list.append", 1)?; + arity_exact(args, "append", 1)?; unsafe { w_list_append(args[0], args[1]) }; Ok(w_none()) } pub fn list_method_extend(args: &[PyObjectRef]) -> Result { require_list_receiver(args, "extend", true)?; - arity_exact(args, "list.extend", 1)?; + arity_exact(args, "extend", 1)?; let list = args[0]; let other = args[1]; unsafe { @@ -574,7 +671,7 @@ pub fn list_method_pop(args: &[PyObjectRef]) -> Result Result { require_list_receiver(args, "clear", true)?; - arity_no_args(args, "list.clear")?; + arity_no_args(args, "clear")?; unsafe { pyre_object::listobject::w_list_clear(args[0]) }; Ok(w_none()) } @@ -582,7 +679,7 @@ pub fn list_method_clear(args: &[PyObjectRef]) -> Result Result { require_list_receiver(args, "copy", true)?; - arity_no_args(args, "list.copy")?; + arity_no_args(args, "copy")?; let list = args[0]; unsafe { let n = w_list_len(list); @@ -599,7 +696,7 @@ pub fn list_method_copy(args: &[PyObjectRef]) -> Result Result { require_list_receiver(args, "reverse", true)?; - arity_no_args(args, "list.reverse")?; + arity_no_args(args, "reverse")?; unsafe { pyre_object::listobject::w_list_reverse(args[0]) }; Ok(w_none()) } @@ -611,13 +708,17 @@ pub fn list_method_sort(args: &[PyObjectRef]) -> Result 1 { - return Err(crate::PyError::type_error(format!( - "sorted() takes at most 1 positional argument ({} given)", - positional.len() - ))); - } - crate::builtins::kwarg_reject_unknown(kwargs, &["key", "reverse"], "sorted")?; + // `sort($self, /, *, key=None, reverse=False)` — the receiver is the only + // positional slot, so everything after it has to arrive by keyword. + crate::builtins::clinic_arity( + "sort", + positional.len() - 1, + crate::builtins::real_kwarg_count(kwargs), + 0, + 0, + 2, + )?; + crate::builtins::kwarg_reject_unknown(kwargs, &["key", "reverse"], "sort")?; let key_fn = crate::builtins::kwarg_get(kwargs, "key") .filter(|key| unsafe { !pyre_object::is_none(*key) }); let reverse = crate::builtins::kwarg_get(kwargs, "reverse") @@ -625,53 +726,10 @@ pub fn list_method_sort(args: &[PyObjectRef]) -> Result { - for index in order { - w_list_append( - list, - pyre_object::gc_roots::shadow_stack_get(item_base + index), - ); - } - if modified { - return Err(crate::PyError::new( - crate::PyErrorKind::ValueError, - "list modified during sort", - )); - } - } - Err(err) => { - for index in 0..saved_len { - w_list_append( - list, - pyre_object::gc_roots::shadow_stack_get(item_base + index), - ); - } - return Err(err); - } - } - } + let _roots = pyre_object::gc_roots::push_roots(); + let list_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(list); + crate::builtins::sort_list_in_place(list_slot, key_fn, reverse)?; Ok(w_none()) } @@ -696,7 +754,7 @@ pub fn list_method_index(args: &[PyObjectRef]) -> Result Ok(w_int_new(i)), crate::listobject::FindOrCountResult::NotFound => { @@ -720,7 +778,7 @@ pub fn list_method_index(args: &[PyObjectRef]) -> Result Result { require_list_receiver(args, "count", true)?; - arity_exact(args, "list.count", 1)?; + arity_exact(args, "count", 1)?; let list = args[0]; let value = args[1]; match crate::listobject::w_list_find_or_count(list, value, 0, i64::MAX, true)? { @@ -735,7 +793,7 @@ pub fn list_method_count(args: &[PyObjectRef]) -> Result Result { require_list_receiver(args, "remove", true)?; - arity_exact(args, "list.remove", 1)?; + arity_exact(args, "remove", 1)?; crate::listobject::w_list_remove(args[0], args[1])?; Ok(w_none()) } @@ -758,7 +816,9 @@ pub fn str_method_join(args: &[PyObjectRef]) -> Result Result<(PyObjectRef, PyObjectRef), crate::PyError> { let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); + crate::builtins::clinic_arity( + fn_name, + pos.len() - 1, + crate::builtins::real_kwarg_count(kwargs), + 0, + 2, + 0, + )?; crate::builtins::kwarg_reject_unknown(kwargs, &["sep", "maxsplit"], fn_name)?; crate::builtins::kwarg_reject_duplicate(kwargs, fn_name, "sep", pos.get(1).is_some())?; crate::builtins::kwarg_reject_duplicate(kwargs, fn_name, "maxsplit", pos.get(2).is_some())?; @@ -1049,7 +1117,7 @@ pub fn str_method_casefold(args: &[PyObjectRef]) -> Result Result { - arity_exact(args, "str.format_map", 1)?; + arity_exact(args, "format_map", 1)?; let fmt = args[0]; let mapping = args[1]; str_method_format_core(fmt, &[], None, Some(mapping)) @@ -1104,6 +1172,7 @@ fn extract_strip_chars(arg: PyObjectRef, fn_name: &str) -> Result Result { require_receiver(args, "strip")?; + arity_at_most(args, "strip", 1)?; let s = unsafe { w_str_get_wtf8(args[0]) }; let chars = match args.get(1) { Some(&a) => extract_strip_chars(a, "strip")?, @@ -1114,6 +1183,7 @@ pub fn str_method_strip(args: &[PyObjectRef]) -> Result Result { require_receiver(args, "lstrip")?; + arity_at_most(args, "lstrip", 1)?; let s = unsafe { w_str_get_wtf8(args[0]) }; let chars = match args.get(1) { Some(&a) => extract_strip_chars(a, "lstrip")?, @@ -1124,6 +1194,7 @@ pub fn str_method_lstrip(args: &[PyObjectRef]) -> Result Result { require_receiver(args, "rstrip")?; + arity_at_most(args, "rstrip", 1)?; let s = unsafe { w_str_get_wtf8(args[0]) }; let chars = match args.get(1) { Some(&a) => extract_strip_chars(a, "rstrip")?, @@ -2219,10 +2290,22 @@ fn format_rbigint(num: &BigInt, spec: &Wtf8, type_name: &str) -> Result unreachable!("integer format uses radix 2, 8, 10, or 16"), }; let negative = num.int_lt(0); - let mut magnitude = num.format(digits, "", "", 0).map_err(|error| match error { - pyre_object::rbigint::RBigIntError::Memory => crate::PyError::memory_error(""), - _ => unreachable!("validated radix formatting returned an unrelated error"), - })?; + // Only the decimal conversion is quadratic, so only it carries the + // `sys.set_int_max_str_digits` limit; the power-of-two radices are exempt. + let maxdigits = if radix == 10 { + crate::module::sys::state::int_max_str_digits() + } else { + 0 + }; + let mut magnitude = + num.format(digits, "", "", maxdigits as i64) + .map_err(|error| match error { + pyre_object::rbigint::RBigIntError::Memory => crate::PyError::memory_error(""), + pyre_object::rbigint::RBigIntError::MaxStrDigits => { + crate::builtins::int_max_str_digits_error(maxdigits) + } + _ => unreachable!("validated radix formatting returned an unrelated error"), + })?; if negative { debug_assert!(magnitude.starts_with('-')); magnitude.remove(0); @@ -3172,6 +3255,14 @@ pub fn str_method_encode(args: &[PyObjectRef]) -> Result| -> Result, crate::PyError> { @@ -4238,7 +4329,7 @@ fn is_identifier(s: &str) -> bool { /// a sign character (`+`/`-`), the sign stays at the front and zeros /// fill between it and the digits (`'-42'.zfill(5) == '-0042'`). pub fn str_method_zfill(args: &[PyObjectRef]) -> Result { - arity_exact(args, "str.zfill", 1)?; + arity_exact(args, "zfill", 1)?; let s = unsafe { w_str_get_wtf8(args[0]) }; let width = crate::builtins::space_index_w(args[1])?.max(0) as usize; let len = unsafe { pyre_object::w_str_len(args[0]) }; @@ -5030,6 +5121,7 @@ pub(crate) fn str_result_unchanged(obj: PyObjectRef) -> PyObjectRef { pub fn str_method_center(args: &[PyObjectRef]) -> Result { arity_at_least(args, "center", 1)?; + arity_at_most(args, "center", 2)?; let s = unsafe { w_str_get_wtf8(args[0]) }; let width = crate::builtins::space_index_w(args[1])?.max(0) as usize; let fillchar = pad_fillchar(args, "center")?; @@ -5051,6 +5143,7 @@ pub fn str_method_center(args: &[PyObjectRef]) -> Result Result { arity_at_least(args, "ljust", 1)?; + arity_at_most(args, "ljust", 2)?; let s = unsafe { w_str_get_wtf8(args[0]) }; let width = crate::builtins::space_index_w(args[1])?.max(0) as usize; let fillchar = pad_fillchar(args, "ljust")?; @@ -5068,6 +5161,7 @@ pub fn str_method_ljust(args: &[PyObjectRef]) -> Result Result { arity_at_least(args, "rjust", 1)?; + arity_at_most(args, "rjust", 2)?; let s = unsafe { w_str_get_wtf8(args[0]) }; let width = crate::builtins::space_index_w(args[1])?.max(0) as usize; let fillchar = pad_fillchar(args, "rjust")?; @@ -5116,30 +5210,43 @@ pub fn str_method_isspace(args: &[PyObjectRef]) -> Result bool { - let mut has_cased = false; - let mut all_match = true; + let mut cased = false; for cp in s.code_points() { - if let Some(c) = cp.to_char() { - if c.is_alphabetic() { - has_cased = true; - let ok = if want_upper { - c.is_uppercase() - } else { - c.is_lowercase() - }; - if !ok { - all_match = false; - } - } + let Some(c) = cp.to_char() else { continue }; + // `unicodedb.istitle` is category Lt, which is neither `is_uppercase` + // (Lu + Other_Uppercase) nor `is_lowercase` (Ll + Other_Lowercase); + // it disqualifies both predicates. + let (rejects, qualifies) = if want_upper { + (c.is_lowercase() || case::is_titlecase(c), c.is_uppercase()) + } else { + (c.is_uppercase() || case::is_titlecase(c), c.is_lowercase()) + }; + if rejects { + return false; + } + if !cased && qualifies { + cased = true; } } - has_cased && all_match + cased } /// PyPy: unicodeobject.py descr_isupper @@ -5240,7 +5347,7 @@ fn wtf8_replace(input: &Wtf8, sub: &Wtf8, by: &Wtf8, maxcount: i64) -> (Wtf8Buf, /// PyPy: unicodeobject.py descr_partition pub fn str_method_partition(args: &[PyObjectRef]) -> Result { - arity_exact(args, "str.partition", 1)?; + arity_exact(args, "partition", 1)?; if !unsafe { pyre_object::is_str(args[1]) } { return Err(crate::PyError::type_error(format!( "must be str, not {}", @@ -5264,7 +5371,7 @@ pub fn str_method_partition(args: &[PyObjectRef]) -> Result Result { - arity_exact(args, "str.rpartition", 1)?; + arity_exact(args, "rpartition", 1)?; if !unsafe { pyre_object::is_str(args[1]) } { return Err(crate::PyError::type_error(format!( "must be str, not {}", @@ -5489,14 +5596,31 @@ pub fn str_method_expandtabs(args: &[PyObjectRef]) -> Result Result { - arity_exact(args, "str.translate", 1)?; + arity_exact(args, "translate", 1)?; let s = unsafe { w_str_get_wtf8(args[0]) }; let table = args[1]; let mut result = Wtf8Buf::with_capacity(s.len()); unsafe { for cp in s.code_points() { let key = w_int_new(cp.to_u32() as i64); - match crate::baseobjspace::finditem(table, key)? { + // `unicode_translate` clears any LookupError the lookup raises + // and keeps the character, so a table that indexes rather than + // maps (a str, a list) leaves unmapped code points alone. + let found = match crate::baseobjspace::finditem(table, key) { + Ok(found) => found, + Err(e) + if matches!( + e.kind, + crate::PyErrorKind::LookupError + | crate::PyErrorKind::KeyError + | crate::PyErrorKind::IndexError + ) => + { + None + } + Err(e) => return Err(e), + }; + match found { None => result.push(cp), Some(val) if is_none(val) => {} Some(val) if is_int(val) => { @@ -6173,7 +6297,7 @@ pub fn dict_method_pop(args: &[PyObjectRef]) -> Result Result { require_receiver(args, "popitem")?; - arity_no_args(args, "dict.popitem")?; + arity_no_args(args, "popitem")?; let dict = resolve_dict_backing(args[0]); if dict.is_null() { return Err(crate::PyError::key_error("popitem(): dictionary is empty")); @@ -6307,18 +6431,8 @@ mod dict_method_tests { /// tupleobject.py descr_index — tuple.index(value[, start[, stop]]). pub fn tuple_method_index(args: &[PyObjectRef]) -> Result { require_tuple_receiver(args, "index", true)?; - if args.len() < 2 { - return Err(crate::PyError::type_error(format!( - "index expected at least 1 argument, got {}", - args_given(args) - ))); - } - if args.len() > 4 { - return Err(crate::PyError::type_error(format!( - "index expected at most 3 arguments, got {}", - args.len() - 1 - ))); - } + arity_at_least(args, "index", 1)?; + arity_at_most(args, "index", 3)?; let tup = args[0]; let value = args[1]; // descr_index defaults: w_start=0, w_stop=maxint; unwrap_start_stop does @@ -6341,7 +6455,7 @@ pub fn tuple_method_index(args: &[PyObjectRef]) -> Result Option<&'static crate::gateway::MethodOwner> // receiver reaches those accessors as type confusion — reproducibly a // SIGSEGV for `type(iter(())).__length_hint__(None)`. "iterator" => pyre_object::is_seq_iter, + // Producer-specific identities over the same `W_SeqIterObject` payload. + "str_ascii_iterator" => pyre_object::is_seq_iter, + "str_iterator" => pyre_object::is_seq_iter, + "bytes_iterator" => pyre_object::is_seq_iter, + "bytearray_iterator" => pyre_object::is_seq_iter, + "memory_iterator" => pyre_object::is_seq_iter, + "array.arrayiterator" => pyre_object::is_seq_iter, "list_iterator" => pyre_object::is_list_iter, "list_reverseiterator" => pyre_object::is_list_reverse_iter, "tuple_iterator" => pyre_object::is_tuple_iter, @@ -1819,14 +1882,45 @@ unsafe fn stamp_method_owners(ns: PyObjectRef, owner: &'static crate::gateway::M .into_iter() .filter_map(|(key, _)| pyre_object::w_str_get_value_opt(key).map(str::to_owned)) .collect(); + // `type_ready_fill_dict` names a static type's methods after the type, + // so `list.append.__qualname__` is "list.append". The qualifier is the + // last component of `tp_name`: `array.array`'s methods report + // "array.tolist", not "array.array.tolist". + let qualifier = owner + .type_name + .rsplit('.') + .next() + .unwrap_or(owner.type_name); for key in keys { - if key == "__new__" { - continue; - } + let _key_roots = pyre_object::gc_roots::push_roots(); + // The qualname is allocated before the namespace lookup and read back + // from the shadow stack, so no collection point separates the + // descriptor pointers below from the store that uses them: a colliding + // key can route `w_dict_getitem_str` through a user `__eq__`, which + // allocates and would relocate a `descr` read before it. + let qualname_slot = pyre_object::gc_roots::shadow_stack_len(); + pyre_object::gc_roots::pin_root(pyre_object::w_str_new(&format!("{qualifier}.{key}"))); let ns = pyre_object::gc_roots::shadow_stack_get(ns_slot); - let Some(descr) = pyre_object::w_dict_getitem_str(ns, &key) else { + let Some(entry) = pyre_object::w_dict_getitem_str(ns, &key) else { continue; }; + if entry.is_null() { + continue; + } + // A `classmethod` / `staticmethod` entry wraps the callable that + // carries the name, and neither receives an instance: they take the + // qualified name but not the receiver test that the owner stamps — + // the same split `__new__` gets. + let wrapped = unsafe { + if pyre_object::function::is_classmethod(entry) { + Some(pyre_object::function::w_classmethod_get_func(entry)) + } else if pyre_object::function::is_staticmethod(entry) { + Some(pyre_object::function::w_staticmethod_get_func(entry)) + } else { + None + } + }; + let descr = wrapped.unwrap_or(entry); if descr.is_null() || !crate::function::is_function_carrier(descr) { continue; } @@ -1834,7 +1928,24 @@ unsafe fn stamp_method_owners(ns: PyObjectRef, owner: &'static crate::gateway::M if code.is_null() || !crate::gateway::is_builtin_code(code) { continue; } - crate::gateway::builtin_code_set_owner(code, owner); + crate::function::function_set_qualname( + descr, + pyre_object::gc_roots::shadow_stack_get(qualname_slot), + ); + if wrapped.is_none() && key != "__new__" { + crate::gateway::builtin_code_set_owner(code, owner); + // `type_ready_fill_dict` hands each `tp_methods` entry to + // `PyDescr_NewMethod`, so it is a `method_descriptor` and its + // `__get__` yields a `builtin_function_or_method`. The slot half + // of the same sweep (`add_operators` → `PyDescr_NewWrapper`) wants + // `wrapper_descriptor`, which pyre does not tag yet, so those keep + // the `FunctionWithFixedCode` spelling. + if unsafe { pyre_object::py_type_check(descr, &crate::function::FUNCTION_TYPE) } + && !crate::gateway::is_slot_wrapper(owner.type_name, &key) + { + unsafe { crate::function::function_retag_method_descriptor(descr) }; + } + } } } @@ -1874,11 +1985,15 @@ unsafe fn stamp_new_descr_self(ns: PyObjectRef, type_obj: PyObjectRef) { pyre_object::gc_roots::pin_root(type_obj); if let Some(w_new) = pyre_object::w_dict_getitem_str(ns, "__new__") { - if !w_new.is_null() && pyre_object::function::is_staticmethod(w_new) { - let inner = pyre_object::function::w_staticmethod_get_func(w_new); - if !inner.is_null() && crate::function::is_function(inner) { - crate::function::function_set_new_self(inner, type_obj); - } + // A user class reaches type creation with `__new__` already wrapped in + // `staticmethod`; a builtin type's entry is the carrier itself. + let carrier = if !w_new.is_null() && pyre_object::function::is_staticmethod(w_new) { + pyre_object::function::w_staticmethod_get_func(w_new) + } else { + w_new + }; + if !carrier.is_null() && crate::function::is_function(carrier) { + crate::function::function_set_new_self(carrier, type_obj); } } // typeobject.py:1738-1742 — `if isinstance(descrvalue, GetSetProperty): @@ -1996,6 +2111,17 @@ fn new_typeobject_with_base_and_layout( let ns = pyre_object::w_dict_new(); pyre_object::gc_roots::pin_root(ns); init(ns); + // `type_ready_set_dict` — a static type whose `tp_name` is qualified + // publishes the leading component as a `__module__` entry, so + // `array.array.__dict__["__module__"] == "array"`. An unqualified name + // stores nothing and the `type.__module__` getset reports "builtins". + if let Some((module, _)) = name.rsplit_once('.') { + // Allocate the value first: reading `ns` before `w_str_new` would hand + // the store an address the allocation is free to move. + let w_module = w_str_new(module); + let ns = pyre_object::gc_roots::shadow_stack_get(ns_slot); + unsafe { pyre_object::w_dict_setitem_str_no_proxy(ns, "__module__", w_module) }; + } // The namespace is complete, so every method descriptor in it can be // bound to the type that defines it before the type goes live. if let Some(owner) = method_owner(name) { @@ -2055,6 +2181,11 @@ fn new_typeobject_with_base_and_layout( unsafe { w_type_set_mro(type_obj, mro) }; let ns = pyre_object::gc_roots::shadow_stack_get(ns_slot); unsafe { stamp_new_descr_self(ns, type_obj) }; + // `typeobject.py:1789-1790 TypeCache.ready(w_type)` runs `w_type.ready()` + // for every builtin typedef the space cache builds, exactly as + // `_type_new` (typeobject.py:970) does for a heap type — so a builtin + // shows up in `base.__subclasses__()` too. + unsafe { pyre_object::typeobject::w_type_ready(type_obj) }; type_obj } @@ -2131,6 +2262,10 @@ pub fn make_builtin_type_with_bases( unsafe { w_type_set_mro(type_obj, mro) }; let ns = pyre_object::gc_roots::shadow_stack_get(ns_slot); unsafe { stamp_new_descr_self(ns, type_obj) }; + // Readying registers the new type on every entry of `__bases__`, so a + // multi-base builtin such as `io.UnsupportedOperation(OSError, ValueError)` + // reaches both `OSError.__subclasses__()` and `ValueError.__subclasses__()`. + unsafe { pyre_object::typeobject::w_type_ready(type_obj) }; type_obj } @@ -2263,24 +2398,24 @@ fn complex_descr_new(args: &[PyObjectRef]) -> Result Result, ) -> PyObjectRef { - // `BuiltinFunction`-typed so `type(int.__new__)` differs from a user - // `def`'s `function`, letting `copyreg._reduce_ex`'s - // `isinstance(new, type(int.__new__))` match only builtin `tp_new` - // wrappers (mirrors `builtin_function_or_method`). `__self__` is - // stamped at type-finalisation via `stamp_new_descr_self`. - let f = crate::gateway::make_builtin_function_as_builtin("__new__", func); - pyre_object::w_staticmethod_new(f) + crate::gateway::make_builtin_function_as_builtin("__new__", func) } /// Signature-aware [`make_new_descr`] for builtin constructors with keyword @@ -2289,8 +2424,7 @@ pub(crate) fn make_new_descr_with_signature( func: fn(&[PyObjectRef]) -> Result, signature: crate::gateway::Signature, ) -> PyObjectRef { - let f = crate::make_builtin_function_as_builtin_with_signature("__new__", func, signature); - pyre_object::w_staticmethod_new(f) + crate::make_builtin_function_as_builtin_with_signature("__new__", func, signature) } /// `typeobject.c tp_new_wrapper` — `__new__` takes the class to instantiate @@ -2307,10 +2441,24 @@ fn new_descr_class(args: &[PyObjectRef], type_name: &str) -> Result Result, -) -> PyObjectRef { - pyre_object::w_staticmethod_new(make_builtin_function("maketrans", func)) +/// `$owner` names the type the static method is reached through, which is +/// what its keyword rejection reports (`str.maketrans() takes no keyword +/// arguments`); the body itself is variadic, so the trailing marker dict +/// would otherwise arrive as one more translation-table argument. +macro_rules! make_maketrans_descr { + ($owner:literal, $func:expr) => {{ + fn maketrans(args: &[PyObjectRef]) -> Result { + let (args, kwargs) = crate::builtins::split_builtin_kwargs(args); + if crate::builtins::has_real_kwargs(kwargs) { + return Err(crate::PyError::type_error(concat!( + $owner, + ".maketrans() takes no keyword arguments" + ))); + } + ($func)(args) + } + pyre_object::w_staticmethod_new(make_builtin_function("maketrans", maketrans)) + }}; } /// `moduleobject.c module_new` — allocate an anonymous `Module` @@ -3395,12 +3543,6 @@ fn filter_descr_new(args: &[PyObjectRef]) -> Result let (positional, kwargs) = crate::builtins::split_builtin_kwargs(args); let cls = positional.first().copied().unwrap_or(pyre_object::PY_NULL); let args_w = positional.get(1..).unwrap_or(&[]); - if args_w.len() != 2 { - return Err(crate::PyError::type_error(format!( - "filter expected 2 arguments, got {}", - args_w.len() - ))); - } // `space.getattr` and keyword-name inspection are allowed to allocate in // the source gateway. Keep the subtype and both positional arguments live @@ -3408,10 +3550,13 @@ fn filter_descr_new(args: &[PyObjectRef]) -> Result let _roots = pyre_object::gc_roots::push_roots(); pyre_object::gc_roots::pin_root(cls); let cls_slot = pyre_object::gc_roots::shadow_stack_len() - 1; - pyre_object::gc_roots::pin_root(args_w[0]); - let predicate_slot = pyre_object::gc_roots::shadow_stack_len() - 1; - pyre_object::gc_roots::pin_root(args_w[1]); - let iterable_slot = pyre_object::gc_roots::shadow_stack_len() - 1; + // Pin whatever operands the call supplied — the argument-count check + // itself comes after the keyword inspection, which may collect. + let mut arg_slots = [usize::MAX; 2]; + for (slot, &arg) in arg_slots.iter_mut().zip(args_w) { + pyre_object::gc_roots::pin_root(arg); + *slot = pyre_object::gc_roots::shadow_stack_len() - 1; + } let kwargs_slot = kwargs.map(|dict| { pyre_object::gc_roots::pin_root(dict); pyre_object::gc_roots::shadow_stack_len() - 1 @@ -3432,14 +3577,22 @@ fn filter_descr_new(args: &[PyObjectRef]) -> Result }; let rooted_kwargs = kwargs_slot.map(|slot| unsafe { pyre_object::gc_roots::shadow_stack_get(slot) }); + // `filter_new` runs `_PyArg_NoKeywords` before `PyArg_UnpackTuple`, so a + // keyword call is a keyword error whatever its positional count is. if init_matches && crate::builtins::has_real_kwargs(rooted_kwargs) { return Err(crate::PyError::type_error( "filter() takes no keyword arguments", )); } + if args_w.len() != 2 { + return Err(crate::PyError::type_error(format!( + "filter expected 2 arguments, got {}", + args_w.len() + ))); + } let value = crate::builtins::builtin_filter(&[ - unsafe { pyre_object::gc_roots::shadow_stack_get(predicate_slot) }, - unsafe { pyre_object::gc_roots::shadow_stack_get(iterable_slot) }, + unsafe { pyre_object::gc_roots::shadow_stack_get(arg_slots[0]) }, + unsafe { pyre_object::gc_roots::shadow_stack_get(arg_slots[1]) }, ])?; pyre_object::gc_roots::pin_root(value); let value_slot = pyre_object::gc_roots::shadow_stack_len() - 1; @@ -4598,7 +4751,7 @@ fn init_list_type(ns: PyObjectRef) { "__getitem__", |args| { crate::type_methods::require_list_receiver(args, "__getitem__", true)?; - crate::type_methods::arity_exact(args, "list.__getitem__", 1)?; + crate::type_methods::arity_exact(args, "__getitem__", 1)?; crate::baseobjspace::getitem_slot(args[0], args[1]) }, 2, @@ -4698,7 +4851,7 @@ fn init_list_type(ns: PyObjectRef) { // `reversed(list)` (walks `getitem(seq, remaining)` downward). let obj = crate::type_methods::require_list_receiver(args, "__reversed__", true)?; - crate::type_methods::arity_no_args(args, "list.__reversed__")?; + crate::type_methods::arity_no_args(args, "__reversed__")?; let n = unsafe { pyre_object::w_list_len(obj) } as i64; Ok(pyre_object::w_list_reverse_iter_new(obj, n - 1)) }, @@ -4770,12 +4923,12 @@ fn init_list_type(ns: PyObjectRef) { "__imul__", |args| { crate::type_methods::require_list_receiver(args, "__imul__", false)?; - // listobject.py descr_inplace_mul: the count goes through - // `__index__`; a non-index operand becomes NotImplemented. + // listobject.py descr_inplace_mul: `wrap_indexargfunc` + // reduces the count through `__index__` before the slot + // runs, so a non-index operand raises here rather than + // reporting NotImplemented. crate::type_methods::arity_slot(args, 1)?; - let Some(w_count) = list_repeat_index(args[1])? else { - return Ok(pyre_object::w_not_implemented()); - }; + let w_count = crate::baseobjspace::getindex_repeat(args[1])?; unsafe { crate::objspace::descroperation::list_inplace_repeat(args[0], w_count)? }; @@ -4803,40 +4956,12 @@ fn init_list_type(ns: PyObjectRef) { } } -/// `space.getindex_w(w_obj, space.w_OverflowError)`: run `w_obj.__index__()` -/// and return the resulting int/long object, converting an out-of-index-range -/// value to an `OverflowError` that names the ORIGINAL operand (not the -/// `__index__` result). A non-`__index__` operand raises the `TypeError` from -/// `space.index`. Callers that repeat a sequence share this so `str`/`tuple` -/// honour a custom `__index__` exactly like `list`/`bytes`. -fn getindex_repeat(w_obj: PyObjectRef) -> Result { - let w_count = crate::baseobjspace::space_index(w_obj)?; - match crate::baseobjspace::int_w(w_count) { - Ok(_) => Ok(w_count), - Err(e) if e.kind == crate::PyErrorKind::OverflowError => Err(crate::PyError::new( - crate::PyErrorKind::OverflowError, - format!( - "cannot fit '{}' into an index-sized integer", - crate::baseobjspace::object_functionstr_type_name(w_obj) - ), - )), - Err(e) => Err(e), - } -} - /// Coerce a `list`/`tuple` `* n` / `*= n` repeat count through `getindex_repeat` /// (`getindex_w`). An operand without `__index__` yields `None`, which the /// caller maps to NotImplemented so the `*`/`*=` operator can try a reflected /// `__rmul__` and otherwise emit the "can't multiply sequence by non-int" /// message; any other coercion error propagates. This is `descr_mul`'s /// `try/except TypeError -> NotImplemented` wrapper (`listobject.py`). -fn list_repeat_index(w_obj: PyObjectRef) -> Result, crate::PyError> { - match getindex_repeat(w_obj) { - Ok(w_count) => Ok(Some(w_count)), - Err(e) if e.kind == crate::PyErrorKind::TypeError => Ok(None), - Err(e) => Err(e), - } -} /// `listobject.c:list_repeat` — `list * n` / `n * list`. The count goes /// through `__index__`, so any object implementing it repeats the list. @@ -4851,9 +4976,9 @@ fn list_descr_rmul(args: &[PyObjectRef]) -> Result fn list_descr_mul_impl(args: &[PyObjectRef], name: &str) -> Result { crate::type_methods::require_list_receiver(args, name, false)?; crate::type_methods::arity_slot(args, 1)?; - let Some(w_count) = list_repeat_index(args[1])? else { - return Ok(pyre_object::w_not_implemented()); - }; + // `wrap_indexargfunc` reduces the count through `__index__` before + // `sq_repeat` runs, so a non-index operand raises here. + let w_count = crate::baseobjspace::getindex_repeat(args[1])?; unsafe { crate::objspace::descroperation::list_repeat(args[0], w_count) } } @@ -4867,7 +4992,7 @@ fn str_descr_mul(args: &[PyObjectRef]) -> Result { let w_count = if unsafe { pyre_object::pyobject::is_int_or_long(args[1]) } { args[1] } else { - getindex_repeat(args[1])? + crate::baseobjspace::getindex_repeat(args[1])? }; unsafe { crate::objspace::descroperation::str_repeat(args[0], w_count) } } @@ -5548,7 +5673,7 @@ fn init_str_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "maketrans", - make_maketrans_descr(|args| { + make_maketrans_descr!("str", |args: &[PyObjectRef]| { if args.is_empty() { return Err(crate::PyError::type_error( "maketrans expected at least 1 argument, got 0", @@ -5830,7 +5955,7 @@ fn init_dict_type(ns: PyObjectRef) { make_builtin_function_with_arity( "__getitem__", |args| { - crate::type_methods::arity_exact(args, "dict.__getitem__", 1)?; + crate::type_methods::arity_exact(args, "__getitem__", 1)?; unsafe { if pyre_object::is_dict(args[0]) { return crate::baseobjspace::getitem(args[0], args[1]); @@ -5869,7 +5994,7 @@ fn init_dict_type(ns: PyObjectRef) { make_builtin_function_with_arity( "__contains__", |args| { - crate::type_methods::arity_exact(args, "dict.__contains__", 1)?; + crate::type_methods::arity_exact(args, "__contains__", 1)?; let dict = crate::type_methods::resolve_dict_backing(args[0]); if !dict.is_null() { return match unsafe { @@ -6226,7 +6351,7 @@ fn init_dict_type(ns: PyObjectRef) { // regardless of key type by dispatching through the // strategy's `clear` (`celldict.py:162-164` for // module dicts). `w_dict_clear` does the dispatch. - crate::type_methods::arity_no_args(args, "dict.clear")?; + crate::type_methods::arity_no_args(args, "clear")?; let d = crate::type_methods::resolve_dict_backing(args[0]); if !d.is_null() { unsafe { pyre_object::dictmultiobject::w_dict_clear(d) }; @@ -6930,6 +7055,13 @@ fn init_frame_type(ns: PyObjectRef) { if f.is_null() { return Ok(pyre_object::w_none()); } + // Both arms end at `fast2locals` (the proxy routes its reads + // back through the frame), which reads `locals_cells_stack_w` + // directly — so the virtualizable has to be materialized first or + // the mapping comes back EMPTY. `sys._getframe` gets that for + // free from `gettopframe_nohidden`; a frame reached through a + // traceback's `tb_frame` does not. + crate::executioncontext::force_frame_before_locals_read(f); let frame = unsafe { &mut *f }; if frame.code().flags.contains(crate::CodeFlags::OPTIMIZED) { return Ok(crate::pyframe::frame_locals_proxy::new(args[1])); @@ -8258,13 +8390,8 @@ fn tuple_descr_mul_impl(args: &[PyObjectRef], name: &str) -> Result pyre_object::PyObjectRef { &pyre_object::typedef::GETSET_DESCRIPTOR_TYPE as *const PyType, ); // typedef.py:446 assert not GetSetProperty.typedef.acceptable_as_base_class - unsafe { pyre_object::w_type_set_acceptable_as_base_class(tp, false) }; + // No `__new__` in the typedef either, so `tp_new` is NULL. + unsafe { + pyre_object::w_type_set_disallow_instantiation(tp); + pyre_object::w_type_set_acceptable_as_base_class(tp, false); + } // `init_typeobjects` would normally hand the W_TypeObject // to `set_instantiate(pytype, w_typeobject)` so allocators // can stamp `ob_header.w_class` at construction time @@ -9372,32 +9503,16 @@ fn patch_getset_descriptor_metadata() { if !crate::type_dict_has_storage(tp) { return; } - // typedef.py:470 __name__ - crate::type_dict_store( - tp, - "__name__", - copy_for_type( - make_getset_descriptor_named( - make_builtin_function_with_arity( - "__name__", - |args| { - let descr = args[1]; - if descr.is_null() { - return Ok(pyre_object::w_none()); - } - let name = unsafe { pyre_object::typedef::w_getset_get_name(descr) }; - if name.is_null() { - return Ok(pyre_object::w_none()); - } - Ok(name) - }, - 2, - ), - "__name__", - ), + // `descrobject.c descr_members` supplies __objclass__ and __name__. This + // post-init pass runs with the type object already built, so `w_cls` is + // bound here rather than by `stamp_new_descr_self`. + for (name, kind) in DESCR_MEMBERS { + crate::type_dict_store( tp, - ), - ); + name, + pyre_object::w_member_new_direct(kind, name.to_owned(), tp), + ); + } // typedef.py:471 __qualname__ = GetSetProperty(descr_get_qualname) // // ```python @@ -9478,52 +9593,6 @@ fn patch_getset_descriptor_metadata() { tp, ), ); - // typedef.py:472 __objclass__ = GetSetProperty(descr_get_objclass) - // - // ```python - // def descr_get_objclass(self, space): - // if self.w_objclass is not None: - // return self.w_objclass - // if self.reqcls is not None: - // return space.gettypeobject(self.reqcls.typedef) - // raise oefmt(space.w_AttributeError, - // "generic self has no __objclass__") - // ``` - crate::type_dict_store( - tp, - "__objclass__", - copy_for_type( - make_getset_descriptor_named( - make_builtin_function_with_arity( - "__objclass__", - |args| { - let descr = args[1]; - if descr.is_null() { - return Err(crate::PyError::attribute_error( - "generic self has no __objclass__", - )); - } - unsafe { - let w_objclass = pyre_object::typedef::w_getset_get_objclass(descr); - if !w_objclass.is_null() { - return Ok(w_objclass); - } - let reqcls = pyre_object::typedef::w_getset_get_reqcls(descr); - if !reqcls.is_null() { - return Ok(reqcls); - } - Err(crate::PyError::attribute_error( - "generic self has no __objclass__", - )) - } - }, - 2, - ), - "__objclass__", - ), - tp, - ), - ); // typedef.py:473 __doc__ = interp_attrproperty('doc', ...) crate::type_dict_store( tp, @@ -9922,6 +9991,29 @@ fn init_type_type(ns: PyObjectRef) { }); unsafe { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy(ns, "mro", mro_method) }; + // typeobject.py:1274-1275 `descr___prepare__` — + // + // ```python + // def descr___prepare__(space, __args__): + // return space.newdict(module=True) + // ``` + // + // installed at :1323 as `interp2app(descr___prepare__, + // as_classmethod=True)`. Every argument is swallowed: the default + // implementation only has to produce the mapping a class body executes + // in, so a metaclass that does not override it never sees the name, + // bases, or keywords `build_class` forwards. + unsafe { + pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( + ns, + "__prepare__", + pyre_object::function::w_classmethod_new(make_builtin_function( + "__prepare__", + |_args| Ok(pyre_object::w_dict_new()), + )), + ) + }; + // typeobject.py:1269-1272 descr___subclasses__ — return the list of // immediate subclasses recorded in `weak_subclasses` (dead weakrefs // filtered out by `w_type_get_subclasses`). @@ -9938,6 +10030,7 @@ fn init_type_type(ns: PyObjectRef) { )); } }; + crate::type_methods::arity_no_args(args, "__subclasses__")?; let subs = unsafe { pyre_object::w_type_get_subclasses(cls, true) }; Ok(pyre_object::w_list_new(subs)) }); @@ -9949,6 +10042,44 @@ fn init_type_type(ns: PyObjectRef) { ) }; + // typeobject.py:1317-1318 `__instancecheck__ = interp2app(type_isinstance)` + // / `__subclasscheck__ = interp2app(type_issubtype)` (:1280-1287). Both + // take the recursive form that does NOT re-consult the override, which is + // what lets `ABCMeta.__instancecheck__` fall back on `type`'s without + // looping, and what `super().__instancecheck__(x)` in a metaclass reaches. + let instancecheck_method = make_builtin_function_with_arity( + "__instancecheck__", + |args| { + crate::type_methods::arity_exact(args, "__instancecheck__", 1)?; + let matched = + unsafe { crate::baseobjspace::p_recursive_isinstance_type_w(args[1], args[0])? }; + Ok(pyre_object::w_bool_from(matched)) + }, + 2, + ); + let subclasscheck_method = make_builtin_function_with_arity( + "__subclasscheck__", + |args| { + crate::type_methods::arity_exact(args, "__subclasscheck__", 1)?; + let matched = + unsafe { crate::baseobjspace::p_recursive_issubclass_w(args[1], args[0])? }; + Ok(pyre_object::w_bool_from(matched)) + }, + 2, + ); + unsafe { + pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( + ns, + "__instancecheck__", + instancecheck_method, + ); + pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( + ns, + "__subclasscheck__", + subclasscheck_method, + ); + }; + // `pypy/objspace/std/typeobject.py:614-624 get_module` / // `:1241-1247 descr_get__module` / `descr_set__module`. // For heaptype (user-defined classes) the value is read from / @@ -10003,6 +10134,7 @@ fn init_type_type(ns: PyObjectRef) { // `A.__module__ = "x"` is reflected in `A.__dict__`. let cls = args[1]; let value = args[2]; + check_set_special_type_attr(cls, value, "__module__")?; unsafe { if pyre_object::is_type(cls) { crate::type_dict_store(cls, "__module__", value); @@ -10023,7 +10155,7 @@ fn init_type_type(ns: PyObjectRef) { make_getset_property_named( module_getter, module_setter, - pyre_object::PY_NULL, + make_builtin_function_with_arity("__module__", type_del_module, 2), "__module__", ), ) @@ -10066,13 +10198,8 @@ fn init_type_type(ns: PyObjectRef) { |args| { let w_type = args[1]; let w_value = args[2]; - // typeobject.py:1048 — only heap types may be renamed. - if !unsafe { pyre_object::w_type_is_heaptype(w_type) } { - return Err(crate::PyError::type_error(format!( - "can't set {}.__name__", - unsafe { pyre_object::w_type_get_name(w_type) } - ))); - } + // Only heap types may be renamed. + check_set_special_type_attr(w_type, w_value, "__name__")?; // typeobject.py:1050 — `space.isinstance_w(w_value, space.w_text)` // accepts str and any str subclass, not only the exact type. if !unsafe { crate::baseobjspace::isinstance_str_w(w_value) } { @@ -10107,7 +10234,12 @@ fn init_type_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__name__", - make_getset_property_named(name_getter, name_setter, pyre_object::PY_NULL, "__name__"), + make_getset_property_named( + name_getter, + name_setter, + make_builtin_function_with_arity("__name__", type_del_name, 2), + "__name__", + ), ) }; @@ -10123,13 +10255,7 @@ fn init_type_type(ns: PyObjectRef) { |args| { let w_type = args[1]; let value = args[2]; - // typeobject.py:1066-1067 — builtin types are immutable. - if !unsafe { pyre_object::w_type_is_heaptype(w_type) } { - return Err(crate::PyError::type_error(format!( - "can't set {}.__qualname__", - unsafe { pyre_object::w_type_get_name(w_type) } - ))); - } + check_set_special_type_attr(w_type, value, "__qualname__")?; if !unsafe { crate::baseobjspace::isinstance_str_w(value) } { return Err(crate::PyError::type_error(format!( "can only assign string to {}.__qualname__, not '{}'", @@ -10145,17 +10271,6 @@ fn init_type_type(ns: PyObjectRef) { }, 3, ); - let qualname_deleter = make_builtin_function_with_arity( - "__qualname__", - |args| { - let w_type = args[1]; - Err(crate::PyError::type_error(format!( - "cannot delete '__qualname__' attribute of immutable type '{}'", - unsafe { pyre_object::w_type_get_name(w_type) } - ))) - }, - 2, - ); unsafe { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, @@ -10163,7 +10278,7 @@ fn init_type_type(ns: PyObjectRef) { make_getset_property_named( qualname_getter, qualname_setter, - qualname_deleter, + make_builtin_function_with_arity("__qualname__", type_del_qualname, 2), "__qualname__", ), ) @@ -10188,7 +10303,7 @@ fn init_type_type(ns: PyObjectRef) { make_getset_property_named( bases_getter, bases_setter, - pyre_object::PY_NULL, + make_builtin_function_with_arity("__bases__", type_del_bases, 2), "__bases__", ), ) @@ -10305,6 +10420,56 @@ unsafe fn type_in_mro(w_base: PyObjectRef, w_type: PyObjectRef) -> bool { .any(|&entry| std::ptr::eq(entry, w_type)) } +/// `typeobject.c check_set_special_type_attr` — the shared guard the +/// `__name__` / `__qualname__` / `__module__` / `__bases__` setters run +/// before touching the type. A static type carries +/// `Py_TPFLAGS_IMMUTABLETYPE`, so it refuses assignment *and* deletion under +/// the "cannot set" wording; a heap type only refuses the deletion (a null +/// `w_value`). +fn check_set_special_type_attr( + w_type: PyObjectRef, + w_value: PyObjectRef, + name: &str, +) -> Result<(), crate::PyError> { + let immutable = !unsafe { pyre_object::w_type_is_heaptype(w_type) }; + if !immutable && !w_value.is_null() { + return Ok(()); + } + let verb = if immutable { "set" } else { "delete" }; + let type_name = unsafe { pyre_object::w_type_get_name(w_type) }; + Err(crate::PyError::type_error(format!( + "cannot {verb} '{name}' attribute of immutable type '{type_name}'" + ))) +} + +/// The `fdel` half of the special type getsets: deletion is never allowed, +/// so it is `check_set_special_type_attr` with a null value. Without an +/// `fdel` the generic getset `__delete__` slot would answer with PyPy's +/// `AttributeError` (typedef.py:404) instead. +fn special_type_attr_delete( + args: &[PyObjectRef], + name: &str, +) -> Result { + check_set_special_type_attr(args[1], pyre_object::PY_NULL, name)?; + Ok(pyre_object::w_none()) +} + +fn type_del_name(args: &[PyObjectRef]) -> Result { + special_type_attr_delete(args, "__name__") +} + +fn type_del_qualname(args: &[PyObjectRef]) -> Result { + special_type_attr_delete(args, "__qualname__") +} + +fn type_del_module(args: &[PyObjectRef]) -> Result { + special_type_attr_delete(args, "__module__") +} + +fn type_del_bases(args: &[PyObjectRef]) -> Result { + special_type_attr_delete(args, "__bases__") +} + /// `type.__bases__` setter (typeobject.py:1064-1105 `descr_set__bases__`). /// Heap types only; the new bases must be a non-empty tuple of classes whose /// best base shares the current instance layout (so instances stay valid). @@ -10315,11 +10480,7 @@ fn type_set_bases(args: &[PyObjectRef]) -> Result { let w_type = args[1]; let w_value = args.get(2).copied().unwrap_or(pyre_object::PY_NULL); let type_name = pyre_object::w_type_get_name(w_type); - if !pyre_object::w_type_is_heaptype(w_type) { - return Err(crate::PyError::type_error(format!( - "can't set {type_name}.__bases__" - ))); - } + check_set_special_type_attr(w_type, w_value, "__bases__")?; if w_value.is_null() || !pyre_object::is_tuple(w_value) { return Err(crate::PyError::type_error(format!( "can only assign tuple to {type_name}.__bases__, not {}", @@ -10996,6 +11157,8 @@ pub(crate) unsafe fn direct_member_get(member: PyObjectRef, obj: PyObjectRef) -> pyre_object::MEMBER_COMPLEX_IMAG => Ok(pyre_object::w_float_new(unsafe { pyre_object::w_complex_get_imag(obj) })), + pyre_object::MEMBER_DESCR_OBJCLASS => unsafe { descr_member_objclass(obj) }, + pyre_object::MEMBER_DESCR_NAME => unsafe { descr_member_name(obj) }, _ => Err(crate::PyError::attribute_error(unsafe { pyre_object::w_member_get_name(member) })), @@ -11055,6 +11218,84 @@ pub(crate) unsafe fn direct_member_delete( } } +/// `descrobject.c descr_members` names one `PyDescrObject` offset per entry +/// because every descriptor type shares that header. The payloads here — +/// GetSetProperty, Member, and the Function carrier behind `method_descriptor` +/// and `wrapper_descriptor` — keep the owner in their own field, so the two +/// readers below dispatch on the receiver and then use the accessor the +/// matching typedef already exposes. +unsafe fn descr_member_objclass(obj: PyObjectRef) -> crate::PyResult { + unsafe { + if pyre_object::typedef::is_getset_property(obj) { + // typedef.py:405-411 descr_get_objclass — w_objclass, else reqcls, + // else AttributeError. + let owner = getset_descriptor_owner(obj); + if owner.is_null() { + return Err(crate::PyError::attribute_error( + "generic self has no __objclass__", + )); + } + return Ok(owner); + } + if pyre_object::is_member(obj) { + // typedef.py:539 interp_attrproperty_w('w_cls') — None when unset. + let owner = pyre_object::w_member_get_cls(obj); + return Ok(if owner.is_null() { + pyre_object::w_none() + } else { + owner + }); + } + if crate::function::is_function_carrier(obj) { + return crate::function::fget_func_objclass(obj); + } + Err(crate::PyError::attribute_error("__objclass__")) + } +} + +unsafe fn descr_member_name(obj: PyObjectRef) -> crate::PyResult { + unsafe { + if pyre_object::typedef::is_getset_property(obj) { + // typedef.py:470 interp_attrproperty('name', wrapfn="newtext_or_none") + let name = pyre_object::typedef::w_getset_get_name(obj); + return Ok(if name.is_null() { + pyre_object::w_none() + } else { + name + }); + } + if pyre_object::is_member(obj) { + // typedef.py:538 interp_attrproperty('name') + return Ok(pyre_object::w_str_new(pyre_object::w_member_get_name(obj))); + } + if crate::function::is_function_carrier(obj) { + return Ok(crate::function::fget_func_name(obj)); + } + Err(crate::PyError::attribute_error("__name__")) + } +} + +/// The `descr_members` pair, in `descrobject.c` order. +const DESCR_MEMBERS: [(&str, u32); 2] = [ + ("__objclass__", pyre_object::MEMBER_DESCR_OBJCLASS), + ("__name__", pyre_object::MEMBER_DESCR_NAME), +]; + +/// Install `descr_members` into a descriptor type's namespace dict. `w_cls` +/// is left for `stamp_new_descr_self`, which fills every Member in a builtin +/// namespace once the type object exists. +fn install_descr_members(ns: PyObjectRef) { + for (name, kind) in DESCR_MEMBERS { + unsafe { + pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( + ns, + name, + pyre_object::w_member_new_direct(kind, name.to_owned(), pyre_object::PY_NULL), + ); + } + } +} + fn init_function_type(ns: PyObjectRef) { init_function_type_common(ns); // CPython 3.14 `func_memberlist`: these five entries are direct @@ -11122,11 +11363,12 @@ fn init_function_type(ns: PyObjectRef) { }, 3, ); + let dict_deleter = make_builtin_function_with_arity("__dict__", dict_del_rejected, 2); unsafe { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__dict__", - make_getset_property(dict_getter, dict_setter, pyre_object::PY_NULL), + make_getset_property(dict_getter, dict_setter, dict_deleter), ) }; // CPython 3.14 `function.__annotate__`: callable-or-None, not deletable. @@ -11462,19 +11704,14 @@ fn init_builtin_function_type(ns: PyObjectRef) { )?; let carrier = unsafe { builtin_function_carrier(func) }; let name = unsafe { crate::function_get_name(carrier) }; - if is_bound_builtin_method(func) { - let instance = unsafe { pyre_object::w_method_get_self(func) }; - let type_name = crate::typedef::r#type(instance) - .map(|tp| unsafe { pyre_object::w_type_get_name(tp.as_ptr()) }) - .unwrap_or("object"); - Ok(pyre_object::w_str_new(&format!( - "" - ))) + let w_self = if is_bound_builtin_method(func) { + unsafe { pyre_object::w_method_get_self(func) } } else { - Ok(pyre_object::w_str_new(&format!( - "" - ))) - } + unsafe { crate::function::function_get_self_or_none(carrier) } + }; + Ok(pyre_object::w_str_new(&unsafe { + crate::function::builtin_function_repr_text(name, w_self) + })) }, 1, ), @@ -11923,34 +12160,24 @@ fn init_slot_wrapper_type(ns: PyObjectRef) { // its owner in Function.w_objclass. for (name, getter) in [ ( - "__name__", + "__qualname__", (|args: &[PyObjectRef]| { - let descr = slot_wrapper_receiver(args[1], "__name__")?; - Ok(pyre_object::w_str_new(unsafe { - crate::function::function_get_name(descr) - })) + let descr = slot_wrapper_receiver(args[1], "__qualname__")?; + let owner = unsafe { crate::function::fget_func_objclass(descr)? }; + let owner_qualname = crate::baseobjspace::getattr_str(owner, "__qualname__")?; + let Some(owner_qualname) = + (unsafe { pyre_object::w_str_get_value_opt(owner_qualname) }) + else { + return Err(crate::PyError::type_error( + "descriptor owner __qualname__ is not a string", + )); + }; + let method_name = unsafe { crate::function::function_get_name(descr) }; + Ok(pyre_object::w_str_new(&format!( + "{owner_qualname}.{method_name}" + ))) }) as crate::gateway::BuiltinCodeFn, ), - ("__objclass__", |args: &[PyObjectRef]| { - let descr = slot_wrapper_receiver(args[1], "__objclass__")?; - unsafe { crate::function::fget_func_objclass(descr) } - }), - ("__qualname__", |args: &[PyObjectRef]| { - let descr = slot_wrapper_receiver(args[1], "__qualname__")?; - let owner = unsafe { crate::function::fget_func_objclass(descr)? }; - let owner_qualname = crate::baseobjspace::getattr_str(owner, "__qualname__")?; - let Some(owner_qualname) = - (unsafe { pyre_object::w_str_get_value_opt(owner_qualname) }) - else { - return Err(crate::PyError::type_error( - "descriptor owner __qualname__ is not a string", - )); - }; - let method_name = unsafe { crate::function::function_get_name(descr) }; - Ok(pyre_object::w_str_new(&format!( - "{owner_qualname}.{method_name}" - ))) - }), ("__doc__", |args: &[PyObjectRef]| { let descr = slot_wrapper_receiver(args[1], "__doc__")?; Ok(unsafe { crate::function::fget_func_doc(descr) }) @@ -11974,6 +12201,10 @@ fn init_slot_wrapper_type(ns: PyObjectRef) { ) }; } + // `descrobject.c descr_members` — the carrier keeps `d_type` in + // `Function.w_objclass` and `d_name` in `Function.name`, both published as + // members rather than getsets. + install_descr_members(ns); } fn method_descriptor_receiver(obj: PyObjectRef, name: &str) -> Result { @@ -12035,34 +12266,24 @@ fn init_method_descriptor_type(ns: PyObjectRef) { // the corresponding immutable BuiltinCode carrier in Function fields. for (name, getter) in [ ( - "__name__", + "__qualname__", (|args: &[PyObjectRef]| { - let descr = method_descriptor_receiver(args[1], "__name__")?; - Ok(pyre_object::w_str_new(unsafe { - crate::function::function_get_name(descr) - })) + let descr = method_descriptor_receiver(args[1], "__qualname__")?; + let owner = unsafe { crate::function::fget_func_objclass(descr)? }; + let owner_qualname = crate::baseobjspace::getattr_str(owner, "__qualname__")?; + let Some(owner_qualname) = + (unsafe { pyre_object::w_str_get_value_opt(owner_qualname) }) + else { + return Err(crate::PyError::type_error( + "descriptor owner __qualname__ is not a string", + )); + }; + let method_name = unsafe { crate::function::function_get_name(descr) }; + Ok(pyre_object::w_str_new(&format!( + "{owner_qualname}.{method_name}" + ))) }) as crate::gateway::BuiltinCodeFn, ), - ("__objclass__", |args: &[PyObjectRef]| { - let descr = method_descriptor_receiver(args[1], "__objclass__")?; - unsafe { crate::function::fget_func_objclass(descr) } - }), - ("__qualname__", |args: &[PyObjectRef]| { - let descr = method_descriptor_receiver(args[1], "__qualname__")?; - let owner = unsafe { crate::function::fget_func_objclass(descr)? }; - let owner_qualname = crate::baseobjspace::getattr_str(owner, "__qualname__")?; - let Some(owner_qualname) = - (unsafe { pyre_object::w_str_get_value_opt(owner_qualname) }) - else { - return Err(crate::PyError::type_error( - "descriptor owner __qualname__ is not a string", - )); - }; - let method_name = unsafe { crate::function::function_get_name(descr) }; - Ok(pyre_object::w_str_new(&format!( - "{owner_qualname}.{method_name}" - ))) - }), ("__doc__", |args: &[PyObjectRef]| { let descr = method_descriptor_receiver(args[1], "__doc__")?; Ok(unsafe { crate::function::fget_func_doc(descr) }) @@ -12086,6 +12307,10 @@ fn init_method_descriptor_type(ns: PyObjectRef) { ) }; } + // `descrobject.c descr_members` — the carrier keeps `d_type` in + // `Function.w_objclass` and `d_name` in `Function.name`, both published as + // members rather than getsets. + install_descr_members(ns); unsafe { pyre_object::w_dict_setitem_str_no_proxy( @@ -12594,7 +12819,12 @@ fn init_member_descriptor_type(ns: PyObjectRef) { }; match found { Some(v) => Ok(v), - None => Err(crate::baseobjspace::raiseattrerror(obj, slot_name, None)), + // A read, so no "and no __dict__ for setting new + // attributes" suffix: `_PyObject_GenericSetAttrWithDict` + // adds that on the store path only. + None => Err(crate::baseobjspace::raiseattrerror( + obj, slot_name, None, false, + )), } }), ) @@ -12699,58 +12929,13 @@ fn init_member_descriptor_type(ns: PyObjectRef) { }), ) }; - // typedef.py:538 __name__ = interp_attrproperty('name', ...) - let name_getter = make_builtin_function_with_arity( - "__name__", - |args| { - let member = args.get(1).copied().unwrap_or(pyre_object::PY_NULL); - if member.is_null() || !unsafe { pyre_object::typedef::is_member(member) } { - return Ok(pyre_object::w_none()); - } - Ok(pyre_object::w_str_new(unsafe { - pyre_object::w_member_get_name(member) - })) - }, - 2, - ); - unsafe { - pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( - ns, - "__name__", - make_getset_descriptor(name_getter), - ) - }; - // typedef.py:539 `__objclass__ = interp_attrproperty_w('w_cls', - // cls=Member)` — read-only. `interp_attrproperty_w` - // (typedef.py:465-474) fetches the attribute and substitutes - // `space.w_None` when the slot is `None`; mirror that fget shape - // arm-for-arm. The `is_member` guard stays as a defensive type - // check at the builtin-function boundary (PyPy's - // `descr_property_get` rejects non-Member instances before - // reaching fget; pyre's GetSetProperty path is less strict). - let objclass_getter = make_builtin_function_with_arity( - "__objclass__", - |args| { - let member = args.get(1).copied().unwrap_or(pyre_object::PY_NULL); - if !unsafe { pyre_object::typedef::is_member(member) } { - return Ok(pyre_object::w_none()); - } - let w_value = unsafe { pyre_object::w_member_get_cls(member) }; - if w_value.is_null() { - Ok(pyre_object::w_none()) - } else { - Ok(w_value) - } - }, - 2, - ); - unsafe { - pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( - ns, - "__objclass__", - make_getset_descriptor(objclass_getter), - ) - }; + // typedef.py:538-539 `__name__ = interp_attrproperty('name', ...)` and + // `__objclass__ = interp_attrproperty_w('w_cls', cls=Member)`, both + // read-only. `descrobject.c descr_members` publishes the same two values + // as members, which is also what makes them reachable on a Member whose + // `w_cls` is still unset: the reader substitutes None the way + // `interp_attrproperty_w` (typedef.py:465-474) does. + install_descr_members(ns); // CPython 3.14 `PyMemberDescr_Type` metadata. PyPy's Member typedef // stops at __name__/__objclass__; these four entries are the selected // 3.14 surface. @@ -13286,7 +13471,9 @@ fn staticmethod_annotate_del(args: &[PyObjectRef]) -> crate::PyResult { staticmethod_wrapped_attr_del(args, "__annotate__") } -fn staticmethod_dict_del(_args: &[PyObjectRef]) -> crate::PyResult { +/// `PyObject_GenericSetDict` with a null value — no `__dict__`-carrying +/// object lets the slot be removed. +fn dict_del_rejected(_args: &[PyObjectRef]) -> crate::PyResult { Err(crate::PyError::type_error("cannot delete __dict__")) } @@ -13308,7 +13495,7 @@ fn staticmethod_descr_repr(args: &[PyObjectRef]) -> crate::PyResult { fn init_staticmethod_type(ns: PyObjectRef) { let dict_getter = make_builtin_function_with_arity("__dict__", descr_get_dict, 2); let dict_setter = make_builtin_function_with_arity("__dict__", descr_set_dict, 3); - let dict_deleter = make_builtin_function_with_arity("__dict__", staticmethod_dict_del, 2); + let dict_deleter = make_builtin_function_with_arity("__dict__", dict_del_rejected, 2); let annotations_getter = make_builtin_function_with_arity("__annotations__", staticmethod_annotations_get, 2); let annotations_setter = @@ -13552,10 +13739,6 @@ fn classmethod_annotate_del(args: &[PyObjectRef]) -> crate::PyResult { classmethod_wrapped_attr_del(args, "__annotate__") } -fn classmethod_dict_del(_args: &[PyObjectRef]) -> crate::PyResult { - Err(crate::PyError::type_error("cannot delete __dict__")) -} - /// function.py:767-768 `descr_repr` / CPython 3.14 `cm_repr`. fn classmethod_descr_repr(args: &[PyObjectRef]) -> crate::PyResult { let cm = classmethod_require(args.first().copied().unwrap_or(PY_NULL), "__repr__")?; @@ -13574,7 +13757,7 @@ fn classmethod_descr_repr(args: &[PyObjectRef]) -> crate::PyResult { fn init_classmethod_type(ns: PyObjectRef) { let dict_getter = make_builtin_function_with_arity("__dict__", descr_get_dict, 2); let dict_setter = make_builtin_function_with_arity("__dict__", descr_set_dict, 3); - let dict_deleter = make_builtin_function_with_arity("__dict__", classmethod_dict_del, 2); + let dict_deleter = make_builtin_function_with_arity("__dict__", dict_del_rejected, 2); let annotations_getter = make_builtin_function_with_arity("__annotations__", classmethod_annotations_get, 2); let annotations_setter = @@ -13687,6 +13870,12 @@ pub(crate) const FUNCTION_DOC: &str = r#"Create a function object. /// CPython 3.14 `PyMethod_Type.tp_doc`. pub(crate) const METHOD_DOC: &str = "Create a bound instance method object."; +/// `PyType_Type.tp_doc`. `init_type_type` installs a `__doc__` getset under +/// the same key so a heap subclass can replace its own docstring, which +/// shadows the metatype's own doc the way `property`'s and `function`'s do. +pub(crate) const TYPE_DOC: &str = "type(object) -> the object's type\n\ + type(name, bases, dict, **kwds) -> a new type"; + /// CPython 3.14 `PyProperty_Type.tp_doc`. The instance-level `__doc__` /// descriptor occupies the same type-dict key; `baseobjspace` serves this /// separate type doc for the exact builtin, matching PyPy's TypeDef rawdict @@ -14220,23 +14409,25 @@ int_binop_rev!( ); /// `int.__rpow__(self, base[, mod])` — the reflected slot accepts an /// optional modulus argument, so it validates arity as one-or-two. +/// +/// `intobject.py:712-718 descr_rpow` then hands the work to +/// `w_base.descr_pow(space, self, w_modulus)`: the modulus travels with the +/// swapped operands instead of being dropped, so `(4).__rpow__(2, 5)` is +/// `pow(2, 4, 5)`. fn int_dunder_rpow(args: &[PyObjectRef]) -> Result { - crate::type_methods::arity_pow(args)?; + crate::type_methods::arity_pow(args, "__rpow__")?; if !unsafe { pyre_object::pyobject::is_int_or_long(args[1]) } { return Ok(pyre_object::w_not_implemented()); } - if args.len() >= 3 && !unsafe { pyre_object::pyobject::is_none(args[2]) } { - if !unsafe { pyre_object::pyobject::is_int_or_long(args[2]) } { - return Ok(pyre_object::w_not_implemented()); + let mut swapped = [args[1], args[0], pyre_object::PY_NULL]; + let len = match args.get(2) { + Some(&modulus) => { + swapped[2] = modulus; + 3 } - return match crate::objspace::descroperation::try_int_long_pow_with_modulo( - args[1], args[0], args[2], - )? { - Some(result) => Ok(result), - None => Ok(pyre_object::w_not_implemented()), - }; - } - crate::objspace::descroperation::pow_builtin(args[1], args[0]) + None => 2, + }; + int_dunder_pow(&swapped[..len]) } int_binop_fwd!( int_dunder_lshift, @@ -14270,7 +14461,7 @@ int_binop_rev!( /// `int.__pow__(self, exp[, mod])` — optional modulus routes through the /// three-argument modular power. fn int_dunder_pow(args: &[PyObjectRef]) -> Result { - crate::type_methods::arity_pow(args)?; + crate::type_methods::arity_pow(args, "__pow__")?; // intobject.py:674 descr_pow — a non-int exponent defers to the other // operand's reflected slot. if !unsafe { pyre_object::pyobject::is_int_or_long(args[1]) } { @@ -14367,7 +14558,7 @@ fn float_pow_reject_modulus(args: &[PyObjectRef]) -> Result<(), crate::PyError> /// `float.__pow__` / `__rpow__` — the ternary-power slot accepts an /// optional modulus argument, so arity is validated as one-or-two. fn float_dunder_pow(args: &[PyObjectRef]) -> Result { - crate::type_methods::arity_pow(args)?; + crate::type_methods::arity_pow(args, "__pow__")?; let b = args[1]; if unsafe { pyre_object::pyobject::is_float(b) || pyre_object::pyobject::is_int_or_long(b) } { // The operand is coerced to a double first, so an over-range int @@ -14380,7 +14571,7 @@ fn float_dunder_pow(args: &[PyObjectRef]) -> Result } } fn float_dunder_rpow(args: &[PyObjectRef]) -> Result { - crate::type_methods::arity_pow(args)?; + crate::type_methods::arity_pow(args, "__rpow__")?; let b = args[1]; if unsafe { pyre_object::pyobject::is_float(b) || pyre_object::pyobject::is_int_or_long(b) } { unsafe { crate::objspace::descroperation::reject_pow_operand_overflow(b)? }; @@ -14435,7 +14626,7 @@ fn complex_pow_reject_modulus(args: &[PyObjectRef]) -> Result<(), crate::PyError /// `complex.__pow__` / `__rpow__` — the ternary-power slot accepts an /// optional modulus argument, so arity is validated as one-or-two. fn complex_dunder_pow(args: &[PyObjectRef]) -> Result { - crate::type_methods::arity_pow(args)?; + crate::type_methods::arity_pow(args, "__pow__")?; if complex_binop_operand(args[1]) { complex_pow_reject_modulus(args)?; crate::objspace::descroperation::pow_builtin(args[0], args[1]) @@ -14444,7 +14635,7 @@ fn complex_dunder_pow(args: &[PyObjectRef]) -> Result Result { - crate::type_methods::arity_pow(args)?; + crate::type_methods::arity_pow(args, "__rpow__")?; if complex_binop_operand(args[1]) { complex_pow_reject_modulus(args)?; crate::objspace::descroperation::pow_builtin(args[1], args[0]) @@ -15038,7 +15229,7 @@ fn init_int_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__round__", - make_builtin_function("__round__", crate::builtins::builtin_round), + make_builtin_function("__round__", crate::type_methods::number_dunder_round), ) }; unsafe { @@ -15615,54 +15806,49 @@ fn init_float_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "fromhex", - pyre_object::function::w_classmethod_new(make_builtin_function_with_arity( - "fromhex", - |args| { - // float.fromhex(s) — PyPy: floatobject.py descr_fromhex. - // Parse hexadecimal floating-point literals like '0x1.8p3'. - if args.len() < 2 { - return Err(crate::PyError::type_error( - "fromhex() requires a string argument", - )); + // Registered without a fixed arity so the body's own `arity_exact` + // words the mismatch: a class method's receiver is the class, and + // only the body knows to discount it. + pyre_object::function::w_classmethod_new(make_builtin_function("fromhex", |args| { + // float.fromhex(s) — PyPy: floatobject.py descr_fromhex. + // Parse hexadecimal floating-point literals like '0x1.8p3'. + crate::type_methods::arity_exact(args, "fromhex", 1)?; + let s_arg = if unsafe { pyre_object::is_str(args[1]) } { + unsafe { pyre_object::w_str_get_value(args[1]).to_string() } + } else { + return Err(crate::PyError::type_error( + "fromhex() requires a string argument", + )); + }; + // Delegate parsing to the shared hex-float reader, which rounds + // round-half-even over the full exponent range (subnormals down to + // 0x1p-1074), accepts the inf/nan spellings, handles surrounding + // ASCII whitespace itself, and flags overflow distinctly. + match rustpython_common::float_ops::from_hex(&s_arg) { + Ok(v) => { + let w_float = pyre_object::w_float_new(v); + // floatobject.py:419: return + // space.call_function(w_cls, w_float). This runs a + // subclass's __new__ and __init__ rather than merely + // retagging the parsed base float. + crate::call::call_function_impl_result(args[0], &[w_float]) } - let s_arg = if unsafe { pyre_object::is_str(args[1]) } { - unsafe { pyre_object::w_str_get_value(args[1]).to_string() } - } else { - return Err(crate::PyError::type_error( - "fromhex() requires a string argument", - )); - }; - // Delegate parsing to the shared hex-float reader, which rounds - // round-half-even over the full exponent range (subnormals down to - // 0x1p-1074), accepts the inf/nan spellings, handles surrounding - // ASCII whitespace itself, and flags overflow distinctly. - match rustpython_common::float_ops::from_hex(&s_arg) { - Ok(v) => { - let w_float = pyre_object::w_float_new(v); - // floatobject.py:419: return - // space.call_function(w_cls, w_float). This runs a - // subclass's __new__ and __init__ rather than merely - // retagging the parsed base float. - crate::call::call_function_impl_result(args[0], &[w_float]) - } - Err(e) => { - use rustpython_common::float_ops::HexFloatError; - Err(match e { - HexFloatError::Overflow => crate::PyError::overflow_error( - "hexadecimal value too large to represent as a float", - ), - HexFloatError::TooLong => crate::PyError::value_error( - "hexadecimal string too long to convert", - ), - HexFloatError::Invalid => crate::PyError::value_error( - "invalid hexadecimal floating-point string", - ), - }) - } + Err(e) => { + use rustpython_common::float_ops::HexFloatError; + Err(match e { + HexFloatError::Overflow => crate::PyError::overflow_error( + "hexadecimal value too large to represent as a float", + ), + HexFloatError::TooLong => crate::PyError::value_error( + "hexadecimal string too long to convert", + ), + HexFloatError::Invalid => crate::PyError::value_error( + "invalid hexadecimal floating-point string", + ), + }) } - }, - 2, - )), + } + })), ) }; unsafe { @@ -15779,7 +15965,7 @@ fn init_float_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__round__", - make_builtin_function("__round__", crate::builtins::builtin_round), + make_builtin_function("__round__", crate::type_methods::number_dunder_round), ) }; unsafe { @@ -16279,7 +16465,7 @@ fn init_bool_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, and_name, - make_builtin_function(and_name, f), + make_builtin_function_with_arity(and_name, f, 2), ) }; unsafe { @@ -16624,7 +16810,7 @@ fn init_object_type(ns: PyObjectRef) { "descriptor '__repr__' of 'object' object needs an argument", )); } - crate::type_methods::arity_no_args(args, "object.__repr__")?; + crate::type_methods::arity_no_args(args, "__repr__")?; let obj = args[0]; unsafe { if pyre_object::is_instance(obj) { @@ -16657,7 +16843,7 @@ fn init_object_type(ns: PyObjectRef) { "descriptor '__str__' of 'object' object needs an argument", )); } - crate::type_methods::arity_no_args(args, "object.__str__")?; + crate::type_methods::arity_no_args(args, "__str__")?; // Delegate to __repr__ to avoid infinite recursion // PyPy: objectobject.py descr___str__ → space.repr(w_self) Ok(pyre_object::w_str_new_managed(&unsafe { @@ -16681,7 +16867,7 @@ fn init_object_type(ns: PyObjectRef) { "unbound method object.__format__() needs an argument", )); } - crate::type_methods::arity_exact(args, "object.__format__", 1)?; + crate::type_methods::arity_exact(args, "__format__", 1)?; // object.__format__(self, format_spec): the spec must be // a `str` (a `bytes` spec is rejected like any other // non-`str`); a non-empty one is unsupported, an empty @@ -16715,7 +16901,7 @@ fn init_object_type(ns: PyObjectRef) { "unbound method object.__reduce__() needs an argument", )); } - crate::type_methods::arity_no_args(args, "object.__reduce__")?; + crate::type_methods::arity_no_args(args, "__reduce__")?; crate::reduce_protocol::descr_reduce(args[0]) }, 1, @@ -16734,7 +16920,7 @@ fn init_object_type(ns: PyObjectRef) { "unbound method object.__reduce_ex__() needs an argument", )); } - crate::type_methods::arity_exact(args, "object.__reduce_ex__", 1)?; + crate::type_methods::arity_exact(args, "__reduce_ex__", 1)?; let proto = crate::builtins::space_index_w(args[1])?; crate::reduce_protocol::descr_reduce_ex(args[0], proto) }, @@ -16754,7 +16940,7 @@ fn init_object_type(ns: PyObjectRef) { "unbound method object.__getstate__() needs an argument", )); } - crate::type_methods::arity_no_args(args, "object.__getstate__")?; + crate::type_methods::arity_no_args(args, "__getstate__")?; crate::reduce_protocol::object_getstate_default(args[0]) }, 1, @@ -16773,7 +16959,7 @@ fn init_object_type(ns: PyObjectRef) { "unbound method object.__dir__() needs an argument", )); } - crate::type_methods::arity_no_args(args, "object.__dir__")?; + crate::type_methods::arity_no_args(args, "__dir__")?; crate::builtins::object_dir_default(args[0]) }, 1, @@ -16784,19 +16970,18 @@ fn init_object_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__sizeof__", - make_builtin_function_with_arity( - "__sizeof__", - |args| { - if args.is_empty() { - return Err(crate::PyError::type_error( - "unbound method object.__sizeof__() needs an argument", - )); - } - crate::type_methods::arity_no_args(args, "object.__sizeof__")?; - object_descr_sizeof(args) - }, - 1, - ), + // No declared arity: the body reports the mismatch itself, under + // the class that declares the descriptor rather than under the + // receiver's class the gateway would read. + make_builtin_function("__sizeof__", |args| { + if args.is_empty() { + return Err(crate::PyError::type_error( + "unbound method object.__sizeof__() needs an argument", + )); + } + crate::type_methods::arity_no_args_of(Some("object"), args, "__sizeof__")?; + object_descr_sizeof(args) + }), ) }; // typeobject.py descr___init_subclass__ — the default accepts no @@ -16832,7 +17017,13 @@ fn init_object_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "__subclasshook__", - make_builtin_function("__subclasshook__", |_| Ok(pyre_object::w_not_implemented())), + // objectobject.py:413 `interp2app(descr___subclasshook__, + // as_classmethod=True)` — the hook receives the class it is + // consulted for, so a read off any type binds to that type. + pyre_object::function::w_classmethod_new(make_builtin_function( + "__subclasshook__", + |_| Ok(pyre_object::w_not_implemented()), + )), ) }; // PyPy: objectobject.py descr___setattr__ @@ -17513,7 +17704,7 @@ fn init_bytes_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "maketrans", - make_maketrans_descr(bytes_maketrans), + make_maketrans_descr!("bytes", bytes_maketrans), ) }; unsafe { @@ -17696,9 +17887,7 @@ fn init_bytes_type(ns: PyObjectRef) { fn bytes_descr_repeat(args: &[PyObjectRef]) -> Result { crate::type_methods::arity_slot(args, 1)?; - let Some(count) = list_repeat_index(args[1])? else { - return Ok(pyre_object::w_not_implemented()); - }; + let count = crate::baseobjspace::getindex_repeat(args[1])?; unsafe { crate::objspace::descroperation::bytes_repeat(args[0], count) } } @@ -18059,7 +18248,9 @@ fn bytes_require_no_args(args: &[PyObjectRef], name: &str) -> Result<(), crate:: } else { "bytes" }; - crate::type_methods::arity_no_args(args, &format!("{owner}.{name}")) + // The declaring class, not the receiver's own type: a `bytes` subclass + // instance still reports `bytes.capitalize`. + crate::type_methods::arity_no_args_of(Some(owner), args, name) } fn bytes_method_upper(args: &[PyObjectRef]) -> Result { @@ -18121,16 +18312,19 @@ fn bytes_strip( fn bytes_method_strip(args: &[PyObjectRef]) -> Result { crate::type_methods::require_receiver(args, "strip")?; + crate::type_methods::arity_at_most(args, "strip", 1)?; bytes_strip(args, true, true) } fn bytes_method_lstrip(args: &[PyObjectRef]) -> Result { crate::type_methods::require_receiver(args, "lstrip")?; + crate::type_methods::arity_at_most(args, "lstrip", 1)?; bytes_strip(args, true, false) } fn bytes_method_rstrip(args: &[PyObjectRef]) -> Result { crate::type_methods::require_receiver(args, "rstrip")?; + crate::type_methods::arity_at_most(args, "rstrip", 1)?; bytes_strip(args, false, true) } @@ -18349,6 +18543,14 @@ fn bytes_split(args: &[PyObjectRef], forward: bool) -> Result Result found` TypeError. fn bytes_method_join(args: &[PyObjectRef]) -> Result { - if args.len() != 2 { - return Err(crate::PyError::type_error(format!( - "join() takes exactly one argument ({} given)", - args.len().saturating_sub(1) - ))); - } + crate::type_methods::arity_exact(args, "join", 1)?; // Python 3.14's bytearray join holds a buffer export on the separator // while materialising the iterable and copying its items (GH-112625). // A re-entrant iterator therefore cannot resize/clear the separator out @@ -18497,7 +18694,9 @@ fn bytes_method_join(args: &[PyObjectRef]) -> Result = Vec::new(); @@ -18768,7 +18967,7 @@ fn bytes_method_center(args: &[PyObjectRef]) -> Result Result { - crate::type_methods::arity_exact(args, "bytes.zfill", 1)?; + crate::type_methods::arity_exact(args, "zfill", 1)?; let data = unsafe { pyre_object::bytesobject::bytes_like_data(args[0]) }; let width = crate::builtins::space_index_w(args[1])?; let len = data.len() as i64; @@ -18902,17 +19101,23 @@ fn bytes_method_removesuffix(args: &[PyObjectRef]) -> Result Result { let data = unsafe { pyre_object::bytesobject::bytes_like_data(args[0]) }; let (positional, kwargs) = crate::builtins::split_builtin_kwargs(&args[1..]); - let given = positional.len() + crate::builtins::real_kwarg_count(kwargs); - if given > 2 { - return Err(crate::PyError::type_error(format!( - "translate() takes at most 2 arguments ({given} given)" - ))); - } + crate::builtins::clinic_arity( + "translate", + positional.len(), + crate::builtins::real_kwarg_count(kwargs), + 1, + 2, + 0, + )?; + // The missing-positional report wins over the unknown-keyword one: + // `b''.translate(bogus=1)` names the absent `table`, not `bogus`. let Some(&table_obj) = positional.first() else { return Err(crate::PyError::type_error( "translate() takes at least 1 positional argument (0 given)", @@ -19108,11 +19313,6 @@ fn bytes_maketrans(args: &[PyObjectRef]) -> Result /// the end raises the even-count error, any other non-hex byte raises /// the positional error. fn parse_hex_string(args: &[PyObjectRef]) -> Result, crate::PyError> { - if args.len() != 1 { - return Err(crate::PyError::type_error( - "fromhex() takes exactly one argument", - )); - } let a = args[0]; if unsafe { pyre_object::is_str(a) } { return parse_hex_bytes(unsafe { pyre_object::w_str_get_value(a) }.as_bytes()); @@ -19187,15 +19387,55 @@ fn int_from_bytes(args: &[PyObjectRef]) -> Result { pos.len() - 1 ))); } - // `byteorder` and `signed` are the only keywords the gateway signature - // accepts; anything else is an unexpected-keyword TypeError. - crate::builtins::kwarg_reject_unknown(kwargs, &["byteorder", "signed"], "from_bytes")?; - let data_obj = pos.get(1).copied().ok_or_else(|| { + // `bytes` and `byteorder` are positional-or-keyword; supplying one both + // ways is an error rather than the keyword silently winning. + let bytes_kw = crate::builtins::kwarg_get(kwargs, "bytes"); + let byteorder_kw = crate::builtins::kwarg_get(kwargs, "byteorder"); + if bytes_kw.is_some() && pos.len() > 1 { + return Err(crate::PyError::type_error( + "argument for from_bytes() given by name ('bytes') and position (1)", + )); + } + if byteorder_kw.is_some() && pos.len() > 2 { + return Err(crate::PyError::type_error( + "argument for from_bytes() given by name ('byteorder') and position (2)", + )); + } + // Every declared slot is filled before the unrecognized keywords are + // reported, so a call missing `bytes` is reported against `bytes`. + let data_obj = pos.get(1).copied().or(bytes_kw).ok_or_else(|| { crate::PyError::type_error("from_bytes() missing required argument 'bytes' (pos 1)") })?; - // bytesobject.py `makebytesdata_w`: `__bytes__` takes precedence over the - // buffer/iterable conversion and must itself return a bytes instance. + crate::builtins::kwarg_reject_unknown(kwargs, &["bytes", "byteorder", "signed"], "from_bytes")?; + // The clinic `str byteorder` converter runs before `PyBytes_FromObject` + // touches the payload, so a bad byte order is reported even when the + // payload could not have been converted either. + let byteorder = match pos.get(2).copied().or(byteorder_kw) { + None => "big", + Some(b) if unsafe { pyre_object::is_str(b) } => { + match unsafe { pyre_object::w_str_get_value(b) } { + "little" => "little", + "big" => "big", + _ => { + return Err(crate::PyError::value_error( + "byteorder must be either 'little' or 'big'", + )); + } + } + } + Some(b) => { + let tname = unsafe { pyre_object::type_name_of(b) }; + return Err(crate::PyError::type_error(format!( + "from_bytes() argument 'byteorder' must be str, not {tname}" + ))); + } + }; + // `makebytesdata_w` — `__bytes__` takes precedence over the buffer / + // iterable conversion and must itself return a bytes instance. let bytes_method = unsafe { crate::baseobjspace::lookup(data_obj, "__bytes__") }; + // `_convert_from_buffer_or_iterable` — the buffer protocol, else an + // iterable of ints. A str is iterable but never a byte source, and + // neither is an object with no iterator at all. let bytes: Vec = if let Some(method) = bytes_method { let w_type = crate::typedef::r#type(data_obj).map_or(pyre_object::PY_NULL, |t| t.as_ptr()); let w_bytes = @@ -19210,16 +19450,17 @@ fn int_from_bytes(args: &[PyObjectRef]) -> Result { } else if unsafe { pyre_object::bytesobject::is_bytes_like(data_obj) } { unsafe { pyre_object::bytesobject::bytes_like_data(data_obj).to_vec() } } else { - // `_convert_from_buffer_or_iterable`: unicode is rejected before the - // generic iterable-of-bytes path. In particular, an empty string is - // not accepted as an empty byte sequence. - let str_type = crate::typedef::gettypeobject(&pyre_object::STR_TYPE); - if unsafe { crate::baseobjspace::isinstance_w(data_obj, str_type) } { - return Err(crate::PyError::type_error( - "cannot convert 'str' object to bytes", - )); + let not_bytes = format!( + "cannot convert '{}' object to bytes", + crate::baseobjspace::object_functionstr_type_name(data_obj) + ); + if unsafe { pyre_object::is_str(data_obj) } { + return Err(crate::PyError::type_error(not_bytes)); } - let items = crate::builtins::collect_iterable(data_obj)?; + // `PySequence_Fast` wording applies to the `iter(obj)` failure only: + // a `TypeError` the iterator raises from `__next__` is that object's + // error and keeps its own message and traceback. + let items = crate::builtins::sequence_fast(data_obj, ¬_bytes)?; let mut v = Vec::with_capacity(items.len()); for it in items { let n = crate::baseobjspace::int_w(it)?; @@ -19232,37 +19473,6 @@ fn int_from_bytes(args: &[PyObjectRef]) -> Result { } v }; - // byteorder is positional-or-keyword; supplying both is an error rather - // than the keyword silently winning. - let byteorder_kw = crate::builtins::kwarg_get(kwargs, "byteorder"); - let byteorder_pos = pos.get(2).copied(); - if byteorder_kw.is_some() && byteorder_pos.is_some() { - return Err(crate::PyError::type_error( - "got multiple values for argument 'byteorder'", - )); - } - // `byteorder='text'` unwraps through `space.text_w`; a non-str value is a - // TypeError, and only a str that is neither 'little'/'big' is a ValueError. - let byteorder = match byteorder_pos.or(byteorder_kw) { - None => "big", - Some(b) if unsafe { pyre_object::is_str(b) } => { - match unsafe { pyre_object::w_str_get_value(b) } { - "little" => "little", - "big" => "big", - _ => { - return Err(crate::PyError::value_error( - "byteorder must be either 'little' or 'big'", - )); - } - } - } - Some(b) => { - let tname = unsafe { pyre_object::type_name_of(b) }; - return Err(crate::PyError::type_error(format!( - "expected str, got {tname} object" - ))); - } - }; let signed = crate::builtins::kwarg_get(kwargs, "signed") .map(crate::baseobjspace::is_true) .transpose()? @@ -19302,6 +19512,7 @@ fn int_from_bytes(args: &[PyObjectRef]) -> Result { // `cls(value)` when called on a subclass. fn bytes_fromhex(args: &[PyObjectRef]) -> Result { let cls = args.first().copied().unwrap_or(pyre_object::PY_NULL); + crate::type_methods::arity_exact(args, "fromhex", 1)?; let out = parse_hex_string(&args[1..])?; let w_bytes = pyre_object::bytesobject::w_bytes_from_bytes(&out); let base = crate::typedef::gettypeobject(&pyre_object::bytesobject::BYTES_TYPE); @@ -19314,6 +19525,7 @@ fn bytes_fromhex(args: &[PyObjectRef]) -> Result { fn bytearray_fromhex(args: &[PyObjectRef]) -> Result { let cls = args.first().copied().unwrap_or(pyre_object::PY_NULL); + crate::type_methods::arity_exact(args, "fromhex", 1)?; let out = parse_hex_string(&args[1..])?; let base = crate::typedef::gettypeobject(&pyre_object::bytearrayobject::BYTEARRAY_TYPE); if cls.is_null() || crate::baseobjspace::is_w(cls, base) { @@ -19339,6 +19551,14 @@ fn bytearray_fromhex(args: &[PyObjectRef]) -> Result Result { crate::type_methods::require_receiver(args, "hex")?; let (pos, kwargs) = crate::builtins::split_builtin_kwargs(args); + crate::builtins::clinic_arity( + "hex", + pos.len() - 1, + crate::builtins::real_kwarg_count(kwargs), + 0, + 2, + 0, + )?; crate::builtins::kwarg_reject_unknown(kwargs, &["sep", "bytes_per_sep"], "hex")?; crate::builtins::kwarg_reject_duplicate(kwargs, "hex", "sep", pos.get(1).is_some())?; crate::builtins::kwarg_reject_duplicate(kwargs, "hex", "bytes_per_sep", pos.get(2).is_some())?; @@ -19355,7 +19575,9 @@ pub(crate) fn bytes_method_hex(args: &[PyObjectRef]) -> Result crate::baseobjspace::int_w(o)?, + // `_Py_strhex_impl`'s clinic `int bytes_per_sep` converter reads + // `__index__`, so a non-index argument is named in the error. + Some(o) => crate::builtins::space_index_w(o)?, None => 1, }; @@ -19889,6 +20111,14 @@ pub(crate) fn bytes_method_decode(args: &[PyObjectRef]) -> Result Result Result { - crate::type_methods::arity_exact(args, "bytearray.append", 1)?; + crate::type_methods::arity_exact(args, "append", 1)?; unsafe { crate::builtins::bytearray_check_exports(args[0])? }; let b = bytearray_byte_arg(args[1])?; unsafe { pyre_object::bytearrayobject::w_bytearray_vec_mut(args[0]).push(b) }; @@ -20340,7 +20570,7 @@ fn bytearray_method_append(args: &[PyObjectRef]) -> Result Result { - crate::type_methods::arity_at_least(args, "extend", 1)?; + crate::type_methods::arity_exact(args, "extend", 1)?; unsafe { crate::builtins::bytearray_check_exports(args[0])? }; let other = args[1]; // Materialize the new bytes before mutating so `x.extend(x)` is safe. @@ -20396,7 +20626,7 @@ fn bytearray_method_extend(args: &[PyObjectRef]) -> Result Result { - crate::type_methods::arity_at_least(args, "insert", 2)?; + crate::type_methods::arity_exact_unpack(args, "insert", 2)?; unsafe { crate::builtins::bytearray_check_exports(args[0])? }; let index = crate::builtins::space_index_w(args[1])?; let b = bytearray_byte_arg(args[2])?; @@ -20431,6 +20661,7 @@ fn bytearray_method_remove(args: &[PyObjectRef]) -> Result Result { crate::type_methods::require_receiver(args, "pop")?; + crate::type_methods::arity_at_most(args, "pop", 1)?; unsafe { crate::builtins::bytearray_check_exports(args[0])?; let vec = pyre_object::bytearrayobject::w_bytearray_vec_mut(args[0]); @@ -20461,7 +20692,7 @@ fn bytearray_method_pop(args: &[PyObjectRef]) -> Result Result { crate::type_methods::require_receiver(args, "reverse")?; - crate::type_methods::arity_no_args(args, "bytearray.reverse")?; + crate::type_methods::arity_no_args(args, "reverse")?; unsafe { pyre_object::bytearrayobject::w_bytearray_vec_mut(args[0]).reverse() }; Ok(pyre_object::w_none()) } @@ -20469,7 +20700,7 @@ fn bytearray_method_reverse(args: &[PyObjectRef]) -> Result Result { crate::type_methods::require_receiver(args, "clear")?; - crate::type_methods::arity_no_args(args, "bytearray.clear")?; + crate::type_methods::arity_no_args(args, "clear")?; unsafe { crate::builtins::bytearray_check_exports(args[0])?; pyre_object::bytearrayobject::w_bytearray_vec_mut(args[0]).clear(); @@ -20481,7 +20712,7 @@ fn bytearray_method_clear(args: &[PyObjectRef]) -> Result Result { crate::type_methods::require_receiver(args, "copy")?; - crate::type_methods::arity_no_args(args, "bytearray.copy")?; + crate::type_methods::arity_no_args(args, "copy")?; let data = unsafe { pyre_object::bytesobject::bytes_like_data(args[0]) }; Ok(pyre_object::bytearrayobject::w_bytearray_from_bytes(data)) } @@ -21082,7 +21313,7 @@ fn init_bytearray_type(ns: PyObjectRef) { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy( ns, "maketrans", - make_maketrans_descr(bytes_maketrans), + make_maketrans_descr!("bytearray", bytes_maketrans), ) }; unsafe { @@ -21222,12 +21453,10 @@ fn init_bytearray_type(ns: PyObjectRef) { make_builtin_function_with_arity( "__imul__", |args| { - // descr_inplace_mul: the count goes through `__index__`; a - // non-index operand becomes NotImplemented. + // descr_inplace_mul: `wrap_indexargfunc` reduces the + // count through `__index__` before the slot runs. crate::type_methods::arity_slot(args, 1)?; - let Some(w_count) = list_repeat_index(args[1])? else { - return Ok(pyre_object::w_not_implemented()); - }; + let w_count = crate::baseobjspace::getindex_repeat(args[1])?; unsafe { crate::objspace::descroperation::bytearray_inplace_repeat(args[0], w_count)? }; @@ -21460,11 +21689,15 @@ macro_rules! setlike_method_gateways { ($set_fn:ident, $frozenset_fn:ident, $name:literal, $implementation:ident) => { fn $set_fn(args: &[PyObjectRef]) -> Result { crate::type_methods::require_set_receiver(args, $name, true)?; + // Every set method takes its operands as a bare `*others`, so a + // keyword would otherwise reach the body as one more operand. + crate::type_methods::reject_kwargs(args, $name)?; $implementation(args) } fn $frozenset_fn(args: &[PyObjectRef]) -> Result { crate::type_methods::require_frozenset_receiver(args, $name, true)?; + crate::type_methods::reject_kwargs(args, $name)?; $implementation(args) } }; @@ -22503,7 +22736,7 @@ fn set_method_intersection_update( fn set_method_symmetric_difference_update( args: &[pyre_object::PyObjectRef], ) -> Result { - crate::type_methods::arity_exact(args, "set.symmetric_difference_update", 1)?; + crate::type_methods::arity_exact(args, "symmetric_difference_update", 1)?; let w_other_as_set = set_operand_as_set(args[1])?; let w_new = set_symmetric_difference_storage(args[0], w_other_as_set)?; // `setobject.py` — the computed storage replaces self's. @@ -22654,7 +22887,7 @@ fn init_set_type(ns: PyObjectRef) { "add", |args| { crate::type_methods::require_set_receiver(args, "add", true)?; - crate::type_methods::arity_exact(args, "set.add", 1)?; + crate::type_methods::arity_exact(args, "add", 1)?; // `try_hash_value` may run a user `__hash__` that // allocates and triggers a moving minor collection; // root `self` and the element across it, then reload. @@ -22690,7 +22923,7 @@ fn init_set_type(ns: PyObjectRef) { "discard", |args| { crate::type_methods::require_set_receiver(args, "discard", true)?; - crate::type_methods::arity_exact(args, "set.discard", 1)?; + crate::type_methods::arity_exact(args, "discard", 1)?; set_discard_from_set(args[0], args[1])?; Ok(pyre_object::w_none()) }, @@ -22706,7 +22939,7 @@ fn init_set_type(ns: PyObjectRef) { "remove", |args| { crate::type_methods::require_set_receiver(args, "remove", true)?; - crate::type_methods::arity_exact(args, "set.remove", 1)?; + crate::type_methods::arity_exact(args, "remove", 1)?; if !set_discard_from_set(args[0], args[1])? { return Err(crate::PyError::key_error_with_key(args[1])); } @@ -22724,7 +22957,7 @@ fn init_set_type(ns: PyObjectRef) { "pop", |args| { crate::type_methods::require_set_receiver(args, "pop", true)?; - crate::type_methods::arity_no_args(args, "set.pop")?; + crate::type_methods::arity_no_args(args, "pop")?; if let Some(item) = unsafe { pyre_object::w_set_popitem(args[0]) } { return Ok(item); } @@ -22745,7 +22978,7 @@ fn init_set_type(ns: PyObjectRef) { "clear", |args| { crate::type_methods::require_set_receiver(args, "clear", true)?; - crate::type_methods::arity_no_args(args, "set.clear")?; + crate::type_methods::arity_no_args(args, "clear")?; unsafe { pyre_object::w_set_clear(args[0]) }; Ok(pyre_object::w_none()) }, @@ -22759,6 +22992,7 @@ fn init_set_type(ns: PyObjectRef) { "update", make_builtin_function("update", |args| { crate::type_methods::require_set_receiver(args, "update", true)?; + crate::type_methods::reject_kwargs(args, "update")?; set_method_update(args) }), ) @@ -22773,6 +23007,7 @@ fn init_set_type(ns: PyObjectRef) { "difference_update", make_builtin_function("difference_update", |args| { crate::type_methods::require_set_receiver(args, "difference_update", true)?; + crate::type_methods::reject_kwargs(args, "difference_update")?; set_method_difference_update(args) }), ) @@ -22783,6 +23018,7 @@ fn init_set_type(ns: PyObjectRef) { "intersection_update", make_builtin_function("intersection_update", |args| { crate::type_methods::require_set_receiver(args, "intersection_update", true)?; + crate::type_methods::reject_kwargs(args, "intersection_update")?; set_method_intersection_update(args) }), ) @@ -22797,6 +23033,7 @@ fn init_set_type(ns: PyObjectRef) { "symmetric_difference_update", true, )?; + crate::type_methods::reject_kwargs(args, "symmetric_difference_update")?; set_method_symmetric_difference_update(args) }), ) @@ -23644,6 +23881,65 @@ fn init_sequence_iterator_type(ns: PyObjectRef) { } } +/// Python 3.14 `PySeqIter_Type` restricted to `memory_iterator`'s surface: +/// `__iter__` and `__next__` only. The shared `W_SeqIterObject` payload +/// carries the index and length a `__length_hint__` / `__setstate__` would +/// read, but the type does not expose them, and `__reduce__` falls through to +/// `object`'s, which refuses to pickle it. +fn init_memory_iterator_type(ns: PyObjectRef) { + unsafe { pyre_object::w_dict_setitem_str(ns, "__doc__", pyre_object::w_none()) }; + let entries = [ + ( + "__iter__", + make_builtin_function_with_arity("__iter__", crate::baseobjspace::iter_self_method, 1), + ), + ( + "__next__", + make_builtin_function_with_arity("__next__", crate::baseobjspace::iter_next_method, 1), + ), + ]; + for (name, value) in entries { + unsafe { pyre_object::w_dict_setitem_str_no_proxy(ns, name, value) }; + } +} + +/// `arrayiterator`'s Python 3.14 surface: the iteration protocol plus the +/// `__reduce__` / `__setstate__` pickle pair, but no `__length_hint__` — +/// `arrayiter_type` declares no `__length_hint__` slot even though the shared +/// `W_SeqIterObject` payload could answer one. +fn init_array_iterator_type(ns: PyObjectRef) { + unsafe { pyre_object::w_dict_setitem_str(ns, "__doc__", pyre_object::w_none()) }; + let entries = [ + ( + "__iter__", + make_builtin_function_with_arity("__iter__", crate::baseobjspace::iter_self_method, 1), + ), + ( + "__next__", + make_builtin_function_with_arity("__next__", crate::baseobjspace::iter_next_method, 1), + ), + ( + "__reduce__", + make_builtin_function_with_arity( + "__reduce__", + crate::baseobjspace::seq_iter_reduce_method, + 1, + ), + ), + ( + "__setstate__", + make_builtin_function_with_arity( + "__setstate__", + crate::baseobjspace::seq_iter_setstate_method, + 2, + ), + ), + ]; + for (name, value) in entries { + unsafe { pyre_object::w_dict_setitem_str_no_proxy(ns, name, value) }; + } +} + /// Python 3.14 `PyCallIter_Type` (`callable_iterator`) surface. PyPy 3.11's /// `_CallableIterator` is app-level and has only the iteration methods; 3.14 /// additionally exposes the native pickle reduction hook. diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/bridge_subwalk.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/bridge_subwalk.rs index 27e8f4e6dae..c39b5f2a11c 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/bridge_subwalk.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/bridge_subwalk.rs @@ -661,6 +661,63 @@ pub fn dispatch_via_miframe( /// the root register banks come straight from the bridge-seeded `root_sym` /// rather than a live caller [`WalkContext`] (the root walk has not started — /// this resumes mid-flight). +/// `blackhole.py:1711 `_copy_data_from_miframe`: the concrete register image a +/// paused caller resumes from when a descendant abort converts the framestack +/// into blackhole interpreters. +/// +/// [`capture_inline_parent_blackhole`] builds this for a caller the walker is +/// still standing in, by reading its live concrete shadow banks. A frame +/// rebuilt from a guard's resume data has no such bank — its per-color boxes +/// are all this side holds. They carry the same intrinsic concrete upstream +/// reads (`history.py:803` `*FrontendOp(pos, value)`; `box.getint()` / +/// `box.getref_base()` at :1718/:1724), so resolve them through +/// `concrete_of_opref` instead. +/// +/// `OpRef::NONE` is upstream's `if box is not None` skip. A live box whose +/// concrete is unknown declines the whole capture: an MIFrame that silently +/// left one live register at its default resumes on a stale value. +fn capture_reconstructed_parent_blackhole( + ctx: &TraceCtx, + resume_pc: usize, + int_boxes: &[(usize, OpRef)], + ref_boxes: &[(usize, OpRef)], + float_boxes: &[(usize, OpRef)], +) -> Option { + let mut int_values = Vec::with_capacity(int_boxes.len()); + for &(color, opref) in int_boxes { + if opref == OpRef::NONE { + continue; + } + let Some(majit_ir::Value::Int(value)) = ctx.concrete_of_opref(opref) else { + return None; + }; + int_values.push((color, value)); + } + let mut ref_values = Vec::with_capacity(ref_boxes.len()); + for &(color, opref) in ref_boxes { + if opref == OpRef::NONE { + continue; + } + let Some(majit_ir::Value::Ref(value)) = ctx.concrete_of_opref(opref) else { + return None; + }; + ref_values.push((color, value.as_usize() as pyre_object::PyObjectRef)); + } + // Floats keep their OpRefs — `build_multi_frame_miframe` resolves them + // while the trace context is still live, the same as the walker capture. + let float_values = float_boxes + .iter() + .copied() + .filter(|&(_, opref)| opref != OpRef::NONE) + .collect(); + Some(InlineParentBlackhole { + resume_pc, + int_values, + ref_values, + float_values, + }) +} + pub(crate) fn compute_bridge_root_parent_frame( root_sym: &Sym, trace_ctx: &mut TraceCtx, @@ -691,11 +748,11 @@ pub(crate) fn compute_bridge_root_parent_frame( .bridge_registers_r() .cloned() .unwrap_or_else(|| root_sym.registers_r().to_vec()); - if let Some(result_color) = unsafe { &(*root_sym.jitcode()).payload } + let result_color = unsafe { &(*root_sym.jitcode()).payload } .result_color_trivia_for_jitcode_pc(root_pc) .map(|c| c as usize) - .filter(|&c| c != u16::MAX as usize) - { + .filter(|&c| c != u16::MAX as usize); + if let Some(result_color) = result_color { if result_color < regs_r.len() { regs_r[result_color] = trace_ctx.const_ref(pyre_object::PY_NULL as i64); } @@ -725,11 +782,40 @@ pub(crate) fn compute_bridge_root_parent_frame( None, &[], ); + // The concrete image this paused root resumes from when a descendant + // sub-walk aborts and the drain converts the chain instead of rewinding to + // the guard. The Ref bank skips the call-result color for the same reason + // it was NULLed above: the callee's blackhole writes it on return. + let blackhole = crate::state::try_frame_liveness_reg_indices_by_bank_at_with_jitcode_pc( + jitcode_index as i32, + root_liveness_word, + ) + .and_then(|live| { + let pairs = |colors: &[u32], regs: &[OpRef]| -> Vec<(usize, OpRef)> { + colors + .iter() + .map(|&color| color as usize) + .map(|color| (color, regs.get(color).copied().unwrap_or(OpRef::NONE))) + .collect() + }; + let ref_pairs: Vec<(usize, OpRef)> = pairs(&live.ref_, ®s_r) + .into_iter() + .filter(|&(color, _)| Some(color) != result_color) + .collect(); + capture_reconstructed_parent_blackhole( + trace_ctx, + root_pc, + &pairs(&live.int, root_sym.registers_i()), + &ref_pairs, + &pairs(&live.float, root_sym.registers_f()), + ) + }); + Some(InlineParentFrame { jitcode_index, call_jitcode_pc: None, call_stack_overrides: Vec::new(), - blackhole: None, + blackhole, resume_coord: ParentResumeCoord::Backxlat(root_pc), // Parent-frame words are never branch-tagged; negative tags belong to // a branch guard's own top-frame word. @@ -820,14 +906,19 @@ pub(crate) fn recipe_parent_frame_from_recipe( let maps = crate::state::bridge_semantic_maps_from_pc(recipe.jitcode_index, recipe.jitcode_pc); let null_ref = ctx.const_ref(pyre_object::PY_NULL as i64); let mut boxes = Vec::with_capacity(banks.total_len()); + // Per-color `(color, box)` pairs for the blackhole capture below, collected + // alongside the liveness-ordered box list the snapshot consumes. + let mut bh_int = Vec::with_capacity(banks.int.len()); + let mut bh_ref = Vec::with_capacity(banks.ref_.len()); + let mut bh_float = Vec::with_capacity(banks.float.len()); for &color in &banks.int { - boxes.push( - recipe - .registers_i - .get(color as usize) - .copied() - .unwrap_or(OpRef::NONE), - ); + let opref = recipe + .registers_i + .get(color as usize) + .copied() + .unwrap_or(OpRef::NONE); + boxes.push(opref); + bh_int.push((color as usize, opref)); } // Ref bank, in liveness-color order — mirror the retired MIFrame encoder // `get_list_of_active_boxes` (trace_opcode.rs) box-for-box: @@ -852,35 +943,49 @@ pub(crate) fn recipe_parent_frame_from_recipe( let is_portal_red_scratch = semantic_idx.is_none() && ((color == frame_reg && frame_reg != sentinel) || (color == ec_reg && ec_reg != sentinel)); - if is_portal_red_scratch { - boxes.push(if color == frame_reg { + let opref = if is_portal_red_scratch { + if color == frame_reg { frame_box } else { ec_box - }); - continue; - } - let slot = semantic_idx.or_else(|| (c < recipe.valuestackdepth).then_some(c))?; - boxes.push(recipe.registers_r.get(slot).copied().unwrap_or(OpRef::NONE)); + } + } else { + let slot = semantic_idx.or_else(|| (c < recipe.valuestackdepth).then_some(c))?; + recipe.registers_r.get(slot).copied().unwrap_or(OpRef::NONE) + }; + boxes.push(opref); + bh_ref.push((c, opref)); } for &color in &banks.float { - boxes.push( - recipe - .registers_f - .get(color as usize) - .copied() - .unwrap_or(OpRef::NONE), - ); + let opref = recipe + .registers_f + .get(color as usize) + .copied() + .unwrap_or(OpRef::NONE); + boxes.push(opref); + bh_float.push((color as usize, opref)); } if boxes.iter().any(|b| b.is_none()) { return None; } + // Same capture as the bridge root's, off the recipe's decoded boxes. The + // reconstructed `frame`/`ec` reds are freshly emitted here, so a portal-red + // scratch color has no concrete yet and declines — leaving the drain's + // pre-existing rollback rather than a half-filled blackhole frame. + let blackhole = capture_reconstructed_parent_blackhole( + ctx, + recipe.jitcode_pc as usize, + &bh_int, + &bh_ref, + &bh_float, + ); + Some(InlineParentFrame { jitcode_index: recipe.jitcode_index as u32, call_jitcode_pc: call_jit_pc, call_stack_overrides: Vec::new(), - blackhole: None, + blackhole, // The recipe's resolved word was `backxlat_py_pc(jitcode_index, // jitcode_pc)` by construction, exactly the bridge-root flavor. resume_coord: ParentResumeCoord::Backxlat(recipe.jitcode_pc as usize), @@ -1125,7 +1230,23 @@ pub(crate) fn drive_bridge_frame_subwalk( v, ); } - walk(callee_code, entry, &mut sub_wc) + let outcome = walk(callee_code, entry, &mut sub_wc); + // `pyjitpl.py:2914 handle_guard_failure` wraps `_handle_guard_failure` + // in `except SwitchToBlackhole as stb: + // self.run_blackhole_interp_to_cancel_tracing(stb)` (:2930-2931), which + // converts the frames `interpret()` reached and runs them forward + // (`blackhole.py:1799`); `_handle_guard_failure` itself ends + // `assert False, "should always raise"` (:2956). An aborted bridge is + // never rewound to its guard upstream. This sub-walk has already + // concrete-executed the reconstructed callee's residual calls (the + // `is_authoritative_executor` contract above), so the caller's rollback + // would replay them. Capture the frames while this `WalkContext` still + // owns their banks — `drive_bridge_carrier_walk`'s abort tail adopts + // the image, and a decline there leaves the pre-existing rollback. + if let Err(ref error) = outcome { + let _ = latch_abort_blackhole(&sub_wc, error.stop_pc()); + } + outcome }; drop(parent_guards); Some(outcome) diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs index fad1e814d05..a597a6533ac 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs @@ -1598,6 +1598,23 @@ pub enum DispatchError { len: usize, bank: &'static str, }, + /// A register operand byte indexed a slot the walk never wrote. + /// `MIFrame.registers_*` start as `[None] * num_regs` + /// (`pyjitpl.py:190-197`) and a jitcode only names a register the + /// codewriter's liveness proves is assigned on every path reaching the + /// read, so upstream never observes the hole. Pyre's codewriter can + /// still emit one — an exception edge whose merge block reads a + /// value-stack slot none of its predecessors renames into — and the + /// `OpRef::NONE` that read yields is not a box: recording it produces an + /// op argument no backend can bind (`regalloc.py:611-622` `env[box]` + /// KeyError; dynasm `RegisterManager.loc`, cranelift `resolve_opref`). + /// Decline the walk at the read instead of carrying the hole into the + /// trace. + RegisterReadUnbound { + pc: usize, + reg: usize, + bank: &'static str, + }, /// A `d`-coded descr index resolved past the descr pool. Surfaces /// either an assembler-pass bug (descr index out of range) or a /// caller mismatch between the codewriter's descr table size and @@ -2031,6 +2048,7 @@ impl DispatchError { Self::UndecodableOpcode { .. } => "UndecodableOpcode", Self::UnsupportedOpname { .. } => "UnsupportedOpname", Self::RegisterOutOfRange { .. } => "RegisterOutOfRange", + Self::RegisterReadUnbound { .. } => "RegisterReadUnbound", Self::DescrIndexOutOfRange { .. } => "DescrIndexOutOfRange", Self::ExpectedJitCodeDescr { .. } => "ExpectedJitCodeDescr", Self::SubJitCodeNotFound { .. } => "SubJitCodeNotFound", @@ -2093,6 +2111,67 @@ impl DispatchError { } } + /// The jitcode coordinate the walk stopped at. Every variant records the + /// `pc` of the instruction that could not be walked, and none of them ran + /// that instruction's arm, so this doubles as the resume coordinate a + /// blackhole conversion has to `setposition` to (`blackhole.py:1804` + /// `copy_data_from_miframe` reads each level's `frame.pc` the same way). + /// One arm per variant so a new variant fails to compile until it says + /// where it stopped. + pub(crate) fn stop_pc(&self) -> usize { + match self { + Self::UndecodableOpcode { pc, .. } + | Self::UnsupportedOpname { pc, .. } + | Self::RegisterOutOfRange { pc, .. } + | Self::RegisterReadUnbound { pc, .. } + | Self::DescrIndexOutOfRange { pc, .. } + | Self::ExpectedJitCodeDescr { pc, .. } + | Self::SubJitCodeNotFound { pc, .. } + | Self::InlineCallArityMismatch { pc, .. } + | Self::InlineCallIntArityMismatch { pc, .. } + | Self::InlineCallFloatArityMismatch { pc, .. } + | Self::UnexpectedVoidSubReturn { pc, .. } + | Self::UnexpectedNonVoidSubReturn { pc, .. } + | Self::ReraiseWithoutLastExcValue { pc, .. } + | Self::LastExcValueWithoutActiveException { pc, .. } + | Self::CatchExceptionWithActiveException { pc, .. } + | Self::ResidualCallDescrNotCallDescr { pc, .. } + | Self::ResidualCallArgUnbound { pc, .. } + | Self::ExpectedSwitchDescr { pc, .. } + | Self::SwitchValueNotConcrete { pc, .. } + | Self::GotoIfNotValueNotConcrete { pc, .. } + | Self::IntOvfOperandNotConcrete { pc, .. } + | Self::NotInTraceRequiresConcreteExecution { pc, .. } + | Self::JitForceVirtualRequiresConcreteResolver { pc, .. } + | Self::VableBoxNotSeeded { pc, .. } + | Self::VableArrayDescrMalformed { pc, .. } + | Self::VableArrayMissingVirtualizableInfo { pc, .. } + | Self::VableArrayIndexOutOfRange { pc, .. } + | Self::VableArrayIndexNotConcrete { pc, .. } + | Self::AbortMarkerReached { pc, .. } + | Self::ConcreteShadowAllocationFailed { pc, .. } + | Self::AbortPermanentMarkerReached { pc, .. } + | Self::MayForceNullRefArgUnsupported { pc, .. } + | Self::VableEscapedDuringResidualCall { pc, .. } + | Self::GuardSnapshotVableUntyped { pc, .. } + | Self::GuardResumeCoordinateUnavailable { pc, .. } + | Self::LastExceptionWithoutActiveException { pc, .. } + | Self::JitMergePointGreenKeyUnresolved { pc, .. } + | Self::LoopHeaderJdIndexUnresolved { pc, .. } + | Self::SubWalkClosedLoop { pc, .. } + | Self::BranchGuardKeptStackUnsupported { pc, .. } + | Self::NonStandardVableFinishPortalUnsupported { pc, .. } + | Self::LoopBearingCalleeInlineUnsupported { pc, .. } + | Self::FieldDescrMissingParentDescr { pc, .. } + | Self::OrthodoxSubWalkTraceUnsupported { pc, .. } + | Self::UnfoldableListAppendResidualUnsupported { pc, .. } + | Self::BranchGuardUnrestorableKeptStackPermanent { pc, .. } + | Self::InplaceContainerMutationUnsupported { pc, .. } + | Self::ExcEdgeNoInFrameCatch { pc, .. } + | Self::TraceTooLong { pc, .. } => *pc, + } + } + /// Construct the callee-inline decline. The /// `LoopBearingCalleeInlineUnsupported` variant is emitted from ~20 sites /// (multi-frame seed preconditions, snapshot capture, hazard scan), so @@ -2309,7 +2388,7 @@ pub fn walk( // // `blackhole_if_trace_too_long` raises AFTER `run_one_step`, so the // forward image must carry `pc`, the already-advanced `next_pc`, rather - // than `opcode_position`. `latch_trace_too_long_blackhole` copies the + // than `opcode_position`. `latch_abort_blackhole` copies the // live MIFrame registers while this WalkContext still owns them; the // run-per-fn epilogue drives that image forward exactly like RPython's // `run_blackhole_interp_to_cancel_tracing`. @@ -2331,7 +2410,7 @@ pub fn walk( // replay would resume the caller without delivering the return or // raise that this step produced. let snapshot_safe = trace_too_long_blackhole_snapshot_safe(&outcome); - let blackhole_latched = snapshot_safe && latch_trace_too_long_blackhole(ctx, pc); + let blackhole_latched = snapshot_safe && latch_abort_blackhole(ctx, pc); if trace_too_long_abort_safe(&outcome, blackhole_latched, fbw_executed_effect_count()) { let ops = ctx.trace_ctx.num_recorded_ops(); crate::state::note_root_trace_too_long( @@ -2521,6 +2600,32 @@ fn read_ref_reg( op: &DecodedOp, operand_offset: usize, ctx: &WalkContext<'_, '_, Sym>, +) -> Result { + let reg = code[op.pc + 1 + operand_offset] as usize; + let value = read_ref_reg_raw(code, op, operand_offset, ctx)?; + // `[None] * num_regs` initial state (`pyjitpl.py:190-197`): a slot the + // walk never wrote holds no box, and `OpRef::NONE` is not one. See + // [`DispatchError::RegisterReadUnbound`]. + if value.is_none() { + return Err(DispatchError::RegisterReadUnbound { + pc: op.pc, + reg, + bank: "r", + }); + } + Ok(value) +} + +/// [`read_ref_reg`] without the unbound-slot check, for the handful of arms +/// that give an unwritten slot their own, more specific decline — the +/// `*_vable_*` family reads its object operand this way so an unseeded +/// virtualizable register still surfaces [`DispatchError::VableBoxNotSeeded`] +/// instead of the generic [`DispatchError::RegisterReadUnbound`]. +fn read_ref_reg_raw( + code: &[u8], + op: &DecodedOp, + operand_offset: usize, + ctx: &WalkContext<'_, '_, Sym>, ) -> Result { let byte_pc = op.pc + 1 + operand_offset; let reg = code[byte_pc] as usize; @@ -10288,66 +10393,77 @@ fn handle( .bridge_info() .map(|b| (b.trace_id, b.fail_index)); let has_targets = driver.meta_interp().has_compiled_targets(key); + // A close that did not compile is not retried on a later + // crossing of the same header: the attempt runs the optimizer + // over the whole trace-so-far, and the decline is deterministic, + // so an inner loop crossed N times would pay N optimizer passes + // over a growing trace (see + // `TraceCtx::declined_cross_loop_closes`). + let already_declined = ctx.trace_ctx.cross_loop_close_declined(key); if !has_partial && has_targets { - let outcome = match bridge_origin { - // Guard-origin: existing bridge path. - Some(_) => { - driver - .meta_interp_mut() - .compile_trace(key, &live_args, bridge_origin) - } - // pyjitpl.py interp-origin: a - // function-entry trace (ResumeFromInterpDescr) - // closes as an entry bridge jumping into the - // already-compiled hot loop (compile.py); - // a trace rooted at a *loop header* falls back to - // the plain bridge shape. - None => match driver.compile_trace_entry_data() { - Some((original_green_key, mut entry_meta)) => { - // `compile_trace_entry_data` clones the active - // trace metadata, whose `namespace_dependent` is - // only finalized by `finish_trace_namespace_dependency` - // after the walk returns. An entry bridge is - // compiled mid-walk, before that finalize, so a - // trace that has already read a module global - // would otherwise install the bridge with a stale - // `namespace_dependent = false` and let it be - // re-entered after later namespace growth. Fold in - // the live per-trace flag so the bridge keeps the - // conservative namespace gate. - entry_meta.namespace_dependent |= ctx.trace_ctx.reads_module_global; - driver.meta_interp_mut().compile_trace_from_interp( - key, - &live_args, - original_green_key, - entry_meta, - ) - } - None => driver - .meta_interp_mut() - .compile_trace(key, &live_args, None), - }, - }; - if matches!(outcome, majit_metainterp::CompileOutcome::Compiled { .. }) { - if majit_metainterp::majit_log_enabled() { - eprintln!( - "[jit][walker-reached-loop-header] compile_trace success: \ + if !already_declined { + let outcome = match bridge_origin { + // Guard-origin: existing bridge path. + Some(_) => driver.meta_interp_mut().compile_trace( + key, + &live_args, + bridge_origin, + ), + // pyjitpl.py interp-origin: a + // function-entry trace (ResumeFromInterpDescr) + // closes as an entry bridge jumping into the + // already-compiled hot loop (compile.py); + // a trace rooted at a *loop header* falls back to + // the plain bridge shape. + None => match driver.compile_trace_entry_data() { + Some((original_green_key, mut entry_meta)) => { + // `compile_trace_entry_data` clones the active + // trace metadata, whose `namespace_dependent` is + // only finalized by `finish_trace_namespace_dependency` + // after the walk returns. An entry bridge is + // compiled mid-walk, before that finalize, so a + // trace that has already read a module global + // would otherwise install the bridge with a stale + // `namespace_dependent = false` and let it be + // re-entered after later namespace growth. Fold in + // the live per-trace flag so the bridge keeps the + // conservative namespace gate. + entry_meta.namespace_dependent |= + ctx.trace_ctx.reads_module_global; + driver.meta_interp_mut().compile_trace_from_interp( + key, + &live_args, + original_green_key, + entry_meta, + ) + } + None => driver + .meta_interp_mut() + .compile_trace(key, &live_args, None), + }, + }; + if matches!(outcome, majit_metainterp::CompileOutcome::Compiled { .. }) { + if majit_metainterp::majit_log_enabled() { + eprintln!( + "[jit][walker-reached-loop-header] compile_trace success: \ key={} pc={} bridge={:?}", - key, next_instr, bridge_origin - ); + key, next_instr, bridge_origin + ); + } + // pyjitpl.py raise_if_successful() — the + // successful compile_trace ends tracing; surface + // the dedicated outcome so the driver maps it to + // `TraceAction::CompileTrace` (no further compile + // or abort on this session). + driver.note_compile_trace_success(); + return Ok(( + DispatchOutcome::CompileTracePending { + loop_header_pc: next_instr, + }, + op.next_pc, + )); } - // pyjitpl.py raise_if_successful() — the - // successful compile_trace ends tracing; surface - // the dedicated outcome so the driver maps it to - // `TraceAction::CompileTrace` (no further compile - // or abort on this session). - driver.note_compile_trace_success(); - return Ok(( - DispatchOutcome::CompileTracePending { - loop_header_pc: next_instr, - }, - op.next_pc, - )); + ctx.trace_ctx.note_cross_loop_close_declined(key); } // The jump did not take (`compile.compile_trace` returns // None when none of the existing loop tokens match). Fall diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs index 048a0323895..ae767337ded 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs @@ -42,10 +42,16 @@ thread_local! { /// Pre-flush frame state captured by [`flush_active_frame_escape`] so a /// post-call commit withdrawal can put the live frame back. The legacy /// replay's correctness contract is "the live frame still holds pre-walk - /// state"; a committed force-flush breaks it, so every path that does NOT - /// adopt the committed resume pc must restore this first. + /// state"; a committed force-flush breaks it, so the replay leg restores + /// this before re-entering. Which leg runs is only known at walk end, so + /// the withdrawal arms `ESCAPE_FLUSH_UNDO_PENDING` and the epilogue + /// decides. static ESCAPE_FLUSH_UNDO: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + /// Set when the force arm withdrew its commit: the pre-flush frame has to + /// come back, but only on the legacy-replay leg (see + /// `mark_escape_flush_undo_pending`). + static ESCAPE_FLUSH_UNDO_PENDING: std::cell::Cell = const { std::cell::Cell::new(false) }; /// Opcode-scoped purity window: `(py_pc, every_prior_residual_reentrant)` /// for the Python opcode currently being walked. Re-executing a committed /// escape re-runs the WHOLE opcode, so the latch gate must know whether any @@ -144,26 +150,53 @@ pub(crate) fn reset_single_frame_blackhole() { }); } -/// Snapshot the live meta-interpreter framestack for -/// `SwitchToBlackhole(ABORT_TOO_LONG)`. +/// Name each decline under `PYRE_FBW_DEBUG_ABORT`, the way `build_multi_frame_ +/// miframe`'s `s2dbg!` and `try_adopt_multi_frame_blackhole`'s `mfdbg!` name +/// theirs: an unlatched abort and a latch that was never reached both end in the +/// same replay, and the two want different fixes. +macro_rules! latchdbg { + ($($a:tt)*) => { + if fbw_debug_abort_enabled() { + eprintln!("[latch-decline] {}", format!($($a)*)); + } + }; +} + +/// Snapshot the live meta-interpreter framestack for a `SwitchToBlackhole` +/// that stops the walk at an arbitrary coordinate. /// -/// RPython calls `blackhole_if_trace_too_long()` immediately after -/// `MIFrame.run_one_step()` and `convert_and_run_from_pyjitpl` copies every -/// live MIFrame at its already-advanced `pc` (`pyjitpl.py:2863-2866`, -/// `blackhole.py:1799-1821`). The full-body walker has the same state here: -/// `resume_pc` is `walk()`'s post-step `next_pc`, and the current -/// [`WalkContext`] plus [`WalkSession::framestack`] own the concrete banks for -/// the live frame and all paused callers. +/// Two triggers reach it, and both need the same image: +/// +/// - `ABORT_TOO_LONG`. RPython calls `blackhole_if_trace_too_long()` +/// immediately after `MIFrame.run_one_step()` and +/// `convert_and_run_from_pyjitpl` copies every live MIFrame at its +/// already-advanced `pc` (`pyjitpl.py:2863-2866`, `blackhole.py:1799-1821`). +/// `resume_pc` is `walk()`'s post-step `next_pc`. +/// - A bridge carrier sub-walk that stopped on a walker capability gap. That +/// sub-walk IS the reconstructed callee's one real execution (see +/// `drive_bridge_frame_subwalk`'s `is_authoritative_executor` contract), so +/// the drain may not discard it and let the guard resume from `rd_numb` — +/// that re-runs every residual it already ran. `resume_pc` is the +/// unexecuted instruction the walk stopped at +/// ([`DispatchError::stop_pc`]), which is the same "arbitrary coordinate" +/// shape. Upstream never rewinds an aborted bridge either: +/// `_handle_guard_failure` ends `assert False, "should always raise"` +/// (`pyjitpl.py:2956`) and the conversion continues from the frames +/// `interpret()` reached. +/// +/// Either way the current [`WalkContext`] plus [`WalkSession::framestack`] own +/// the concrete banks for the live frame and all paused callers. /// /// Return `false` without publishing a partial image when any live value is /// unresolved. A zero-effect walk may then use the legacy entry replay; /// an effectful walk must keep recording until a complete image can be built, /// because replaying it would apply the effect twice. -pub(crate) fn latch_trace_too_long_blackhole( +pub(crate) fn latch_abort_blackhole( ctx: &WalkContext<'_, '_, Sym>, resume_pc: usize, ) -> bool { if !ctx.is_authoritative_executor { + latchdbg!("not-authoritative"); return false; } let last_exc_value = match ctx.last_exc_value_concrete { @@ -191,10 +224,12 @@ pub(crate) fn latch_trace_too_long_blackhole( }) } }) else { + latchdbg!("no-snapshot-sym-jitcode"); return false; }; let Some(miframe) = build_trace_too_long_single_frame_miframe(ctx, jitcode, resume_pc) else { + latchdbg!("sf-build-miframe"); return false; }; // `walk()` has already executed this step, so returning TraceTooLong @@ -203,11 +238,13 @@ pub(crate) fn latch_trace_too_long_blackhole( // to entry replay. Keep the same boundary: incomplete images merely // keep recording until a later step supplies a complete handoff. let Some(jitcode_index) = i32::try_from(miframe.jitcode.index()).ok() else { + latchdbg!("sf-jitcode-index"); return false; }; if ctx.trace_ctx.virtualizable_info().is_none() || crate::state::concrete_nlocals(cf_addr).is_none() { + latchdbg!("sf-no-vinfo-or-nlocals"); return false; } let root_addr = if live_root_addr != 0 { @@ -228,6 +265,7 @@ pub(crate) fn latch_trace_too_long_blackhole( || !crate::state::can_write_back_outer_locals(ctx.trace_ctx, vable_frame) || !crate::state::can_publish_frame_stack(cf_addr, vable_frame) { + latchdbg!("sf-vable-frame-mismatch"); return false; } // Keep the per-frame red identity seeded by `frame_box`. The @@ -248,9 +286,11 @@ pub(crate) fn latch_trace_too_long_blackhole( let Some(framestack) = build_multi_frame_miframe(ctx, resume_pc, InnermostMiframeBuild::TraceTooLong) else { + latchdbg!("mf-build-miframe"); return false; }; if !multi_frame_blackhole_preflight(ctx, &framestack) { + latchdbg!("mf-preflight"); return false; } FBW_MULTI_FRAME_BLACKHOLE.with(|slot| { @@ -263,6 +303,11 @@ pub(crate) fn latch_trace_too_long_blackhole( }); true } else { + latchdbg!( + "no-arm framestack_empty={} inline_subwalk={}", + ctx.session.borrow().framestack.is_empty(), + ctx.fbw_mode.inline_subwalk + ); false } } @@ -278,6 +323,7 @@ fn multi_frame_blackhole_preflight( framestack: &majit_metainterp::MIFrameStack, ) -> bool { if ctx.trace_ctx.virtualizable_info().is_none() || ctx.fbw_mode.snapshot_sym.is_null() { + latchdbg!("pf-no-vinfo-or-sym"); return false; } let sym = unsafe { &*ctx.fbw_mode.snapshot_sym }; @@ -287,6 +333,13 @@ fn multi_frame_blackhole_preflight( _ => sym.live_vable_frame_addr(), }; let root = if live_root != 0 { live_root } else { snapshot }; + latchdbg!( + "pf-root-caps snapshot={snapshot:#x} root={root:#x} nlocals={} locals={} writeback={} publish={}", + crate::state::concrete_nlocals(snapshot).is_some(), + crate::state::capture_frame_locals(root).is_some(), + crate::state::can_write_back_outer_locals(ctx.trace_ctx, root), + crate::state::can_publish_frame_stack(snapshot, root) + ); if crate::state::concrete_nlocals(snapshot).is_none() || crate::state::capture_frame_locals(root).is_none() || !crate::state::can_write_back_outer_locals(ctx.trace_ctx, root) @@ -298,23 +351,33 @@ fn multi_frame_blackhole_preflight( let mut seen = Vec::with_capacity(framestack.frames.len()); for (index, frame) in framestack.frames.iter().enumerate() { let Ok(jitcode_index) = i32::try_from(frame.jitcode.index()) else { + latchdbg!("pf-jitcode-index"); return false; }; let frame_reg = crate::state::portal_red_regs_at(jitcode_index).0; if frame_reg == u16::MAX { + latchdbg!("pf-frame-reg-none"); return false; } let Some(frame_ptr) = frame.ref_values.get(frame_reg as usize).copied().flatten() else { + latchdbg!( + "pf-frame-ptr-unset index={index}/{} jitcode={} frame_reg={frame_reg}", + framestack.frames.len(), + frame.jitcode.name() + ); return false; }; let frame_ptr = frame_ptr as usize; let Some(stack_base) = crate::state::concrete_nlocals(frame_ptr) else { + latchdbg!("pf-nlocals"); return false; }; let Some(stack_depth) = crate::state::concrete_stack_depth(frame_ptr) else { + latchdbg!("pf-stack-depth"); return false; }; let Some(array_len) = crate::state::concrete_frame_array_len(frame_ptr) else { + latchdbg!("pf-array-len"); return false; }; if stack_depth < stack_base @@ -323,6 +386,7 @@ fn multi_frame_blackhole_preflight( || (index > 0 && frame_ptr == root) || seen.contains(&frame_ptr) { + latchdbg!("pf-shape"); return false; } seen.push(frame_ptr); @@ -468,6 +532,20 @@ fn build_single_frame_miframe( *slot = Some(value as i64); } } + // `num_regs_f()` is the third bank `_copy_data_from_miframe` walks, so the + // rationale above covers floats too. There is no float concrete shadow — + // resolve the recorded box, and leave the color unset when it has none. + for color in 0..miframe.float_values.len() { + if miframe.float_values[color].is_some() { + continue; + } + let Some(&opref) = ctx.registers_f.get(color) else { + continue; + }; + if let Some(majit_ir::Value::Float(value)) = ctx.trace_ctx.concrete_of_opref(opref) { + miframe.float_values[color] = Some(value.to_bits() as i64); + } + } Some(miframe) } @@ -485,7 +563,8 @@ pub(super) fn build_trace_too_long_single_frame_miframe( resume_pc: usize, ) -> Option { let mut miframe = majit_metainterp::MIFrame::new(jitcode, resume_pc); - fill_trace_too_long_register_banks(ctx, &mut miframe).then_some(miframe) + fill_trace_too_long_register_banks(ctx, &mut miframe); + Some(miframe) } /// Fill every currently-known register color, matching @@ -498,10 +577,29 @@ pub(super) fn build_trace_too_long_single_frame_miframe( /// colors outside that marker-local live set may be read later. RPython copies /// all three complete MIFrame banks; do the same for this abort instead of /// relying on the narrower resume-liveness cache. +/// +/// A color the walk cannot supply a concrete for is **skipped**, not a reason +/// to refuse the frame. `blackhole.py:1713-1730 _copy_data_from_miframe` +/// guards every bank entry with `if box is not None` and calls `setarg_*` only +/// for the ones that have a value; it has no failing path, and neither does +/// its caller `convert_and_run_from_pyjitpl` (`blackhole.py:1799-1821`). An +/// unfilled color is one the interpreter does not read at this pc — that is +/// what makes it unfilled — so refusing the whole frame over it only cost the +/// abort its handoff. fn fill_trace_too_long_register_banks( ctx: &WalkContext<'_, '_, Sym>, miframe: &mut majit_metainterp::MIFrame, -) -> bool { +) { + // Name the skipped bank and color under `PYRE_FBW_DEBUG_ABORT`, the way + // `build_multi_frame_miframe` names its own: "innermost declined" alone + // does not say which register had no concrete. + macro_rules! s2dbg { + ($($a:tt)*) => { + if fbw_debug_abort_enabled() { + eprintln!("[s2-fill-skip] {}", format!($($a)*)); + } + }; + } for color in 0..miframe.int_values.len() { if let Some(value) = ctx .concrete_registers_i @@ -524,7 +622,8 @@ fn fill_trace_too_long_register_banks( continue; } let Some(majit_ir::Value::Int(value)) = ctx.trace_ctx.concrete_of_opref(opref) else { - return false; + s2dbg!("int color={color} opref={opref:?} has no concrete"); + continue; }; miframe.int_values[color] = Some(value); } @@ -555,7 +654,7 @@ fn fill_trace_too_long_register_banks( if let Some(value) = forwarded.or(from_shadow) { miframe.ref_values[color] = Some(value); } else if opref.is_some_and(|value| value != OpRef::NONE) { - return false; + s2dbg!("ref color={color} opref={opref:?} has no concrete"); } } @@ -570,11 +669,11 @@ fn fill_trace_too_long_register_banks( continue; } let Some(majit_ir::Value::Float(value)) = ctx.trace_ctx.concrete_of_opref(opref) else { - return false; + s2dbg!("float color={color} opref={opref:?} has no concrete"); + continue; }; miframe.float_values[color] = Some(value.to_bits() as i64); } - true } #[derive(Clone, Copy)] @@ -971,10 +1070,15 @@ pub fn flush_active_frame_escape(ctx: &TraceCtx, frame: *mut pyre_interpreter::P EscapeResumeKind::RerunsOpcode }; COMMITTED_FRAME_ESCAPE_PC.with(|committed| committed.set(Some((py_pc, kind)))); - } else { + } else if !crate::state::flush_locals_region_to_frame(ctx, expected) { // All-or-nothing decline: nothing was written, nothing to undo. discard_escape_flush_undo(); } + // A declined full flush still escaped the virtualizable, so the + // locals region is written anyway (`virtualizable.py:101-138 + // write_boxes` has no decline) — otherwise the callee reads an + // array of nulls. That write claims no resume pc, and the undo + // stays armed so the legacy replay re-enters the pre-flush frame. // A directly matched frame escaped whether or not the flush // committed, so the two signals stay decoupled. A redirected one // is reported only once the resume pc is committed: forcing @@ -1021,10 +1125,11 @@ fn capture_escape_flush_undo(frame: usize) { }); } -/// Put the pre-flush frame state back. Called on every path that does not -/// adopt the committed escape pc (commit withdrawal, an unforced or -/// rootless continuation) so the -/// legacy replay re-enters a pristine frame. +/// Put the pre-flush frame state back so the legacy replay re-enters a +/// pristine frame. Called from the unforced / rootless continuation (where +/// the walk goes on and must not see the moved frame) and, for a withdrawn +/// commit, from the walk-end epilogue once no resume-PAST continuation has +/// claimed the flushed frame. pub(crate) fn restore_escape_flush_undo() { ESCAPE_FLUSH_UNDO.with(|slot| { let Some(undo) = slot.borrow_mut().take() else { @@ -1047,6 +1152,22 @@ pub(crate) fn discard_escape_flush_undo() { ESCAPE_FLUSH_UNDO.with(|slot| { *slot.borrow_mut() = None; }); + ESCAPE_FLUSH_UNDO_PENDING.with(|slot| slot.set(false)); +} + +/// Note that the forced residual's commit was withdrawn, so the pre-flush +/// frame has to come back IF the walk ends up on the legacy replay. The +/// restore itself waits for [`take_escape_flush_undo_pending`] at walk end: +/// the resume-PAST continuation keeps the flushed frame (upstream's +/// `virtualizable.py:101-138 write_boxes` has no undo once the vable is +/// forced), and only the replay-from-entry leg needs the pre-walk state back. +fn mark_escape_flush_undo_pending() { + ESCAPE_FLUSH_UNDO_PENDING.with(|slot| slot.set(true)); +} + +/// Consume the deferred-restore request armed by the force arm. +pub(crate) fn take_escape_flush_undo_pending() -> bool { + ESCAPE_FLUSH_UNDO_PENDING.with(|slot| slot.replace(false)) } /// Opcode-scoped effect window check (see [`ESCAPE_OPCODE_WINDOW`]): true iff @@ -2358,19 +2479,28 @@ pub(crate) fn try_execute_residual_call_via_executor( // Latch the operand-stack mirror for the escape flush: at force time // the walk-end flush needs the caller's mid-expression stack, which // the vable shadow cannot provide (`reconstructed_all_ref_call_stack` - // resolves the same mirror for the inline-abort Entry carrier). Only - // a non-writing residual is latched — a committed escape resumes AT - // this opcode and re-executes it in the interpreter, which must not - // re-apply a heap write. The write gate is load-bearing beyond the - // obvious setters: forcing is NOT limited to frame-introspection - // reads (`hook_access_field`, rvirtualizable.py:49-53, forces on - // every redirected-field access, reads AND writes), and the mutating - // forcers — the `f_lineno`/`f_trace` setters, `sys.settrace`, - // `_warnings.warn` (forces the caller frame for `__name__`, then - // mutates `__warningregistry__` with no user frame entered) — are - // excluded here only because every Python-visible frame MUTATOR is a - // Void-returning store or a CALL-shaped helper. What survives the - // gates is attribute/item READS of frame-family objects, which are + // resolves the same mirror for the inline-abort Entry carrier). + // + // A WRITING residual is latched too, but never to resume AT this + // opcode: the withdrawal below cancels its commit and restores the + // pre-flush frame unconditionally, so the interpreter never + // re-executes it and never re-applies the write. What the commit + // leaves behind for it is a WITNESS — it proves every mirror slot + // resolved to a concrete non-null Ref, i.e. the walk holds a complete + // mid-expression stack image, which is exactly the precondition for + // building the blackhole resume PAST the residual. Forcing is NOT + // limited to frame-introspection reads (`hook_access_field`, + // rvirtualizable.py:49-53, forces on every redirected-field access, + // reads AND writes), and every Python-visible frame MUTATOR (the + // `f_lineno`/`f_trace` setters, `sys.settrace`, `_warnings.warn`, + // which forces the caller frame for `__name__` and then mutates + // `__warningregistry__` with no user frame entered) is a + // Void-returning store or a CALL-shaped helper, so all of them land + // on the writing side and take that withdrawal. + // + // A non-writing residual that entered no user frame keeps its commit + // and the resume-AT-opcode semantics: what survives the gates there + // is attribute/item READS of frame-family objects, which are // idempotently re-executable (re-execution reads the same flushed // values the first execution saw; a token re-force is a no-op). // @@ -2401,7 +2531,6 @@ pub(crate) fn try_execute_residual_call_via_executor( // run_perfn_walk epilogue that adopts (or restores) a committed // escape flush, so a commit there would strand a moved frame. let escape_stack = (escape_frame != 0 - && !writes_live_heap && !ctx.fbw_mode.inline_subwalk && !ctx.trace_ctx.is_bridge_trace && ctx.vstack_valid @@ -2487,18 +2616,40 @@ pub(crate) fn try_execute_residual_call_via_executor( func_ptr as usize, ); } - // The escaping residual also entered a user Python frame whose - // body may have committed irreversible effects; a committed - // escape resume would re-execute this opcode and re-run that - // body. Withdraw the commit AND restore the pre-flush frame so - // the legacy replay re-enters pristine pre-walk state (the flush - // moved the live frame mid-iteration; replaying on top of it - // loses journal-rolled-back effects and re-runs partial state). - if heap_write_odometer_before - .is_some_and(|before| pyre_interpreter::call::frame_entry_count() != before) + // Sampled BEFORE the withdrawal below: a commit means the escape + // flush made the LIVE frame authoritative at this opcode — the + // latched operand-stack mirror resolved and + // `flush_walk_end_state_to_frame_with_full_stack` wrote every + // local and every mid-expression stack slot. Without it the frame + // still holds trace-entry values, which a resume PAST the residual + // would read back through the virtualizable and get a stale local. + let escape_flush_committed = committed_frame_escape_pc().is_some(); + // The escaping residual either wrote live heap outside the + // journals, or entered a user Python frame whose body may have + // committed irreversible effects. Either way a committed escape + // resume would re-execute this opcode and so re-apply that write + // / re-run that body. Withdraw the commit AND restore the + // pre-flush frame so the legacy replay re-enters pristine + // pre-walk state (the flush moved the live frame mid-iteration; + // replaying on top of it loses journal-rolled-back effects and + // re-runs partial state). The sample above already read the + // commit, so the mirror-resolved witness survives the withdrawal + // for the blackhole gate below. + if writes_live_heap + || heap_write_odometer_before + .is_some_and(|before| pyre_interpreter::call::frame_entry_count() != before) { cancel_committed_frame_escape_pc(); - restore_escape_flush_undo(); + // The restore is DEFERRED to the walk-end epilogue, which is + // where the legacy replay is actually chosen. Undoing it here + // also undid `write_boxes`' locals materialization on the + // resume-PAST path below — the blackhole then continued into a + // frame whose fastlocals were back to their unwritten nulls + // (`x = 1; a = sys._getframe(0); return x` returned NULL). + // The capture stays armed; `run_perfn_walk` restores it only + // when neither the force blackhole nor a committed escape pc + // takes over the continuation. + mark_escape_flush_undo_pending(); } // C3 S1: the writes-live-heap force shape cannot safely resume AT // this opcode, but a top-level one-frame walk can resume PAST it diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs index 07a8e83826b..dc8c1107bf6 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs @@ -8832,6 +8832,16 @@ pub(crate) fn try_walker_load_global_cell_fold( return Ok(false); } let w_globals = ns_ptr as pyre_object::PyObjectRef; + // The namespace operand is the fold's authority: both legs end at + // `guard_current_frame_globals_identity`, which bakes it as the expected + // `ConstPtr` and declines outright on a null one. An inlined callee whose + // namespace register is unseeded presents it as a null `Ref`, so decline + // here instead of walking the builtins leg, which reads `__builtins__` + // straight out of it. The residual re-resolves the globals from the frame + // it runs on, so declining stays correct. + if w_globals.is_null() { + return Ok(false); + } // `namei` is the raw `LOAD_GLOBAL` oparg; bit 0 is the push-NULL flag, // so the `co_names` index is `namei >> 1` (mirror `bh_load_global_fn`). let name_idx = (namei as usize) >> 1; diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs index 3181dd226e8..32bcc1be29f 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs @@ -3108,6 +3108,66 @@ fn ref_return_with_out_of_range_register_surfaces_typed_error() { ); } +#[test] +fn raise_with_unwritten_register_surfaces_register_read_unbound() { + // A jitcode whose `raise/r` names a Ref register no op ever writes leaves + // that slot at its `[None] * num_regs` initial value. Carrying the + // `OpRef::NONE` into the trace produced `Finish(_)` against + // `ExitFrameWithExceptionDescrRef` (fail-arg type Ref), which no backend + // can bind — dynasm panicked in `RegisterManager.loc`, cranelift in + // `resolve_opref`. The read declines instead. + let raise_byte = *insns_opname_to_byte() + .get("raise/r") + .expect("`raise/r` must be in insns table"); + let code = [raise_byte, 0x01]; + let mut tc = fresh_trace_ctx(); + let session = std::cell::RefCell::new(WalkSession::default()); + let mut registers_r = [OpRef::NONE, OpRef::NONE]; + let mut concrete_registers_r = [ConcreteValue::Null, ConcreteValue::Null]; + let mut wc = WalkContext { + callee_shadow: None, + inline_callee_consts: None, + fbw_mode: test_fbw_mode(), + session: &session, + registers_r: &mut registers_r, + registers_i: &mut [], + registers_f: &mut [], + concrete_registers_r: &mut concrete_registers_r, + concrete_registers_i: &mut [], + descr_refs: &[], + raw_descrs: RawDescrPool::Global, + is_authoritative_executor: false, + trace_ctx: &mut tc, + is_top_level: true, + sub_jitcode_lookup: &no_sub_jitcodes, + last_exc_value: None, + last_exc_value_concrete: ConcreteValue::Null, + entry_py_pc: EntryPyPc::Py(0), + outer_resume_marker_jit_pc: None, + outer_jitcode_index: 0, + outer_active_boxes: Vec::new(), + store_subscr_fn_addr: None, + pending_guard_snapshot_error: None, + vstack_boxes: Vec::new(), + vstack_depth: 0, + vstack_cur_pypc: 0, + vstack_valid: false, + vstack_last_ref: OpRef::NONE, + vstack_reorder_ceiling: u32::MAX, + live_before_jit_pc: usize::MAX, + live_after_jit_pc: usize::MAX, + }; + let err = step(&code, 0, &mut wc).expect_err("must surface RegisterReadUnbound"); + assert_eq!( + err, + DispatchError::RegisterReadUnbound { + pc: 0, + reg: 1, + bank: "r" + }, + ); +} + #[test] fn step_through_int_return_records_finish_with_int_descr() { // `int_return/i` mirrors `ref_return/r` on the int bank. diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/vable_ops.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/vable_ops.rs index 9b77e4c126d..f70a689cecc 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/vable_ops.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/vable_ops.rs @@ -102,7 +102,7 @@ pub(crate) fn getfield_vable_via_metainterp( ctx: &mut WalkContext<'_, '_, Sym>, dst_bank: char, ) -> Result<(DispatchOutcome, usize), DispatchError> { - let obj = read_ref_reg(code, op, 0, ctx)?; + let obj = read_ref_reg_raw(code, op, 0, ctx)?; // Inside an inlined-callee sub-walk, a scalar getfield_vable_r // of the callee's namespace(idx5)/pycode(idx1) must resolve to the callee's // compile-time `InlineCalleeConsts` whether the callee frame is unseeded @@ -274,7 +274,7 @@ pub(crate) fn setfield_vable_via_metainterp( if fold_frame_reg != u16::MAX && code[op.pc + 1] as u16 == fold_frame_reg { return Ok((DispatchOutcome::Continue, op.next_pc)); } - let obj = read_ref_reg(code, op, 0, ctx)?; + let obj = read_ref_reg_raw(code, op, 0, ctx)?; // Same unseeded-register guard as `getfield_vable_via_metainterp`: // a `None` box would resize the heapcache flag vector to 16 GiB. if obj.is_none() { @@ -452,7 +452,7 @@ pub(crate) fn getarrayitem_vable_via_metainterp( } } } - let vable = read_ref_reg(code, op, 0, ctx)?; + let vable = read_ref_reg_raw(code, op, 0, ctx)?; // An unseeded walker Ref register holds `OpRef::None` (`raw() == // u32::MAX`); feeding it into the metainterp vable path would resize // the heapcache flag vector to 16 GiB. Bail to a trace abort, mirroring @@ -618,7 +618,7 @@ pub(crate) fn setarrayitem_vable_via_metainterp( return Ok((DispatchOutcome::Continue, op.next_pc)); } } - let vable = read_ref_reg(code, op, 0, ctx)?; + let vable = read_ref_reg_raw(code, op, 0, ctx)?; // See `getarrayitem_vable_via_metainterp`: an unseeded `OpRef::None` // vable would resize the heapcache flag vector to 16 GiB; bail instead. if vable.is_none() { @@ -767,7 +767,7 @@ pub(crate) fn arraylen_vable_via_metainterp( op: &DecodedOp, ctx: &mut WalkContext<'_, '_, Sym>, ) -> Result<(DispatchOutcome, usize), DispatchError> { - let vable = read_ref_reg(code, op, 0, ctx)?; + let vable = read_ref_reg_raw(code, op, 0, ctx)?; // See `getarrayitem_vable_via_metainterp`: an unseeded `OpRef::None` // vable would resize the heapcache flag vector to 16 GiB; bail instead. if vable.is_none() { diff --git a/pyre/pyre-jit-trace/src/state.rs b/pyre/pyre-jit-trace/src/state.rs index 4f9a7a7984a..df92be07c15 100644 --- a/pyre/pyre-jit-trace/src/state.rs +++ b/pyre/pyre-jit-trace/src/state.rs @@ -4749,6 +4749,71 @@ fn flush_walk_end_state_to_frame_inner( true } +/// Write back ONLY the locals/cells region (absolute slots `0..nlocals`) from +/// the virtualizable shadow, leaving the operand-stack region, +/// `valuestackdepth` and `last_instr` untouched. +/// +/// `virtualizable.py:101-138 write_boxes` writes the whole array on every +/// force, with no way to decline: a callee handed the frame reads its +/// fastlocals straight out of the array (`pyframe.py:548-552 fast2locals`), and +/// an unforced array of nulls renders as an EMPTY `f_locals` mapping — a wrong +/// answer, not a stale one. The merge-point flush cannot carry that on its +/// own because it is all-or-nothing across the operand stack too, and a +/// mid-expression stack slot reads NULL from the shadow, declining the whole +/// write. The locals region has no such hazard: a NULL local is a legitimate +/// unbound local. +/// +/// Resume-pc authority stays with the full flush — this writes state only, so +/// the caller keeps the escape-flush undo armed and a legacy replay from the +/// trace entry still re-enters the pre-flush frame. +pub(crate) fn flush_locals_region_to_frame(ctx: &TraceCtx, frame: usize) -> bool { + if frame == 0 { + return false; + } + let Some(nlocals) = concrete_nlocals(frame) else { + return false; + }; + let Some(info) = ctx.virtualizable_info() else { + return false; + }; + let base = info.num_static_extra_boxes; + // Validation pass first: it allocates nothing, so a missing entry leaves + // the frame untouched (same all-or-nothing discipline as the full flush). + // `Value::Void` is the shadow's "no concrete half" sentinel, not a NULL + // local: writing it back would box to `PY_NULL` and DESTROY the slot the + // walk is holding in a register. Decline instead. + for abs in 0..nlocals { + match ctx.virtualizable_entry_at(base + abs) { + Some((_, Value::Void)) | None => return false, + Some(_) => {} + } + } + let frame_ptr = frame as *const u8; + let arr_ptr = unsafe { + *(frame_ptr.add(PYFRAME_LOCALS_CELLS_STACK_OFFSET) + as *const *mut pyre_object::FixedObjectArray) + }; + if arr_ptr.is_null() || unsafe { &*arr_ptr }.as_slice().len() < nlocals { + return false; + } + for abs in 0..nlocals { + let Some((_opref, value)) = ctx.virtualizable_entry_at(base + abs) else { + return false; + }; + let boxed = boxed_slot_value_for_type(Type::Ref, &value); + unsafe { + (*arr_ptr).as_mut_slice()[abs] = boxed; + } + // Boxing an Int/Float slot allocates, and each minor collection + // consumes the array's remembered-set entry, so re-arm per store. + frame_array_write_barrier(frame as *mut u8, arr_ptr); + } + if crate::jitcode_dispatch::fbw_debug_abort_enabled() { + eprintln!("[fbw-flush] locals-region written: nlocals={nlocals}"); + } + true +} + /// gh#467 forward-flush AT an inlined-callee CALL boundary. When an /// supported abort fires inside an inline sub-walk whose callee executed no /// concrete effect, the outer frame is flushed as of the CALL that @@ -13339,6 +13404,61 @@ pub(crate) fn setup_reconstructed_callee_frame( w_globals_const, ec_const, ); + // `perform_call` gives every MIFrame a real recording-time frame object + // before `setup_call` installs its argument boxes (`pyjitpl.py:2445-2476`), + // and the forward-inline callee mirrors that with a GC-managed `FrameBox` + // whose pointer is stamped onto the emitted vable + // (`inline_call.rs:3551-3569`). The reconstructed carrier callee needs the + // same object: its residual calls run through + // `execute_inline_residual_call(frame, nargs)`, and the abort image names + // this framestack level by its frame pointer. A vable with no concrete + // makes both decline — the residual call on its frame argument, and the + // blackhole preflight on the unset frame pointer — so the drain aborts + // after its sub-walk already executed a side effect and the rollback to the + // guard then replays it. + if !w_code.is_null() { + // `FrameBox::new` allocates, so the slot values captured at guard + // failure must be forwarded through real shadow-stack slots first + // (`gctransform/framework.py` push_roots/pop_roots around a collection + // point). Mirror the vable image exactly: a slot with no box is the + // `NewArrayClear` zero-fill, i.e. an unbound local, and stays PY_NULL. + let arg_roots = pyre_object::gc_roots::push_roots(); + let arg_root_base = pyre_object::gc_roots::shadow_stack_len(); + for (k, &opref) in locals_boxes.iter().enumerate() { + let obj = match recipe.concrete_r.get(k) { + Some(&majit_ir::Value::Ref(majit_ir::GcRef(ptr))) + if ptr != 0 && !opref.is_none() => + { + ptr as pyre_object::PyObjectRef + } + _ => PY_NULL, + }; + pyre_object::gc_roots::pin_root(obj); + } + let concrete_locals: Vec = (0..locals_boxes.len()) + .map(|k| pyre_object::gc_roots::shadow_stack_get(arg_root_base + k)) + .collect(); + let mut frame = pyre_interpreter::pyframe::FrameBox::new( + pyre_interpreter::pyframe::PyFrame::new_for_call_with_closure_and_globals_obj( + w_code, + &concrete_locals, + w_globals, + execution_context, + PY_NULL, + pyre_interpreter::pyframe::FrameLocalsArrayAllocation::OldGenGc, + ), + ); + drop(arg_roots); + let concrete_frame_ptr = frame.as_mut_ptr(); + ctx.set_opref_concrete( + frame_vable, + majit_ir::Value::Ref(majit_ir::GcRef(concrete_frame_ptr as usize)), + ); + // GC-managed `FrameBox::drop` relinquishes only the host handle; the + // frontend op above keeps the frame reachable through + // `MetaInterp::walk_active_trace_refs`. + drop(frame); + } let mut pending = assemble_bridge_inline_pending(ctx, recipe, execution_context, parent_frames); pending.sym.frame = frame_vable; diff --git a/pyre/pyre-jit-trace/src/trace.rs b/pyre/pyre-jit-trace/src/trace.rs index 55f8bce20b5..d5d537866c3 100644 --- a/pyre/pyre-jit-trace/src/trace.rs +++ b/pyre/pyre-jit-trace/src/trace.rs @@ -146,6 +146,10 @@ pub(crate) enum WalkEndCommitLeg { /// converted to blackhole frames and run forward, never replayed from the /// trace entry. TraceTooLong = 9, + /// A bridge carrier sub-walk stopped on a walker capability gap after + /// concrete-executing the reconstructed callee: its frames were converted + /// and run forward instead of the drain rewinding to the guard. + CarrierAbort = 10, } /// Where a committing leg puts the interpreter, relative to the effects the @@ -1595,6 +1599,13 @@ fn drive_bridge_carrier_walk( crate::jitcode_dispatch::bool_box_truth_reset(); crate::jitcode_dispatch::fbw_finish_payload_reset(); crate::jitcode_dispatch::fbw_store_journal_reset(); + // A prior walk's blackhole image must not be adopted as this drain's + // continuation; the sub-walks below latch their own. + crate::jitcode_dispatch::reset_single_frame_blackhole(); + // Odometer baseline for the abort tail: the drain may only rewind to the + // guard while the reconstructed frames it drove executed nothing + // irreversible. + let effects_at_entry = crate::jitcode_dispatch::fbw_executed_effect_count(); let root_ec = sym.concrete_execution_context(); if std::env::var_os("PYRE_P2_DIAG").is_some() { @@ -1837,14 +1848,45 @@ fn drive_bridge_carrier_walk( } } + // `pyjitpl.py:2949 run_blackhole_interp_to_cancel_tracing` → + // `blackhole.py:1799 convert_and_run_from_pyjitpl`. The sub-walk above is + // the reconstructed callee's ONE real execution (`drive_bridge_frame_subwalk` + // is an authoritative executor), so once its odometer has moved the drain + // may not hand the guard back to a blackhole resume from `rd_numb`: the + // store journal unwinds the eager stores, but nothing unwinds a residual + // call that wrote the heap or entered a Python frame, and the guard resume + // re-runs the callee from the same coordinate. Upstream has no such + // rewind — `_handle_guard_failure` ends `assert False, "should always + // raise"` (`pyjitpl.py:2956`). Drive the frames the sub-walk reached + // instead; they were latched at its stop coordinate. + // + // Ordering: adopt BEFORE `discard_bridge_carrier_walk`, whose + // `carrier_ec_leave` closes the scopes the chain's frames still belong to. + // A declined adopt leaves everything to the rollback below, which is the + // pre-existing behaviour. + let live_root_addr = sym.live_vable_frame_addr(); + let adopted = crate::jitcode_dispatch::fbw_executed_effect_count() != effects_at_entry + && try_adopt_blackhole(ctx, cf_addr, live_root_addr, WalkEndCommitLeg::CarrierAbort); + if crate::jitcode_dispatch::fbw_debug_abort_enabled() { + eprintln!( + "[p2-drain-abort] effects={} adopted={adopted}", + crate::jitcode_dispatch::fbw_executed_effect_count() - effects_at_entry, + ); + } discard_bridge_carrier_walk(ctx, sym, entry_depth, pre_pos, &pre_virtualref_boxes); crate::jitcode_dispatch::bool_box_truth_reset(); crate::jitcode_dispatch::fbw_finish_payload_reset(); - // Non-commit epilogue: the sub-walk concrete-executed the reconstructed - // callee, and the blackhole replays it from the guard, so restore the - // pre-walk heap rather than dropping the journals (which would leave every - // eager store standing to be applied a second time). - crate::jitcode_dispatch::fbw_store_journal_rollback(); + if adopted { + // The chain ran the callee forward from where the sub-walk stopped, so + // the eager stores it journaled stand exactly once. + crate::jitcode_dispatch::fbw_store_journal_commit(); + } else { + // Non-commit epilogue: the sub-walk concrete-executed the reconstructed + // callee, and the blackhole replays it from the guard, so restore the + // pre-walk heap rather than dropping the journals (which would leave every + // eager store standing to be applied a second time). + crate::jitcode_dispatch::fbw_store_journal_rollback(); + } p2_drain_abort() } @@ -2410,6 +2452,7 @@ fn try_adopt_multi_frame_blackhole( }; } let Some(mut latched) = crate::jitcode_dispatch::take_multi_frame_blackhole() else { + mfdbg!("no latched multi-frame image"); return false; }; let depth = latched.framestack.len(); @@ -3605,22 +3648,22 @@ fn run_perfn_walk( &walk_result, Err(crate::jitcode_dispatch::DispatchError::TraceTooLong { .. }) ) && try_adopt_blackhole(ctx, cf_addr, live_root_addr, WalkEndCommitLeg::TraceTooLong); - let force_blackhole_adopted = - matches!( - &walk_result, - Err(crate::jitcode_dispatch::DispatchError::VableEscapedDuringResidualCall { .. }) - ) && try_adopt_blackhole(ctx, cf_addr, live_root_addr, WalkEndCommitLeg::VableEscape); + let vable_escaped = matches!( + &walk_result, + Err(crate::jitcode_dispatch::DispatchError::VableEscapedDuringResidualCall { .. }) + ); + let force_blackhole_adopted = vable_escaped + && try_adopt_blackhole(ctx, cf_addr, live_root_addr, WalkEndCommitLeg::VableEscape); + let mut escape_pc_adopted = false; if trace_too_long_adopted && crate::jitcode_dispatch::fbw_debug_abort_enabled() { eprintln!("[fbw-blackhole] adopted ABORT_TOO_LONG forward resume"); } if !force_blackhole_adopted - && matches!( - &walk_result, - Err(crate::jitcode_dispatch::DispatchError::VableEscapedDuringResidualCall { .. }) - ) + && vable_escaped && let Some((resume_py_pc, escape_kind)) = crate::jitcode_dispatch::take_committed_frame_escape_pc() { + escape_pc_adopted = true; // BOTH flushes inside `flush_active_frame_escape` rewind: they take // the same `py_pc` and the same `last_instr = pc - 1`, so the // escaping opcode re-runs either way. They differ in whether the @@ -3671,6 +3714,21 @@ fn run_perfn_walk( } } } + // The force arm withdrew its commit and DEFERRED the frame restore to + // here. Neither continuation that keeps the flushed frame ran, so the + // walk falls back to replaying the traced region from its entry: put + // the pre-flush locals / operand stack / resume coordinate back so the + // replay re-derives them instead of compounding onto the walk's + // mid-region values. When a blackhole terminal or a committed escape + // pc DID take over, the flush stands — `virtualizable.py:101-138 + // write_boxes` has no undo once the vable is forced, and the resumed + // interpreter reads its fastlocals straight out of that array. + if crate::jitcode_dispatch::take_escape_flush_undo_pending() + && !force_blackhole_adopted + && !escape_pc_adopted + { + crate::jitcode_dispatch::restore_escape_flush_undo(); + } let call_forward_abort = match &walk_result { Err(crate::jitcode_dispatch::DispatchError::AbortPermanentMarkerReached { pc }) => { Some((*pc, true)) diff --git a/pyre/pyre-jit-trace/src/trace_opcode.rs b/pyre/pyre-jit-trace/src/trace_opcode.rs index e3dfd60d082..b35691d825d 100644 --- a/pyre/pyre-jit-trace/src/trace_opcode.rs +++ b/pyre/pyre-jit-trace/src/trace_opcode.rs @@ -210,9 +210,8 @@ pub(crate) extern "C" fn normalize_raise_varargs_jit( }; match result { Ok(obj) if pyre_object::is_exception(obj) => obj, - Ok(_) => { - PyError::type_error("exceptions must derive from BaseException").to_exc_object() - } + Ok(obj) => pyre_interpreter::error::exception_from_call_type_error(exc, obj) + .to_exc_object(), Err(mut err) => err.to_exc_object(), } } else { diff --git a/pyre/pyre-jit/src/call_jit.rs b/pyre/pyre-jit/src/call_jit.rs index dfb83b307fa..776fe2c1d4a 100644 --- a/pyre/pyre-jit/src/call_jit.rs +++ b/pyre/pyre-jit/src/call_jit.rs @@ -417,7 +417,7 @@ extern "C" fn jit_call_user_function_from_frame( let frame = unsafe { &*(frame_ptr as *const PyFrame) }; let args = unsafe { std::slice::from_raw_parts(args_ptr as *const PyObjectRef, nargs as usize) }; - // Depth tracked by pyre_interpreter::call::CALL_DEPTH (call_user_function path). + // Depth tracked by pyre_interpreter::call::PY_RECURSION_DEPTH (eval-loop entry). match pyre_interpreter::call::call_user_function(frame, callable as PyObjectRef, args) { Ok(result) => result as i64, Err(mut err) => { @@ -1919,40 +1919,6 @@ fn jit_blackhole_resume_from_guard( None } -/// RAII guard registering each slot of the `#326` rollback snapshot's -/// `locals` copy as a GC root for the duration of `bh.run()`. The snapshot -/// is a plain `Vec` holding raw object pointers; the collector -/// is moving (incminimark nursery -> oldgen copying), so a minor collection -/// during the forward run would relocate those objects and leave the Vec -/// holding from-space pointers. Registering each element slot makes the -/// root walker forward them in place (`collector.rs` reads `*slot`, copies, -/// writes back), so the abort arm restores the live pointers rather than -/// stale ones. Mirrors `LocalsRoot` / the callee-locals root in `call.rs`. -struct VableRollbackRoots { - slots: Vec<*mut *mut u8>, -} - -impl VableRollbackRoots { - fn register(base: *const PyObjectRef, len: usize) -> Self { - let mut slots = Vec::with_capacity(len); - for i in 0..len { - let slot = unsafe { base.add(i) } as *mut *mut u8; - if unsafe { pyre_object::gc_hook::try_gc_add_root(slot) } { - slots.push(slot); - } - } - Self { slots } - } -} - -impl Drop for VableRollbackRoots { - fn drop(&mut self) { - for &slot in &self.slots { - pyre_object::gc_hook::try_gc_remove_root(slot); - } - } -} - /// RAII guard registering each `Ref`-typed slot of the resume `deadframe` /// copy as a GC root for the duration of `blackhole_from_resumedata`. /// @@ -1965,7 +1931,7 @@ impl Drop for VableRollbackRoots { /// freed memory. Resume *constants* are already forwarded by /// `rd_consts_root_walker_area`, but the box-sourced slots here are not. /// Registering each `Ref` element slot makes the root walker forward it in -/// place, mirroring `VableRollbackRoots` (#326). +/// place. /// /// Only `Ref`-typed slots are registered: `Int`/`Float` slots hold raw /// scalars (`decode_ref` boxes those lazily via `box_int`/`box_float`), and a @@ -2246,7 +2212,7 @@ pub fn blackhole_resume_via_rd_numb( // live (to-space) pointer rather than a dangling from-space one. The // `to_vec` uses the host allocator, so it cannot itself trigger a GC. let mut deadframe_buf: Vec = deadframe.to_vec(); - let _deadframe_roots = ResumeDeadframeRoots::register(&mut deadframe_buf, deadframe_types); + let deadframe_roots = ResumeDeadframeRoots::register(&mut deadframe_buf, deadframe_types); let deadframe: &[i64] = &deadframe_buf; // resume.py:983-991 _prepare_virtuals: convert RdVirtualInfo → VirtualInfo @@ -2331,6 +2297,19 @@ pub fn blackhole_resume_via_rd_numb( } bh.virtualizable_info = crate::eval::get_virtualizable_info(); } + // Last read of `deadframe`. `blackhole_from_resumedata` has copied every + // live value into the blackhole register banks, which the collector reaches + // through their own root source (`walk_bh_regs`), so the off-heap copy is + // dead from here on. Release its roots BEFORE the forward run: `bh.run()` + // executes the whole remainder of the resumed frame — for a module-level + // frame that is the rest of the program — and every `Ref` cell left + // registered pins the object it happened to hold at the guard, whether or + // not the resumed code still uses it. A `for v in gen(): break` leaves the + // abandoned generator in such a cell, so it is never collected and its + // `finally` never runs. `blackhole.py:1782-1796 resume_in_blackhole` ends + // `deadframe`'s live range at `_prepare_resume_from_failure`, before + // `_run_forever`, for the same reason. + drop(deadframe_roots); // resume.py:1332-1343 builds the caller chain (`nextblackholeinterp`) // but does not set the virtualizable-info handle on each frame. pyre // stores the vinfo per-`BlackholeInterpreter` (RPython reads it from @@ -2421,46 +2400,22 @@ pub fn blackhole_resume_via_rd_numb( eprintln!("[blackhole-resume] rd_numb path, chain built, running _run_forever",); } - // #326 blackhole-continuation rollback snapshot. The blackhole - // commits every STORE_FAST / operand push to the virtualizable heap - // frame as it runs forward (`setarrayitem_vable_*` / - // `setfield_vable_i`). If it later aborts — an opcode pyre cannot - // translate emits `BC_ABORT_PERMANENT` — the deopt drops back to the - // plain interpreter, which re-runs from the guard's resume PC. But - // the heap frame still carries the aborted run's partial forward - // mutations, so any side effect already committed before the abort is - // applied a second time. Capture the live frame here, right after the - // resume restore put it at the guard snapshot and before `bh.run()` - // mutates it, so the abort arm can roll it back and the interpreter's - // re-run applies each side effect exactly once. - // - // The snapshot holds raw `PyObjectRef`s across `bh.run()`; the GC is a - // moving collector (#336), so a minor collection during the run could - // relocate these. `VableRollbackRoots` below registers each `locals` - // slot with the root walker so the collector forwards them in place and - // the abort arm restores live pointers, not from-space ones. Capture - // the snapshotted frame pointer too, so the abort arm can confirm the - // frame that aborted is the same one this state belongs to before - // restoring it. - let vable_rollback: Option<(*mut PyFrame, Vec, usize, isize)> = { - let frame_ptr = bh.virtualizable_ptr as *mut PyFrame; - if frame_ptr.is_null() { - None - } else { - let frame = unsafe { &*frame_ptr }; - Some(( - frame_ptr, - frame.locals_w().as_slice().to_vec(), - frame.valuestackdepth, - frame.last_instr, - )) - } - }; - // Keep the snapshot's locals rooted for the whole forward run / abort - // window; dropped (roots removed) when this function returns. - let _vable_rollback_roots = vable_rollback - .as_ref() - .map(|(_, locals, _, _)| VableRollbackRoots::register(locals.as_ptr(), locals.len())); + // #326: no rollback snapshot is taken across `bh.run()`. The blackhole + // commits every STORE_FAST / operand push to the virtualizable heap frame + // as it runs forward, and an abort used to restore that frame to the + // guard snapshot so the interpreter's re-run from the guard resume PC + // "applied each side effect exactly once". That reasoning only covered + // frame-local state: a `print`, a heap store or a user frame the blackhole + // already executed has no undo, so rewinding re-applied every one of them + // (`x = [1,2]; ; x.append(3); class C: pass` left `[1,2,3,3]`). + // The only abort that can reach the blackhole at runtime sits on a Python + // opcode boundary — `abort` is gated out of blackhole dispatch by + // `PyJitCode::has_abort_opcode`, leaving `abort_permanent` (a whole + // unsupported opcode) and the two declined-call rejections (whose call + // never ran) — so the frame the blackhole leaves behind is a valid resume + // point and the interpreter continues from it. This is + // `convert_and_run_from_pyjitpl`'s shape (`blackhole.py:1799-1821`): each + // frame resumes at its own current pc, never rewound. // blackhole.py:1794-1795 resume_in_blackhole: // current_exc = _prepare_resume_from_failure(guard_opnum, deadframe) @@ -2606,34 +2561,6 @@ pub fn blackhole_resume_via_rd_numb( }; } if !bh.got_exception && bh.aborted { - // #326: roll the virtualizable heap frame back to the guard - // snapshot captured before `bh.run()`, discarding this aborted - // run's partial forward mutations. The interpreter resumes - // from the guard resume PC with the pre-blackhole frame state, - // so every side effect (the aborting opcode's included) is - // applied exactly once instead of twice. - if let Some((snap_frame_ptr, locals, vsd, last_instr)) = &vable_rollback { - let frame_ptr = bh.virtualizable_ptr as *mut PyFrame; - // Roll back only when the frame that aborted is the same one - // the snapshot was captured from. The `_run_forever` loop - // reassigns `bh` to a caller on callee return / exception - // propagation; a later abort then lands on the caller frame, - // whose valuestackdepth / last_instr would be clobbered with - // the callee's snapshot. A per-frame snapshot for that - // multi-frame case is the #124 stack-snapshot epic; until - // then, skip rather than corrupt the caller frame. - if !frame_ptr.is_null() && frame_ptr == *snap_frame_ptr { - let frame = unsafe { &mut *frame_ptr }; - let arr = frame.locals_w_mut().as_mut_slice(); - // The locals_cells_stack array length is fixed for a - // frame's lifetime; restore it verbatim. - if arr.len() == locals.len() { - arr.copy_from_slice(locals); - } - frame.valuestackdepth = *vsd; - frame.last_instr = *last_instr; - } - } if nbody_debug { eprintln!( "[nbody-debug] blackhole_resume_via_rd_numb failed: bh.aborted position={} last_opcode_position={}", @@ -3077,6 +3004,12 @@ pub fn trace_and_compile_from_bridge( if descr_arc.is_guard_forced() { return BridgeResolution::ResumeBlackhole; } + // compile.py:702-703 `must_compile() and not stack_almost_full()`: this is + // pyre's own guard-failure entry, so the diagnostic that suppresses bridge + // recording has to be read here as well as in the jitdriver's paths. + if majit_metainterp::no_bridge_enabled() { + return BridgeResolution::ResumeBlackhole; + } let Some((green_key, trace_id, fail_index)) = bridge_source_identity_from_descr(descr_arc) else { // compile.py:725-729 `_trace_and_compile_from_bridge` raises @@ -5942,10 +5875,8 @@ pub extern "C" fn bh_normalize_raise_varargs_with_frame( }; match result { Ok(obj) if pyre_object::is_exception(obj) => obj, - Ok(_) => pyre_interpreter::PyError::type_error( - "exceptions must derive from BaseException", - ) - .to_exc_object(), + Ok(obj) => pyre_interpreter::error::exception_from_call_type_error(exc, obj) + .to_exc_object(), Err(mut err) => err.to_exc_object(), } } else { diff --git a/pyre/pyre-jit/src/eval.rs b/pyre/pyre-jit/src/eval.rs index e7eea903d0d..69211d88a47 100644 --- a/pyre/pyre-jit/src/eval.rs +++ b/pyre/pyre-jit/src/eval.rs @@ -1362,9 +1362,16 @@ fn build_gc() -> Box { w_int_tid, )); debug_assert_eq!(w_bool_tid, W_BOOL_GC_TYPE_ID); - let range_iter_tid = gc.register_type(TypeInfo::object_subclass( + // The payload is three machine ints; the one traced offset is the + // header's `w_class`, which a `class R(range_iterator)` instance points + // at a managed heap type. + let range_iter_tid = gc.register_type(TypeInfo::object_subclass_with_gc_ptrs( std::mem::size_of::(), object_tid, + ::DESCRIPTOR + .ptr_offsets + .to_vec(), )); debug_assert_eq!(range_iter_tid, RANGE_ITER_GC_TYPE_ID); // rlist.py:116 parity: W_ListObject has a single GC pointer @@ -1738,12 +1745,37 @@ fn build_gc() -> Box { // W_SeqIterObject (list/tuple iterator) — typed payload via // `#[pyre_class]`. Pre-registered ahead of the foreign-pytype // loop so the GC walker reaches the inline `seq` field. - register_pyre_class( + let seq_iter_tid = register_pyre_class( &mut gc, &mut pytype_to_tid, ::DESCRIPTOR, ); + // The producer-specific str / bytes / bytearray / memoryview / array + // iterator identities carry the same `W_SeqIterObject` payload and traced + // `seq` edge, so they alias its vtable rather than minting ids of their + // own — the shape the six dict view iterators use below. + for tp in [ + &pyre_object::iterobject::STR_ASCII_ITER_TYPE, + &pyre_object::iterobject::STR_ITER_TYPE, + &pyre_object::iterobject::BYTES_ITER_TYPE, + &pyre_object::iterobject::BYTEARRAY_ITER_TYPE, + &pyre_object::iterobject::MEMORY_ITER_TYPE, + &pyre_object::iterobject::ARRAY_ITER_TYPE, + ] { + majit_gc::GcAllocator::register_vtable_for_type( + &mut gc, + tp as *const _ as usize, + seq_iter_tid, + ); + pytype_to_tid.insert(tp as *const _ as usize, seq_iter_tid); + pyre_object::gc_hook::register_pyre_class_offsets( + tp as *const _ as usize, + ::DESCRIPTOR + .ptr_offsets, + ); + } // W_Count / W_Repeat (`itertools.count` / `itertools.repeat`) — // typed payload via `#[pyre_class]`. Neither PyType is in // `all_foreign_pytypes()`, so pre-registration here is the only @@ -3241,6 +3273,23 @@ fn build_gc() -> Box { ::DESCRIPTOR, ); + // The remaining `#[pyre_class]` types carry no inline `PyObjectRef` + // payload field, so the only edge the marker has to forward is the + // header's `w_class` — which a Python subclass instance + // (`class L(_thread.LockType)`) points at a managed heap type. Appended + // last so every automatic type id assigned above stays put. + for descriptor in pyre_interpreter::all_w_class_only_descriptors() { + register_pyre_class(&mut gc, &mut pytype_to_tid, descriptor); + } + // Their immortal counterparts take no type id — the collector never walks + // an `allocate`d object — so only the immortal-root walker's offset + // registry learns the edge. + for descriptor in pyre_interpreter::all_immortal_w_class_only_descriptors() { + pyre_object::gc_hook::register_pyre_class_offsets( + descriptor.pytype_ptr as usize, + descriptor.ptr_offsets, + ); + } // ── GC-root registration completeness oracle ───────────────────────── // Every `#[pyre_class]` type appends its descriptor to the whole-program // `PYRE_CLASS_DESCRIPTORS` slice. A type with inline managed children @@ -5225,12 +5274,12 @@ fn init_callbacks() { }); } -/// Read the call depth from pyre-interpreter's CALL_DEPTH TLS. -/// Replaces the separate JIT_CALL_DEPTH — single source of truth. -// dont_look_inside: reads CALL_DEPTH TLS; no registry-resolvable accessor. +/// Read the Python recursion depth from pyre-interpreter's +/// PY_RECURSION_DEPTH TLS — the single source of truth for both crates. +// dont_look_inside: reads a TLS; no registry-resolvable accessor. #[majit_macros::dont_look_inside] pub(crate) fn call_depth() -> u32 { - pyre_interpreter::call::call_depth() + pyre_interpreter::call::py_recursion_depth() } /// RPython green_key = (pycode, next_instr). @@ -5246,8 +5295,8 @@ pub fn make_green_key(code_ptr: *const (), pc: usize) -> u64 { majit_ir::pypyjit_greenkey_uhash(pc, false, code_ptr as u64) } -// JIT_CALL_DEPTH removed — pyre-interpreter::call::CALL_DEPTH is the single -// source of truth. call_depth() reads it. No more Box allocation. +// JIT_CALL_DEPTH removed — pyre-interpreter::call::PY_RECURSION_DEPTH is the +// single source of truth. call_depth() reads it. /// RPython compile.py:204-207 (record_loop_or_bridge) parity: /// Register the compiled artifact's invalidation flag with all quasi-immutable @@ -6706,6 +6755,12 @@ fn unsupported_jit_shape_uncached(code: &pyre_interpreter::CodeObject) -> Unsupp } fn eval_with_jit_inner(frame: &mut PyFrame) -> PyResult { + // The JIT-side frame-activation seam: a frame that runs entirely as + // compiled code returns from `try_function_entry_jit` without reaching an + // eval loop, so the recursion budget is spent here, where every JIT route + // through the frame — compiled, JIT eval loop, or declined to the plain + // evaluator — passes exactly once. + let _recursion_depth = pyre_interpreter::call::enter_recursive_frame(frame); // Phase B of GC init: register root walkers that reference // interpreter state. Safe here — the interpreter is initialized. // Phase A (GC build + backend install) ran at boot in init_jit_hooks. @@ -6799,56 +6854,9 @@ fn eval_with_jit_inner(frame: &mut PyFrame) -> PyResult { // portal_ptr = eval_loop_jit at depth 0 (has jit_merge_point + // can_enter_jit back-edge), plain interpreter at depth > 0. if let Some(result) = try_function_entry_jit(frame_root.frame()) { - if majit_metainterp::majit_log_enabled() { - log_named_global_result( - frame_root.frame(), - "eval_with_jit_inner.try_function_entry_jit", - ); - } return result; } - let result = handle_jitexception(frame_root.frame()); - if majit_metainterp::majit_log_enabled() { - log_named_global_result( - frame_root.frame(), - "eval_with_jit_inner.handle_jitexception", - ); - } - result -} - -fn log_named_global_result(frame: &PyFrame, label: &str) { - unsafe { - let w_globals = frame.get_w_globals(); - if w_globals.is_null() { - return; - } - let Some(value) = pyre_object::w_dict_getitem_str(w_globals, "result") else { - return; - }; - if value.is_null() { - eprintln!("[jit][{label}] result=NULL"); - return; - } - // pyobject.rs:308 `is_int` returns true for both INT_TYPE and - // BOOL_TYPE (bool is a W_IntObject subclass sharing `intval`). Match - // INT_TYPE strictly here so the log labels a bool result distinctly - // in the branch below. - if pyre_object::pyobject::py_type_check(value, &pyre_object::pyobject::INT_TYPE) { - eprintln!( - "[jit][{label}] result_ptr=0x{:x} kind=int intval={}", - value as usize, - pyre_object::intobject::w_int_get_value(value), - ); - } else if pyre_object::pyobject::is_bool(value) { - eprintln!("[jit][{label}] result_ptr=0x{:x} kind=bool", value as usize,); - } else { - eprintln!( - "[jit][{label}] result_ptr=0x{:x} kind=other", - value as usize, - ); - } - } + handle_jitexception(frame_root.frame()) } /// warmspot.py:970-983 ContinueRunningNormally → portal_ptr(*args) parity. @@ -7076,6 +7084,10 @@ fn eval_loop_jit(frame: &mut PyFrame) -> LoopResult { // FBW FOR_ITER Option-C guard snapshots this around a residual call to // detect a body effect that ran through user code. pyre_interpreter::call::bump_frame_entry_count(); + // Spend one unit of the recursion budget on this frame's activation, in + // case this loop was reached without going through `eval_with_jit_inner`. + // A frame the wrapper already accounted spends nothing here. + let _recursion_depth = pyre_interpreter::call::enter_recursive_frame(frame_root.frame()); // Count this eval-loop activation for the GC safepoint's // at_outermost_activation gate (gh#393). The gate allows collection // at depth ≤ 2 (module + one called function) where the CALL opcode @@ -7742,6 +7754,16 @@ fn handle_fail( // already gone, and bridge setup decodes resume data (allocating) before // `setup_bridge_sym` copies it onto the sym. Park it for the walker first. let _guard_exc_root = majit_metainterp::blackhole::GuardExcRoot::park(guard_exc); + // The exit values are a host copy of the JITFRAME slots; the JITFRAME's + // own gcmap rooting ended when `execute_token` returned. Bridge tracing + // below allocates, so root the Ref slots for this whole call + // (`DeadFrameRefRoots`). + let _deadframe_roots = unsafe { + majit_metainterp::resume::DeadFrameRefRoots::enter(raw_values, |index| { + exit_layout.exit_types.get(index) == Some(&majit_ir::Type::Ref) + || exit_layout.gc_ref_slots.contains(&index) + }) + }; // A failure reported through a retired/inlined source descr can belong to // an invalidated JitCellToken even while the outer entry token is still @@ -7961,6 +7983,14 @@ pub(crate) fn resume_in_blackhole_from_exit_layout( // decode must not consume one. jd0 guards pass `false`. novable: bool, ) -> crate::call_jit::BlackholeResult { + // Same deadframe rooting as `handle_fail`: `decode_ref`'s TAGBOX arm reads + // these slots after the resume construction has already allocated. + let _deadframe_roots = unsafe { + majit_metainterp::resume::DeadFrameRefRoots::enter(raw_values, |index| { + exit_layout.exit_types.get(index) == Some(&majit_ir::Type::Ref) + || exit_layout.gc_ref_slots.contains(&index) + }) + }; if majit_metainterp::majit_log_enabled() { eprintln!( "[dynasm-debug] resume_in_blackhole: raw_values.len={} exit_types.len={} rd_numb={:?}", @@ -10318,8 +10348,22 @@ pub(crate) fn decode_and_restore_guard_failure( // (outer) frame's depth, and the matching pc value does not make it // correct. Single-frame guards keep the prior `resume_pc != ni` // behavior. + // + // It addresses the PHYSICAL frame, so it applies only while the + // innermost section belongs to that frame's OWN code object. + // `consume_vable_info` (resume.py:1399-1408) writes the virtualizable + // from its own resume section and nothing re-points it at an inlined + // callee: a callee frame is a separate object the rebuild + // materializes, and its depth does not index the portal frame's + // `locals_cells_stack_w`. Writing a foreign code object's depth here + // left the live frame BELOW its own stack base (`_Unframer.read`'s 3 + // on `_Unpickler.load`, base 5), and the paired `clear_stack_above` + // then nulled two live locals. if resume_pc != ni || resumed_frames.len() > 1 { - if let Some(code) = innermost.map(|f| f.code as usize) { + if let Some(code) = innermost + .map(|f| f.code as usize) + .filter(|&code| code == jit_state.pycode_as_usize()) + { if let Some(corrected_vsd) = pyre_jit_trace::state::depth_based_vsd_for_wcode(code, resume_pc) { @@ -11188,6 +11232,23 @@ fn extract_interior_field_info(descr: &majit_ir::DescrRef) -> (usize, usize, u8) /// RPython delegates to self.cpu (metainterp_sd.cpu) for allocation. pub(crate) struct PyreBlackholeAllocator; +/// The write barrier every blackhole ref store owes its container. +/// +/// `llmodel.py:723 bh_setfield_gc_r` reaches `:495 write_ref_at_mem`, where the +/// framework GC transformer supplies the barrier around the store. These +/// blackhole setters are plain Rust writes with no transformer and no inline +/// `TRACK_YOUNG_PTRS` test, and `allocate_with_vtable` materializes a resumed +/// virtual into the non-moving old generation, so a young value stored into one +/// creates an old→young edge that never enters `old_objects_pointing_to_young` +/// — and the next minor collection reclaims the value while the container still +/// points at it. +fn write_barrier_after_ref_store(container: i64) { + let container = container as *mut u8; + if pyre_object::gc_hook::try_gc_owns_object(container) { + pyre_object::gc_hook::try_gc_write_barrier(container); + } +} + /// `resume.py:1509-1518 setfield(struct, fieldnum, descr)` byte-write /// helper for integer and float fields. Ref fields use a pointer-width /// store in `bh_setfield_gc_r`, matching `llmodel.py:723`. @@ -11421,9 +11482,7 @@ impl majit_metainterp::resume::BlackholeAllocator for PyreBlackholeAllocator { } // llmodel.py:723 `bh_setfield_gc_r` → :495 `write_ref_at_mem`: the // ref store carries an implied write barrier on the destination struct. - if pyre_object::gc_hook::try_gc_owns_object(struct_ptr as *mut u8) { - pyre_object::gc_hook::try_gc_write_barrier(struct_ptr as *mut u8); - } + write_barrier_after_ref_store(struct_ptr); } fn bh_setfield_gc_f(&self, struct_ptr: i64, value: i64, descr_info: &majit_ir::FieldDescrInfo) { @@ -11497,9 +11556,7 @@ impl majit_metainterp::resume::BlackholeAllocator for PyreBlackholeAllocator { // llmodel.py:659 `bh_setinteriorfield_gc_r` → :495 // `write_ref_at_mem`: the ref store carries an implied write barrier // on the destination array. - if array != 0 && pyre_object::gc_hook::try_gc_owns_object(array as *mut u8) { - pyre_object::gc_hook::try_gc_write_barrier(array as *mut u8); - } + write_barrier_after_ref_store(array); } fn bh_setinteriorfield_gc_f( diff --git a/pyre/pyre-jit/src/jit/codewriter.rs b/pyre/pyre-jit/src/jit/codewriter.rs index 0b43595964d..0d305e6ebed 100644 --- a/pyre/pyre-jit/src/jit/codewriter.rs +++ b/pyre/pyre-jit/src/jit/codewriter.rs @@ -5297,6 +5297,21 @@ impl CodeWriter { /// and register-allocated, jtransform/regalloc/flatten are identity /// transforms. We go directly to assembly. pub fn transform_graph_to_jitcode(&self, code: &CodeObject) -> Option { + // Label-space ceiling. The pre-passes below claim one label per + // instruction index (`flatten.py`'s "pre-create labels for each + // block") plus one exception-landing label per covered pc, and a + // label id is a `u16` because the assembled jump target is a two-byte + // operand (`assembler.py:255 assert 0 <= target <= 0xFFFF`). Upstream + // can assert on that ceiling: its jitcodes come from RPython + // functions, so a violation is a translation-time bug. Pyre compiles + // arbitrary user bytecode, so the same ceiling has to DECLINE at + // runtime and leave the function to the interpreter — the treatment + // `JitCodeBuilder::try_finish` already gives the register-count and + // code-length ceilings. Without this a large enough function aborted + // the process on `new_label`'s overflow before reaching either. + if code.instructions.len().saturating_mul(2) > u16::MAX as usize { + return None; + } // Recover the live globals-stamped PyCode wrapper for `code` from the // `code_ptr → live wrapper` registry. `frame.pycode` is the stable // per-code wrapper that every compiled code has stamped (during the @@ -9052,7 +9067,23 @@ impl CodeWriter { items, py_pc as i64, ); - push_and_bump!(result_value.into(), py_pc); + // Physically write the list into its value-stack slot + // (`pyframe.py:389 pushvalue` → `setarrayitem_vable_r` + // via `jtransform.py:1898 do_fixed_list_setitem`), not + // just bump the symbolic depth. `LIST_APPEND` reads + // its accumulator back through `getarrayitem_vable_r` + // (see below), and the blackhole runs + // BUILD_LIST→LIST_APPEND from the jitcode on a + // mid-frame resume, so the slot must be populated by + // the emitted op rather than relying on a prior + // interpreter write — the same pairing GET_ITER makes + // for FOR_ITER's iterator reload. A list display + // longer than 30 elements compiles to `BUILD_LIST 0` + + // repeated `LIST_APPEND`, so without this the append + // receiver is whatever the slot last held. + let pushed: super::flow::FlowValue = result_value.into(); + current_state.stack.push(pushed.clone()); + emit_pushvalue_ref!(current_depth, current_depth, pushed, py_pc); } // pyopcode.py:1463 BUILD_SLICE: diff --git a/pyre/pyre-macros/src/lib.rs b/pyre/pyre-macros/src/lib.rs index 431603bc615..654e5d0c471 100644 --- a/pyre/pyre-macros/src/lib.rs +++ b/pyre/pyre-macros/src/lib.rs @@ -1256,8 +1256,14 @@ fn expand_pyre_class( named.named.insert(0, ob_field); } - // Collect `PyObjectRef` fields' offsets for GC tracing. Skip `ob` - // because the GC walks the header through the parent (object) tid. + // Collect `PyObjectRef` fields' offsets for GC tracing. `ob` is the + // `PyObject` header; its `w_class` word is the instance -> class edge + // and is listed explicitly. A `#[pyre_class]` instance created for a + // Python subclass carries the (managed) heap type there, and the + // collector traces nothing but the offsets registered for the + // instance's own type id — a parent tid contributes none — so leaving + // it out let the subclass type be swept while instances were live. + // `ob_type` stays out: it always points at the `static PyType`. let mut ptr_field_idents: Vec = Vec::new(); for f in named.named.iter() { let Some(ident) = f.ident.clone() else { @@ -1270,11 +1276,15 @@ fn expand_pyre_class( ptr_field_idents.push(ident); } } - let ptr_offsets_len = ptr_field_idents.len(); - let ptr_offsets_inits: Vec = ptr_field_idents - .iter() - .map(|i| quote! { ::std::mem::offset_of!(#st_name, #i) }) - .collect(); + let ptr_offsets_len = ptr_field_idents.len() + 1; + let ptr_offsets_inits: Vec = + std::iter::once(quote! { ::std::mem::offset_of!(#st_name, ob.w_class) }) + .chain( + ptr_field_idents + .iter() + .map(|i| quote! { ::std::mem::offset_of!(#st_name, #i) }), + ) + .collect(); Ok(quote! { #st @@ -2063,7 +2073,14 @@ fn expand_pyre_methods( ::std::option::Option::None => false, }; if !__pyre_same_tp { + // `ob.w_class` is a traced edge of every + // `#[pyre_class]` layout, and an old-gen payload + // can take a young heap type here, so the store + // joins the remembered set. unsafe { (*__pyre_obj).w_class = __pyre_cls; } + ::pyre_object::gc_hook::try_gc_write_barrier( + __pyre_obj as *mut u8, + ); } } } @@ -2107,6 +2124,16 @@ fn expand_pyre_methods( unsafe { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy(ns, #py_name, #raw_fn) }; }); } + // `__new__` is the one static entry that is not a `tp_methods` + // entry: `add_tp_new_wrapper` stores the carrier itself, so the + // namespace holds a `builtin_function_or_method` bound to the + // owning type rather than a `staticmethod` around one. + MethodKind::Static if py_name == "__new__" => { + registrations.push(quote! { + unsafe { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy(ns, #py_name, + crate::typedef::make_new_descr(#wrapper_name)) }; + }); + } MethodKind::Static => { registrations.push(quote! { unsafe { pyre_object::dictmultiobject::w_dict_setitem_str_no_proxy(ns, #py_name, diff --git a/pyre/pyre-object/src/functional.rs b/pyre/pyre-object/src/functional.rs index dc958e32c84..0fe3396c752 100644 --- a/pyre/pyre-object/src/functional.rs +++ b/pyre/pyre-object/src/functional.rs @@ -482,8 +482,9 @@ pub struct W_IntRangeIterator { /// Field offsets of inline scalar slots — consumed by JIT field-access /// IR (`pyre-jit/src/jit/codewriter.rs` GetfieldGcI / SetfieldGcI). -/// The macro's auto-generated `W_RANGE_ITER_GC_PTR_OFFSETS` is empty -/// here (no PyObjectRef fields) and does not depend on these. +/// The macro's auto-generated `W_RANGE_ITER_GC_PTR_OFFSETS` holds only the +/// header `w_class` edge here (no PyObjectRef payload field) and does not +/// depend on these. pub const RANGE_ITER_CURRENT_OFFSET: usize = std::mem::offset_of!(W_IntRangeIterator, current); pub const RANGE_ITER_REMAINING_OFFSET: usize = std::mem::offset_of!(W_IntRangeIterator, remaining); pub const RANGE_ITER_STEP_OFFSET: usize = std::mem::offset_of!(W_IntRangeIterator, step); diff --git a/pyre/pyre-object/src/interp_array.rs b/pyre/pyre-object/src/interp_array.rs index 6a98b87c40f..dad1af8c7ff 100644 --- a/pyre/pyre-object/src/interp_array.rs +++ b/pyre/pyre-object/src/interp_array.rs @@ -291,11 +291,13 @@ mod tests { #[test] fn w_array_gc_descriptor_traces_subclass_state() { - // Elements remain unboxed in the raw buffer; only the mapdict, - // weakref, and indexed-slot fields are traced. + // Elements remain unboxed in the raw buffer; the traced edges are the + // header `w_class` one every `#[pyre_class]` type reports plus the + // mapdict, weakref, and indexed-slot fields. assert_eq!( W_ARRAY_GC_PTR_OFFSETS, [ + std::mem::offset_of!(W_Array, ob.w_class), std::mem::offset_of!(W_Array, w_dict), std::mem::offset_of!(W_Array, w_weakreflifeline), std::mem::offset_of!(W_Array, w_slots), diff --git a/pyre/pyre-object/src/interp_itertools.rs b/pyre/pyre-object/src/interp_itertools.rs index fd4162e20c2..75831ceac2b 100644 --- a/pyre/pyre-object/src/interp_itertools.rs +++ b/pyre/pyre-object/src/interp_itertools.rs @@ -1253,10 +1253,12 @@ mod tests { #[test] fn w_islice_gc_descriptor_traces_source_iterator() { - assert_eq!(W_ISLICE_GC_PTR_OFFSETS.len(), 1); assert_eq!( - W_ISLICE_GC_PTR_OFFSETS[0], - std::mem::offset_of!(W_ISlice, iterable) + W_ISLICE_GC_PTR_OFFSETS, + [ + std::mem::offset_of!(W_ISlice, ob.w_class), + std::mem::offset_of!(W_ISlice, iterable), + ] ); assert_eq!( ::SIZE, @@ -1266,10 +1268,12 @@ mod tests { #[test] fn w_batched_gc_descriptor_traces_source_iterator() { - assert_eq!(W_BATCHED_GC_PTR_OFFSETS.len(), 1); assert_eq!( - W_BATCHED_GC_PTR_OFFSETS[0], - std::mem::offset_of!(W_Batched, it) + W_BATCHED_GC_PTR_OFFSETS, + [ + std::mem::offset_of!(W_Batched, ob.w_class), + std::mem::offset_of!(W_Batched, it), + ] ); assert_eq!( ::SIZE, @@ -1282,6 +1286,7 @@ mod tests { assert_eq!( W_PRODUCT_GC_PTR_OFFSETS, [ + std::mem::offset_of!(W_Product, ob.w_class), std::mem::offset_of!(W_Product, gears), std::mem::offset_of!(W_Product, indices), std::mem::offset_of!(W_Product, lst), @@ -1298,6 +1303,7 @@ mod tests { assert_eq!( W_COMBINATIONS_GC_PTR_OFFSETS, [ + std::mem::offset_of!(W_Combinations, ob.w_class), std::mem::offset_of!(W_Combinations, pool_w), std::mem::offset_of!(W_Combinations, indices), std::mem::offset_of!(W_Combinations, last_result_w), @@ -1314,6 +1320,7 @@ mod tests { assert_eq!( W_COMBINATIONS_WITH_REPLACEMENT_GC_PTR_OFFSETS, [ + std::mem::offset_of!(W_CombinationsWithReplacement, ob.w_class), std::mem::offset_of!(W_CombinationsWithReplacement, pool_w), std::mem::offset_of!(W_CombinationsWithReplacement, indices), std::mem::offset_of!(W_CombinationsWithReplacement, last_result_w), @@ -1330,6 +1337,7 @@ mod tests { assert_eq!( W_PERMUTATIONS_GC_PTR_OFFSETS, [ + std::mem::offset_of!(W_Permutations, ob.w_class), std::mem::offset_of!(W_Permutations, pool_w), std::mem::offset_of!(W_Permutations, indices), std::mem::offset_of!(W_Permutations, cycles), @@ -1346,6 +1354,7 @@ mod tests { assert_eq!( W_GROUPBY_GC_PTR_OFFSETS, [ + std::mem::offset_of!(W_GroupBy, ob.w_class), std::mem::offset_of!(W_GroupBy, w_iterator), std::mem::offset_of!(W_GroupBy, w_keyfunc), std::mem::offset_of!(W_GroupBy, w_tgtkey), @@ -1365,6 +1374,7 @@ mod tests { assert_eq!( W_GROUPBY_ITERATOR_GC_PTR_OFFSETS, [ + std::mem::offset_of!(W_GroupByIterator, ob.w_class), std::mem::offset_of!(W_GroupByIterator, groupby), std::mem::offset_of!(W_GroupByIterator, w_tgtkey), ] @@ -1380,6 +1390,7 @@ mod tests { assert_eq!( W_TEE_DATAOBJECT_GC_PTR_OFFSETS, [ + std::mem::offset_of!(W_TeeChainedListNode, ob.w_class), std::mem::offset_of!(W_TeeChainedListNode, w_next), std::mem::offset_of!(W_TeeChainedListNode, w_obj), ] @@ -1395,6 +1406,7 @@ mod tests { assert_eq!( W_TEE_ITERABLE_GC_PTR_OFFSETS, [ + std::mem::offset_of!(W_TeeIterable, ob.w_class), std::mem::offset_of!(W_TeeIterable, w_iterator), std::mem::offset_of!(W_TeeIterable, w_chained_list), ] @@ -1407,13 +1419,17 @@ mod tests { #[test] fn w_compress_gc_descriptor_traces_both_iterator_fields() { - assert_eq!(W_COMPRESS_GC_PTR_OFFSETS.len(), 2); + assert_eq!(W_COMPRESS_GC_PTR_OFFSETS.len(), 3); assert_eq!( W_COMPRESS_GC_PTR_OFFSETS[0], - std::mem::offset_of!(W_Compress, w_data) + std::mem::offset_of!(W_Compress, ob.w_class) ); assert_eq!( W_COMPRESS_GC_PTR_OFFSETS[1], + std::mem::offset_of!(W_Compress, w_data) + ); + assert_eq!( + W_COMPRESS_GC_PTR_OFFSETS[2], std::mem::offset_of!(W_Compress, w_selectors) ); assert_eq!( @@ -1424,13 +1440,17 @@ mod tests { #[test] fn w_starmap_gc_descriptor_traces_function_and_iterator() { - assert_eq!(W_STARMAP_GC_PTR_OFFSETS.len(), 2); + assert_eq!(W_STARMAP_GC_PTR_OFFSETS.len(), 3); assert_eq!( W_STARMAP_GC_PTR_OFFSETS[0], - std::mem::offset_of!(W_StarMap, w_fun) + std::mem::offset_of!(W_StarMap, ob.w_class) ); assert_eq!( W_STARMAP_GC_PTR_OFFSETS[1], + std::mem::offset_of!(W_StarMap, w_fun) + ); + assert_eq!( + W_STARMAP_GC_PTR_OFFSETS[2], std::mem::offset_of!(W_StarMap, w_iterable) ); assert_eq!( @@ -1441,10 +1461,11 @@ mod tests { #[test] fn w_accumulate_gc_descriptor_traces_live_state() { - assert_eq!(W_ACCUMULATE_GC_PTR_OFFSETS.len(), 4); + assert_eq!(W_ACCUMULATE_GC_PTR_OFFSETS.len(), 5); assert_eq!( W_ACCUMULATE_GC_PTR_OFFSETS, [ + std::mem::offset_of!(W_Accumulate, ob.w_class), std::mem::offset_of!(W_Accumulate, w_iterable), std::mem::offset_of!(W_Accumulate, w_func), std::mem::offset_of!(W_Accumulate, w_total), @@ -1459,10 +1480,11 @@ mod tests { #[test] fn w_zip_longest_gc_descriptor_traces_iterators_and_fillvalue() { - assert_eq!(W_ZIP_LONGEST_GC_PTR_OFFSETS.len(), 2); + assert_eq!(W_ZIP_LONGEST_GC_PTR_OFFSETS.len(), 3); assert_eq!( W_ZIP_LONGEST_GC_PTR_OFFSETS, [ + std::mem::offset_of!(W_ZipLongest, ob.w_class), std::mem::offset_of!(W_ZipLongest, w_iterators), std::mem::offset_of!(W_ZipLongest, w_fillvalue), ] @@ -1489,13 +1511,17 @@ mod tests { // the collector, and that the descriptor reflects the struct's size. #[test] fn w_cycle_gc_descriptor_traces_both_pointer_fields() { - assert_eq!(W_CYCLE_GC_PTR_OFFSETS.len(), 2); + assert_eq!(W_CYCLE_GC_PTR_OFFSETS.len(), 3); assert_eq!( W_CYCLE_GC_PTR_OFFSETS[0], - std::mem::offset_of!(W_Cycle, w_iterable) + std::mem::offset_of!(W_Cycle, ob.w_class) ); assert_eq!( W_CYCLE_GC_PTR_OFFSETS[1], + std::mem::offset_of!(W_Cycle, w_iterable) + ); + assert_eq!( + W_CYCLE_GC_PTR_OFFSETS[2], std::mem::offset_of!(W_Cycle, saved) ); assert_eq!( @@ -1512,13 +1538,17 @@ mod tests { // struct's size. #[test] fn w_chain_gc_descriptor_traces_both_pointer_fields() { - assert_eq!(W_CHAIN_GC_PTR_OFFSETS.len(), 2); + assert_eq!(W_CHAIN_GC_PTR_OFFSETS.len(), 3); assert_eq!( W_CHAIN_GC_PTR_OFFSETS[0], - std::mem::offset_of!(W_Chain, w_iterables) + std::mem::offset_of!(W_Chain, ob.w_class) ); assert_eq!( W_CHAIN_GC_PTR_OFFSETS[1], + std::mem::offset_of!(W_Chain, w_iterables) + ); + assert_eq!( + W_CHAIN_GC_PTR_OFFSETS[2], std::mem::offset_of!(W_Chain, w_it) ); assert_eq!( diff --git a/pyre/pyre-object/src/iterobject.rs b/pyre/pyre-object/src/iterobject.rs index a5aa4415dca..22fcbde24c7 100644 --- a/pyre/pyre-object/src/iterobject.rs +++ b/pyre/pyre-object/src/iterobject.rs @@ -45,20 +45,63 @@ pub struct W_TupleIterObject { pub index: i64, } +// Python 3.14 gives str / bytes / bytearray / memoryview iteration its own +// concrete type per producer, while PyPy serves all four from the abstract +// `sequenceiterator`. Keep PyPy's single payload and `descr_next` and give +// each producer the 3.14-visible identity through its own `PyType`, the way +// `dictmultiobject`'s six view iterators share `W_BaseDictMultiIterObject`. +pub static STR_ASCII_ITER_TYPE: PyType = crate::pyobject::new_pytype("str_ascii_iterator"); +pub static STR_ITER_TYPE: PyType = crate::pyobject::new_pytype("str_iterator"); +pub static BYTES_ITER_TYPE: PyType = crate::pyobject::new_pytype("bytes_iterator"); +pub static BYTEARRAY_ITER_TYPE: PyType = crate::pyobject::new_pytype("bytearray_iterator"); +pub static MEMORY_ITER_TYPE: PyType = crate::pyobject::new_pytype("memory_iterator"); +pub static ARRAY_ITER_TYPE: PyType = crate::pyobject::new_pytype("array.arrayiterator"); + +/// The Python-visible iterator type for a sequence iterator over `seq`. +/// +/// 3.14 splits str iteration by storage: an all-ASCII str yields +/// `str_ascii_iterator`, anything wider yields `str_iterator`. Producers with +/// no specialized type keep the shared `sequenceiterator`. +fn seq_iter_type_for(seq: PyObjectRef) -> &'static PyType { + unsafe { + if crate::is_str(seq) { + if crate::unicodeobject::w_str_is_ascii(seq) { + &STR_ASCII_ITER_TYPE + } else { + &STR_ITER_TYPE + } + } else if crate::bytesobject::is_bytes(seq) { + &BYTES_ITER_TYPE + } else if crate::bytearrayobject::is_bytearray(seq) { + &BYTEARRAY_ITER_TYPE + } else if crate::memoryview::is_w_memoryview(seq) { + &MEMORY_ITER_TYPE + } else if crate::interp_array::is_array(seq) { + &ARRAY_ITER_TYPE + } else { + &SEQ_ITER_TYPE + } + } +} + pub fn w_seq_iter_new(seq: PyObjectRef, length: usize) -> PyObjectRef { // `gct_fv_gc_malloc` bracket pattern (`framework.py:853-856`). let _roots = crate::gc_roots::push_roots(); + let seq_slot = crate::gc_roots::shadow_stack_len(); crate::gc_roots::pin_root(seq); - W_SeqIterObject::allocate_stable(W_SeqIterObject { + let seq = crate::gc_roots::shadow_stack_get(seq_slot); + let tp = seq_iter_type_for(seq); + let value = W_SeqIterObject { ob: PyObject { - ob_type: std::ptr::null(), - w_class: std::ptr::null_mut(), + ob_type: tp as *const PyType, + w_class: crate::pyobject::get_instantiate(tp), }, seq, index: 0, length: length as i64, empty_kind: unsafe { if crate::is_str(seq) { 1 } else { 0 } }, - }) + }; + crate::lltype::malloc_typed_stable(value) as PyObjectRef } pub fn w_list_iter_new(seq: PyObjectRef) -> PyObjectRef { @@ -107,7 +150,40 @@ pub unsafe fn is_seq_iter(obj: PyObjectRef) -> bool { if crate::tagged_int::CAN_BE_TAGGED && crate::tagged_int::is_tagged_int(obj) { return false; } - !obj.is_null() && unsafe { (*obj).ob_type == &SEQ_ITER_TYPE as *const PyType } + if obj.is_null() { + return false; + } + // Every producer-specific identity minted by `seq_iter_type_for` carries + // the same `W_SeqIterObject` payload, so all of them answer yes here. + let tp = unsafe { (*obj).ob_type }; + tp == &SEQ_ITER_TYPE as *const PyType + || tp == &STR_ASCII_ITER_TYPE as *const PyType + || tp == &STR_ITER_TYPE as *const PyType + || tp == &BYTES_ITER_TYPE as *const PyType + || tp == &BYTEARRAY_ITER_TYPE as *const PyType + || tp == &MEMORY_ITER_TYPE as *const PyType + || tp == &ARRAY_ITER_TYPE as *const PyType +} + +/// `memory_iterator` — the one producer-specific `W_SeqIterObject` identity +/// that exposes no pickle protocol (`__length_hint__` / `__setstate__` absent, +/// `__reduce__` inherited from `object`, which refuses). +#[inline] +pub unsafe fn is_memory_iter(obj: PyObjectRef) -> bool { + if crate::tagged_int::CAN_BE_TAGGED && crate::tagged_int::is_tagged_int(obj) { + return false; + } + !obj.is_null() && (*obj).ob_type == &MEMORY_ITER_TYPE as *const PyType +} + +/// `array.arrayiterator` — carries `__reduce__` / `__setstate__` but, unlike +/// the str and bytes flavours, no `__length_hint__`. +#[inline] +pub unsafe fn is_array_iter(obj: PyObjectRef) -> bool { + if crate::tagged_int::CAN_BE_TAGGED && crate::tagged_int::is_tagged_int(obj) { + return false; + } + !obj.is_null() && (*obj).ob_type == &ARRAY_ITER_TYPE as *const PyType } #[inline] diff --git a/pyre/pyre-object/src/listobject.rs b/pyre/pyre-object/src/listobject.rs index 21be11f7642..ae1258fd008 100644 --- a/pyre/pyre-object/src/listobject.rs +++ b/pyre/pyre-object/src/listobject.rs @@ -658,6 +658,71 @@ pub fn w_list_new_empty() -> PyObjectRef { w_list_new_object(Vec::new()) } +/// Build the backing storage a `strategy`-strategy list holding `items` needs, +/// without installing it anywhere: the typed blocks (empty unless the matching +/// strategy) first, then the Object-strategy items block. +/// +/// The caller must have pinned every element of `items` — the unboxing and the +/// block allocation below can both collect. The returned block is young; the +/// caller must pin it across any further allocation before storing it. +/// +/// Each typed block stays a bare Rust local until the caller installs it, so +/// every one of them is pinned across the allocations that follow it: old-gen +/// is mark-sweep, and an unrooted block with no heap edge yet is sweepable, not +/// merely immobile (`IntArray::pin_block`). The caller owns the `push_roots` +/// scope those pins live in and closes the bracket with +/// [`ListStorage::reload_typed_blocks`] after its last allocation. +unsafe fn build_list_storage(items: &[PyObjectRef], strategy: ListStrategy) -> ListStorage { + let int_seed: Vec = if let ListStrategy::Integer = strategy { + items.iter().map(|&item| plain_int_w(item)).collect() + } else { + Vec::new() + }; + let int_items = IntArray::from_vec(int_seed); + let int_block_root = int_items.pin_block(); + let float_seed: Vec = if let ListStrategy::Float = strategy { + items.iter().map(|&item| w_float_get_value(item)).collect() + } else { + Vec::new() + }; + let float_items = FloatArray::from_vec(float_seed); + let float_block_root = float_items.pin_block(); + let (length, block) = if let ListStrategy::Object = strategy { + (items.len(), alloc_list_items_block_gc(items)) + } else { + (0usize, std::ptr::null_mut()) + }; + ListStorage { + length, + block, + int_items, + float_items, + int_block_root, + float_block_root, + } +} + +/// Backing storage built but not yet installed, plus the shadow-stack slots the +/// typed blocks are pinned in. +struct ListStorage { + length: usize, + block: *mut ItemsBlock, + int_items: IntArray, + float_items: FloatArray, + int_block_root: usize, + float_block_root: usize, +} + +impl ListStorage { + /// The `pop_roots` half of [`IntArray::pin_block`]'s bracket: re-read both + /// typed blocks once the caller's last allocation is behind them, since + /// both take their heap edge from the store that follows. + fn reload_typed_blocks(&mut self) { + self.int_items.reload_block(self.int_block_root); + self.float_items.reload_block(self.float_block_root); + } +} + fn w_list_new_with_strategy(items: Vec, strategy: ListStrategy) -> PyObjectRef { // `gct_fv_gc_malloc` bracket pattern (`framework.py:853-856`): // pin every PyObjectRef in `items` before the GC malloc paths @@ -672,37 +737,12 @@ fn w_list_new_with_strategy(items: Vec, strategy: ListStrategy) -> crate::gc_roots::pin_root(item); } - // Build the typed backing blocks (empty unless the matching strategy) first, - // then the Object-strategy items block. Each block stays a bare Rust local - // until the `W_ListObject` below is built, so every one of them is pinned - // across the allocations that follow it: old-gen is mark-sweep, and an - // unrooted block with no heap edge yet is sweepable, not merely immobile - // (`IntArray::pin_block`). - let int_seed: Vec = if let ListStrategy::Integer = strategy { - items - .iter() - .map(|&item| unsafe { plain_int_w(item) }) - .collect() - } else { - Vec::new() - }; - let mut int_items = IntArray::from_vec(int_seed); - let int_block_root = int_items.pin_block(); - let float_seed: Vec = if let ListStrategy::Float = strategy { - items - .iter() - .map(|&item| unsafe { w_float_get_value(item) }) - .collect() - } else { - Vec::new() - }; - let mut float_items = FloatArray::from_vec(float_seed); - let float_block_root = float_items.pin_block(); - let (length, mut items_block) = if let ListStrategy::Object = strategy { - (items.len(), unsafe { alloc_list_items_block_gc(&items) }) - } else { - (0usize, std::ptr::null_mut()) - }; + // The nursery `items_block` is allocated last and pinned across the + // `try_gc_alloc_stable` header alloc — the only allocation that can + // relocate it, since the typed-block allocs precede it. The typed blocks + // carry their own pins out of `build_list_storage`. + let mut storage = unsafe { build_list_storage(&items, strategy) }; + let (length, mut items_block) = (storage.length, storage.block); // Phase L2: pin the (possibly young, GC-managed) items block across the // W_ListObject header allocation below — `try_gc_alloc_stable` may trigger a // collection that relocates the nursery block, so re-read its moved address @@ -726,8 +766,12 @@ fn w_list_new_with_strategy(items: Vec, strategy: ListStrategy) -> let raw = crate::gc_hook::try_gc_alloc_stable_raw(W_LIST_GC_TYPE_ID, W_LIST_OBJECT_SIZE); // `pop_roots` for the two typed blocks: this was the last allocation they // had to survive, and both take their heap edge from the struct built below. - int_items.reload_block(int_block_root); - float_items.reload_block(float_block_root); + storage.reload_typed_blocks(); + let ListStorage { + int_items, + float_items, + .. + } = storage; if raw.is_null() { let boxed = Box::new(W_ListObject { ob_header: header, @@ -1460,6 +1504,85 @@ pub unsafe fn w_list_pop_end(obj: PyObjectRef) -> Option { } } +/// The unwrapped Integer-strategy storage `IntegerListStrategy.sort` +/// (listobject.py:1963) orders in place, or `None` when the list holds a +/// different strategy. Handed out as a raw pointer + length because the +/// caller (the `descr_sort` level) owns the sort; nothing in the sort boxes a +/// value, so no collection can move the block while it is ordered. +/// +/// # Safety +/// `obj` must point to a valid `W_ListObject`, and the returned pointer is +/// only valid until the list's storage is next resized or re-strategised. +pub unsafe fn w_list_int_items_raw(obj: PyObjectRef) -> Option<(*mut i64, usize)> { + let list = &mut *(obj as *mut W_ListObject); + if list.strategy != ListStrategy::Integer { + return None; + } + let items = list.int_items.as_mut_slice(); + Some((items.as_mut_ptr(), items.len())) +} + +/// The Float-strategy counterpart of [`w_list_int_items_raw`] +/// (`FloatListStrategy.sort`, listobject.py:2067). +/// +/// # Safety +/// As [`w_list_int_items_raw`]. +pub unsafe fn w_list_float_items_raw(obj: PyObjectRef) -> Option<(*mut f64, usize)> { + let list = &mut *(obj as *mut W_ListObject); + if list.strategy != ListStrategy::Float { + return None; + } + let items = list.float_items.as_mut_slice(); + Some((items.as_mut_ptr(), items.len())) +} + +/// Whether the list still holds the EmptyListStrategy. +/// +/// `descr_sort` (listobject.py:873) uses this to tell whether the user mucked +/// with the receiver while it was emptied for the sort: any mutation switches +/// the list off the Empty strategy and a list never switches back, so an +/// append followed by a pop is caught even though the length is 0 again. +/// +/// # Safety +/// `obj` must point to a valid `W_ListObject`. +pub unsafe fn w_list_is_empty_strategy(obj: PyObjectRef) -> bool { + (*(obj as *const W_ListObject)).strategy == ListStrategy::Empty +} + +/// listobject.py:52 `W_ListObject.__init__` applied to an existing list: +/// re-pick the strategy for `items` and install fresh storage, dropping +/// whatever the list held. This is how `descr_sort` (listobject.py:879) puts +/// the sorted items back — one bulk install, not an append loop, so the +/// storage is sized once and the strategy is decided from the whole item set. +/// +/// # Safety +/// `obj` must point to a valid `W_ListObject`; every element of `items` live. +pub unsafe fn w_list_init_items(obj: PyObjectRef, items: Vec) { + // Build the replacement storage before touching the list: the allocations + // below can collect, and `items` is only reachable through the caller's + // roots until the install. + let _roots = crate::gc_roots::push_roots(); + let obj_slot = crate::gc_roots::shadow_stack_len(); + crate::gc_roots::pin_root(obj); + let strategy = list_strategy_for(&items); + let mut storage = build_list_storage(&items, strategy); + let obj = crate::gc_roots::shadow_stack_get(obj_slot); + let list = &mut *(obj as *mut W_ListObject); + // `drop_object_items`' `try_gc_owns_object` query is a safepoint and the + // fresh blocks have no heap edge until the stores below, so close their pin + // bracket only once it is behind them (`IntArray::install`). + list.drop_object_items(); + storage.reload_typed_blocks(); + list.length = storage.length; + list.items = storage.block; + list.strategy = strategy; + list.int_items = storage.int_items; + list.float_items = storage.float_items; + if strategy == ListStrategy::Object { + list_write_barrier(obj); + } +} + /// listobject.py:391 W_ListObject.clear — switches to EmptyListStrategy. /// /// Drops any typed storage and resets the list to the EmptyListStrategy diff --git a/pyre/pyre-object/src/pyobject.rs b/pyre/pyre-object/src/pyobject.rs index a95f9110469..fa037212076 100644 --- a/pyre/pyre-object/src/pyobject.rs +++ b/pyre/pyre-object/src/pyobject.rs @@ -620,6 +620,11 @@ pub const SUBCLASS_RANGE_HIERARCHY: &[(u32, Option)] = &[ (149, Some(0)), (150, Some(0)), (152, Some(0)), + // `_thread` lock / RLock / handle, registered at the absolute tail of + // `build_gc` for the header `w_class` edge (`all_w_class_only_descriptors`). + (153, Some(0)), + (154, Some(0)), + (155, Some(0)), ]; /// Compute subclass IDs from [`SUBCLASS_RANGE_HIERARCHY`] and write every @@ -1015,6 +1020,15 @@ pub fn all_subclass_range_aliases() -> Vec { subclass_range_alias(21, &crate::function::CLASSMETHOD_TYPE), subclass_range_alias(22, &crate::_pypy_generic_alias::UNION_TYPE), subclass_range_alias(23, &crate::iterobject::SEQ_ITER_TYPE), + // The producer-specific str/bytes/bytearray/memoryview/array iterator + // identities all carry the `W_SeqIterObject` payload, so they share + // its GC type id the way the six dict view iterators share 115. + subclass_range_alias(23, &crate::iterobject::STR_ASCII_ITER_TYPE), + subclass_range_alias(23, &crate::iterobject::STR_ITER_TYPE), + subclass_range_alias(23, &crate::iterobject::BYTES_ITER_TYPE), + subclass_range_alias(23, &crate::iterobject::BYTEARRAY_ITER_TYPE), + subclass_range_alias(23, &crate::iterobject::MEMORY_ITER_TYPE), + subclass_range_alias(23, &crate::iterobject::ARRAY_ITER_TYPE), subclass_range_alias(24, typed::()), subclass_range_alias(25, typed::()), // `enumerate` W_Enumerate — auto-id `allocate_stable` registered at the diff --git a/pyre/pyre-object/src/typedef.rs b/pyre/pyre-object/src/typedef.rs index 09f1fa3f4f4..99672a45abb 100644 --- a/pyre/pyre-object/src/typedef.rs +++ b/pyre/pyre-object/src/typedef.rs @@ -263,6 +263,15 @@ pub const MEMBER_MODULE_DICT: u32 = MEMBER_DIRECT_FLAG | 5; /// CPython 3.14 `complex_members`: `Py_T_DOUBLE`, `Py_READONLY`. pub const MEMBER_COMPLEX_REAL: u32 = MEMBER_DIRECT_FLAG | 6; pub const MEMBER_COMPLEX_IMAG: u32 = MEMBER_DIRECT_FLAG | 7; +/// `descrobject.c descr_members`, shared by every descriptor type: the owning +/// class (`PyDescrObject.d_type`) and the attribute name (`d_name`), both +/// read-only. PyPy publishes the same two values as GetSetProperty +/// (`typedef.py:470-472`, `:538-539`); the descriptor kind is the 3.14 +/// difference. The descriptor payloads here — GetSetProperty, Member and the +/// Function carrier — do not share a header, so the reader dispatches on the +/// receiver instead of reading one fixed offset. +pub const MEMBER_DESCR_OBJCLASS: u32 = MEMBER_DIRECT_FLAG | 8; +pub const MEMBER_DESCR_NAME: u32 = MEMBER_DIRECT_FLAG | 9; /// Create a new Member descriptor. pub fn w_member_new(index: u32, name: String, w_cls: PyObjectRef) -> PyObjectRef { diff --git a/pyre/pyre-object/src/typeobject.rs b/pyre/pyre-object/src/typeobject.rs index 646cb8898bd..00d2a82db9b 100644 --- a/pyre/pyre-object/src/typeobject.rs +++ b/pyre/pyre-object/src/typeobject.rs @@ -1246,6 +1246,62 @@ pub unsafe fn w_type_set_acceptable_as_base_class(obj: PyObjectRef, v: bool) { // ── Subclass tree (typeobject.py:640-689) ──────────────────────────── +// `add_subclass` / `remove_subclass` / `get_subclasses` are indivisible under +// PyPy's GIL: each one reallocates or reindexes the same out-of-line +// `weak_subclasses` vector. Pyre keeps the parent type as the sole semantic +// owner and uses the same narrow address-striped reentrant synchronization +// `w_list_lock` / `w_dict_lock` use around those transitions. Reentrant +// because `get_subclasses` can be reached from inside a mutation on the same +// thread through the `mutated()` recursion. +struct ForkSubclassesLock(std::cell::UnsafeCell>); +unsafe impl Sync for ForkSubclassesLock {} + +impl ForkSubclassesLock { + fn new() -> Self { + Self(std::cell::UnsafeCell::new( + parking_lot::ReentrantMutex::new(()), + )) + } + + fn get(&self) -> &parking_lot::ReentrantMutex<()> { + unsafe { &*self.0.get() } + } + + unsafe fn reinit_after_fork(&self) { + unsafe { self.0.get().write(parking_lot::ReentrantMutex::new(())) }; + } +} + +static SUBCLASSES_LOCKS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| (0..256).map(|_| ForkSubclassesLock::new()).collect()); + +type SubclassesGuard = parking_lot::lock_api::ReentrantMutexGuard< + 'static, + parking_lot::RawMutex, + parking_lot::RawThreadId, + (), +>; + +/// Only the acquire is opaque to the tracer, the same split `w_list_lock` uses: +/// the guard-holding bodies stay look-inside. +#[majit_macros::dont_look_inside] +unsafe fn w_type_subclasses_lock(w_parent: PyObjectRef) -> SubclassesGuard { + let lock = SUBCLASSES_LOCKS[(w_parent as usize >> 4) & (SUBCLASSES_LOCKS.len() - 1)].get(); + if let Some(guard) = lock.try_lock() { + return guard; + } + let blocked = majit_gc::gc_sync::before_external_block(); + let guard = lock.lock(); + drop(blocked); + guard +} + +pub fn subclasses_locks_after_fork_child() { + for lock in SUBCLASSES_LOCKS.iter() { + unsafe { lock.reinit_after_fork() }; + } +} + /// `typeobject.py:640-662 W_TypeObject.add_subclass`. /// /// Records `w_subclass` in `w_parent.weak_subclasses` if not @@ -1267,6 +1323,10 @@ pub unsafe fn w_type_add_subclass(w_parent: PyObjectRef, w_subclass: PyObjectRef if !is_type(w_parent) || !is_type(w_subclass) { return; } + // Serialize against a concurrent `remove_subclass` / `get_subclasses` / + // `add_subclass` on the same parent: the null-check-then-install below and + // the `push` reallocation both invalidate what another thread is indexing. + let _subclasses_guard = w_type_subclasses_lock(w_parent); let parent = &mut *(w_parent as *mut W_TypeObject); // Builtin parents need the prebuilt root walk; this is harmless for a // GC-managed heap parent. @@ -1310,6 +1370,7 @@ pub unsafe fn w_type_remove_subclass(w_parent: PyObjectRef, w_subclass: PyObject if !is_type(w_parent) { return; } + let _subclasses_guard = w_type_subclasses_lock(w_parent); let parent = &mut *(w_parent as *mut W_TypeObject); if parent.weak_subclasses.is_null() { return; @@ -1344,6 +1405,7 @@ pub unsafe fn w_type_get_subclasses( if w_parent.is_null() || !is_type(w_parent) { return Vec::new(); } + let _subclasses_guard = w_type_subclasses_lock(w_parent); let parent = &*(w_parent as *const W_TypeObject); if parent.weak_subclasses.is_null() { return Vec::new(); diff --git a/pyre/pyre-wasm-runner/src/main.rs b/pyre/pyre-wasm-runner/src/main.rs index 8e06ebc2544..ff06d5da516 100644 --- a/pyre/pyre-wasm-runner/src/main.rs +++ b/pyre/pyre-wasm-runner/src/main.rs @@ -219,8 +219,8 @@ fn main() { let source = std::fs::read_to_string(&script) .map_err(|e| format!("read script {}: {e}", script.display()))?; match engine { - WasmEngine::Wasmtime => run(&module_path, &source).map_err(fmt_err), - WasmEngine::Wasmi => wasmi_host::run(&module_path, &source), + WasmEngine::Wasmtime => run(&module_path, &source, &script).map_err(fmt_err), + WasmEngine::Wasmi => wasmi_host::run(&module_path, &source, &script), } }) .expect("spawn worker thread"); @@ -250,7 +250,7 @@ fn fatal(msg: &str) -> ! { std::process::exit(1); } -fn run(module_path: &PathBuf, source: &str) -> Result { +fn run(module_path: &PathBuf, source: &str, script: &Path) -> Result { let mut config = Config::new(); // Allow the interpreter's deep recursion before wasmtime raises a stack // overflow trap; the interpreter's own recursion limit normally fires @@ -372,6 +372,23 @@ fn run(module_path: &PathBuf, source: &str) -> Result { set_force.call(&mut store, selector)?; } + // Name the script so the guest compiles it under its real path: that is + // what a traceback prints, what its source-line lookup reads back through + // `pyre_host.host_read`, and the directory that heads `sys.path`. Absent + // on a module predating the export, leaving the guest on ``. + if let Ok(set_path) = + instance.get_typed_func::<(u32, u32), ()>(&mut store, "pyre_set_script_path") + { + let name = script.to_string_lossy(); + let nlen = name.len() as u32; + if nlen != 0 { + let p = alloc.call(&mut store, nlen)?; + memory.write(&mut store, p as usize, name.as_bytes())?; + set_path.call(&mut store, (p, nlen))?; + dealloc.call(&mut store, (p, nlen))?; + } + } + let src = source.as_bytes(); let len = src.len() as u32; let in_ptr = if len == 0 { @@ -590,6 +607,14 @@ fn run(module_path: &PathBuf, source: &str) -> Result { .get_typed_func::<(), u64>(&mut store, "pyre_jit_execute_count") .and_then(|f| f.call(&mut store, ())) .ok(); + let guest_jit_compile_count = instance + .get_typed_func::<(), u64>(&mut store, "pyre_jit_compile_count") + .and_then(|f| f.call(&mut store, ())) + .ok(); + let guest_jit_compile_cache_hits = instance + .get_typed_func::<(), u64>(&mut store, "pyre_jit_compile_cache_hits") + .and_then(|f| f.call(&mut store, ())) + .ok(); let host = store.data(); eprintln!( "[jit-stats] compiles={} compile_ms={:.1} executes={} jit_calls={} linear_mem={} gc_oldgen={} gc_nursery={} \ @@ -606,6 +631,14 @@ fn run(module_path: &PathBuf, source: &str) -> Result { heap_live_bytes, heap_live_count, ); + if let (Some(materialized), Some(cache_hits)) = + (guest_jit_compile_count, guest_jit_compile_cache_hits) + { + eprintln!( + "[jit-stats] materialized={} compile_cache_hits={}", + materialized, cache_hits + ); + } if !host.exec_hist.is_empty() { let mut v: Vec<_> = host.exec_hist.iter().collect(); v.sort_by(|a, b| b.1.cmp(a.1)); @@ -683,6 +716,23 @@ fn run(module_path: &PathBuf, source: &str) -> Result { memory.read(&store, out_ptr as usize, &mut out)?; dealloc.call(&mut store, (out_ptr, out_len))?; } + // The guest has no descriptors, so its fd-2 bytes and its exit status come + // back through their own exports. Absent on a module predating them, in + // which case the run keeps the old stdout-only, always-0 behaviour. + let mut err_bytes = Vec::new(); + if let Ok(take_stderr) = instance.get_typed_func::<(), u64>(&mut store, "pyre_take_stderr") { + let packed = take_stderr.call(&mut store, ())?; + let (ptr, elen) = ((packed >> 32) as u32, (packed & 0xffff_ffff) as u32); + if elen != 0 { + err_bytes = vec![0u8; elen as usize]; + memory.read(&store, ptr as usize, &mut err_bytes)?; + dealloc.call(&mut store, (ptr, elen))?; + } + } + let exit_code = instance + .get_typed_func::<(), i32>(&mut store, "pyre_exit_code") + .and_then(|f| f.call(&mut store, ())) + .unwrap_or(0); if len != 0 { dealloc.call(&mut store, (in_ptr, len))?; } @@ -690,6 +740,9 @@ fn run(module_path: &PathBuf, source: &str) -> Result { use std::io::Write; std::io::stdout().write_all(&out)?; std::io::stdout().flush()?; + if !err_bytes.is_empty() { + std::io::stderr().write_all(&err_bytes)?; + } startup_lap("dealloc+stdout"); // Exit without running the wasmtime `Store`/`Module`/`Engine` destructors. // Dropping them frees ~40MB of mapped code + linear memory that the OS @@ -700,9 +753,9 @@ fn run(module_path: &PathBuf, source: &str) -> Result { // `PYRE_WASM_FULL_TEARDOWN=1` restores the drops for leak diagnostics. if std::env::var_os("PYRE_WASM_FULL_TEARDOWN").is_none() { std::io::stderr().flush().ok(); - std::process::exit(0); + std::process::exit(exit_code); } - Ok(0) + Ok(exit_code) } /// Load the main module, using a compiled `.cwasm` cache to skip diff --git a/pyre/pyre-wasm-runner/src/wasmi_host.rs b/pyre/pyre-wasm-runner/src/wasmi_host.rs index 56677547f1f..e7423234324 100644 --- a/pyre/pyre-wasm-runner/src/wasmi_host.rs +++ b/pyre/pyre-wasm-runner/src/wasmi_host.rs @@ -73,7 +73,7 @@ fn install_decline_hook() { }); } -pub fn run(module_path: &Path, source: &str) -> Result { +pub fn run(module_path: &Path, source: &str, script: &Path) -> Result { install_decline_hook(); let mut config = Config::default(); // Raise the interpreter's value-stack / recursion ceilings so the deep @@ -121,6 +121,24 @@ pub fn run(module_path: &Path, source: &str) -> Result { .get_typed_func::<(u32, u32), ()>(&store, "pyre_dealloc") .map_err(estr)?; + // Name the script so the guest compiles it under its real path: that is + // what a traceback prints, what its source-line lookup reads back through + // `pyre_host.host_read`, and the directory that heads `sys.path`. Absent + // on a module predating the export, leaving the guest on ``. + if let Ok(set_path) = instance.get_typed_func::<(u32, u32), ()>(&store, "pyre_set_script_path") + { + let name = script.to_string_lossy(); + let nlen = name.len() as u32; + if nlen != 0 { + let p = alloc.call(&mut store, nlen).map_err(estr)?; + memory + .write(&mut store, p as usize, name.as_bytes()) + .map_err(estr)?; + set_path.call(&mut store, (p, nlen)).map_err(estr)?; + dealloc.call(&mut store, (p, nlen)).map_err(estr)?; + } + } + let src = source.as_bytes(); let len = src.len() as u32; let in_ptr = if len == 0 { @@ -160,6 +178,26 @@ pub fn run(module_path: &Path, source: &str) -> Result { .map_err(estr)?; dealloc.call(&mut store, (out_ptr, out_len)).map_err(estr)?; } + // The guest has no descriptors, so its fd-2 bytes and its exit status come + // back through their own exports. Absent on a module predating them, in + // which case the run keeps the old stdout-only, always-0 behaviour. + let mut err_bytes = Vec::new(); + if let Ok(take_stderr) = instance.get_typed_func::<(), u64>(&store, "pyre_take_stderr") { + let packed = take_stderr.call(&mut store, ()).map_err(estr)?; + let (ptr, elen) = ((packed >> 32) as u32, (packed & 0xffff_ffff) as u32); + if elen != 0 { + err_bytes = vec![0u8; elen as usize]; + memory + .read(&store, ptr as usize, &mut err_bytes) + .map_err(estr)?; + dealloc.call(&mut store, (ptr, elen)).map_err(estr)?; + } + } + let exit_code = instance + .get_typed_func::<(), i32>(&store, "pyre_exit_code") + .map_err(estr) + .and_then(|f| f.call(&mut store, ()).map_err(estr)) + .unwrap_or(0); if len != 0 { dealloc.call(&mut store, (in_ptr, len)).map_err(estr)?; } @@ -167,7 +205,10 @@ pub fn run(module_path: &Path, source: &str) -> Result { use std::io::Write; std::io::stdout().write_all(&out).map_err(estr)?; std::io::stdout().flush().map_err(estr)?; - Ok(0) + if !err_bytes.is_empty() { + std::io::stderr().write_all(&err_bytes).map_err(estr)?; + } + Ok(exit_code) } fn build_linker(engine: &Engine) -> Result, String> { diff --git a/pyre/pyre-wasm-test/src/main.rs b/pyre/pyre-wasm-test/src/main.rs index 95a2c6b4721..ae40c54775e 100644 --- a/pyre/pyre-wasm-test/src/main.rs +++ b/pyre/pyre-wasm-test/src/main.rs @@ -5,8 +5,8 @@ thread_local! { static TEST_OUTPUT: RefCell = RefCell::new(String::new()); } -fn capture_print(s: &str) { - TEST_OUTPUT.with(|buf| buf.borrow_mut().push_str(s)); +fn capture_print(bytes: &[u8]) { + TEST_OUTPUT.with(|buf| buf.borrow_mut().push_str(&String::from_utf8_lossy(bytes))); } fn run_test(name: &str, source: &str, expected: &str) { diff --git a/pyre/pyre-wasm/src/lib.rs b/pyre/pyre-wasm/src/lib.rs index 15980adc03c..4a7c2b5a754 100644 --- a/pyre/pyre-wasm/src/lib.rs +++ b/pyre/pyre-wasm/src/lib.rs @@ -325,6 +325,20 @@ pub extern "C" fn pyre_jit_execute_count() -> u64 { majit_backend_wasm::jit_execute_count() } +/// Diagnostic-only: modules that crossed the lazy materialization gate. +#[cfg(all(target_arch = "wasm32", feature = "wasm-host"))] +#[unsafe(no_mangle)] +pub extern "C" fn pyre_jit_compile_count() -> u64 { + majit_backend_wasm::jit_compile_count() +} + +/// Diagnostic-only: byte-identical module cache hits. +#[cfg(all(target_arch = "wasm32", feature = "wasm-host"))] +#[unsafe(no_mangle)] +pub extern "C" fn pyre_jit_compile_cache_hits() -> u64 { + majit_backend_wasm::jit_compile_cache_hits() +} + /// Diagnostic-only: read a guard-failure → bridge-trace gate tally from the /// metainterp (`majit_metainterp::MC_DIAG`). Same export-not-import rationale /// as `pyre_jit_bridge_diag`. @@ -413,12 +427,31 @@ fn install_panic_hook() { #[cfg(any(feature = "web", feature = "wasm-host"))] thread_local! { static OUTPUT_BUF: RefCell = RefCell::new(String::new()); + /// fd-2 capture. wasm32 has no descriptors, so everything the interpreter + /// writes to stderr — tracebacks, warnings, `sys.stderr.write` — is + /// collected here and handed to the embedder separately from stdout. + static ERR_BUF: RefCell> = const { RefCell::new(Vec::new()) }; + /// Process status the run ends with: 0, or `SystemExit`'s code, or 1 for + /// an uncaught exception. `targetpypystandalone.py:37 entry_point` returns + /// the same value; the embedder exits with it. + static EXIT_CODE: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Path the source came from, if the embedder named one. The guest is + /// handed source text, not a file, so without it every code object + /// compiles as `` and a traceback can name neither the file nor + /// the offending line. + static SCRIPT_PATH: RefCell> = const { RefCell::new(None) }; } #[cfg(any(feature = "web", feature = "wasm-host"))] fn install_wasm_print_hook() { - pyre_interpreter::set_print_hook(|s| { - OUTPUT_BUF.with(|buf| buf.borrow_mut().push_str(s)); + pyre_interpreter::set_print_hook(|b| { + // The hook is handed raw fd-1 bytes; this embedder returns a `String` + // to its host, so the decode happens here rather than inside the + // interpreter, where it would corrupt every embedder's output. + OUTPUT_BUF.with(|buf| buf.borrow_mut().push_str(&String::from_utf8_lossy(b))); + }); + pyre_interpreter::set_stderr_hook(|b| { + ERR_BUF.with(|buf| buf.borrow_mut().extend_from_slice(b)); }); } @@ -446,13 +479,36 @@ fn run_python_impl(source: &str) -> String { #[cfg(feature = "web")] pyre_interpreter::importing::mount_embedded_stdlib(std::path::Path::new("/lib-python/3")); #[cfg(all(target_arch = "wasm32", feature = "wasm-host"))] - host_fs_provider::install(); + { + // `pymain_sys_path_add_path0`: the script's directory heads + // `sys.path`, ahead of the stdlib root `install` appends, so a module + // beside the script shadows one of the same name in the stdlib. + if let Some(dir) = SCRIPT_PATH + .with(|p| p.borrow().clone()) + .and_then(|p| std::path::Path::new(&p).parent().map(|d| d.to_path_buf())) + { + pyre_interpreter::importing::add_sys_path(&dir); + } + host_fs_provider::install(); + } install_wasm_print_hook(); OUTPUT_BUF.with(|buf| buf.borrow_mut().clear()); + ERR_BUF.with(|buf| buf.borrow_mut().clear()); + EXIT_CODE.with(|c| c.set(0)); - let code = match compile_source(source, Mode::Exec) { + let filename = SCRIPT_PATH.with(|p| p.borrow().clone()); + let filename = filename.as_deref().unwrap_or(""); + let code = match compile_source_with_filename(source, Mode::Exec, filename) { Ok(code) => code, - Err(e) => return format!("SyntaxError: {e}"), + Err(e) => { + // `pyrex::run_source` renders the same `File "…", line N` + caret + // banner on stderr and exits 1. + pyre_interpreter::eprint_syntax_error(&pyre_interpreter::compile_err_to_syntax_error( + e, source, + )); + EXIT_CODE.with(|c| c.set(1)); + return String::new(); + } }; let execution_context = std::rc::Rc::new(PyExecutionContext::default()); @@ -462,6 +518,17 @@ fn run_python_impl(source: &str) -> String { // JIT-compiled loop — can resolve its parent frame instead of tripping the // fail-fast topframe assert. pyre_interpreter::call::set_last_exec_ctx(std::rc::Rc::as_ptr(&execution_context)); + // objspace.py `space.user_del_action`: the queue every registered + // finalizer's trigger fires into (`finalizer_queue_trigger` reads + // `ec.user_del_action`). Without it the pointer stays null and the trigger + // returns silently, so NOTHING is ever finalized on this entry point — no + // `__del__`, no generator `finally`, not even under an explicit + // `gc.collect()`. `pyrex::setup_exec_context` installs it at boot; this + // entry point is the other launcher and needs the same step. + unsafe { + let ec_ptr = std::rc::Rc::as_ptr(&execution_context) as *mut PyExecutionContext; + (*ec_ptr).install_user_del_action(); + } // Register the __build_class__ callback. Class construction resolves the // live frame from the execution-context slot seeded above. pyre_interpreter::call::register_build_class(); @@ -493,6 +560,7 @@ fn run_python_impl(source: &str) -> String { })) { Ok(r) => r, Err(_) => { + EXIT_CODE.with(|c| c.set(1)); let panic_msg = OUTPUT_BUF.with(|buf| buf.borrow().clone()); return if panic_msg.is_empty() { "[pyre] unknown panic".to_string() @@ -514,21 +582,45 @@ fn run_python_impl(source: &str) -> String { } } Err(e) => { - if !output.is_empty() && !output.ends_with('\n') { - output.push('\n'); + // `pyrex::real_main`: a `SystemExit` sets the status and prints + // nothing; anything else prints its traceback and exits 1. Both + // go to stderr, so the run's stdout stays byte-comparable with the + // native binaries instead of gaining an `Error: …` tail. + if e.kind == pyre_interpreter::PyErrorKind::SystemExit { + EXIT_CODE.with(|c| c.set(pyre_interpreter::system_exit_code(&e))); + } else { + pyre_interpreter::eprint_exception(&e, true); + EXIT_CODE.with(|c| c.set(1)); } - output.push_str(&format!("Error: {e}")); } } output } +/// Bytes the run wrote to fd 2, draining the buffer. +#[cfg(any(feature = "web", feature = "wasm-host"))] +fn take_stderr() -> Vec { + ERR_BUF.with(|buf| std::mem::take(&mut *buf.borrow_mut())) +} + /// Browser / JS entry point: marshalled by wasm-bindgen. +/// +/// The browser has one output channel, so the fd-2 text (traceback, warnings) +/// is appended to the returned string rather than handed over separately the +/// way the C-ABI surface does it. #[cfg(feature = "web")] #[wasm_bindgen] pub fn run_python(source: &str) -> String { - run_python_impl(source) + let mut output = run_python_impl(source); + let err = take_stderr(); + if !err.is_empty() { + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(&String::from_utf8_lossy(&err)); + } + output } /// Native-host (`wasm-host` feature) C-ABI surface. @@ -538,7 +630,12 @@ pub fn run_python(source: &str) -> String { /// 1. `pyre_alloc(len)` → reserve `len` bytes, write the UTF-8 source there; /// 2. `pyre_run_python(ptr, len)` → run it, returns a packed `u64` /// (`hi32` = result pointer, `lo32` = result byte length); -/// 3. read the UTF-8 result, then `pyre_dealloc(ptr, len)` both buffers. +/// 3. read the UTF-8 result, then `pyre_dealloc(ptr, len)` both buffers; +/// 4. `pyre_take_stderr()` → the same packed pair for the run's fd-2 bytes, +/// and `pyre_exit_code()` → the status to exit with. +/// +/// `pyre_set_script_path(ptr, len)` may precede step 2 to name the file the +/// source came from. #[cfg(feature = "wasm-host")] mod host_abi { use super::run_python_impl; @@ -609,29 +706,73 @@ mod host_abi { /// the host must free with `pyre_dealloc`. #[unsafe(no_mangle)] pub extern "C" fn pyre_run_python(ptr: *const u8, len: usize) -> u64 { - let result = if ptr.is_null() || len == 0 { - run_python_impl("") - } else { - // Reject a (ptr, len) that escapes linear memory before forming a - // slice; the embedder supplies these raw, so an out-of-range pair - // would otherwise be undefined behaviour. - let mem_bytes = core::arch::wasm32::memory_size(0).saturating_mul(65536); - match (ptr as usize).checked_add(len) { - Some(end) if end <= mem_bytes => { - let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; - run_python_impl(&String::from_utf8_lossy(bytes)) - } - _ => "Error: input buffer out of wasm memory bounds".to_string(), - } + let result = match guest_str(ptr, len) { + Some(source) => run_python_impl(&source), + None => "Error: input buffer out of wasm memory bounds".to_string(), }; - let out = result.into_bytes(); - let out_len = out.len(); - let out_ptr = pyre_alloc(out_len); - if out_len != 0 { - unsafe { std::ptr::copy_nonoverlapping(out.as_ptr(), out_ptr, out_len) }; + pack_into_guest(result.into_bytes()) + } + + /// Name the file the next `pyre_run_python` source came from. It becomes + /// the compiled code's filename — hence what a traceback prints and what + /// its source-line lookup reads — and its directory heads `sys.path`. + /// Without it the guest only has source text, so both fall back to + /// ``. + #[unsafe(no_mangle)] + pub extern "C" fn pyre_set_script_path(ptr: *const u8, len: usize) { + let path = guest_str(ptr, len).filter(|s| !s.is_empty()); + super::SCRIPT_PATH.with(|p| *p.borrow_mut() = path); + } + + /// Copy `[ptr, ptr+len)` out of linear memory as UTF-8. The embedder + /// supplies the pair raw, so a range escaping linear memory is rejected + /// (`None`) before a slice is formed rather than being undefined + /// behaviour; an empty buffer reads as the empty string. + fn guest_str(ptr: *const u8, len: usize) -> Option { + if ptr.is_null() || len == 0 { + return Some(String::new()); + } + let mem_bytes = core::arch::wasm32::memory_size(0).saturating_mul(65536); + match (ptr as usize).checked_add(len) { + Some(end) if end <= mem_bytes => { + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; + Some(String::from_utf8_lossy(bytes).into_owned()) + } + _ => None, + } + } + + /// Take the bytes the run wrote to fd 2 (traceback, warnings, + /// `sys.stderr.write`) and hand them over the same way as the stdout + /// result: a packed `(ptr << 32) | len` the host frees with + /// `pyre_dealloc`. Draining, so a second call returns an empty buffer. + /// + /// wasm32 has no descriptors, so without this export everything the + /// interpreter writes to stderr is discarded. + #[unsafe(no_mangle)] + pub extern "C" fn pyre_take_stderr() -> u64 { + pack_into_guest(super::take_stderr()) + } + + /// Status the last `pyre_run_python` ended with: `SystemExit`'s code, 1 for + /// an uncaught exception or a `SyntaxError`, else 0. The host exits with + /// it, as `pyrex` does with `targetpypystandalone.py:37 entry_point`'s + /// return value. + #[unsafe(no_mangle)] + pub extern "C" fn pyre_exit_code() -> i32 { + super::EXIT_CODE.with(|c| c.get()) + } + + /// Copy `bytes` into a fresh guest buffer and pack its pointer and length + /// into the `(hi32, lo32)` pair every result-returning export uses. + fn pack_into_guest(bytes: Vec) -> u64 { + let len = bytes.len(); + let ptr = pyre_alloc(len); + if len != 0 { + unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, len) }; } - ((out_ptr as u64) << 32) | (out_len as u64) + ((ptr as u64) << 32) | (len as u64) } } diff --git a/pyre/pyrex/src/lib.rs b/pyre/pyrex/src/lib.rs index e855c02bf72..943ec4fec1e 100644 --- a/pyre/pyrex/src/lib.rs +++ b/pyre/pyrex/src/lib.rs @@ -1187,27 +1187,24 @@ fn finalize_runtime(canonical: pyre_object::PyObjectRef, ec_ptr: *const PyExecut } } -/// Keep the object references carried by a pending SystemExit live and -/// relocatable while interpreter finalization performs collections. PyPy's -/// OperationError is GC-visible RPython state; pyre's Rust `PyError` is not, so -/// its three raw PyObjectRef fields need the same explicit shadow-root -/// treatment as other host-stack temporaries. +/// Resolve a pending `SystemExit`'s status, then finalize and exit with it. +/// +/// The order matters: `app_main.py:114-129 handle_sys_exit` runs at +/// application level, i.e. *before* `targetpypystandalone.py:88 +/// finally: space.finish()`. Resolving `e.code` is an ordinary attribute +/// lookup that walks the exception's type and that type's dict, and +/// `finalize_runtime` collects — pinning the three raw `PyObjectRef` fields +/// of the Rust `PyError` (which, unlike PyPy's GC-visible `OperationError`, +/// the collector cannot see) does not keep that type's dict alive. Reading +/// the code after the collection spun forever inside the type dict's +/// `IndexMap` probe for a user-defined `SystemExit` subclass. fn finalize_system_exit( - mut error: pyre_interpreter::PyError, + error: pyre_interpreter::PyError, canonical: pyre_object::PyObjectRef, ec_ptr: *const PyExecutionContext, ) -> ! { - let roots = pyre_object::gc_roots::push_roots(); - let base = pyre_object::gc_roots::shadow_stack_len(); - for value in [error.exc_object, error.w_name_context, error.w_obj_context] { - pyre_object::gc_roots::pin_root(value); - } + let code = pyre_interpreter::system_exit_code(&error); finalize_runtime(canonical, ec_ptr); - error.exc_object = pyre_object::gc_roots::shadow_stack_get(base); - error.w_name_context = pyre_object::gc_roots::shadow_stack_get(base + 1); - error.w_obj_context = pyre_object::gc_roots::shadow_stack_get(base + 2); - let code = system_exit_code(&error); - drop(roots); maybe_print_jit_stats(); std::process::exit(code); } @@ -1269,7 +1266,41 @@ fn run_module(module: &str, no_site: bool) { maybe_print_jit_stats(); } +/// `os.path.abspath` for the run filename, or `None` for the `-c ""` +/// command path, which has no script. +/// +/// A relative path resolves against `sys_path_cwd()` — the seam-provided +/// virtual cwd under sandbox — before normalizing, so `std::path::absolute` +/// never consults (and leaks) the trusted process cwd. Off sandbox this is +/// identical to `absolute(filename)`. +fn absolute_script_path(filename: &str) -> Option { + if filename == "" { + return None; + } + if filename == "" { + // A stdin run records the literal ``: there is no path to + // absolutize. + return Some(filename.to_string()); + } + let path = Path::new(filename); + let to_absolutize = if path.is_absolute() { + path.to_path_buf() + } else { + sys_path_cwd().join(path) + }; + Some(match std::path::absolute(&to_absolutize) { + Ok(p) => p.to_string_lossy().into_owned(), + Err(_) => filename.to_string(), + }) +} + fn run_source(source: &str, mode: Mode, filename: &str, no_site: bool) { + // `config_run_filename_abspath` absolutizes the run filename before the + // module is compiled, so `co_filename` — and with it every `File "…"` line + // a traceback prints — carries the absolute path, not the literal argv + // spelling. `sys.argv[0]` keeps the spelling and is set elsewhere. + let main_file = absolute_script_path(filename); + let filename = main_file.as_deref().unwrap_or(filename); let code = match compile_source_with_filename(source, mode, filename) { Ok(code) => code, Err(e) => { @@ -1342,27 +1373,6 @@ fn run_source(source: &str, mode: Mode, filename: &str, no_site: bool) { // `sys.argv[0]` keeps the literal command-line path. A stdin run records // the literal ``: there is no path to absolutize and no file to // bind a `SourceFileLoader` to. - let main_file: Option = match filename { - "" => None, - "" => Some(filename.to_string()), - _ => { - // Resolve a relative path against `sys_path_cwd()` — the - // seam-provided virtual cwd under sandbox — before normalizing, so - // `std::path::absolute` never consults (and leaks) the trusted - // process cwd. Off sandbox this is identical to - // `absolute(filename)`. - let path = Path::new(filename); - let to_absolutize = if path.is_absolute() { - path.to_path_buf() - } else { - sys_path_cwd().join(path) - }; - Some(match std::path::absolute(&to_absolutize) { - Ok(p) => p.to_string_lossy().into_owned(), - Err(_) => filename.to_string(), - }) - } - }; if let Some(file) = main_file.as_deref() { let _ = pyre_interpreter::baseobjspace::setattr_str( main_module, @@ -1424,37 +1434,6 @@ fn run_source(source: &str, mode: Mode, filename: &str, no_site: bool) { maybe_print_jit_stats(); } -/// app_main.py:114-129 `handle_sys_exit` — `exitcode = e.code`; None -/// exits 0; otherwise `int(exitcode)` and a value `int()` rejects is -/// printed to stderr with exit status 1. `e.code` itself is `args[0]` -/// for a 1-arg raise and the whole args tuple otherwise -/// (interp_exceptions.py:993-998 `W_SystemExit.descr_init`). -fn system_exit_code(e: &pyre_interpreter::PyError) -> i32 { - let exc = e.exc_object; - if exc.is_null() { - // No object-backed SystemExit means no `code` attribute (the - // class default None), i.e. a success exit. - return 0; - } - let code = match pyre_interpreter::getattr(exc, pyre_object::w_str_new("code")) { - Ok(c) => c, - Err(_) => return 1, - }; - if unsafe { pyre_object::is_none(code) } { - return 0; - } - match pyre_interpreter::builtins::builtin_int(&[code]) { - Ok(w_int) => unsafe { pyre_object::w_int_get_value(w_int) as i32 }, - Err(_) => { - // app_main.py:124-125 `print(exitcode, file=sys.stderr)`. - let text = unsafe { pyre_interpreter::display::py_str(code) } - .unwrap_or_else(|_| "".to_string()); - eprintln!("{text}"); - 1 - } - } -} - #[cfg(test)] mod tests { use super::parse_heapsize; diff --git a/pyre/pyrex/src/repl.rs b/pyre/pyrex/src/repl.rs index 6dedc8cb8bf..2310897ff02 100644 --- a/pyre/pyrex/src/repl.rs +++ b/pyre/pyrex/src/repl.rs @@ -182,7 +182,7 @@ pub fn run_repl(quiet: bool, no_site: bool) { if err.kind == pyre_interpreter::PyErrorKind::SystemExit { // exit()/quit() raise SystemExit; terminate the REPL with // its code instead of printing a traceback and continuing. - pending_exit = Some(crate::system_exit_code(&err)); + pending_exit = Some(pyre_interpreter::system_exit_code(&err)); break; } pyre_interpreter::eprint_exception(&err, true);