Skip to content

interpreter/ruby: add Ruby 4.0.1 support for stack unwinding#1123

Merged
fabled merged 18 commits into
open-telemetry:mainfrom
liad-miggo:ruby-4.0-support
Feb 11, 2026
Merged

interpreter/ruby: add Ruby 4.0.1 support for stack unwinding#1123
fabled merged 18 commits into
open-telemetry:mainfrom
liad-miggo:ruby-4.0-support

Conversation

@liad-miggo
Copy link
Copy Markdown
Contributor

@liad-miggo liad-miggo commented Jan 27, 2026

Summary

  • Add support for Ruby 4.0.1 stack unwinding and symbolization
  • Update version gate to allow Ruby 4.0.x (up to 4.1.0 exclusive)
  • Add iseq_constant_body offsets for Ruby 4.0+
  • Add rb_ractor_struct.running_ec offsets for Ruby 4.0+ (reduced due to Port API redesign)
  • Add coredump test cases for both amd64 and arm64 architectures

Background

Ruby 4.0 was released December 2025 with significant internal changes:

  • New ZJIT compiler replaces some YJIT fields in rb_iseq_constant_body
  • New lvar_states field added to rb_iseq_constant_body
  • Complete redesign of rb_ractor_sync with Port-based API
  • Removed receiving_mutex and barrier_wait_cond from rb_ractor_struct

Changes

Offsets determined via GDB analysis of Ruby 4.0.1 binaries:

Struct Field Ruby 3.4.x Ruby 4.0+
iseq_constant_body local_iseq 168 176
iseq_constant_body size 352 304
rb_ractor_struct running_ec (amd64) 0x180 0x138
rb_ractor_struct running_ec (arm64) 0x190 0x148

Test plan

  • Verified against Ruby 4.0.1 coredumps on both amd64 and arm64
  • Produces complete stack traces with function names, file paths, and line numbers
  • All existing Ruby interpreter tests pass
  • Added coredump test cases: tools/coredump/testdata/amd64/ruby-4.0.1-loop.json and tools/coredump/testdata/arm64/ruby-4.0.1-loop.json

@liad-miggo liad-miggo requested review from a team as code owners January 27, 2026 10:08
@linux-foundation-easycla
Copy link
Copy Markdown

linux-foundation-easycla Bot commented Jan 27, 2026

CLA Signed

The committers listed above are authorized under a signed CLA.

  • ✅ login: liad-miggo / name: liad eliyahu (16530ea)

@liad-miggo liad-miggo force-pushed the ruby-4.0-support branch 3 times, most recently from 10d53f8 to fb139e3 Compare January 27, 2026 10:39
@fabled
Copy link
Copy Markdown
Contributor

fabled commented Jan 27, 2026

@dalehamel care to review?

Ruby 4.0 was released December 2025 with significant internal changes:
- New ZJIT compiler replaces some YJIT fields in rb_iseq_constant_body
- New lvar_states field added to rb_iseq_constant_body
- Complete redesign of rb_ractor_sync with Port-based API
- Removed receiving_mutex and barrier_wait_cond from rb_ractor_struct

Changes:
- Update version gate to allow Ruby 4.0.x (up to 4.1.0 exclusive)
- Add iseq_constant_body offsets for Ruby 4.0+ (size: 352 bytes)
- Add rb_ractor_struct.running_ec offset for Ruby 4.0+
  - amd64: 0x150 (reduced from 0x180 due to struct changes)
  - arm64: 0x160 (reduced from 0x190 due to struct changes)

Tested successfully against Ruby 4.0.1 running Rails 8 with Puma,
producing complete stack traces with function names, file paths,
and line numbers.
@fabled
Copy link
Copy Markdown
Contributor

fabled commented Jan 27, 2026

@liad-miggo Can you restore the coredump test, and send the datafiles? The coredump utility has export command to package the datafiles as a tarball.

