diff options
| author | Randy Stauner <randy@r4s6.net> | 2026-02-20 09:39:11 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-20 11:39:11 -0500 |
| commit | e730ac41be4d427f06540e0f67fa16bbdced4789 (patch) | |
| tree | ff57155873c727713532635b78cfd4d030759bab /bootstraptest | |
| parent | 906176adb49abcd22bb1da082744ea9e413b96d9 (diff) | |
YJIT: Fix version_map use-after-free from mutable aliasing UB
Multiple YJIT functions created overlapping `&'static mut IseqPayload`
references by calling `get_iseq_payload()` multiple times for the same
iseq. Overlapping &mut is UB in rust's aliasing model, and as consequence,
we trigered use-after-free on the `version_map` Vec header due to false
claims of LLVM `noalias`.
This manifested as crashes in various YJIT operations (block lookup,
GC marking, block removal) that dereference the stale pointer.
Fix by moving `delayed_deallocation` and `get_or_create_version_list`
from free functions (which each call `get_iseq_payload()` internally)
to methods on `IseqPayload` that operate through `&mut self`. This
lets callers obtain a single payload reference and use it for all
operations without creating overlapping mutable borrows.
The three fixed call sites:
1. `rb_yjit_tracing_invalidate_all` (invariants.rs): The loop called
`delayed_deallocation()` which internally called `get_iseq_payload()`,
creating a second `&mut` overlapping with the outer `payload` reference.
Fix: call `payload.delayed_deallocation()` method instead.
2. `add_block_version` (core.rs): Called `get_or_create_version_list()`
then later `get_iseq_payload()` for pages, creating two references.
Fix: use a single `get_or_create_iseq_payload()` call then call the
`get_or_create_version_list()` method on it for both version_map and
pages access.
Also adds regression tests exercising tracing invalidation with
on-stack methods and suspended fibers.
[alan: edited commit message]
Reviewed-by: Alan Wu <alanwu@ruby-lang.org>
Diffstat (limited to 'bootstraptest')
| -rw-r--r-- | bootstraptest/test_yjit.rb | 49 |
1 files changed, 49 insertions, 0 deletions
diff --git a/bootstraptest/test_yjit.rb b/bootstraptest/test_yjit.rb index cc7d9f1aeb..f19629ec0e 100644 --- a/bootstraptest/test_yjit.rb +++ b/bootstraptest/test_yjit.rb @@ -5486,3 +5486,52 @@ assert_normal_exit %q{ test test } + +# regression test for tracing invalidation with on-stack compiled methods +# Exercises the on_stack_iseqs path in rb_yjit_tracing_invalidate_all +# where delayed deallocation must not create aliasing &mut references +# to IseqPayload (use-after-free of version_map backing storage). +assert_normal_exit %q{ + def deep = 42 + def mid = deep + def outer = mid + + # Compile all three methods with YJIT + 10.times { outer } + + # Enable tracing from within a call chain so that outer/mid/deep + # are on the stack when rb_yjit_tracing_invalidate_all runs. + # This triggers the on_stack_iseqs (delayed deallocation) path. + def deep + TracePoint.new(:line) {}.enable + 42 + end + + outer + + # After invalidation, verify YJIT can recompile and run correctly + def deep = 42 + 10.times { outer } +} + +# regression test for tracing invalidation with on-stack fibers +# Suspended fibers have iseqs on their stack that must survive invalidation. +assert_equal '42', %q{ + def compiled_method + Fiber.yield + 42 + end + + # Compile the method + 10.times { compiled_method rescue nil } + + fiber = Fiber.new { compiled_method } + fiber.resume # suspends inside compiled_method — it's now on the fiber's stack + + # Enable tracing while compiled_method is on the fiber's stack. + # This triggers rb_yjit_tracing_invalidate_all with on-stack iseqs. + TracePoint.new(:call) {}.enable + + # Resume the fiber — compiled_method's iseq must still be valid + fiber.resume.to_s +} |
