From d89a9f82c86ace247f274a883ac9f3c463f6544e Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Sat, 28 Mar 2026 13:12:35 +0000 Subject: [PATCH 1/3] Extract `check_event_support` helper to reduce duplication in `vm_trace.c` (#16587) Extract check_event_support helper to reduce duplication in vm_trace.c Five rb_tracearg_* functions repeated the same event validation pattern. Extract it into a check_event_support() helper. --- vm_trace.c | 43 +++++++++++++------------------------------ 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/vm_trace.c b/vm_trace.c index 74bc1fce807b83..42b9991e7141bc 100644 --- a/vm_trace.c +++ b/vm_trace.c @@ -1117,15 +1117,18 @@ rb_tracearg_self(rb_trace_arg_t *trace_arg) return trace_arg->self; } -VALUE -rb_tracearg_return_value(rb_trace_arg_t *trace_arg) +static void +check_event_support(rb_trace_arg_t *trace_arg, rb_event_flag_t supported) { - if (trace_arg->event & (RUBY_EVENT_RETURN | RUBY_EVENT_C_RETURN | RUBY_EVENT_B_RETURN)) { - /* ok */ - } - else { + if (!(trace_arg->event & supported)) { rb_raise(rb_eRuntimeError, "not supported by this event"); } +} + +VALUE +rb_tracearg_return_value(rb_trace_arg_t *trace_arg) +{ + check_event_support(trace_arg, RUBY_EVENT_RETURN | RUBY_EVENT_C_RETURN | RUBY_EVENT_B_RETURN); if (UNDEF_P(trace_arg->data)) { rb_bug("rb_tracearg_return_value: unreachable"); } @@ -1135,12 +1138,7 @@ rb_tracearg_return_value(rb_trace_arg_t *trace_arg) VALUE rb_tracearg_raised_exception(rb_trace_arg_t *trace_arg) { - if (trace_arg->event & (RUBY_EVENT_RAISE | RUBY_EVENT_RESCUE)) { - /* ok */ - } - else { - rb_raise(rb_eRuntimeError, "not supported by this event"); - } + check_event_support(trace_arg, RUBY_EVENT_RAISE | RUBY_EVENT_RESCUE); if (UNDEF_P(trace_arg->data)) { rb_bug("rb_tracearg_raised_exception: unreachable"); } @@ -1152,12 +1150,7 @@ rb_tracearg_eval_script(rb_trace_arg_t *trace_arg) { VALUE data = trace_arg->data; - if (trace_arg->event & (RUBY_EVENT_SCRIPT_COMPILED)) { - /* ok */ - } - else { - rb_raise(rb_eRuntimeError, "not supported by this event"); - } + check_event_support(trace_arg, RUBY_EVENT_SCRIPT_COMPILED); if (UNDEF_P(data)) { rb_bug("rb_tracearg_eval_script: unreachable"); } @@ -1176,12 +1169,7 @@ rb_tracearg_instruction_sequence(rb_trace_arg_t *trace_arg) { VALUE data = trace_arg->data; - if (trace_arg->event & (RUBY_EVENT_SCRIPT_COMPILED)) { - /* ok */ - } - else { - rb_raise(rb_eRuntimeError, "not supported by this event"); - } + check_event_support(trace_arg, RUBY_EVENT_SCRIPT_COMPILED); if (UNDEF_P(data)) { rb_bug("rb_tracearg_instruction_sequence: unreachable"); } @@ -1201,12 +1189,7 @@ rb_tracearg_instruction_sequence(rb_trace_arg_t *trace_arg) VALUE rb_tracearg_object(rb_trace_arg_t *trace_arg) { - if (trace_arg->event & (RUBY_INTERNAL_EVENT_NEWOBJ | RUBY_INTERNAL_EVENT_FREEOBJ)) { - /* ok */ - } - else { - rb_raise(rb_eRuntimeError, "not supported by this event"); - } + check_event_support(trace_arg, RUBY_INTERNAL_EVENT_NEWOBJ | RUBY_INTERNAL_EVENT_FREEOBJ); if (UNDEF_P(trace_arg->data)) { rb_bug("rb_tracearg_object: unreachable"); } From 546e2eba7ee6751cb1a800946e3365779b5a56b2 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Sat, 28 Mar 2026 13:28:35 +0000 Subject: [PATCH 2/3] Add `make html-server` for live-reloading doc preview (#16494) RDoc now has a `--server` mode that starts a local HTTP server with live reload. Add an `html-server` make target so developers can preview Ruby documentation while editing source files. Usage: `make html-server` from the build directory, then visit http://localhost:4000. Editing any source file's documentation comment will automatically refresh the browser. Use `make html-server RDOC_SERVER_PORT=8080` to change the port. --- common.mk | 5 +++++ doc/contributing/documentation_guide.md | 13 ++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/common.mk b/common.mk index 1c3ad312e587b7..be27c434ab63bf 100644 --- a/common.mk +++ b/common.mk @@ -77,6 +77,7 @@ INSTALL_DOC_OPTS = --rdoc-output="$(RDOCOUT)" --html-output="$(HTMLOUT)" RDOC_GEN_OPTS = --no-force-update \ --exclude '^lib/rubygems/core_ext/kernel_require\.rb$$' \ $(empty) +RDOC_SERVER_PORT = 4000 INITOBJS = dmyext.$(OBJEXT) dmyenc.$(OBJEXT) NORMALMAINOBJ = main.$(OBJEXT) @@ -630,6 +631,10 @@ html: PHONY $(RDOC_DEPENDS) $(RBCONFIG) @echo Generating RDoc HTML files $(Q) $(RDOC) --op "$(HTMLOUT)" $(RDOC_GEN_OPTS) $(RDOCFLAGS) . +html-server: PHONY $(RDOC_DEPENDS) $(RBCONFIG) + @echo Starting RDoc server with live reload + $(Q) $(RDOC) --server=$(RDOC_SERVER_PORT) $(RDOC_GEN_OPTS) $(RDOCFLAGS) . + RDOC_COVERAGE_EXCLUDES = -x ^ext/json -x ^ext/openssl -x ^ext/psych \ -x ^lib/bundler -x ^lib/rubygems \ -x ^lib/did_you_mean -x ^lib/error_highlight -x ^lib/syntax_suggest diff --git a/doc/contributing/documentation_guide.md b/doc/contributing/documentation_guide.md index 9945ab57fbf28a..7c73ad1c50b0f2 100644 --- a/doc/contributing/documentation_guide.md +++ b/doc/contributing/documentation_guide.md @@ -20,12 +20,19 @@ build directory: make html ``` +Or, to start a live-reloading server that automatically refreshes +the browser when you edit source files: + +```sh +make html-server +``` + +Then visit http://localhost:4000 in your browser. +To use a different port: `make html-server RDOC_SERVER_PORT=8080`. + If you don't have a build directory, follow the [quick start guide](building_ruby.md#label-Quick+start+guide) up to step 4. -Then you can preview your changes by opening -`{build folder}/.ext/html/index.html` file in your browser. - ## Goal The goal of Ruby documentation is to impart the most important From 64e103651259836837595771ed163ba71f0f1d43 Mon Sep 17 00:00:00 2001 From: Alan Wu Date: Mon, 23 Mar 2026 22:31:19 -0400 Subject: [PATCH 3/3] ZJIT: Fn stub: Move args to create appropriate unfilled optional param gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a SendDirect lands in a callee that fails to compile, we need to reconstruct the interpreter state at the entry point of the callee in function_stub_hit(). SendDirect never leaves gaps in its args Vec, and consequently, it contains no nils for unspecified optional parameters. ```ruby def fail(a = a, b:) = ::RubyVM::ZJIT.induce_compile_failure! def entry = fail(b: 0) ``` ``` two views of the same two stack slots │ caller's perspective │ callee's persepctive ┌────┐ │ ┌───┐ │ ?? │ │ │ b │ ├────┤ │ ├───┤ │ -1 │ │ │ a │ └────┘ │ └───┘ (argc=1) │ ``` It's up to function_stub_hit() to create the gap (of `a` in the example) and move any non-optional arguments (`b` in the example) beyond the slice of unspecified optional parameters to their proper place according to the local table. We didn't do this movement previously so returned the wrong result in some cases. Fixes the included test cases. To keep the IseqCall struct 24 bytes while accommodating for the new argc field, I've imposed a ~65K limit on the number of arguments that codegen compiles. Should be plenty, and we already have a similar limit on VM stack size. Co-authored-by: Takashi Kokubun --- zjit/src/codegen.rs | 78 +++++++++++++++++++++++++++++++++------ zjit/src/hir.rs | 6 +++ zjit/src/hir/opt_tests.rs | 45 ++++++++++++++++++++++ zjit/src/hir/tests.rs | 4 +- 4 files changed, 120 insertions(+), 13 deletions(-) diff --git a/zjit/src/codegen.rs b/zjit/src/codegen.rs index 9846a0e70a23f1..b2b1db1cf010d9 100644 --- a/zjit/src/codegen.rs +++ b/zjit/src/codegen.rs @@ -626,7 +626,10 @@ fn gen_insn(cb: &mut CodeBlock, jit: &mut JITState, asm: &mut Assembler, functio &Insn::Send { cd, block: Some(BlockHandler::BlockIseq(blockiseq)), state, reason, .. } => gen_send(jit, asm, cd, blockiseq, &function.frame_state(state), reason), &Insn::Send { cd, block: Some(BlockHandler::BlockArg), state, reason, .. } => gen_send(jit, asm, cd, std::ptr::null(), &function.frame_state(state), reason), &Insn::SendForward { cd, blockiseq, state, reason, .. } => gen_send_forward(jit, asm, cd, blockiseq, &function.frame_state(state), reason), - Insn::SendDirect { cme, iseq, recv, args, kw_bits, block, state, .. } => gen_send_iseq_direct(cb, jit, asm, *cme, *iseq, opnd!(recv), opnds!(args), *kw_bits, &function.frame_state(*state), *block), + &Insn::SendDirect { cme, iseq, recv, ref args, kw_bits, block, state, .. } => gen_send_iseq_direct( + cb, jit, asm, cme, iseq, opnd!(recv), opnds!(args), + kw_bits, &function.frame_state(state), block, + ), &Insn::InvokeSuper { cd, blockiseq, state, reason, .. } => gen_invokesuper(jit, asm, cd, blockiseq, &function.frame_state(state), reason), &Insn::InvokeSuperForward { cd, blockiseq, state, reason, .. } => gen_invokesuperforward(jit, asm, cd, blockiseq, &function.frame_state(state), reason), &Insn::InvokeBlock { cd, state, reason, .. } => gen_invokeblock(jit, asm, cd, &function.frame_state(state), reason), @@ -1669,7 +1672,7 @@ fn gen_send_iseq_direct( }; // Make a method call. The target address will be rewritten once compiled. - let iseq_call = IseqCall::new(iseq, num_optionals_passed); + let iseq_call = IseqCall::new(iseq, num_optionals_passed.try_into().expect("checked in HIR"), args.len().try_into().expect("checked in HIR")); let dummy_ptr = cb.get_write_ptr().raw_ptr(cb); jit.iseq_calls.push(iseq_call.clone()); let ret = asm.ccall_with_iseq_call(dummy_ptr, c_args, &iseq_call); @@ -3081,10 +3084,12 @@ c_callable! { // mutability (Cell) requires exclusive access. let iseq_call = unsafe { Rc::from_raw(iseq_call_ptr as *const IseqCall) }; let iseq = iseq_call.iseq.get(); + let argc = iseq_call.argc; + let num_opts_filled = iseq_call.jit_entry_idx; // JIT-to-JIT calls don't eagerly fill nils to non-parameter locals. // If we side-exit from function_stub_hit (before JIT code runs), we need to set them here. - fn prepare_for_exit(iseq: IseqPtr, cfp: CfpPtr, sp: *mut VALUE, compile_error: &CompileError) { + fn prepare_for_exit(iseq: IseqPtr, cfp: CfpPtr, sp: *mut VALUE, argc: u16, num_opts_filled: u16, compile_error: &CompileError) { unsafe { // Caller frames are materialized by jit_exec() after the entry trampoline returns. // The current frame's pc and iseq are already set by function_stub_hit before this point. @@ -3092,11 +3097,58 @@ c_callable! { // Set SP which gen_push_frame() doesn't set rb_set_cfp_sp(cfp, sp); - // Fill nils to uninitialized (non-argument) locals let local_size = get_iseq_body_local_table_size(iseq).to_usize(); - let num_params = iseq.params().size.to_usize(); - let base = sp.offset(-local_size_and_idx_to_bp_offset(local_size, num_params) as isize); - slice::from_raw_parts_mut(base, local_size - num_params).fill(Qnil); + let params = iseq.params(); + let params_size = params.size.to_usize(); + let frame_base = sp.offset(-local_size_and_idx_to_bp_offset(local_size, 0) as isize); + let locals = slice::from_raw_parts_mut(frame_base, local_size); + // Fill nils to uninitialized (non-parameter) locals + locals.get_mut(params_size..).unwrap_or_default().fill(Qnil); + + // SendDirect packs args without gaps for unfilled optionals. + // When we exit to the interpreter, we need to shift args right + // to create the gap and nil-fill the unfilled optional slots. + // + // Example: def target(req, a = a, b = b, kw:); target(1, kw: 2) + // lead_num=1, opt_num=2, opts_filled=0, argc=2 + // + // locals[] as placed by SendDirect (argc=2, no gaps): + // [req, kw_val, ?, ?, ?, ...] + // 0 1 + // ^----caller's args----^ + // + // locals[] expected by interpreter (params_size=4): + // [req, a, b, kw_val, ?, ...] + // 0 1 2 3 + // ^nil ^nil^--moved--^ + // + // gap_start = lead_num + opts_filled = 1 + // gap_end = lead_num + opt_num = 3 + // We move locals[gap_start..argc] to locals[gap_end..], then + // nil-fill locals[gap_start..gap_end]. + let opt_num: usize = params.opt_num.try_into().expect("ISEQ opt_num should be non-negative"); + let opts_filled = num_opts_filled.to_usize(); + let opts_unfilled = opt_num.saturating_sub(opts_filled); + if opts_unfilled > 0 { + let argc = argc.to_usize(); + let lead_num: usize = params.lead_num.try_into().expect("ISEQ lead_num should be non-negative"); + let param_locals = &mut locals[..params_size]; + // Gap of unspecified optional parameters + let gap_start = lead_num + opts_filled; + let gap_end = lead_num + opt_num; + // When there are arguments in the gap, shift them past the gap + let args_overlapping_gap = gap_start..argc; + if !args_overlapping_gap.is_empty() { + assert!( + gap_end.checked_add(args_overlapping_gap.len()) + .is_some_and(|new_end| new_end <= param_locals.len()) , + "shift past gap out-of-bounds. params={params:#?} args_overlapping_gap={args_overlapping_gap:?}" + ); + param_locals.copy_within(args_overlapping_gap, gap_end); + } + // Nil-fill the now-vacant optional parameter slots + param_locals[gap_start..gap_end].fill(Qnil); + } } // Increment a compile error counter for --zjit-stats @@ -3126,7 +3178,7 @@ c_callable! { // We'll use this Rc again, so increment the ref count decremented by from_raw. unsafe { Rc::increment_strong_count(iseq_call_ptr as *const IseqCall); } - prepare_for_exit(iseq, cfp, sp, compile_error); + prepare_for_exit(iseq, cfp, sp, argc, num_opts_filled, compile_error); return ZJITState::get_exit_trampoline_with_counter().raw_ptr(cb); } @@ -3141,7 +3193,7 @@ c_callable! { // We'll use this Rc again, so increment the ref count decremented by from_raw. unsafe { Rc::increment_strong_count(iseq_call_ptr as *const IseqCall); } - prepare_for_exit(iseq, cfp, sp, &compile_error); + prepare_for_exit(iseq, cfp, sp, argc, num_opts_filled, &compile_error); ZJITState::get_exit_trampoline_with_counter() }); cb.mark_all_executable(); @@ -3466,7 +3518,10 @@ pub struct IseqCall { pub iseq: Cell, /// Index that corresponds to [crate::hir::jit_entry_insns] - jit_entry_idx: u32, + jit_entry_idx: u16, + + /// Argument count passing to the HIR function + argc: u16, /// Position where the call instruction starts start_addr: Cell>, @@ -3479,12 +3534,13 @@ pub type IseqCallRef = Rc; impl IseqCall { /// Allocate a new IseqCall - fn new(iseq: IseqPtr, jit_entry_idx: u32) -> IseqCallRef { + fn new(iseq: IseqPtr, jit_entry_idx: u16, argc: u16) -> IseqCallRef { let iseq_call = IseqCall { iseq: Cell::new(iseq), start_addr: Cell::new(None), end_addr: Cell::new(None), jit_entry_idx, + argc, }; Rc::new(iseq_call) } diff --git a/zjit/src/hir.rs b/zjit/src/hir.rs index b0a53ec56493f4..c50ba467750811 100644 --- a/zjit/src/hir.rs +++ b/zjit/src/hir.rs @@ -2417,6 +2417,12 @@ fn can_direct_send(function: &mut Function, block: BlockId, iseq: *const rb_iseq return false; } + // IseqCall stores num_optionals_passed and argc as u16 + if u16::try_from(args.len()).is_err() { + function.set_dynamic_send_reason(send_insn, TooManyArgsForLir); + return false; + } + can_send } diff --git a/zjit/src/hir/opt_tests.rs b/zjit/src/hir/opt_tests.rs index c9f2a45b048adc..60b0f72da5c8db 100644 --- a/zjit/src/hir/opt_tests.rs +++ b/zjit/src/hir/opt_tests.rs @@ -14960,6 +14960,51 @@ mod hir_opt_tests { "); } + #[test] + fn test_exit_from_function_stub_for_opt_keyword_callee() { + // We have a SendDirect to a callee that fails to compile, + // so the function stub has to take care of exiting to + // interpreter. + eval(" + def target(a = binding.local_variable_get(:a), b: nil) + ::RubyVM::ZJIT.induce_compile_failure! + [a, b] + end + + def entry = target(b: -1) + + raise 'wrong' unless [nil, -1] == entry + raise 'wrong' unless [nil, -1] == entry + "); + + crate::hir::tests::hir_build_tests::assert_compile_fails("target", ParseError::DirectiveInduced); + let hir = hir_string("entry"); + assert!(hir.contains("SendDirect"), "{hir}"); + } + + #[test] + fn test_exit_from_function_stub_for_lead_opt() { + // We have a SendDirect to a callee that fails to compile, + // so the function stub has to take care of exiting to + // interpreter. + let result = eval(" + def target(_required, a = a, b = b) + ::RubyVM::ZJIT.induce_compile_failure! + a + end + + def entry = target(1) + + entry + entry + "); + assert_eq!(Qnil, result); + + crate::hir::tests::hir_build_tests::assert_compile_fails("target", ParseError::DirectiveInduced); + let hir = hir_string("entry"); + assert!(hir.contains("SendDirect"), "{hir}"); + } + #[test] fn test_recompile_no_profile_send() { // Test the SideExit → recompile flow: a no-profile send becomes a SideExit, diff --git a/zjit/src/hir/tests.rs b/zjit/src/hir/tests.rs index 72012b7eba9504..706afea9a896fa 100644 --- a/zjit/src/hir/tests.rs +++ b/zjit/src/hir/tests.rs @@ -207,7 +207,7 @@ mod snapshot_tests { } #[cfg(test)] -pub mod hir_build_tests { +pub(crate) mod hir_build_tests { use super::*; use crate::options::set_call_threshold; use insta::assert_snapshot; @@ -275,7 +275,7 @@ pub mod hir_build_tests { } #[track_caller] - fn assert_compile_fails(method: &str, reason: ParseError) { + pub fn assert_compile_fails(method: &str, reason: ParseError) { let iseq = crate::cruby::with_rubyvm(|| get_method_iseq("self", method)); unsafe { crate::cruby::rb_zjit_profile_disable(iseq) }; let result = iseq_to_hir(iseq);