@liad-miggo liad-miggo force-pushed the ruby-4.0-support branch 2 times, most recently from 5b76f90 to 593feed Compare January 27, 2026 13:04
Add support for Ruby 4.0.1 interpreter stack unwinding with correct
struct offsets determined via GDB analysis:

- rb_iseq_constant_body: local_iseq=176, size=304
- rb_ractor_struct.running_ec: amd64=0x138, arm64=0x148

Ruby 4.0+ has different struct layouts due to ZJIT replacing YJIT
and internal API changes like the Port-based Ractor API.

Includes coredump test cases for both arm64 and amd64 architectures
that verify proper Ruby stack trace extraction with source locations.
@liad-miggo
Copy link
Copy Markdown
Contributor Author

@fabled

I've added.

@fabled
Copy link
Copy Markdown
Contributor

fabled commented Jan 29, 2026

I've added.

Can export the actual data files (coredump export), and give us a link where to download them from. One of the maintaners/approvers can then upload the coredump data files to the place where they need to be. Thanks.

liad-miggo and others added 4 commits January 29, 2026 17:55
@dalehamel
Copy link
Copy Markdown
Contributor

I already had Shopify#20 prepared, but was waiting for #1101 to merge. Now that it has merged, this PR needs to be updated to include the updated objspace structs for finding the GC bits.

I can submit a PR to your branch to reconcile these if you'd like @liad-miggo

Copy link
Copy Markdown
Contributor

@dalehamel dalehamel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, I filed liad-miggo#1 to try and reconcile this with the other offsets i had set for this, but still need to verify with rbenv and ruby-install on amd64

Comment thread interpreter/ruby/ruby.go
vms.iseq_constant_body.insn_info_size = 128
vms.iseq_constant_body.succ_index_table = 136
vms.iseq_constant_body.local_iseq = 176
vms.iseq_constant_body.size_of_iseq_constant_body = 304
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with ruby-install via:

ruby-install 4.0.1 -- --enable-shared

I get 360 for this. Likewise with rbenv (installed from git, with ruby-build plugin installed from git also):

rbenv install 4.0.1

I likewise get 360 for this. Both done in colima (ubuntu 24.04) on aarch64, m4 macbook.

In my draft PR it was 354, but i think it is also wrong and should be 360, i verified it is the same on 4.0.0.

