ml.ruby-lang.org
Sign In Sign Up
Manage this list Sign In Sign Up

Keyboard Shortcuts

Thread View

  • j: Next unread message
  • k: Previous unread message
  • j a: Jump to all threads
  • j l: Jump to MailingList overview

ruby-core

Thread Start a new thread
Download
Threads by month
  • ----- 2026 -----
  • September
  • August
  • July
  • June
  • May
  • April
  • March
  • February
  • January
  • ----- 2025 -----
  • December
  • November
  • October
  • September
  • August
  • July
  • June
  • May
  • April
  • March
  • February
  • January
  • ----- 2024 -----
  • December
  • November
  • October
  • September
  • August
  • July
  • June
  • May
  • April
  • March
  • February
  • January
  • ----- 2023 -----
  • December
  • November
  • October
  • September
  • August
  • July
  • June
  • May
  • April
  • March
  • February
  • January
  • ----- 2022 -----
  • December
  • November
ruby-core@ml.ruby-lang.org

September 2026

  • 2 participants
  • 117 discussions
[ruby-core:126226] [Ruby Bug#22224] YJIT: rb_yjit_invalidate_ep_is_bp takes the VM lock stopping Ractors on every Proc materialization; multi-Ractor throughput collapses (up to ~150x)
by yaroslavmarkin (Yaroslav Markin) 14 Sep '26

14 Sep '26
Issue #22224 has been reported by yaroslavmarkin (Yaroslav Markin). ---------------------------------------- Bug #22224: YJIT: rb_yjit_invalidate_ep_is_bp takes the VM lock stopping Ractors on every Proc materialization; multi-Ractor throughput collapses (up to ~150x) https://bugs.ruby-lang.org/issues/22224 * Author: yaroslavmarkin (Yaroslav Markin) * Status: Open * ruby -v: 4.0.6 * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- Hi! Fair warning: a significant portion of this bugreport and experimentation/benchmarking was done by an agent. Sorry for the slop, but it was so much faster to benchmark and find a root issue. ## Summary With YJIT enabled, `vm_make_env_each` calls `rb_yjit_invalidate_ep_is_bp` on every Proc/lambda environment materialization. That function enters `with_vm_lock` unconditionally (the only early return covers boot, before `INVARIANTS` is initialized). In multi-Ractor mode this lock acquisition is `rb_jit_vm_lock_then_barrier`: the VM lock plus a stop-all-Ractors barrier. The result: any workload that creates Procs loses parallel scalability under Ractors, and beyond ~2 Ractors adding workers makes the whole process slower in absolute terms. In the repro below, 8 Ractors with YJIT run ~150x slower than 1 Ractor with YJIT, and ~195x slower than 8 Ractors without YJIT, on the same code. Note the invalidation itself is not the cost. After the first escape of a given iseq, its `no_ep_escape_iseqs` entry is an empty set forever (`ep_is_bp()` returns false for it, so no new blocks are ever registered), yet every subsequent materialization still pays lock + global barrier to look up the entry and iterate nothing. ## Standalone repro (no gems) ```ruby # Usage: ruby [--yjit] yjit_ractor_repro.rb [proc|calc] [n_ractors] [seconds] MODE = (ARGV[0] || "proc").to_sym N = (ARGV[1] || 1).to_i DUR = (ARGV[2] || 3).to_f def make_proc(i) x = i -> { x + 1 } # captures x: the frame env is materialized on every call end def work_proc(n) s = 0 n.times { |i| s += make_proc(i).call } s end def work_calc(n) s = 0 n.times { |i| s += (i * i) % 7 } # same shape, no Proc escapes s end def bench_loop(mode, dur) count = 0 deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + dur while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline mode == :proc ? work_proc(1000) : work_calc(1000) count += 1000 end count end bench_loop(MODE, 0.5) # warm up: get the methods YJIT-compiled before measuring total = if N == 1 bench_loop(MODE, DUR) else N.times.map { Ractor.new(MODE, DUR) { |m, d| bench_loop(m, d) } }.sum(&:value) end puts format("mode=%s yjit=%s ractors=%d throughput=%.2fM iters/s", MODE, RubyVM::YJIT.enabled?, N, total / DUR / 1_000_000.0) ``` Results on Apple M1 Pro (10 cores), macOS, `ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin25]`, 3 s per cell: | workload | ractors | interpreter (M iters/s) | --yjit (M iters/s) | |----------|---------|-------------------------|--------------------| | calc (control, no Proc) | 1 | 21.3 | 129.4 | | calc | 4 | 83.0 | 495.3 | | calc | 8 | 162.2 | **969.1** | | proc | 1 | 4.5 | 6.0 | | proc | 4 | 7.7 | **0.14** | | proc | 8 | 7.8 | **0.04** | The control shows this is not a general YJIT-vs-Ractor problem: on the Proc-free workload YJIT scales superbly (969M iters/s at 8 Ractors). Only the environment-materializing workload collapses, and only with YJIT on. ## Where the time goes Native sampling (macOS `sample`) of a loaded multi-Ractor process shows almost all threads parked in `__psynch_cvwait`, with the barrier initiated from: ``` vm_make_env_each -> rb_yjit_invalidate_ep_is_bp (yjit/src/invariants.rs) -> with_vm_lock -> rb_jit_vm_lock_then_barrier -> rb_ractor_sched_barrier_start (victims: rb_ractor_sched_barrier_join / ractor_sched_barrier_join_wait_locked) ``` In a 6 s sample of a 5-Ractor process, barrier-related frames appear ~9,000 times vs ~50 in the single-Ractor run of the same workload. Ruby-side profilers cannot see this: the wait time is attributed as diffuse "self time" across whatever frames are on top, which is presumably why it has gone unreported. Current code (`yjit/src/invariants.rs`, same on master as of 2026-08-03): ```rust pub extern "C" fn rb_yjit_invalidate_ep_is_bp(iseq: IseqPtr) { // Skip tracking EP escapes on boot. We don't need to invalidate anything during boot. if unsafe { INVARIANTS.is_none() } { return; } with_vm_lock(src_loc!(), || { let no_ep_escape_iseqs = &mut Invariants::get_instance().no_ep_escape_iseqs; match no_ep_escape_iseqs.get_mut(&iseq) { Some(blocks) => { for block in mem::take(blocks) { invalidate_block_version(&block); incr_counter!(invalidate_ep_escape); } } None => { no_ep_escape_iseqs.insert(iseq, HashSet::new()); } } }); } ``` ## Real-world impact Rails 8.1 enables YJIT by default, and a Rails request materializes many Proc environments (middleware blocks, route handling, view rendering), so any Rails app served by a multi-Ractor server hits this out of the box. Found while investigating https://github.com/yaroslav/kino/issues/6, where a stock Rails 8.1 app showed worker scaling inverting: more Ractors, less total throughput. Rails 8.1.3 health-check endpoint (`/up`, no database), `ab -c 64 -k`, kino `:ractor` mode, N workers x 1 thread, requests/sec: | workers | YJIT on (Rails default) | YJIT off | |---------|-------------------------|----------| | 1 | 6,416 | 3,964 | | 2 | 5,036 | 6,999 | | 5 | 2,800 | 13,269 | | 8 | 2,003 | 12,596 | Fully disabling GC changed the YJIT-on numbers by only ~7%, ruling out GC barriers as the driver; the native profile above identifies the initiator. -- https://bugs.ruby-lang.org/
3 2
0 0
[ruby-core:126720] [Ruby Bug#22316] String#bit_set can access out-of-bounds memory after reentrant Warning.warn mutation
by cozerercument (Boran Per) 14 Sep '26

14 Sep '26
Issue #22316 has been reported by cozerercument (Boran Per). ---------------------------------------- Bug #22316: String#bit_set can access out-of-bounds memory after reentrant Warning.warn mutation https://bugs.ruby-lang.org/issues/22316 * Author: cozerercument (Boran Per) * Status: Open * ruby -v: ruby 4.1.0dev (2026-09-14T08:30:15Z master 03a426ec8d) +PRISM [x86_64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- `String#bit_set` can access memory outside the current String backing buffer when a chilled String is mutated reentrantly from a customized `Warning.warn` callback. The bit offset is validated against the String length before `rb_str_modify()` is called. For a chilled String, `rb_str_modify()` can emit a deprecation warning and invoke Ruby-level `Warning.warn`. If that callback shrinks/reallocates the same String, the previously validated offset becomes stale. After the callback returns, `String#bit_set` continues using that offset without re-validating it against the new String length. With an AddressSanitizer build this produces a deterministic heap-buffer-overflow. ## Tested revision ```text ruby 4.1.0dev (2026-09-14T08:30:15Z master 03a426ec8d) +PRISM [x86_64-linux] ``` Commit: ```text 03a426ec8d30929558bf1244b4ad95eb7e1f54d2 ``` ## Reproduction Build Ruby with AddressSanitizer enabled and run: ```ruby $VERBOSE = true Warning[:deprecated] = true $victim = "A" * 8192 $reentered = false module ReallocateWarning def warn(message, category: nil, **kwargs) if category == :deprecated && !$reentered $reentered = true STDERR.puts "[+] callback entered" STDERR.puts "[+] before clear: #{$victim.bytesize}" $victim.clear $victim << ("B" * 1024) STDERR.puts "[+] after rebuild: #{$victim.bytesize}" return nil end super end end Warning.extend(ReallocateWarning) STDERR.puts "[*] initial size=#{$victim.bytesize}" # Valid against the original 8192-byte String. $victim.bit_set(7000 * 8) STDERR.puts "[!] bit_set returned normally" ``` Run with: ```sh ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=1" \ ./miniruby poc_single_bit_reentrant.rb ``` ## Actual result The callback shrinks the String from 8192 bytes to 1024 bytes: ```text [*] initial size=8192 [+] callback entered [+] before clear: 8192 [+] after rebuild: 1024 ``` AddressSanitizer then reports: ```text ERROR: AddressSanitizer: heap-buffer-overflow READ of size 1 ``` Relevant stack: ```text str_apply_bit_mask ../string.c:7097 str_mutate_single_bit ../string.c:7175 str_mutate_bit ../string.c:7191 rb_str_bit_set ../string.c:7246 ``` The process aborts with exit status `134`. ## Expected result Reentrant mutation during the warning callback should not leave a previously validated bit offset usable against the new, smaller String buffer. The operation should either revalidate the bounds against the current String state or fail safely. ## Root cause The offset is checked against `RSTRING_LEN(str)` before `rb_str_modify(str)`. `rb_str_modify()` may invoke the deprecation-warning path for a chilled String, which can call a customized Ruby-level `Warning.warn`. That callback can mutate and shrink/reallocate the same receiver. After `rb_str_modify()` returns, the implementation obtains the current `RSTRING_PTR(str)`, but continues using the offset validated against the old String length without checking it again against the current `RSTRING_LEN(str)`. The region form of the bit-mutation APIs appears to be affected by the same reentrancy issue, but the single-bit example above is the minimal reproducer. I also verified that the underlying single-bit check/modify/access ordering existed before the recent region-argument change, so I am not attributing introduction of the issue to that change. ---Files-------------------------------- poc_single_bit_reentrant.out (8.12 KB) poc_single_bit_reentrant.rb (8.73 KB) -- https://bugs.ruby-lang.org/
1 0
0 0
[ruby-core:126087] [Ruby Bug#22196] Heap-use-after-free in `fiber_switch` with transfer-terminated async tasks on 3.4.10
by scohen-tines (seth cohen) 14 Sep '26

14 Sep '26
Issue #22196 has been reported by scohen-tines (seth cohen). ---------------------------------------- Bug #22196: Heap-use-after-free in `fiber_switch` with transfer-terminated async tasks on 3.4.10 https://bugs.ruby-lang.org/issues/22196 * Author: scohen-tines (seth cohen) * Status: Open * ruby -v: ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- # Heap-use-after-free in `fiber_switch` reading terminated fiber's status (regression via Bug #21955 backport) Ruby 3.4.10 intermittently segfaults while an async-http workload terminates task fibers after truncated chunked HTTP responses. The equivalent workload completed 20 high-churn attempts on Ruby 3.4.9 without a crash. ASan shows a heap-use-after-free: the outgoing fiber's `rb_fiber_t` is freed by GC while `fiber_switch` still reads its status on the `Fiber#transfer` path. ## Environment ```text ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` - Linux `aarch64` Docker image. - `async-1.32.1`, `async-http-0.64.2`, and `async-pool-0.10.3`. - The ASan build uses `-fsanitize=address -fno-omit-frame-pointer -g -O0` and has YJIT disabled. ## Expected behavior An incomplete chunked HTTP response should fail its async task with `EOFError`; the VM should not crash while cleaning up the task fiber. ## Actual behavior Ruby exits with a segmentation fault in the fiber stack pool: ```text -- C level backtrace -- fiber_pool_stack_release fiber_stack_release fiber_switch rb_fiber_start ``` One GDB run on a source-built 3.4.10 reached: ```text #0 fiber_pool_stack_release (stack=0xffffc4012ea0) at cont.c:770 pool = 0x0 #1 fiber_stack_release (fiber=0xffffc4012b00) at cont.c:903 #2 fiber_switch (...) at cont.c:2728 ``` We first hit this in a production service; the harness below reproduces the same C-level frames using only async-http against a local server, with no application code. ## AddressSanitizer result An ASan build makes the bug deterministic. With 50 in-process request bursts, all five stock 3.4.10 runs report: ```text ERROR: AddressSanitizer: heap-use-after-free READ of size 1 #0 fiber_switch /src/ruby-3.4.10/cont.c:2727 ... freed by thread T2 here: cont_free /src/ruby-3.4.10/cont.c:1079 fiber_free /src/ruby-3.4.10/cont.c:1164 ... previously allocated by thread T3 here: fiber_t_alloc /src/ruby-3.4.10/cont.c:1986 SUMMARY: AddressSanitizer: heap-use-after-free /src/ruby-3.4.10/cont.c:2727 in fiber_switch ``` Line 2727 of `cont.c` is the new `FIBER_TERMINATED_P(fiber)` condition (see Regression candidate below). Thus the first invalid operation is reading the freed `rb_fiber_t` object's status. It occurs before `fiber_pool_stack_release`; the latter is a downstream manifestation when the stale status reads as terminated and the release branch continues. The full report is attached as `stock-1.log`. ## Minimal reproduction harness The attached harness consists of: - `repro.rb`: local raw HTTP server plus direct async-http client; - `Gemfile` and `Gemfile.lock`: pinned async dependencies; - `Dockerfile` and `run.sh`: repeat the workload on a stock Ruby image. Run: ```sh ./run.sh 3.4.10 20 ``` This is probabilistic; successful runs are expected. The harness retains a log for every attempt and reports the first interpreter crash. The workload synchronizes groups of ten failures by default: ```sh FAULT_BARRIER=10 ./run.sh 3.4.10 50 ``` Run the same source comparison under ASan (deterministic): ```sh BATCHES=50 ./run-asan.sh 5 ``` `./run.sh 3.4.9 20` completes all 20 attempts without a crash; 3.4.9 predates the `148f263a` backport. ## Regression candidate Ruby 3.4.10 includes ruby_3_4 commit [`148f263a`](https://github.com/ruby/ruby/commit/148f263a0d299e4532c753470a282144c9104820), the backport of [`dc1777d`](https://github.com/ruby/ruby/commit/dc1777d01770ab62ec99ff6fa4cf622098f44968) for [Bug #21955](https://bugs.ruby-lang.org/issues/21955). That changes `fiber_switch` as follows: ```diff - if (resuming_fiber && FIBER_TERMINATED_P(fiber)) { + if (FIBER_TERMINATED_P(fiber)) { fiber_stack_release(fiber); } ``` `Fiber#transfer` passes a null `resuming_fiber`, so the old condition short-circuited without reading `fiber->status`. The new condition dereferences the stale `fiber` pointer before deciding whether to release its stack. We have not tested ruby-head/3.5, but the same condition is present there since `dc1777d`, so we expect it to be affected. ## Controlled source comparison The harness also includes: ```sh BATCHES=20 ./run-ab.sh 20 ``` It builds the published Ruby 3.4.10 source twice with the same compiler and workload: 1. stock 3.4.10; 2. 3.4.10 with only the above condition restored to `resuming_fiber && FIBER_TERMINATED_P(fiber)`. | Build | Attempts | `fiber_pool_stack_release` crashes | | --- | ---: | ---: | | stock 3.4.10 | 20 | 4 | | 3.4.10 with Bug #21955 condition reverted | 20 | 0 | The stock run also had one additional process abort with: ```text malloc(): unaligned tcache chunk detected ``` All 20 reverted-build processes completed successfully. These runs used `BATCHES=20`, 100 requests per batch, concurrency 20, and a barrier of 10 simultaneous truncated responses. The image is built from the Ruby 3.4.10 release tarball with `-O0 -g3 -fno-omit-frame-pointer`; the only source difference between the two images is the condition shown above. The ASan A/B uses `BATCHES=50`: all 5 stock runs report the above heap-use-after-free, while all 5 reverted runs complete cleanly. ## Request Could the fiber maintainers review why a `rb_fiber_t` can be finalized by GC while `fiber_switch` still holds it as the outgoing fiber? Reinstating the `resuming_fiber` guard avoids the dereference on the transfer path, but a proper fix likely needs to ensure that the outgoing fiber remains live through the switch or otherwise avoid reading it after its lifetime ends. ---Files-------------------------------- ruby-3.4.10-fiber-uaf.tar.gz (19.7 KB) stock-1.log (58.4 KB) -- https://bugs.ruby-lang.org/
4 5
0 0
[ruby-core:126717] [Ruby Bug#22218] Line TracePoint misses executed loop condition after a guard
by ioquatix (Samuel Williams) 14 Sep '26

14 Sep '26
Issue #22218 has been updated by ioquatix (Samuel Williams). Thanks for the back ports! ---------------------------------------- Bug #22218: Line TracePoint misses executed loop condition after a guard https://bugs.ruby-lang.org/issues/22218#change-119015 * Author: ioquatix (Samuel Williams) * Status: Closed * Assignee: ioquatix (Samuel Williams) * Backport: 3.3: REQUIRED, 3.4: DONE, 4.0: DONE ---------------------------------------- `TracePoint.new(:line)` can miss the line event for an executed loop condition when the loop follows a conditional guard. This is related to [Bug #15980](https://bugs.ruby-lang.org/issues/15980), which fixed the same control-flow problem for line coverage. The coverage-specific workaround does not apply to ordinary line TracePoint consumers, so TracePoint and Coverage can still disagree. ## Affected versions This affects Ruby 3.3 and later. The behavior was reproduced on Ruby 3.4.4 and Ruby 4.0.5, both of which report `[1, 3]`. Ruby 3.3.0 through 3.3.11 retain the same coverage-only compiler guard; the source explicitly notes that the TracePoint line event does not occur. Ruby 4.1 development versions are also affected without the proposed patch. ## Reproduction ```ruby source = <<~RUBY raise if 1 == 2 while true break end RUBY lines = [] TracePoint.new(:line) do |trace| lines << trace.lineno if trace.path == "example.rb" end.enable do eval(source, binding, "example.rb") end p lines ``` ### Actual result ```ruby [1, 3] ``` ### Expected result ```ruby [1, 2, 3] ``` The same problem occurs with a non-constant predicate, for example: ```ruby def read return if @finished while chunk = super consume(chunk) end end ``` ## Cause Jump-to-jump peephole optimization retargets the conditional branch past the synthetic jump carrying the line event. As a result, execution reaches the loop body without visiting the event-bearing instruction. ## Proposed Fix The proposed fix is [ruby/ruby#18122](https://github.com/ruby/ruby/pull/18122). Ruby already stops this jump-to-jump optimization when line coverage is enabled and the skipped jump has event flags. The patch generalizes that existing guard: it skips this specific retargeting whenever the intermediate jump carries `RUBY_EVENT_LINE` or `RUBY_EVENT_COVERAGE_LINE`, regardless of whether coverage is active. Other jump-to-jump folding remains enabled. The change only retains the event-bearing jump that TracePoint must visit; it does not add trace-edge metadata or require new VM, YJIT, or ZJIT handling. This is the smaller alternative suggested in the discussion of [ruby/ruby#17825](https://github.com/ruby/ruby/pull/17825). ### Performance The retained jump costs one additional YARV dispatch when the interpreter traverses an affected edge. Performance-sensitive JIT execution can optimize away the extra control-flow overhead: YJIT emits no machine jump when it can place the target block next, while ZJIT cleans up the jump chain in its control-flow graph. The following results compare Ruby immediately before and after the patch on an Apple M4 Pro. Each result is the median of 9 or 11 samples after 3 warmups: | Targeted benchmark | Baseline | Patched | Change | |---|---:|---:|---:| | Hot affected edge, interpreter | 46.20 M iterations/s | 45.74 M iterations/s | -1.0% | | Minimal guarded reader, interpreter | 30.95 M calls/s | 30.13 M calls/s | -2.7% | | Minimal guarded reader, YJIT | 272.56 M calls/s | 272.56 M calls/s | 0.0% | | Minimal guarded reader, ZJIT | 435.34 M calls/s | 436.89 M calls/s | +0.4% | For a synthetic hot-edge benchmark, two long YJIT runs in opposite order varied between -0.5% and +0.4%, and ZJIT differed by +0.04%. There was therefore no repeatable JIT regression. The interpreter figures are deliberately concentrated worst cases where every iteration or call traverses the affected edge. ## Related work - Previous coverage issue: [Bug #15980](https://bugs.ruby-lang.org/issues/15980) - Earlier edge-metadata approach: [ruby/ruby#17825](https://github.com/ruby/ruby/pull/17825) - Selective peephole fix: [ruby/ruby#18122](https://github.com/ruby/ruby/pull/18122) -- https://bugs.ruby-lang.org/
1 0
0 0
[ruby-core:126716] [Ruby Bug#22268] Backport resolv security fixes (CVE-2026-80212, CVE-2026-80213)
by k0kubun (Takashi Kokubun) 14 Sep '26

14 Sep '26
Issue #22268 has been updated by k0kubun (Takashi Kokubun). Backport changed from 3.3: REQUIRED, 3.4: DONE, 4.0: REQUIRED to 3.3: REQUIRED, 3.4: DONE, 4.0: DONE ruby_4_0 commit:2da9a6ef3f423fb85acfd5c41150bb22cdeb14ef. ---------------------------------------- Bug #22268: Backport resolv security fixes (CVE-2026-80212, CVE-2026-80213) https://bugs.ruby-lang.org/issues/22268#change-119014 * Author: hsbt (Hiroshi SHIBATA) * Status: Closed * Backport: 3.3: REQUIRED, 3.4: DONE, 4.0: DONE ---------------------------------------- The resolv security releases of 2026-08-27 fix CVE-2026-80212 and CVE-2026-80213. https://www.ruby-lang.org/en/news/2026/08/27/multiple-vulnerabilities-in-re… `resolv` is a default gem. master already has the fixes through the automatic sync from `ruby/resolv`, but the stable branches do not. | Branch | Current | Target | PR | | --- | --- | --- | --- | | `ruby_4_0` | 0.7.0 | 0.7.2 | https://github.com/ruby/ruby/pull/18528 | | `ruby_3_4` | 0.7.1 | 0.7.2 | https://github.com/ruby/ruby/pull/18529 | | `ruby_3_3` | 0.3.1 | 0.3.2 | https://github.com/ruby/ruby/pull/18530 | -- https://bugs.ruby-lang.org/
1 0
0 0
[ruby-core:126570] [Ruby Bug#22292] YJIT/ZJIT: Struct accessor crashes after an instance variable is set on a Struct that exactly fills the largest GC slot
by shia (Sangyong Sim) 14 Sep '26

14 Sep '26
Issue #22292 has been reported by shia (Sangyong Sim). ---------------------------------------- Bug #22292: YJIT/ZJIT: Struct accessor crashes after an instance variable is set on a Struct that exactly fills the largest GC slot https://bugs.ruby-lang.org/issues/22292 * Author: shia (Sangyong Sim) * Status: Open * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- ruby -v: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [x86_64-linux] Also reproduced on 4.0.1 and on master (3600d410ad, 2026-09-04). Not reproduced on 3.4.9. ## Summary With YJIT or ZJIT enabled, this sequence crashes the process: 1. Define a Struct whose embedded body exactly fills the largest GC slot. That is 78 members on 4.0.x and 125 on master. 2. Call an accessor enough times for the JIT to compile it. Every instance is embedded at this point. 3. Set an instance variable on any one instance of that class. 4. Create a new instance and call the same accessor. After step 3, every *new* instance of the class is allocated with the heap layout. The compiled accessor still assumes the embedded layout, so it reads the heap pointer as if it were a member value. The interpreter is not affected. Instances created before step 3 are not affected either. ## Reproduction ```ruby n = (ARGV[0] || 78).to_i # 78 on 4.0.x, 125 on master klass = Struct.new(*(1..n).map { |i| :"m#{i}" }, keyword_init: true) reader = ->(s) { s.m2 } make = -> { klass.new(m1: 1, m2: 2, :"m#{n}" => n) } before = make.call 300.times { reader.call(before); make.call } # JIT compiles the accessor; all instances embedded before.instance_variable_set(:@iv, 1) # class now has RCLASS_MAX_IV_COUNT > 0 after = make.call # this instance is heap-allocated raise "wrong value" unless reader.call(after) == 2 && after.send(:"m#{n}") == n puts "ok" ``` ``` $ ruby sN.rb 78 ok $ ruby --yjit sN.rb 78 sN.rb:9: [BUG] Segmentation fault at 0x0000000000000005 $ ruby --zjit sN.rb 78 sN.rb:9: [BUG] Segmentation fault at 0x0000000000000005 ``` On master, pass `125` instead of `78`. No JIT options other than `--yjit` / `--zjit` are needed; the 300 warm-up calls are enough for the default call thresholds. ## Cause `struct_alloc` (master struct.c:830) reserves one extra VALUE in the embedded size once any instance of the class has had an instance variable: ```c size_t embedded_size = offsetof(struct RStruct, as.ary) + (sizeof(VALUE) * n); if (RCLASS_MAX_IV_COUNT(klass) > 0) { /* struct.c:834 */ embedded_size += sizeof(VALUE); } ... if (n > 0 && n <= embed_len_max && rb_gc_size_allocatable_p(embedded_size)) { /* struct.c:842 */ ``` When the embedded size already fills the largest slot, the extra 8 bytes no longer fit. `rb_gc_size_allocatable_p` returns false and every later instance is heap-allocated. So the embedded/heap decision depends on `RCLASS_MAX_IV_COUNT`, which can change at any time. Both JITs make that decision once, when the accessor is compiled, and never check it again: * YJIT: `gen_struct_aref` reads `FL_TEST_RAW(comptime_recv, RSTRUCT_EMBED_LEN_MASK)` from the sample receiver (master yjit/src/codegen.rs:8993). * ZJIT: uses `is_struct_embedded()` from the profiled type (master zjit/src/hir.rs:4967). A comment there says the layout is fixed per class. History of the extra slot: * f3206cc79b (2025-08-06, in 4.0 but not 3.4) added it. In 4.0.x an embedded struct keeps its `fields_obj` reference in that slot. * 40f105bd55 (2026-07-16, master only) moved `fields_obj` into its own field of `struct RStruct`. Since then nothing reads the extra slot, but `struct_alloc` still reserves it. ## Only at the boundary This happens only for the one member count that exactly fills the largest slot. Below it, the extra 8 bytes still fit and the instance stays embedded. Above it, instances are heap-allocated from the start, and the JIT compiles the heap path. Measured on 4.0.6 with `--yjit`: | members | memsize_of before / after ivar | result | |---|---|---| | 70–77 | 640 / 640 | ok | | **78** | **640 / 664** | **SEGV** | | 79–82 | 672–696 / same (heap from the start) | ok | On master the boundary is 125 (1024 -> 1040). ## Possible fix (master) On master the extra slot is unused, so `struct_alloc` can stop reserving it. The embedded/heap decision then depends only on the member count, and the JIT assumption holds. ```diff diff --git a/struct.c b/struct.c index 84a0940ba7..21966e927e 100644 --- a/struct.c +++ b/struct.c @@ -831,9 +831,6 @@ struct_alloc(VALUE klass) { long n = num_members(klass); size_t embedded_size = offsetof(struct RStruct, as.ary) + (sizeof(VALUE) * n); - if (RCLASS_MAX_IV_COUNT(klass) > 0) { - embedded_size += sizeof(VALUE); - } VALUE flags = T_STRUCT; ``` With this change the repro passes under `--yjit` and `--zjit` on master, and `test_struct.rb`, `test_data.rb`, `test_objectspace.rb`, `test_gc_compact.rb` pass. `ObjectSpace.memsize_of` for such a struct stays 1024 after an instance variable is set on the class (previously 1040). ## Possible fix (4.0.x) On 4.0.x the extra slot is still used, so it cannot simply be removed. Instead, decide embedded-vs-heap first, from the member count alone, and add the extra slot only when it still fits: * Before: add 8 bytes whenever the class has had an ivar, then check whether the total fits. At the boundary it does not, so the instance goes to the heap. * After: check whether the members fit. If they do, the instance is embedded, no matter what. Then add the 8 bytes only if they also fit. If they do not, the instance stays embedded and its ivars go through the existing `RSTRUCT_GEN_FIELDS` path (generic fields table). That is the same path an instance allocated before the class had any ivar already uses. Tested on 4.0.6 with this patch applied: ```diff --- a/struct.c +++ b/struct.c @@ -822,29 +822,31 @@ { long n = num_members(klass); size_t embedded_size = offsetof(struct RStruct, as.ary) + (sizeof(VALUE) * n); - if (RCLASS_MAX_IV_COUNT(klass) > 0) { + // Whether an instance is embedded must depend only on the member count: + // the JITs decide the layout of a Struct accessor at compile time and + // assume it holds for every instance of the class. So the extra slot for + // the inline fields_obj reference is only added when it still fits; when + // it does not, the instance stays embedded and its ivars go through the + // RSTRUCT_GEN_FIELDS path, exactly like an instance allocated before the + // class had any ivar. + bool embedded = n > 0 && rb_gc_size_allocatable_p(embedded_size); + if (embedded && RCLASS_MAX_IV_COUNT(klass) > 0 + && rb_gc_size_allocatable_p(embedded_size + sizeof(VALUE))) { embedded_size += sizeof(VALUE); } VALUE flags = T_STRUCT | (RGENGC_WB_PROTECTED_STRUCT ? FL_WB_PROTECTED : 0); - if (n > 0 && rb_gc_size_allocatable_p(embedded_size)) { + if (embedded) { flags |= n << RSTRUCT_EMBED_LEN_SHIFT; - if (RCLASS_MAX_IV_COUNT(klass) == 0) { - // We set the flag before calling `NEWOBJ_OF` in case a NEWOBJ tracepoint does - // attempt to write fields. We'll remove it later if no fields was written to. - flags |= RSTRUCT_GEN_FIELDS; - } + // We set the flag before calling `NEWOBJ_OF` in case a NEWOBJ tracepoint does + // attempt to write fields. We'll remove it later if no fields was written to. + flags |= RSTRUCT_GEN_FIELDS; NEWOBJ_OF(st, struct RStruct, klass, flags, embedded_size, 0); - if (RCLASS_MAX_IV_COUNT(klass) == 0) { - if (!rb_shape_obj_has_fields((VALUE)st) - && embedded_size < rb_gc_obj_slot_size((VALUE)st)) { - FL_UNSET_RAW((VALUE)st, RSTRUCT_GEN_FIELDS); - RSTRUCT_SET_FIELDS_OBJ((VALUE)st, 0); - } - } - else { + if (!rb_shape_obj_has_fields((VALUE)st) + && embedded_size < rb_gc_obj_slot_size((VALUE)st)) { + FL_UNSET_RAW((VALUE)st, RSTRUCT_GEN_FIELDS); RSTRUCT_SET_FIELDS_OBJ((VALUE)st, 0); } ``` Results with the patched 4.0.6 build: * The repro passes under `--yjit` and `--zjit` for 78 members, and at the other slot boundaries (3, 8, 18, 38) as well. * Only the boundary case changes: a 78-member instance created after an ivar is set on the class is now 640 bytes (embedded, `GEN_FIELDS`) instead of 664 (heap). Classes that have room for the slot behave as before; for example, 3 members still go from 40 to 80 and use the inline slot. * `test_struct.rb`, `test_data.rb`, `test_objectspace.rb`, `test_gc_compact.rb`: 180 tests, 0 failures. -- https://bugs.ruby-lang.org/
3 4
0 0
[ruby-core:126714] [Ruby Bug#22257] Prepending a module to an already-included module leaves stale super caches
by k0kubun (Takashi Kokubun) 14 Sep '26

14 Sep '26
Issue #22257 has been updated by k0kubun (Takashi Kokubun). Backport changed from 3.3: WONTFIX, 3.4: REQUIRED, 4.0: REQUIRED to 3.3: WONTFIX, 3.4: REQUIRED, 4.0: DONE ruby_4_0 commit:06611602a958ebe1126be3a5cb151f3c2c8bffcc. ---------------------------------------- Bug #22257: Prepending a module to an already-included module leaves stale super caches https://bugs.ruby-lang.org/issues/22257#change-119012 * Author: eightbitraptor (Matt V-H) * Status: Closed * Target version: 4.1 * Backport: 3.3: WONTFIX, 3.4: REQUIRED, 4.0: DONE ---------------------------------------- When a module is prepended to a module that already has includers, super call sites inside the prepended module keep calling the old method entry after the method is redefined. [**Fix implemented in this PR**](https://github.com/ruby/ruby/pull/18421) <pre> module M; def foo; :m; end; end class D; include M; end M.prepend(Module.new { def foo; super; end }) D.new.foo # prime the super call-site cache M.send(:define_method, :foo) { :hooked } p D.new.foo </pre> I found this while working on an unrelated Ractor issue. But this problem manifests itself for Ractors as follows: <pre> Ractor.new {} require 'set' Kernel.send(:define_method, :require) { |f| $hook = f } require 'set' p $hook # => nil, the hook never runs </pre> This is because creating the first Ractor prepends an internal `RactorRequire` wrapper onto Kernel. After the first require primes the cache in the wrapper, redefining Kernel#require silently does nothing. The linked PR fixes this by registering the backfilled iclass in the module's subclasses list, after the includer walk finishes. -- https://bugs.ruby-lang.org/
1 0
0 0
[ruby-core:126379] [Ruby Feature#22243] Change fork behavior when Process._fork raises an exception during fork with a block argument
by rpeng (Richard Peng) 14 Sep '26

14 Sep '26
Issue #22243 has been reported by rpeng (Richard Peng). ---------------------------------------- Feature #22243: Change fork behavior when Process._fork raises an exception during fork with a block argument https://bugs.ruby-lang.org/issues/22243 * Author: rpeng (Richard Peng) * Status: Open ---------------------------------------- # Problem Ruby exposes Process.fork in two formats - one with block arguments and one without. When a block is passed in, the implicit contract is that a child process will terminate once the block ends. However, it is possible for the child to "escape" the enclosed block in certain circumstances when Process._fork raises an exception. Here's a demonstration of what may occur: ```ruby module BadForkTracker def _fork pid = super if pid == 0 raise "exception during fork in child" # end pid end end Process.singleton_class.prepend(BadForkTracker) loop do sleep(1) puts "I'm parent #{Process.pid}" fork do puts "I'm child #{Process.pid} and I'm exiting" end rescue StandardError => e puts "#{Process.pid} Received Error: #{e}" # <- child ends up in this block, then resumes the loop. most users will not be expecting this. end ``` In practice, there are many libraries that implement "fork tracking" (active_support, datadog, connection_pool, to name a few) by prepending to the `Process` module. Many of which are designed to run user specified callbacks which increases the surface area of errors occurring. This means that on _fork, there's a good chance that something will throw and cause a child to resume execution in the "parent" context. In addition, many workloads (sidekiq, puma) have global rescues that are similar to the example case I provided above, which would unintentionally cause the child to continue execution. # Proposal I am proposing a change in behavior: when Process.fork is called with a block, if Process._fork raises an exception in the child, immediately terminate the child with an error exit code (instead of relying on an enclosing rescue to handle it). This guarantees that a child cannot escape out of the block it was meant to execute. In my opinion, this is more in line with how libraries generally expect this to work (e.g. there are very few libraries that are being defensive and attempting not to throw in `def _fork` overrides). -- https://bugs.ruby-lang.org/
4 4
0 0
[ruby-core:126222] [Ruby Bug#22223] Socket.tcp with connect_timeout returns a phantom "connected" socket for a refused connection on macOS 27 (kernel answers EISCONN on connect retry; SO_ERROR never consulted)
by tdehnke (Tony Dehnke) 14 Sep '26

14 Sep '26
Issue #22223 has been reported by tdehnke (Tony Dehnke). ---------------------------------------- Bug #22223: Socket.tcp with connect_timeout returns a phantom "connected" socket for a refused connection on macOS 27 (kernel answers EISCONN on connect retry; SO_ERROR never consulted) https://bugs.ruby-lang.org/issues/22223 * Author: tdehnke (Tony Dehnke) * Status: Open * ruby -v: ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [arm64-darwin27] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- ## Summary On macOS 27.0 (beta, build 26A5388g, arm64 — M5), `Socket.tcp(host, port, connect_timeout: X)` returns a socket object instead of raising `Errno::ECONNREFUSED` when nothing is listening on the port. The returned socket is not usable (first `write` raises `Errno::EPIPE`). The blocking paths (`connect_timeout: nil`, `TCPSocket.new`) raise `Errno::ECONNREFUSED` correctly. ⚠️ Note: this is a macOS **beta** build; the kernel behavior may change before release. Reporting anyway because Ruby's connect idiom is not robust against it, and the failure is silent (phantom success, not an error). ## Repro (no listener on port 3999) ```ruby require "socket" Socket.tcp("127.0.0.1", 3999, connect_timeout: 1) # => #<Socket ...> (expected: Errno::ECONNREFUSED) Socket.tcp("127.0.0.1", 3999, connect_timeout: 1, fast_fallback: false) # => #<Socket ...> (same — not Happy-Eyeballs-specific) Socket.tcp("127.0.0.1", 3999, connect_timeout: nil) # blocking path # => Errno::ECONNREFUSED (correct) TCPSocket.new("127.0.0.1", 3999) # => Errno::ECONNREFUSED (correct) s = Socket.tcp("127.0.0.1", 3999, connect_timeout: 1) s.write("x") # => Errno::EPIPE — the "connected" socket was never connected ``` Reproduced identically on ruby 3.4.5 (arm64-darwin27) and ruby 3.4.10 (arm64-darwin27). Other tools on the same machine (curl, a blocking C connect) report Connection refused correctly and instantly. ## Root cause observed The classic nonblocking idiom — `connect_nonblock` → wait writable → retry `connect_nonblock`, treating `Errno::EISCONN` as success (as recommended in Ruby's own `connect_nonblock` documentation, and as used by `Socket.tcp`'s connect_timeout path) — is broken by a Darwin 27 kernel behavior change. C-level demonstration on a refused nonblocking connect: ``` first connect: r=-1 errno=36 (Operation now in progress) poll: r=1 revents=0x10 # POLLHUP — yet the fd is reported "ready" retry connect: r=-1 errno=56 (Socket is already connected) # EISCONN, on a REFUSED socket SO_ERROR: 61 (Connection refused) # the truth is still available here ``` So the kernel answers the retry-`connect(2)` with `EISCONN` instead of `ECONNREFUSED`, and Ruby's idiom takes that as "connected". `getsockopt(SO_ERROR)` still reports `ECONNREFUSED` — checking it after writability (before/instead of trusting the connect-retry result) detects the failure correctly. Ruby-level equivalent: ```ruby sock = Socket.new(:INET, :STREAM) addr = Socket.sockaddr_in(3999, "127.0.0.1") begin sock.connect_nonblock(addr) rescue IO::WaitWritable sock.wait_writable(2) # returns "writable" begin sock.connect_nonblock(addr) # raises Errno::EISCONN (!) rescue Errno::EISCONN sock.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int # => 61 ECONNREFUSED end end ``` ## Impact Any code probing "is something listening?" via `Socket.tcp` with a `connect_timeout` silently gets a false positive on this platform. Found in the wild: shakapacker's `Shakapacker::DevServer#running?` (probe with `connect_timeout: 0.1`) reported a webpack dev server that wasn't there, so its Rails middleware proxied every asset request into a dead port — every page 502s with no error pointing anywhere near the cause. ## Suggested direction After waiting for writability in the connect_timeout / HEv2 paths (and possibly in the documented `connect_nonblock` idiom), consult `getsockopt(SO_ERROR)` rather than relying on the retry-`connect` returning `EISCONN` — SO_ERROR carries the correct `ECONNREFUSED` even when the kernel misanswers the retry. (Also plausibly worth an Apple Feedback report; the poll `revents=POLLHUP`-as-ready + `EISCONN`-on-refused combination looks like an OS regression.) ## Environment - macOS 27.0 (beta), build 26A5388g, Apple Silicon (M5), Darwin 27 - ruby 3.4.5 (2025-07-16 revision 20cda200d3) +PRISM [arm64-darwin27] — also ruby 3.4.10 [arm64-darwin27] - Possibly related (different symptom, same neighborhood): Bug #21104 (Net::HTTP failures on macOS ≥ 3.4, hardware-dependent, RUBY_TCP_NO_FAST_FALLBACK workaround). Unlike #21104, this repro is NOT affected by `fast_fallback: false`. -- https://bugs.ruby-lang.org/
4 4
0 0
[ruby-core:126201] [Ruby Bug#22220] Performance regression when requiring `aws-sdk-ec2` in Ruby 4.0.6
by emanuelevans (Emanuel Evans) 14 Sep '26

14 Sep '26
Issue #22220 has been reported by emanuelevans (Emanuel Evans). ---------------------------------------- Bug #22220: Performance regression when requiring `aws-sdk-ec2` in Ruby 4.0.6 https://bugs.ruby-lang.org/issues/22220 * Author: emanuelevans (Emanuel Evans) * Status: Open * ruby -v: 4.0.6 * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- Ruby 4.0.6 introduced a large performance regression when loading `aws-sdk-ec2` (~30s on my machine, compared to <1s for ruby 4.0.5). Script to reproduce: ``` for v in 4.0.5 4.0.6; do docker run --rm ruby:$v-slim bash -c ' gem install --no-document aws-sdk-ec2 -v 1.402.0 >/dev/null 2>&1 ruby -e "t = Process.clock_gettime(Process::CLOCK_MONOTONIC) require \"aws-sdk-ec2\" printf(\"ruby %s: %.2fs\n\", RUBY_VERSION, Process.clock_gettime(Process::CLOCK_MONOTONIC) - t)"' done ``` It appears to be related to `Shape.new` and/or `Class.new` compile times; minimal reproduction: ``` for v in 4.0.5 4.0.6; do docker run --rm ruby:$v-slim ruby -e ' src = "class Shape\n def self.new(**) = nil\nend\nmodule M\n" + (1..16_000).map { |i| %( K_#{i} = Shape.new(name: "k#{i}")) }.join("\n") + "\nend\n" File.write("/tmp/bench.rb", src) t = Process.clock_gettime(Process::CLOCK_MONOTONIC) RubyVM::InstructionSequence.compile_file("/tmp/bench.rb") printf("ruby %s: %.2fs\n", RUBY_VERSION, Process.clock_gettime(Process::CLOCK_MONOTONIC) - t)' done ``` -- https://bugs.ruby-lang.org/
4 4
0 0
  • ← Newer
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • ...
  • 12
  • Older →

HyperKitty Powered by HyperKitty version 1.3.12.