Bug #22292
closedYJIT/ZJIT: Struct accessor crashes after an instance variable is set on a Struct that exactly fills the largest GC slot
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:
- Define a Struct whose embedded body exactly fills the largest GC slot. That is 78 members on 4.0.x and 125 on master.
- Call an accessor enough times for the JIT to compile it. Every instance is embedded at this point.
- Set an instance variable on any one instance of that class.
- 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_arefreadsFL_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_objreference in that slot. - 40f105bd55 (2026-07-16, master only) moved
fields_objinto its own field ofstruct RStruct. Since then nothing reads the extra slot, butstruct_allocstill 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_FIELDSpath (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
--yjitand--zjitfor 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.
Updated by byroot (Jean Boussier) 1 day ago
- Backport changed from 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN to 3.3: DONTNEED, 3.4: DONTNEED, 4.0: REQUIRED
Updated by byroot (Jean Boussier) 1 day ago
- Status changed from Open to Closed
Applied in changeset git|2f72b3d142f4b66c3dfba4a7b49aaa101f9780cd.
struct.c: Strop reserving extra space for ivars
[Bug #22292]
This was a leftover 6f339aebfe9b233304ea98f53274a48f614e7aea
Updated by byroot (Jean Boussier) 1 day ago
The code on master was a forgotten leftover from a recent change I just removed it.
On 4.0, it's a bit weird, I can indeed reproduce on 4.0.6, but I'm failing to reproduce on the ruby_4_0 branch, not yet clear why.
Updated by byroot (Jean Boussier) 1 day ago
- Status changed from Closed to Open
Updated by byroot (Jean Boussier) 1 day ago
Alright, I can reproduce now, but I don't think the proposed patch make sense, as it almost always reserves and extra member, at that point we might as well backport 6f339aebfe9b233304ea98f53274a48f614e7aea, it's simpler and cleaner (not sure if there's an ABI concern though).
I'm also not sure the proposed patch handles NEWOBJ tracepoint correctly.
Updated by byroot (Jean Boussier) 1 day ago
- Status changed from Open to Closed
So I landed in a somewhat half-way patch for 4.0: https://github.com/ruby/ruby/pull/18645