Project

General

Profile

Actions

Bug #22477

closed

rb_ec_clear_vm_stack() can still clear the VM stack before ec->cfp = NULL on aarch64

Bug #22477: rb_ec_clear_vm_stack() can still clear the VM stack before ec->cfp = NULL on aarch64

Added by Eregon (Benoit Daloze) about 18 hours ago. Updated about 16 hours ago.

Status:
Closed
Target version:
-
ruby -v:
ruby 4.1.0dev (2026-10-27T12:50:14Z master 32f06ce98c) +PRISM [arm64-darwin25]
[ruby-core:127252]

Description

The fix for #21441 (e3ec101cc2) sets ec->cfp = NULL before rb_ec_set_vm_stack(ec, NULL, 0) in rb_ec_clear_vm_stack().
That way, thread_profile_frames() called from a signal handler returns early instead of walking a stack that is no longer set on the ec.

However, these are plain stores to distinct fields of the same struct, and rb_ec_set_vm_stack() is inlined, so the compiler is free to reorder them.
On aarch64, clang and GCC both merge the two stack stores into a single stp and emit it before the store to ec->cfp.
Here is rb_ec_clear_vm_stack() on master (vm_stack is at offset 0, vm_stack_size at 0x8 and cfp at 0x10):

stp xzr, xzr, [x0]        ; ec->vm_stack = NULL, vm_stack_size = 0
str xzr, [x0, #0x10]      ; ec->cfp = NULL
ret

This was observed with Apple clang 21 and GCC 16 on macOS arm64, and with clang 18 and GCC 13 on Linux aarch64. GCC 13 on x86_64 happens to emit the ec->cfp store first.

Reproduction

I stopped a terminating thread in lldb right after the stp, then called rb_profile_frames(), which is what a signal landing on that instruction would do:

$ lldb -- ./ruby --disable-gems -e 'Thread.new { 1 }.join'
(lldb) breakpoint set -n rb_threadptr_root_fiber_terminate -o true
(lldb) run
(lldb) breakpoint set -a rb_ec_clear_vm_stack+4 -o true
(lldb) continue
(lldb) p ((rb_execution_context_t *)$x0)->vm_stack
(VALUE *) 0x0000000000000000
(lldb) p ((rb_execution_context_t *)$x0)->cfp
(rb_control_frame_t *) 0x0000000828a4bfc8
(lldb) p (rb_execution_context_t *)$x0 == (rb_execution_context_t *)rb_current_ec_noinline()
(bool) true
(lldb) expr -- VALUE b[256]; int l[256]; (int)rb_profile_frames(0, 256, b, l)
error: Expression execution was interrupted: EXC_BAD_ACCESS (code=1, address=0x0).

It crashes in thread_profile_frames() (vm_backtrace.c:2137). Since ec->cfp is still set, the function does not return early.
end_cfp is then computed from the NULL ec->vm_stack, so the loop never reaches it and walks past the control frames.

Fix

I'll make a PR for master and backport PRs.

Add a compiler signal fence, as vm_push_frame() already does for the same reason:

     ec->cfp = NULL;
+
+    // Ensure the store to ec->cfp above is not reordered after the stores below.
+#if defined HAVE_DECL_ATOMIC_SIGNAL_FENCE && HAVE_DECL_ATOMIC_SIGNAL_FENCE
+    atomic_signal_fence(memory_order_seq_cst);
+#endif
+
     rb_ec_set_vm_stack(ec, NULL, 0);

With the fence, all the compilers above emit the store to ec->cfp no later than the stores clearing the stack, and the rest of vm.o is unchanged.
Under the same lldb steps, ec->cfp is already NULL at rb_ec_clear_vm_stack+4, and rb_profile_frames() returns 0.


Related issues 1 (0 open — 1 closed)

Related to Ruby - Bug #21441: SEGV during thread cleanup if profiler calls thread_profiles_frames at wrong timeClosedActions

Updated by Eregon (Benoit Daloze) about 18 hours ago Actions #1

  • Related to Bug #21441: SEGV during thread cleanup if profiler calls thread_profiles_frames at wrong time added

Updated by Eregon (Benoit Daloze) about 16 hours ago Actions #3

  • Status changed from Open to Closed

Applied in changeset git|db563a357875d4efbadec58b0fd8695ac9a82f42.


Add a signal fence in rb_ec_clear_vm_stack()

Since the fix for Bug #21441, ec->cfp is set to NULL before clearing
the VM stack so that thread_profile_frames() called from a signal
handler returns early instead of walking a stack that is no longer set
on the ec. But these are plain stores to distinct fields of the same
struct, so the compiler is free to reorder them.

With Apple clang 21 and GCC 16 on macOS arm64, and with clang 18 and
GCC 13 on Linux aarch64, both stores of rb_ec_set_vm_stack() were
combined into a single stp emitted before the store to ec->cfp
(vm_stack is at offset 0, vm_stack_size at 0x8 and cfp at 0x10):

stp xzr, xzr, [x0]        ; ec->vm_stack = NULL, vm_stack_size = 0
str xzr, [x0, #0x10]      ; ec->cfp = NULL
ret

So a signal in between would see a non-NULL ec->cfp with a NULL
ec->vm_stack. Stopping there in lldb when a thread terminates and
calling rb_profile_frames() segfaults in thread_profile_frames(), as
end_cfp is computed from the NULL ec->vm_stack and the loop walks past
the control frames. With this change, ec->cfp is already NULL at that
point and rb_profile_frames() returns 0.

Like in vm_push_frame(), use atomic_signal_fence() to keep the store to
ec->cfp first. With it, all these compilers emit the store to ec->cfp no
later than the stores clearing the stack. The rest of vm.o is unchanged
with Apple clang 21, GCC 16 and GCC 13 on aarch64. Clang 21 and 18:

stp xzr, xzr, [x0, #0x8]  ; ec->vm_stack_size = 0, cfp = NULL
str xzr, [x0]             ; ec->vm_stack = NULL
ret

GCC 16 and 13:

str xzr, [x0, #0x10]      ; ec->cfp = NULL
stp xzr, xzr, [x0]        ; ec->vm_stack = NULL, vm_stack_size = 0
ret

With GCC 13 on x86_64, the store to ec->cfp is already emitted first
without the fence, and vm.o is identical with and without it:

endbr64
movq $0x0, 0x10(%rdi)     ; ec->cfp = NULL
movq $0x0, (%rdi)         ; ec->vm_stack = NULL
movq $0x0, 0x8(%rdi)      ; ec->vm_stack_size = 0
ret

[Bug #22477]

Co-Authored-By: Claude Opus 5.5

Actions

Also available in: PDF Atom