dalehamel and others added 3 commits February 3, 2026 10:00
On amd64, the jit members are not added by default at the end. They are not
read anyways, so the smaller size should be safer.
Additional ruby 4.0+ offset updates
@liad-miggo liad-miggo requested a review from dalehamel February 5, 2026 09:39
Comment thread interpreter/ruby/ruby.go Outdated
vms.iseq_constant_body.insn_info_size = 128
vms.iseq_constant_body.succ_index_table = 136
vms.iseq_constant_body.local_iseq = 176
if runtime.GOARCH == "amd64" {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I discovered that the difference here is actually not architecture dependent per se, it has to do with the ruby build toolchain:

https://github.com/ruby/ruby/blob/e04267a14b1a5dea2d2c368e48d41bd3db441f4f/configure.ac#L3982

https://github.com/ruby/ruby/blob/e04267a14b1a5dea2d2c368e48d41bd3db441f4f/vm_core.h#L546-L568

The JIT fields at the end of the struct get added if you have rustc installed. It just so happened that my development environment on aarch64 has them, but the cleaner build box I used for amd64 did not.

If we take a look at pahole for this struct, we see that the size is the same on both platforms until you get to the JIT members at the end:

struct rb_iseq_constant_body {
        enum rb_iseq_type          type;                 /*     0     4 */
        unsigned int               iseq_size;            /*     4     4 */
        VALUE *                    iseq_encoded;         /*     8     8 */                                                                                                               struct rb_iseq_parameters  param;                /*    16    48 */
        /* --- cacheline 1 boundary (64 bytes) --- */
        rb_iseq_location_t         location;             /*    64    48 */
        struct iseq_insn_info      insns_info;           /*   112    32 */                                                                                                               /* --- cacheline 2 boundary (128 bytes) was 16 bytes ago --- */
        const ID  *                local_table;          /*   144     8 */
        enum lvar_state *          lvar_states;          /*   152     8 */
        struct iseq_catch_table *  catch_table;          /*   160     8 */
        const struct rb_iseq_struct  * parent_iseq;      /*   168     8 */
        struct rb_iseq_struct *    local_iseq;           /*   176     8 */
        union iseq_inline_storage_entry * is_entries;    /*   184     8 */                                                                                                               /* --- cacheline 3 boundary (192 bytes) --- */
        struct rb_call_data *      call_data;            /*   192     8 */
        struct {
                rb_snum_t          flip_count;           /*   200     8 */
                VALUE              script_lines;         /*   208     8 */
                VALUE              coverage;             /*   216     8 */
                VALUE              pc2branchindex;       /*   224     8 */
                VALUE *            original_iseq;        /*   232     8 */
        } variable;                                      /*   200    40 */
        unsigned int               local_table_size;     /*   240     4 */
        unsigned int               ic_size;              /*   244     4 */
        unsigned int               ise_size;             /*   248     4 */
        unsigned int               ivc_size;             /*   252     4 */
        /* --- cacheline 4 boundary (256 bytes) --- */
        unsigned int               icvarc_size;          /*   256     4 */
        unsigned int               ci_size;              /*   260     4 */
        unsigned int               stack_max;            /*   264     4 */
        unsigned int               builtin_attrs;        /*   268     4 */
        _Bool                      prism;                /*   272     1 */

        /* XXX 7 bytes hole, try to pack */

        union {
                iseq_bits_t *      list;                 /*   280     8 */
                iseq_bits_t        single;               /*   280     8 */
        } mark_bits;                                     /*   280     8 */
        struct rb_id_table *       outer_variables;      /*   288     8 */
        const rb_iseq_t  *         mandatory_only_iseq;  /*   296     8 */
        rb_jit_func_t              jit_entry;            /*   304     8 */
...

We never actually read any members past mandatory_only_iseq, in fact the latest member in the struct we actually read is much earlier, local_iseq at 176.

I would argue that just using 304 is actually a safer value for the size of the struct that will work on more deployments, as it is possible we could try and do the larger read of 360 and then hit problems when JIT is not enabled, as the read hits invalid memory.

I don't think this issue is unique to ruby 4 either, it looks like the layout has been more or less the same at least since 3.1.0 https://github.com/ruby/ruby/blob/fb4df44d1670e9d25aef6b235a7281199a177edb/vm_core.h#L485, but the making these end members conditional on ZJIT / YJIT was added later

I'd like some feedback from the other otel maintainers what is preferred here. I'm leaning towards just using 304 on both platforms, with the knowledge that if we ever do access jit members of the struct, we'll need to be more careful about this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm leaning towards just using 304 on both platforms, with the knowledge that if we ever do access jit members of the struct, we'll need to be more careful about this.

I'm agreeing with this and it should be documented.

Copy link
Copy Markdown
Member

@christos68k christos68k Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get rid of the GOARCH test, switch to using 304 for length and document your findings in the source?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get rid of the GOARCH test, switch to using 304 for length and document your findings in the source?

Added a suggestion for @liad-miggo that should address this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and accepted the suggestion.

Copy link
Copy Markdown
Contributor

@dalehamel dalehamel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've verified these offsets and resolved this with another draft branch I had for ruby 4 support.

I will call out the comment i've made though on one of the structs, which has a size that is dependent on if you have rustc installed at build time or not.

Comment thread interpreter/ruby/ruby.go Outdated
liad-miggo and others added 2 commits February 7, 2026 18:56
Co-authored-by: Dale Hamel <dalehamel@users.noreply.github.com>
Comment thread interpreter/ruby/ruby.go Outdated
@fabled fabled merged commit 6c00356 into open-telemetry:main Feb 11, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants