Project

General

Profile

Actions

Bug #22292

closed

YJIT/ZJIT: Struct accessor crashes after an instance variable is set on a Struct that exactly fills the largest GC slot

Bug #22292: YJIT/ZJIT: Struct accessor crashes after an instance variable is set on a Struct that exactly fills the largest GC slot

Added by shia (Sangyong Sim) 3 days ago. Updated 1 day ago.

Status:
Closed
Assignee:
-
Target version:
-
[ruby-core:126570]

Description

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

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:

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 --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:

--- 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.
Actions

Also available in: PDF Atom