From c57d594f73576c9e316972e83a2fcc4df730882e Mon Sep 17 00:00:00 2001 From: Max Bernstein Date: Fri, 27 Feb 2026 15:00:29 -0500 Subject: [PATCH 01/11] ZJIT: Use LoadField for Class/Module ivars (#16252) Assume only one box (root box) and invalidate otherwise. Drops C calls to `rb_ivar_get_at_no_ractor_check`. Before: ``` Top-20 calls to C functions from JIT code (77.3% of total 64,311,573): rb_vm_opt_send_without_block: 11,939,854 (18.6%) rb_hash_aref: 5,400,091 ( 8.4%) rb_vm_invokeblock: 4,453,357 ( 6.9%) rb_zjit_writebarrier_check_immediate: 4,279,890 ( 6.7%) rb_vm_getinstancevariable: 3,504,908 ( 5.4%) rb_vm_send: 3,103,424 ( 4.8%) rb_ivar_get_at_no_ractor_check: 2,864,766 ( 4.5%) rb_obj_is_kind_of: 2,313,479 ( 3.6%) rb_hash_aset: 1,903,359 ( 3.0%) Hash#fetch: 1,639,937 ( 2.5%) rb_vm_setinstancevariable: 1,596,791 ( 2.5%) rb_vm_opt_getconstant_path: 1,328,761 ( 2.1%) rb_jit_ary_push: 960,563 ( 1.5%) rb_ec_ary_new_from_values: 722,913 ( 1.1%) rb_class_allocate_instance: 721,483 ( 1.1%) fetch: 713,134 ( 1.1%) rb_str_buf_append: 667,545 ( 1.0%) rb_ivar_get: 585,817 ( 0.9%) rb_hash_new_with_size: 520,347 ( 0.8%) rb_vm_sendforward: 479,029 ( 0.7%) ``` After: ``` Top-20 calls to C functions from JIT code (76.5% of total 62,282,359): rb_vm_opt_send_without_block: 11,939,850 (19.2%) rb_hash_aref: 5,400,092 ( 8.7%) rb_vm_invokeblock: 4,453,357 ( 7.2%) rb_zjit_writebarrier_check_immediate: 4,279,893 ( 6.9%) rb_vm_getinstancevariable: 3,504,920 ( 5.6%) rb_vm_send: 3,103,441 ( 5.0%) rb_obj_is_kind_of: 2,313,510 ( 3.7%) rb_hash_aset: 1,903,359 ( 3.1%) Hash#fetch: 1,639,937 ( 2.6%) rb_vm_setinstancevariable: 1,596,797 ( 2.6%) rb_vm_opt_getconstant_path: 1,328,761 ( 2.1%) rb_jit_ary_push: 960,563 ( 1.5%) rb_ivar_get_at_no_ractor_check: 835,498 ( 1.3%) rb_ec_ary_new_from_values: 722,921 ( 1.2%) rb_class_allocate_instance: 721,492 ( 1.2%) fetch: 713,135 ( 1.1%) rb_str_buf_append: 667,545 ( 1.1%) rb_ivar_get: 585,815 ( 0.9%) rb_hash_new_with_size: 520,348 ( 0.8%) rb_vm_sendforward: 479,029 ( 0.8%) ``` The remaining `rb_ivar_get_at_no_ractor_check` are due to TypedData access, mostly on `Thread`. --- box.c | 4 ++ depend | 1 + jit.c | 12 +++++ yjit/src/cruby_bindings.inc.rs | 1 + zjit.h | 2 + zjit/bindgen/src/main.rs | 1 + zjit/src/codegen.rs | 6 ++- zjit/src/cruby.rs | 5 ++ zjit/src/cruby_bindings.inc.rs | 2 + zjit/src/hir.rs | 61 ++++++++++++++++++++++- zjit/src/hir/opt_tests.rs | 90 +++++++++++++++++++++++++++++++--- zjit/src/invariants.rs | 48 ++++++++++++++++++ zjit/src/profile.rs | 12 +++++ zjit/src/stats.rs | 3 ++ 14 files changed, 237 insertions(+), 11 deletions(-) diff --git a/box.c b/box.c index 0c8c1ca193f1c7..4213f635601ce8 100644 --- a/box.c +++ b/box.c @@ -18,6 +18,7 @@ #include "ruby/util.h" #include "vm_core.h" #include "darray.h" +#include "zjit.h" #include @@ -390,6 +391,9 @@ box_initialize(VALUE box_value) rb_ivar_set(box_value, id_box_entry, entry); + // Invalidate ZJIT code that assumes only the root box is active + rb_zjit_invalidate_root_box(); + return box_value; } diff --git a/depend b/depend index 01fae06efba64b..b941d1490e3238 100644 --- a/depend +++ b/depend @@ -980,6 +980,7 @@ box.$(OBJEXT): {$(VPATH)}vm_core.h box.$(OBJEXT): {$(VPATH)}vm_debug.h box.$(OBJEXT): {$(VPATH)}vm_opts.h box.$(OBJEXT): {$(VPATH)}vm_sync.h +box.$(OBJEXT): {$(VPATH)}zjit.h builtin.$(OBJEXT): $(CCAN_DIR)/check_type/check_type.h builtin.$(OBJEXT): $(CCAN_DIR)/container_of/container_of.h builtin.$(OBJEXT): $(CCAN_DIR)/list/list.h diff --git a/jit.c b/jit.c index c0b8f901ad8903..2d3145e3f3a366 100644 --- a/jit.c +++ b/jit.c @@ -16,12 +16,17 @@ #include "vm_sync.h" #include "internal/fixnum.h" #include "internal/string.h" +#include "internal/class.h" +#include "internal/imemo.h" enum jit_bindgen_constants { // Field offsets for the RObject struct ROBJECT_OFFSET_AS_HEAP_FIELDS = offsetof(struct RObject, as.heap.fields), ROBJECT_OFFSET_AS_ARY = offsetof(struct RObject, as.ary), + // Field offset for prime classext's fields_obj from a class pointer + RCLASS_OFFSET_PRIME_FIELDS_OBJ = offsetof(struct RClass_and_rb_classext_t, classext.fields_obj), + // Field offsets for the RString struct RUBY_OFFSET_RSTRING_LEN = offsetof(struct RString, len), @@ -529,6 +534,13 @@ rb_jit_multi_ractor_p(void) return rb_multi_ractor_p(); } +bool +rb_jit_class_fields_embedded_p(VALUE klass) +{ + VALUE fields_obj = RCLASS_EXT_PRIME(klass)->fields_obj; + return !fields_obj || !FL_TEST_RAW(fields_obj, OBJ_FIELD_HEAP); +} + // Acquire the VM lock and then signal all other Ruby threads (ractors) to // contend for the VM lock, putting them to sleep. ZJIT and YJIT use this to // evict threads running inside generated code so among other things, it can diff --git a/yjit/src/cruby_bindings.inc.rs b/yjit/src/cruby_bindings.inc.rs index 0f59417fd5ddbc..919d1e18afaea0 100644 --- a/yjit/src/cruby_bindings.inc.rs +++ b/yjit/src/cruby_bindings.inc.rs @@ -987,6 +987,7 @@ pub type rb_seq_param_keyword_struct = rb_iseq_constant_body_rb_iseq_parameters_rb_iseq_param_keyword; pub const ROBJECT_OFFSET_AS_HEAP_FIELDS: jit_bindgen_constants = 16; pub const ROBJECT_OFFSET_AS_ARY: jit_bindgen_constants = 16; +pub const RCLASS_OFFSET_PRIME_FIELDS_OBJ: jit_bindgen_constants = 40; pub const RUBY_OFFSET_RSTRING_LEN: jit_bindgen_constants = 16; pub const RUBY_OFFSET_EC_CFP: jit_bindgen_constants = 16; pub const RUBY_OFFSET_EC_INTERRUPT_FLAG: jit_bindgen_constants = 32; diff --git a/zjit.h b/zjit.h index 47240846ff1db0..f42b77cb356dac 100644 --- a/zjit.h +++ b/zjit.h @@ -28,6 +28,7 @@ void rb_zjit_iseq_free(const rb_iseq_t *iseq); void rb_zjit_before_ractor_spawn(void); void rb_zjit_tracing_invalidate_all(void); void rb_zjit_invalidate_no_singleton_class(VALUE klass); +void rb_zjit_invalidate_root_box(void); #else #define rb_zjit_entry 0 static inline void rb_zjit_compile_iseq(const rb_iseq_t *iseq, bool jit_exception) {} @@ -40,6 +41,7 @@ static inline void rb_zjit_constant_state_changed(ID id) {} static inline void rb_zjit_before_ractor_spawn(void) {} static inline void rb_zjit_tracing_invalidate_all(void) {} static inline void rb_zjit_invalidate_no_singleton_class(VALUE klass) {} +static inline void rb_zjit_invalidate_root_box(void) {} #endif // #if USE_ZJIT #define rb_zjit_enabled_p (rb_zjit_entry != 0) diff --git a/zjit/bindgen/src/main.rs b/zjit/bindgen/src/main.rs index dab1a6d92985d6..957c29dc154505 100644 --- a/zjit/bindgen/src/main.rs +++ b/zjit/bindgen/src/main.rs @@ -309,6 +309,7 @@ fn main() { .allowlist_function("rb_assert_holding_vm_lock") .allowlist_function("rb_jit_shape_too_complex_p") .allowlist_function("rb_jit_multi_ractor_p") + .allowlist_function("rb_jit_class_fields_embedded_p") .allowlist_function("rb_jit_vm_lock_then_barrier") .allowlist_function("rb_jit_vm_unlock") .allowlist_function("rb_jit_for_each_iseq") diff --git a/zjit/src/codegen.rs b/zjit/src/codegen.rs index 2e665927055006..a715aaae9b9581 100644 --- a/zjit/src/codegen.rs +++ b/zjit/src/codegen.rs @@ -10,7 +10,8 @@ use std::slice; use crate::backend::current::ALLOC_REGS; use crate::invariants::{ track_bop_assumption, track_cme_assumption, track_no_ep_escape_assumption, track_no_trace_point_assumption, - track_single_ractor_assumption, track_stable_constant_names_assumption, track_no_singleton_class_assumption + track_single_ractor_assumption, track_stable_constant_names_assumption, track_no_singleton_class_assumption, + track_root_box_assumption }; use crate::gc::append_gc_offsets; use crate::payload::{get_or_create_iseq_payload, IseqCodePtrs, IseqVersion, IseqVersionRef, IseqStatus}; @@ -874,6 +875,9 @@ pub fn split_patch_point(asm: &mut Assembler, target: &Target, invariant: Invari Invariant::NoSingletonClass { klass } => { track_no_singleton_class_assumption(klass, code_ptr, side_exit_ptr, version); } + Invariant::RootBoxOnly => { + track_root_box_assumption(code_ptr, side_exit_ptr, version); + } } }); } diff --git a/zjit/src/cruby.rs b/zjit/src/cruby.rs index e51853fa24842f..39ed65d45600c3 100644 --- a/zjit/src/cruby.rs +++ b/zjit/src/cruby.rs @@ -581,6 +581,10 @@ impl VALUE { } } + pub fn class_fields_embedded_p(self) -> bool { + unsafe { rb_jit_class_fields_embedded_p(self) } + } + pub fn as_fixnum(self) -> i64 { assert!(self.fixnum_p()); (self.0 as i64) >> 1 @@ -1491,6 +1495,7 @@ pub(crate) mod ids { name: aref content: b"[]" name: len name: _as_heap + name: _fields_obj name: thread_ptr name: self_ content: b"self" name: rb_ivar_get_at_no_ractor_check diff --git a/zjit/src/cruby_bindings.inc.rs b/zjit/src/cruby_bindings.inc.rs index 172196c312d198..b8e90fdf6f5b69 100644 --- a/zjit/src/cruby_bindings.inc.rs +++ b/zjit/src/cruby_bindings.inc.rs @@ -1875,6 +1875,7 @@ pub const ISEQ_BODY_OFFSET_PARAM: zjit_struct_offsets = 16; pub type zjit_struct_offsets = u32; pub const ROBJECT_OFFSET_AS_HEAP_FIELDS: jit_bindgen_constants = 16; pub const ROBJECT_OFFSET_AS_ARY: jit_bindgen_constants = 16; +pub const RCLASS_OFFSET_PRIME_FIELDS_OBJ: jit_bindgen_constants = 40; pub const RUBY_OFFSET_RSTRING_LEN: jit_bindgen_constants = 16; pub const RUBY_OFFSET_EC_CFP: jit_bindgen_constants = 16; pub const RUBY_OFFSET_EC_INTERRUPT_FLAG: jit_bindgen_constants = 32; @@ -2227,6 +2228,7 @@ unsafe extern "C" { pub fn rb_set_cfp_sp(cfp: *mut rb_control_frame_struct, sp: *mut VALUE); pub fn rb_jit_shape_too_complex_p(shape_id: shape_id_t) -> bool; pub fn rb_jit_multi_ractor_p() -> bool; + pub fn rb_jit_class_fields_embedded_p(klass: VALUE) -> bool; pub fn rb_jit_vm_lock_then_barrier( recursive_lock_level: *mut ::std::os::raw::c_uint, file: *const ::std::os::raw::c_char, diff --git a/zjit/src/hir.rs b/zjit/src/hir.rs index d531b6d8fa1289..a02b54b9bb73e8 100644 --- a/zjit/src/hir.rs +++ b/zjit/src/hir.rs @@ -7,7 +7,7 @@ #![allow(clippy::match_like_matches_macro)] use crate::{ backend::lir::C_ARG_OPNDS, - cast::IntoUsize, codegen::local_idx_to_ep_offset, cruby::*, invariants::has_singleton_class_of, payload::{get_or_create_iseq_payload, IseqPayload}, options::{debug, get_option, DumpHIR}, state::ZJITState, json::Json + cast::IntoUsize, codegen::local_idx_to_ep_offset, cruby::*, invariants::{self, has_singleton_class_of}, payload::{get_or_create_iseq_payload, IseqPayload}, options::{debug, get_option, DumpHIR}, state::ZJITState, json::Json }; use std::{ cell::RefCell, collections::{BTreeSet, HashMap, HashSet, VecDeque}, ffi::{c_void, c_uint, c_int, CStr}, fmt::Display, mem::{align_of, size_of}, ptr, slice::Iter @@ -151,6 +151,9 @@ pub enum Invariant { NoSingletonClass { klass: VALUE, }, + /// Only the root box is active, so we can safely read from the prime classext. + /// Invalidated if a non-root box duplicates any classext. + RootBoxOnly, } impl Invariant { @@ -290,6 +293,7 @@ impl<'a> std::fmt::Display for InvariantPrinter<'a> { class_name, self.ptr_map.map_ptr(klass.as_ptr::())) } + Invariant::RootBoxOnly => write!(f, "RootBoxOnly"), } } } @@ -2182,6 +2186,17 @@ impl Function { } } + /// Assume that only the root box is active, so we can safely read from the prime classext. + /// Returns true if safe to assume so and emits a PatchPoint. + pub fn assume_root_box(&mut self, block: BlockId, state: InsnId) -> bool { + if invariants::non_root_box_created() { + false + } else { + self.push_insn(block, Insn::PatchPoint { invariant: Invariant::RootBoxOnly, state }); + true + } + } + /// Assume that objects of a given class will have no singleton class. /// Returns true if safe to assume so and emits a PatchPoint. /// Returns false if we've already seen a singleton class for this class, @@ -3899,10 +3914,52 @@ impl Function { // entered the compiler. That means we can just return nil for this // shape + iv name self.push_insn(block, Insn::Const { val: Const::Value(Qnil) }) + } else if recv_type.flags().is_t_class_or_module() { + // Class/module ivar: load from prime classext's fields_obj + if self.assume_root_box(block, state) { + // Root box only: load directly from prime classext + let fields_obj = self.push_insn(block, Insn::LoadField { + recv: self_val, id: ID!(_fields_obj), + offset: RCLASS_OFFSET_PRIME_FIELDS_OBJ as i32, + return_type: types::RubyValue, + }); + if recv_type.flags().is_fields_embedded() { + let offset = ROBJECT_OFFSET_AS_ARY as i32 + + (SIZEOF_VALUE * ivar_index.to_usize()) as i32; + self.push_insn(block, Insn::LoadField { + recv: fields_obj, id, offset, + return_type: types::BasicObject, + }) + } else { + let ptr = self.push_insn(block, Insn::LoadField { + recv: fields_obj, id: ID!(_as_heap), + offset: ROBJECT_OFFSET_AS_HEAP_FIELDS as i32, + return_type: types::CPtr, + }); + let offset = SIZEOF_VALUE_I32 * ivar_index as i32; + self.push_insn(block, Insn::LoadField { + recv: ptr, id, offset, + return_type: types::BasicObject, + }) + } + } else { + // Non-root box active: fall back to C call + // NOTE: it's fine to use rb_ivar_get_at_no_ractor_check because + // getinstancevariable does assume_single_ractor_mode() + let ivar_index_insn = self.push_insn(block, Insn::Const { val: Const::CUInt16(ivar_index as u16) }); + self.push_insn(block, Insn::CCall { + cfunc: rb_ivar_get_at_no_ractor_check as *const u8, + recv: self_val, + args: vec![ivar_index_insn], + name: ID!(rb_ivar_get_at_no_ractor_check), + return_type: types::BasicObject, + elidable: true }) + } } else if !recv_type.flags().is_t_object() { + // Non-T_OBJECT, non-class/module (e.g. T_DATA): fall back to C call // NOTE: it's fine to use rb_ivar_get_at_no_ractor_check because // getinstancevariable does assume_single_ractor_mode() - let ivar_index_insn: InsnId = self.push_insn(block, Insn::Const { val: Const::CUInt16(ivar_index as u16) }); + let ivar_index_insn = self.push_insn(block, Insn::Const { val: Const::CUInt16(ivar_index as u16) }); self.push_insn(block, Insn::CCall { cfunc: rb_ivar_get_at_no_ractor_check as *const u8, recv: self_val, diff --git a/zjit/src/hir/opt_tests.rs b/zjit/src/hir/opt_tests.rs index e58e61f3c4e182..1a68242169d8f1 100644 --- a/zjit/src/hir/opt_tests.rs +++ b/zjit/src/hir/opt_tests.rs @@ -7117,7 +7117,7 @@ mod hir_opt_tests { } #[test] - fn test_optimize_getivar_on_module() { + fn test_optimize_getivar_on_module_embedded() { eval(" module M @foo = 42 @@ -7140,15 +7140,52 @@ mod hir_opt_tests { v17:HeapBasicObject = GuardType v6, HeapBasicObject v18:CShape = LoadField v17, :_shape_id@0x1000 v19:CShape[0x1001] = GuardBitEquals v18, CShape(0x1001) - v20:CUInt16[0] = Const CUInt16(0) - v21:BasicObject = CCall v17, :rb_ivar_get_at_no_ractor_check@0x1008, v20 + PatchPoint RootBoxOnly + v21:RubyValue = LoadField v17, :_fields_obj@0x1002 + v22:BasicObject = LoadField v21, :@foo@0x1003 CheckInterrupts - Return v21 + Return v22 + "); + } + + #[test] + fn test_optimize_getivar_on_module_extended() { + eval(r#" + module M + @foo = 42 + for i in 0...1000 + instance_variable_set("@v#{i}", i) + end + def self.test = @foo + end + M.test + "#); + assert_snapshot!(hir_string_proc("M.method(:test)"), @r" + fn test@:7: + bb1(): + EntryPoint interpreter + v1:BasicObject = LoadSelf + Jump bb3(v1) + bb2(): + EntryPoint JIT(0) + v4:BasicObject = LoadArg :self@0 + Jump bb3(v4) + bb3(v6:BasicObject): + PatchPoint SingleRactorMode + v17:HeapBasicObject = GuardType v6, HeapBasicObject + v18:CShape = LoadField v17, :_shape_id@0x1000 + v19:CShape[0x1001] = GuardBitEquals v18, CShape(0x1001) + PatchPoint RootBoxOnly + v21:RubyValue = LoadField v17, :_fields_obj@0x1002 + v22:CPtr = LoadField v21, :_as_heap@0x1003 + v23:BasicObject = LoadField v22, :@foo@0x1004 + CheckInterrupts + Return v23 "); } #[test] - fn test_optimize_getivar_on_class() { + fn test_optimize_getivar_on_class_embedded() { eval(" class C @foo = 42 @@ -7171,10 +7208,47 @@ mod hir_opt_tests { v17:HeapBasicObject = GuardType v6, HeapBasicObject v18:CShape = LoadField v17, :_shape_id@0x1000 v19:CShape[0x1001] = GuardBitEquals v18, CShape(0x1001) - v20:CUInt16[0] = Const CUInt16(0) - v21:BasicObject = CCall v17, :rb_ivar_get_at_no_ractor_check@0x1008, v20 + PatchPoint RootBoxOnly + v21:RubyValue = LoadField v17, :_fields_obj@0x1002 + v22:BasicObject = LoadField v21, :@foo@0x1003 CheckInterrupts - Return v21 + Return v22 + "); + } + + #[test] + fn test_optimize_getivar_on_class_extended() { + eval(r#" + class C + @foo = 42 + for i in 0...1000 + instance_variable_set("@v#{i}", i) + end + def self.test = @foo + end + C.test + "#); + assert_snapshot!(hir_string_proc("C.method(:test)"), @r" + fn test@:7: + bb1(): + EntryPoint interpreter + v1:BasicObject = LoadSelf + Jump bb3(v1) + bb2(): + EntryPoint JIT(0) + v4:BasicObject = LoadArg :self@0 + Jump bb3(v4) + bb3(v6:BasicObject): + PatchPoint SingleRactorMode + v17:HeapBasicObject = GuardType v6, HeapBasicObject + v18:CShape = LoadField v17, :_shape_id@0x1000 + v19:CShape[0x1001] = GuardBitEquals v18, CShape(0x1001) + PatchPoint RootBoxOnly + v21:RubyValue = LoadField v17, :_fields_obj@0x1002 + v22:CPtr = LoadField v21, :_as_heap@0x1003 + v23:BasicObject = LoadField v22, :@foo@0x1004 + CheckInterrupts + Return v23 "); } diff --git a/zjit/src/invariants.rs b/zjit/src/invariants.rs index 7aa13cbfcb4bdd..eacc5d761b31d4 100644 --- a/zjit/src/invariants.rs +++ b/zjit/src/invariants.rs @@ -96,6 +96,12 @@ pub struct Invariants { /// Map from a class to a set of patch points that assume objects of the class /// will have no singleton class. no_singleton_class_patch_points: HashMap>, + + /// Set of patch points that assume only the root box is active + root_box_patch_points: HashSet, + + /// Whether a non-root box has ever been created + non_root_box_created: bool, } impl Invariants { @@ -439,6 +445,48 @@ pub extern "C" fn rb_zjit_tracing_invalidate_all() { }); } +/// Track the JIT code that assumes only the root box is active +pub fn track_root_box_assumption( + patch_point_ptr: CodePtr, + side_exit_ptr: CodePtr, + version: IseqVersionRef, +) { + let invariants = ZJITState::get_invariants(); + invariants.root_box_patch_points.insert(PatchPoint::new( + patch_point_ptr, + side_exit_ptr, + version, + )); +} + +/// Returns true if a non-root box has ever been created. +pub fn non_root_box_created() -> bool { + ZJITState::get_invariants().non_root_box_created +} + +/// Callback for when a non-root box is created. In that case we need to +/// invalidate every block that assumes root-box-only mode. +#[unsafe(no_mangle)] +pub extern "C" fn rb_zjit_invalidate_root_box() { + // If ZJIT isn't enabled, do nothing + if !zjit_enabled_p() { + return; + } + + with_vm_lock(src_loc!(), || { + let invariants = ZJITState::get_invariants(); + invariants.non_root_box_created = true; + + let cb = ZJITState::get_code_block(); + let patch_points = mem::take(&mut invariants.root_box_patch_points); + + // Invalidate all patch points for root box mode + compile_patch_points!(cb, patch_points, "Non-root box created, invalidating root box assumption"); + + cb.mark_all_executable(); + }); +} + /// Returns true if we've seen a singleton class of a given class since boot. /// This is used to avoid an invalidation loop where we repeatedly compile code /// that assumes no singleton class, only to have it invalidated. diff --git a/zjit/src/profile.rs b/zjit/src/profile.rs index 98dd5d5490edcd..4ec79b2e4631ed 100644 --- a/zjit/src/profile.rs +++ b/zjit/src/profile.rs @@ -203,6 +203,10 @@ impl Flags { const IS_STRUCT_EMBEDDED: u32 = 1 << 3; /// Set if the ProfiledType is used for profiling specific objects, not just classes/shapes const IS_OBJECT_PROFILING: u32 = 1 << 4; + /// Class/module fields_obj is embedded (or absent) + const IS_FIELDS_EMBEDDED: u32 = 1 << 5; + /// Object is a T_CLASS or T_MODULE + const IS_T_CLASS_OR_MODULE: u32 = 1 << 6; pub fn none() -> Self { Self(Self::NONE) } @@ -212,6 +216,8 @@ impl Flags { pub fn is_t_object(self) -> bool { (self.0 & Self::IS_T_OBJECT) != 0 } pub fn is_struct_embedded(self) -> bool { (self.0 & Self::IS_STRUCT_EMBEDDED) != 0 } pub fn is_object_profiling(self) -> bool { (self.0 & Self::IS_OBJECT_PROFILING) != 0 } + pub fn is_fields_embedded(self) -> bool { (self.0 & Self::IS_FIELDS_EMBEDDED) != 0 } + pub fn is_t_class_or_module(self) -> bool { (self.0 & Self::IS_T_CLASS_OR_MODULE) != 0 } } /// opt_send_without_block/opt_plus/... should store: @@ -288,6 +294,12 @@ impl ProfiledType { if unsafe { RB_TYPE_P(obj, RUBY_T_OBJECT) } { flags.0 |= Flags::IS_T_OBJECT; } + if unsafe { RB_TYPE_P(obj, RUBY_T_CLASS) || RB_TYPE_P(obj, RUBY_T_MODULE) } { + flags.0 |= Flags::IS_T_CLASS_OR_MODULE; + if obj.class_fields_embedded_p() { + flags.0 |= Flags::IS_FIELDS_EMBEDDED; + } + } Self { class: obj.class_of(), shape: obj.shape_id_of(), flags } } diff --git a/zjit/src/stats.rs b/zjit/src/stats.rs index 9f7a703ed48b07..6b5f46a0b06bfa 100644 --- a/zjit/src/stats.rs +++ b/zjit/src/stats.rs @@ -216,6 +216,7 @@ make_counters! { exit_patchpoint_no_ep_escape, exit_patchpoint_single_ractor_mode, exit_patchpoint_no_singleton_class, + exit_patchpoint_root_box_only, exit_callee_side_exit, exit_obj_to_string_fallback, exit_interrupt, @@ -604,6 +605,8 @@ pub fn side_exit_counter(reason: crate::hir::SideExitReason) -> Counter { => exit_patchpoint_single_ractor_mode, PatchPoint(Invariant::NoSingletonClass { .. }) => exit_patchpoint_no_singleton_class, + PatchPoint(Invariant::RootBoxOnly) + => exit_patchpoint_root_box_only, } } From aec4b44d1e339044d917f233be7025eea5aa672d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 26 Feb 2026 17:58:18 +0900 Subject: [PATCH 02/11] Add tool to update NEWS from GitHub releases --- tool/update-NEWS-github-release.rb | 171 +++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100755 tool/update-NEWS-github-release.rb diff --git a/tool/update-NEWS-github-release.rb b/tool/update-NEWS-github-release.rb new file mode 100755 index 00000000000000..e979102ba49315 --- /dev/null +++ b/tool/update-NEWS-github-release.rb @@ -0,0 +1,171 @@ +#!/usr/bin/env ruby + +require "bundler/inline" +require "json" +require "net/http" +require "uri" + +gemfile do + source "https://rubygems.org" + gem "octokit" + gem "faraday-retry" +end + +Octokit.configure do |c| + c.access_token = ENV["GITHUB_TOKEN"] + c.auto_paginate = true + c.per_page = 100 +end + +# Build a gem=>version map from stdgems.org stdgems.json for a given Ruby version (e.g., "3.4") +def fetch_default_gems_versions(ruby_version) + uri = URI.parse("https://stdgems.org/stdgems.json") + json = JSON.parse(Net::HTTP.get(uri)) + gems = json["gems"] || [] + + map = {} + gems.each do |g| + # Only include default gems (skip ones marked removed) + next if g["removed"] + versions = g["versions"] || {} + + # versions has "default" and "bundled" keys, each containing Ruby version => version mappings + selected_version = nil + + # Try both "default" and "bundled" categories + ["default", "bundled"].each do |category| + category_versions = versions[category] || {} + next if selected_version + + if category_versions.key?(ruby_version) + selected_version = category_versions[ruby_version] + else + # Fall back to the highest patch version matching the given major.minor + major_minor = /^#{Regexp.escape(ruby_version)}\./ + candidates = category_versions.select { |k, _| k.match?(major_minor) } + if !candidates.empty? + # Sort keys as Gem::Version to pick the highest patch + selected_version = candidates.sort_by { |k, _| Gem::Version.new(k) }.last[1] + end + end + end + + next unless selected_version + + name = g["gem"] + # Normalize name to match existing special cases + name = "RubyGems" if name == "rubygems" + map[name] = selected_version + end + + map +end + +# Load gem=>version map from a file or from stdgems.org if a Ruby version is given. +def load_versions(arg) + if arg.nil? + abort "usage: #{File.basename($0)} FROM TO (each can be a file path or Ruby version like 3.4)" + end + if File.exist?(arg) + File.readlines(arg).map(&:split).to_h + elsif arg.match?(/^\d+\.\d+(?:\.\d+)?$/) + fetch_default_gems_versions(arg) + elsif arg.downcase == "news" || arg =~ %r{https?://.*/NEWS\.md} + fetch_versions_to_from_news(arg) + else + abort "Invalid argument: #{arg}. Provide a file path or a Ruby version (e.g., 3.4)." + end +end + +# Build a gem=>version map by parsing the "## Stdlib updates" section from Ruby's NEWS.md +def fetch_versions_to_from_news(arg) + url = arg.downcase == "news" ? "https://raw.githubusercontent.com/ruby/ruby/refs/heads/master/NEWS.md" : arg + uri = URI.parse(url) + body = Net::HTTP.get(uri) + + # Extract the Stdlib updates section + start_idx = body.index(/^## Stdlib updates$/) + unless start_idx + # Try a more lenient search if anchors differ + start_idx = body.index("## Stdlib\nupdates") || body.index("## Stdlib updates") + end + abort "Stdlib updates section not found in NEWS.md" unless start_idx + + section = body[start_idx..-1] + # Stop at the next top-level section header (skip the current header line) + first_line_len = section.lines.first ? section.lines.first.length : 0 + stop_idx = section.index(/^##\s+/, first_line_len) + section = stop_idx ? section[0...stop_idx] : section + + map = {} + + # Normalize lines and collect bullet entries like: "* gemname x.y.z" + section.each_line do |line| + line = line.strip + next unless line.start_with?("*") + # Remove leading bullet + entry = line.sub(/^\*\s+/, "") + + # Some lines can include descriptions or links; we only take simple "name version" + # Accept names with hyphens/underscores and versions like 1.2.3 or 1.2.3.4 + if entry =~ /^([A-Za-z0-9_\-]+)\s+(\d+(?:\.\d+){0,3})\b/ + name = $1 + ver = $2 + name = "RubyGems" if name.downcase == "rubygems" + map[name] = ver + end + end + + map +end + +versions_from = load_versions(ARGV[0]) +versions_to = load_versions("news") +footnote_link = [] + +versions_to.each do |name, version| + # Skip items which do not exist in the FROM map to reduce API calls + next unless versions_from.key?(name) + next if name == "RubyGems" || name == "bundler" + + releases = [] + + case name + when "minitest" + repo = name + org = "minitest" + when "test-unit" + repo = name + org = "test-unit" + when "bundler" + repo = "rubygems" + org = "ruby" + else + repo = name + org = "ruby" + end + + Octokit.releases("#{org}/#{repo}").each do |release| + releases << release.tag_name + end + + # Keep only version-like tags and sort descending by semantic version + releases = releases.select { |t| t =~ /^v\d/ || t =~ /^\d/ || t =~ /^bundler-\d/ } + releases = releases.sort_by { |t| Gem::Version.new(t.sub(/^bundler-/, "").sub(/^v/, "").tr("_", ".")) } + + start_index = releases.index("v#{versions_from[name]}") || releases.index(versions_from[name]) || releases.index("bundler-v#{versions_from[name]}") + end_index = releases.index("v#{versions_to[name]}") || releases.index(versions_to[name]) || releases.index("bundler-v#{versions_to[name]}") + release_range = releases[start_index+1..end_index] if start_index && end_index + + next unless release_range + next if release_range.empty? + + puts "* #{name} #{version}" + puts " * #{versions_from[name]} to #{release_range.map { |rel| + "[#{rel.sub(/^bundler-/, '')}][#{name}-#{rel.sub(/^bundler-/, '')}]"}.join(", ")}" + release_range.each do |rel| + footnote_link << "[#{name}-#{rel.sub(/^bundler-/, '')}]: https://github.com/#{org}/#{repo}/releases/tag/#{rel}" + end +end + +puts footnote_link.join("\n") From 4ab238341327b95b539000f10930f62d27f0dedc Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 26 Feb 2026 18:21:57 +0900 Subject: [PATCH 03/11] Add update mode and NEWS.md parsing Read NEWS.md locally when "news" is requested, parse stdlib updates, collect GitHub release ranges per gem, and optionally update NEWS.md in-place with sub-bullets and footnote links when --update is passed --- tool/update-NEWS-github-release.rb | 219 ++++++++++++++++++++++++----- 1 file changed, 181 insertions(+), 38 deletions(-) diff --git a/tool/update-NEWS-github-release.rb b/tool/update-NEWS-github-release.rb index e979102ba49315..b0f76b44d49725 100755 --- a/tool/update-NEWS-github-release.rb +++ b/tool/update-NEWS-github-release.rb @@ -3,6 +3,7 @@ require "bundler/inline" require "json" require "net/http" +require "set" require "uri" gemfile do @@ -31,12 +32,12 @@ def fetch_default_gems_versions(ruby_version) # versions has "default" and "bundled" keys, each containing Ruby version => version mappings selected_version = nil - + # Try both "default" and "bundled" categories ["default", "bundled"].each do |category| category_versions = versions[category] || {} next if selected_version - + if category_versions.key?(ruby_version) selected_version = category_versions[ruby_version] else @@ -49,7 +50,7 @@ def fetch_default_gems_versions(ruby_version) end end end - + next unless selected_version name = g["gem"] @@ -64,7 +65,7 @@ def fetch_default_gems_versions(ruby_version) # Load gem=>version map from a file or from stdgems.org if a Ruby version is given. def load_versions(arg) if arg.nil? - abort "usage: #{File.basename($0)} FROM TO (each can be a file path or Ruby version like 3.4)" + abort "usage: #{File.basename($0)} FROM [--update]" end if File.exist?(arg) File.readlines(arg).map(&:split).to_h @@ -79,10 +80,25 @@ def load_versions(arg) # Build a gem=>version map by parsing the "## Stdlib updates" section from Ruby's NEWS.md def fetch_versions_to_from_news(arg) - url = arg.downcase == "news" ? "https://raw.githubusercontent.com/ruby/ruby/refs/heads/master/NEWS.md" : arg - uri = URI.parse(url) - body = Net::HTTP.get(uri) + if arg.downcase == "news" + body = read_local_news_md + else + uri = URI.parse(arg) + body = Net::HTTP.get(uri) + end + parse_stdlib_versions_from_news(body) +end + +def read_local_news_md + news_path = File.join(__dir__, "..", "NEWS.md") + unless File.exist?(news_path) + abort "NEWS.md not found at #{news_path}" + end + File.read(news_path) +end + +def parse_stdlib_versions_from_news(body) # Extract the Stdlib updates section start_idx = body.index(/^## Stdlib updates$/) unless start_idx @@ -119,53 +135,180 @@ def fetch_versions_to_from_news(arg) map end -versions_from = load_versions(ARGV[0]) -versions_to = load_versions("news") -footnote_link = [] - -versions_to.each do |name, version| - # Skip items which do not exist in the FROM map to reduce API calls - next unless versions_from.key?(name) - next if name == "RubyGems" || name == "bundler" - - releases = [] - +def resolve_repo(name) case name when "minitest" - repo = name - org = "minitest" + { repo: name, org: "minitest" } when "test-unit" - repo = name - org = "test-unit" + { repo: name, org: "test-unit" } when "bundler" - repo = "rubygems" - org = "ruby" + { repo: "rubygems", org: "ruby" } else - repo = name - org = "ruby" + { repo: name, org: "ruby" } end +end +def fetch_release_range(name, from_version, to_version, org, repo) + releases = [] Octokit.releases("#{org}/#{repo}").each do |release| releases << release.tag_name end - # Keep only version-like tags and sort descending by semantic version + # Keep only version-like tags and sort ascending by semantic version releases = releases.select { |t| t =~ /^v\d/ || t =~ /^\d/ || t =~ /^bundler-\d/ } releases = releases.sort_by { |t| Gem::Version.new(t.sub(/^bundler-/, "").sub(/^v/, "").tr("_", ".")) } - start_index = releases.index("v#{versions_from[name]}") || releases.index(versions_from[name]) || releases.index("bundler-v#{versions_from[name]}") - end_index = releases.index("v#{versions_to[name]}") || releases.index(versions_to[name]) || releases.index("bundler-v#{versions_to[name]}") - release_range = releases[start_index+1..end_index] if start_index && end_index + start_index = releases.index("v#{from_version}") || releases.index(from_version) || releases.index("bundler-v#{from_version}") + end_index = releases.index("v#{to_version}") || releases.index(to_version) || releases.index("bundler-v#{to_version}") + return nil unless start_index && end_index + + range = releases[start_index + 1..end_index] + return nil if range.nil? || range.empty? + + range +end + +def collect_gem_updates(versions_from, versions_to) + results = [] + + versions_to.each do |name, version| + # Skip items which do not exist in the FROM map to reduce API calls + next unless versions_from.key?(name) + next if name == "RubyGems" || name == "bundler" + + info = resolve_repo(name) + org = info[:org] + repo = info[:repo] + + release_range = fetch_release_range(name, versions_from[name], version, org, repo) + next unless release_range + + footnote_links = [] + release_range.each do |rel| + footnote_links << { + ref: "#{name}-#{rel.sub(/^bundler-/, '')}", + url: "https://github.com/#{org}/#{repo}/releases/tag/#{rel}", + tag: rel.sub(/^bundler-/, ''), + } + end + + results << { + name: name, + version: version, + from_version: versions_from[name], + release_range: release_range, + footnote_links: footnote_links, + } + end + + results +end + +def print_results(results) + footnote_lines = [] + + results.each do |r| + puts "* #{r[:name]} #{r[:version]}" + links = r[:release_range].map { |rel| + "[#{rel.sub(/^bundler-/, '')}][#{r[:name]}-#{rel.sub(/^bundler-/, '')}]" + } + puts " * #{r[:from_version]} to #{links.join(', ')}" + r[:footnote_links].each do |fl| + footnote_lines << "[#{fl[:ref]}]: #{fl[:url]}" + end + end + + puts footnote_lines.join("\n") +end + +def update_news_md(results) + news_path = File.join(__dir__, "..", "NEWS.md") + unless File.exist?(news_path) + abort "NEWS.md not found at #{news_path}" + end + content = File.read(news_path) + lines = content.lines + + # Build a lookup: gem name => result + result_by_name = {} + results.each { |r| result_by_name[r[:name]] = r } + + new_lines = [] + i = 0 + while i < lines.length + line = lines[i] + + # Check if this line is a gem bullet like "* gemname x.y.z" + if line =~ /^\* ([A-Za-z0-9_\-]+)\s+(\d+(?:\.\d+){0,3})\b/ + gem_name = $1 + gem_name_normalized = gem_name == "RubyGems" ? "RubyGems" : gem_name - next unless release_range - next if release_range.empty? + new_lines << line - puts "* #{name} #{version}" - puts " * #{versions_from[name]} to #{release_range.map { |rel| - "[#{rel.sub(/^bundler-/, '')}][#{name}-#{rel.sub(/^bundler-/, '')}]"}.join(", ")}" - release_range.each do |rel| - footnote_link << "[#{name}-#{rel.sub(/^bundler-/, '')}]: https://github.com/#{org}/#{repo}/releases/tag/#{rel}" + if result_by_name.key?(gem_name_normalized) + r = result_by_name[gem_name_normalized] + + # Skip any existing sub-bullet lines that follow (lines starting with spaces + *) + while i + 1 < lines.length && lines[i + 1] =~ /^\s+\*/ + i += 1 + end + + # Insert the version diff sub-bullet + links = r[:release_range].map { |rel| + "[#{rel.sub(/^bundler-/, '')}][#{r[:name]}-#{rel.sub(/^bundler-/, '')}]" + } + sub_bullet = " * #{r[:from_version]} to #{links.join(', ')}\n" + new_lines << sub_bullet + end + else + new_lines << line + end + i += 1 + end + + # Collect all new footnote links + all_footnotes = [] + results.each do |r| + r[:footnote_links].each do |fl| + all_footnotes << "[#{fl[:ref]}]: #{fl[:url]}" + end end + + # Remove any existing footnote links that we are about to add (avoid duplicates) + existing_refs = Set.new(all_footnotes.map { |f| f[/^\[([^\]]+)\]:/, 1] }) + new_lines = new_lines.reject do |line| + if line =~ /^\[([^\]]+)\]:\s+https:\/\/github\.com\// + existing_refs.include?($1) + else + false + end + end + + # Ensure the file ends with a newline before adding footnotes + unless new_lines.last&.end_with?("\n") + new_lines << "\n" + end + + # Append footnote links at the end of the file + all_footnotes.each do |footnote| + new_lines << "#{footnote}\n" + end + + File.write(news_path, new_lines.join) + puts "Updated #{news_path} with #{results.length} gem update entries and #{all_footnotes.length} footnote links." end -puts footnote_link.join("\n") +# --- Main --- + +update_mode = ARGV.delete("--update") + +versions_from = load_versions(ARGV[0]) +versions_to = load_versions("news") + +results = collect_gem_updates(versions_from, versions_to) + +print_results(results) + +if update_mode + update_news_md(results) +end From 7c7533514891cc3933d2b4753cb7d647ad19979c Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 26 Feb 2026 18:24:10 +0900 Subject: [PATCH 04/11] Run `ruby tool/update-NEWS-github-release.rb 4.0 --update` --- NEWS.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/NEWS.md b/NEWS.md index 100c4d72f19383..54d875f92457e5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -59,30 +59,47 @@ releases. * RubyGems 4.1.0.dev * bundler 4.1.0.dev * json 2.18.1 + * 2.18.0 to [v2.18.1][json-v2.18.1] * openssl 4.0.1 + * 4.0.0 to [v4.0.1][openssl-v4.0.1] * prism 1.9.0 + * 1.8.0 to [v1.9.0][prism-v1.9.0] * resolv 0.7.1 + * 0.7.0 to [v0.7.1][resolv-v0.7.1] * stringio 3.2.1.dev * strscan 3.1.7.dev + * 3.1.6 to [v3.1.7][strscan-v3.1.7] * syntax_suggest 2.0.3 ### The following bundled gems are updated. * minitest 6.0.2 * test-unit 3.7.7 + * 3.7.3 to [3.7.4][test-unit-3.7.4], [3.7.5][test-unit-3.7.5], [3.7.6][test-unit-3.7.6], [3.7.7][test-unit-3.7.7] * rss 0.3.2 + * 0.3.1 to [0.3.2][rss-0.3.2] * net-imap 0.6.3 + * 0.6.1 to [v0.6.2][net-imap-v0.6.2], [v0.6.3][net-imap-v0.6.3] * rbs 3.10.3 + * 3.10.0 to [v3.10.1][rbs-v3.10.1], [v3.10.2][rbs-v3.10.2], [v3.10.3][rbs-v3.10.3] * typeprof 0.31.1 * debug 1.11.1 + * 1.11.0 to [v1.11.1][debug-v1.11.1] * mutex_m 0.3.0 * resolv-replace 0.2.0 + * 0.1.1 to [v0.2.0][resolv-replace-v0.2.0] * syslog 0.4.0 + * 0.3.0 to [v0.4.0][syslog-v0.4.0] * repl_type_completor 0.1.13 + * 0.1.12 to [v0.1.13][repl_type_completor-v0.1.13] * pstore 0.2.1 + * 0.2.0 to [v0.2.1][pstore-v0.2.1] * rdoc 7.2.0 + * 6.17.0 to [v7.0.0][rdoc-v7.0.0], [v7.0.1][rdoc-v7.0.1], [v7.0.2][rdoc-v7.0.2], [v7.0.3][rdoc-v7.0.3], [v7.1.0][rdoc-v7.1.0], [v7.2.0][rdoc-v7.2.0] * win32ole 1.9.3 + * 1.9.2 to [v1.9.3][win32ole-v1.9.3] * irb 1.17.0 + * 1.16.0 to [v1.17.0][irb-v1.17.0] ### RubyGems and Bundler @@ -108,3 +125,31 @@ A lot of work has gone into making Ractors more stable, performant, and usable. [Feature #15330]: https://bugs.ruby-lang.org/issues/15330 [Feature #21390]: https://bugs.ruby-lang.org/issues/21390 [Feature #21785]: https://bugs.ruby-lang.org/issues/21785 +[json-v2.18.1]: https://github.com/ruby/json/releases/tag/v2.18.1 +[openssl-v4.0.1]: https://github.com/ruby/openssl/releases/tag/v4.0.1 +[prism-v1.9.0]: https://github.com/ruby/prism/releases/tag/v1.9.0 +[resolv-v0.7.1]: https://github.com/ruby/resolv/releases/tag/v0.7.1 +[strscan-v3.1.7]: https://github.com/ruby/strscan/releases/tag/v3.1.7 +[test-unit-3.7.4]: https://github.com/test-unit/test-unit/releases/tag/3.7.4 +[test-unit-3.7.5]: https://github.com/test-unit/test-unit/releases/tag/3.7.5 +[test-unit-3.7.6]: https://github.com/test-unit/test-unit/releases/tag/3.7.6 +[test-unit-3.7.7]: https://github.com/test-unit/test-unit/releases/tag/3.7.7 +[rss-0.3.2]: https://github.com/ruby/rss/releases/tag/0.3.2 +[net-imap-v0.6.2]: https://github.com/ruby/net-imap/releases/tag/v0.6.2 +[net-imap-v0.6.3]: https://github.com/ruby/net-imap/releases/tag/v0.6.3 +[rbs-v3.10.1]: https://github.com/ruby/rbs/releases/tag/v3.10.1 +[rbs-v3.10.2]: https://github.com/ruby/rbs/releases/tag/v3.10.2 +[rbs-v3.10.3]: https://github.com/ruby/rbs/releases/tag/v3.10.3 +[debug-v1.11.1]: https://github.com/ruby/debug/releases/tag/v1.11.1 +[resolv-replace-v0.2.0]: https://github.com/ruby/resolv-replace/releases/tag/v0.2.0 +[syslog-v0.4.0]: https://github.com/ruby/syslog/releases/tag/v0.4.0 +[repl_type_completor-v0.1.13]: https://github.com/ruby/repl_type_completor/releases/tag/v0.1.13 +[pstore-v0.2.1]: https://github.com/ruby/pstore/releases/tag/v0.2.1 +[rdoc-v7.0.0]: https://github.com/ruby/rdoc/releases/tag/v7.0.0 +[rdoc-v7.0.1]: https://github.com/ruby/rdoc/releases/tag/v7.0.1 +[rdoc-v7.0.2]: https://github.com/ruby/rdoc/releases/tag/v7.0.2 +[rdoc-v7.0.3]: https://github.com/ruby/rdoc/releases/tag/v7.0.3 +[rdoc-v7.1.0]: https://github.com/ruby/rdoc/releases/tag/v7.1.0 +[rdoc-v7.2.0]: https://github.com/ruby/rdoc/releases/tag/v7.2.0 +[win32ole-v1.9.3]: https://github.com/ruby/win32ole/releases/tag/v1.9.3 +[irb-v1.17.0]: https://github.com/ruby/irb/releases/tag/v1.17.0 From 9b0a3db0689bb55f443796366d4bfcc41830be2b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 26 Feb 2026 18:38:36 +0900 Subject: [PATCH 05/11] Preserve gem sub-bullets in NEWS.md --- tool/update-NEWS-gemlist.rb | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tool/update-NEWS-gemlist.rb b/tool/update-NEWS-gemlist.rb index 0b5503580d3de9..68284ab76a18ca 100755 --- a/tool/update-NEWS-gemlist.rb +++ b/tool/update-NEWS-gemlist.rb @@ -5,13 +5,29 @@ prevs = [prev, prev.sub(/\.\d+\z/, '')] update = ->(list, type, desc = "updated") do - item = ->(mark = "* ") do + item = ->(mark = "* ", sub_bullets = {}) do "### The following #{type} gem#{list.size == 1 ? ' is' : 's are'} #{desc}.\n\n" + - list.map {|g, v|"#{mark}#{g} #{v}\n"}.join("") + "\n" + list.map {|g, v| + s = "#{mark}#{g} #{v}\n" + s += sub_bullets[g].join("") if sub_bullets[g] + s + }.join("") + "\n" end - news.sub!(/^(?:\*( +)|#+ *)?The following #{type} gems? (?:are|is) #{desc}\.\n+(?:(?(1) \1)\*( *).*\n)*\n*/) do - item["#{$1&.<< " "}*#{$2 || ' '}"] - end or news.sub!(/^## Stdlib updates(?:\n+The following.*(?:\n+( *\* *).*)*)*\n+\K/) do + news.sub!(/^(?:\*( +)|#+ *)?The following #{type} gems? (?:are|is) #{desc}\.\n+(?:(?:(?(1) \1)\*( *).*\n)(?:[ \t]+\*.*\n)*)*\n*/) do + mark = "#{$1&.dup&.<< " "}*#{$2 || ' '}" + # Parse existing sub-bullets from matched section + sb = {}; cg = nil + $~.to_s.each_line do |l| + if l =~ /^\* ([A-Za-z0-9_\-]+)\s/ + cg = $1 + elsif cg && l =~ /^\s+\*/ + (sb[cg] ||= []) << l + else + cg = nil + end + end + item[mark, sb] + end or news.sub!(/^## Stdlib updates(?:\n+The following.*(?:\n+(?:( *\* *).*|[ \t]+\*.*))*)* *\n+\K/) do item[$1 || "* "] end end From e43b35f2f1e394a901bf00e0116b1e182d8246b1 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 26 Feb 2026 18:43:32 +0900 Subject: [PATCH 06/11] Detect previous Ruby version in update script Read include/ruby/version.h to compute the previous Ruby version and use it as the default FROM argument in tool/update-NEWS-github-release.rb. Add a workflow step to run the script for Ruby 4.0 in bundled_gems.yml Detect previous Ruby version in update script --- tool/update-NEWS-github-release.rb | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tool/update-NEWS-github-release.rb b/tool/update-NEWS-github-release.rb index b0f76b44d49725..679e7101a76be8 100755 --- a/tool/update-NEWS-github-release.rb +++ b/tool/update-NEWS-github-release.rb @@ -62,11 +62,20 @@ def fetch_default_gems_versions(ruby_version) map end +def previous_ruby_version + version_h = File.join(__dir__, "..", "include", "ruby", "version.h") + major = minor = nil + File.foreach(version_h) do |l| + major = $1.to_i if l =~ /^\s*#\s*define\s+RUBY_API_VERSION_MAJOR\s+(\d+)/ + minor = $1.to_i if l =~ /^\s*#\s*define\s+RUBY_API_VERSION_MINOR\s+(\d+)/ + end + abort "Cannot detect Ruby version from #{version_h}" unless major && minor + minor > 0 ? "#{major}.#{minor - 1}" : "#{major - 1}.0" +end + # Load gem=>version map from a file or from stdgems.org if a Ruby version is given. def load_versions(arg) - if arg.nil? - abort "usage: #{File.basename($0)} FROM [--update]" - end + arg ||= previous_ruby_version if File.exist?(arg) File.readlines(arg).map(&:split).to_h elsif arg.match?(/^\d+\.\d+(?:\.\d+)?$/) From b4367db8878ab5a6374ad3c01b2bbd6d06e19db9 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 26 Feb 2026 18:44:03 +0900 Subject: [PATCH 07/11] Run GitHub releases updater in workflow --- .github/workflows/bundled_gems.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/bundled_gems.yml b/.github/workflows/bundled_gems.yml index 548db433fb21c0..29ba922e7afe96 100644 --- a/.github/workflows/bundled_gems.yml +++ b/.github/workflows/bundled_gems.yml @@ -72,6 +72,7 @@ jobs: - name: Maintain updated gems list in NEWS run: | ruby tool/update-NEWS-gemlist.rb bundled + ruby tool/update-NEWS-github-releases.rb --update if: ${{ env.UPDATE_ENABLED == 'true' }} - name: Check diffs From a5af2adab17e0f54a094872afd46ce8fa6f6563d Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Fri, 27 Feb 2026 10:19:12 +0900 Subject: [PATCH 08/11] Fix a typo in bundled_gems.yml --- .github/workflows/bundled_gems.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bundled_gems.yml b/.github/workflows/bundled_gems.yml index 29ba922e7afe96..24e132c44416ab 100644 --- a/.github/workflows/bundled_gems.yml +++ b/.github/workflows/bundled_gems.yml @@ -72,7 +72,7 @@ jobs: - name: Maintain updated gems list in NEWS run: | ruby tool/update-NEWS-gemlist.rb bundled - ruby tool/update-NEWS-github-releases.rb --update + ruby tool/update-NEWS-github-release.rb --update if: ${{ env.UPDATE_ENABLED == 'true' }} - name: Check diffs From 3341022648623706cddbc607d08753d2b37b458b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 27 Feb 2026 17:59:36 +0900 Subject: [PATCH 09/11] Add setup-ruby step for tool scripts --- .github/workflows/bundled_gems.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/bundled_gems.yml b/.github/workflows/bundled_gems.yml index 24e132c44416ab..6806d58a15c36f 100644 --- a/.github/workflows/bundled_gems.yml +++ b/.github/workflows/bundled_gems.yml @@ -38,6 +38,10 @@ jobs: with: token: ${{ (github.repository == 'ruby/ruby' && !startsWith(github.event_name, 'pull')) && secrets.MATZBOT_AUTO_UPDATE_TOKEN || secrets.GITHUB_TOKEN }} + - uses: ruby/setup-ruby@90be1154f987f4dc0fe0dd0feedac9e473aa4ba8 # v1.286.0 + with: + ruby-version: 4.0 + - uses: ./.github/actions/setup/directories with: # Skip overwriting MATZBOT_AUTO_UPDATE_TOKEN From 934830970613e16146a446e58501ed1a411b7edd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 02:10:14 +0000 Subject: [PATCH 10/11] Bump actions/upload-artifact Bumps the github-actions group with 1 update in the / directory: [actions/upload-artifact](https://github.com/actions/upload-artifact). Updates `actions/upload-artifact` from 6.0.0 to 7.0.0 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/check_misc.yml | 2 +- .github/workflows/scorecards.yml | 2 +- .github/workflows/wasm.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check_misc.yml b/.github/workflows/check_misc.yml index e900f0912372a2..de6a56af678d41 100644 --- a/.github/workflows/check_misc.yml +++ b/.github/workflows/check_misc.yml @@ -127,7 +127,7 @@ jobs: }} - name: Upload docs - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@v7.0.0 with: path: html name: ${{ steps.docs.outputs.htmlout }} diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index b154476cb5b182..732e2f7c086ef6 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -64,7 +64,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: SARIF file path: results.sarif diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 2242f13e6972e4..3ac9997d19d16a 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -140,7 +140,7 @@ jobs: - run: tar cfz ../install.tar.gz -C ../install . - name: Upload artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ruby-wasm-install path: ${{ github.workspace }}/install.tar.gz @@ -168,7 +168,7 @@ jobs: - name: Save Pull Request number if: ${{ github.event_name == 'pull_request' }} run: echo "${{ github.event.pull_request.number }}" >> ${{ github.workspace }}/github-pr-info.txt - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: ${{ github.event_name == 'pull_request' }} with: name: github-pr-info From 8fb5df26103396819a5b2ac6145d31b1599632bc Mon Sep 17 00:00:00 2001 From: YO4 Date: Thu, 26 Feb 2026 22:26:42 +0900 Subject: [PATCH 11/11] win32/configure.bat accept empty prefix Currently, running `win32/configure.bat --prefix=` outputs `prefix = \=/` in the Makefile. Fix this to prevent invalid values from being output. --- win32/configure.bat | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/win32/configure.bat b/win32/configure.bat index 844dafd8498b4b..8860ffcee0ac3e 100644 --- a/win32/configure.bat +++ b/win32/configure.bat @@ -133,7 +133,8 @@ goto :unknown_opt goto :loopend ; :dir if "%eq%" == "" call :take_arg - echo>> %config_make% %opt:~2% = %arg:\=/% + if defined arg set "arg=%arg:\=/%" + echo>> %config_make% %opt:~2% = %arg% goto :loopend ; :enable if %enable% == yes (