Skip to content

Commit d11565c

Browse files
committed
ZJIT: Fix stack map clobbering VM stack due to wrong starting point
Problem: Any C function called after gen_prepare_non_leaf_call() have the freedom to push values through `cfp->sp`, moving it, and then ask for a zjit_materialize_frames(). (The C function has to restore `cfp->sp` before it returns, but it can move it temporarily.) Since zjit_materialize_frames() used `cfp->sp` as the starting point of the cursor that writes out the stack, it wrote to the wrong slots. In practice this happened when the non-leaf C function fails to make a call and raises ArgumentError. Solution: Stash `SP`, the VM stack base pointer in gen_prepare_non_leaf_call() and add a new stack map opcode for zjit_materialize_frames() to find it. Materialization during non-leaf calls now reliably starts from `base_ptr+stack_size` and we don't look at `cfp->sp`.
1 parent 4288306 commit d11565c

7 files changed

Lines changed: 192 additions & 20 deletions

File tree

vm.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2943,6 +2943,11 @@ zjit_materialize_frames(const rb_execution_context_t *ec, rb_control_frame_t *cf
29432943
else if (ZJIT_STACK_MAP_SKIP_P(entry)) {
29442944
stack -= ZJIT_STACK_MAP_SKIP_SIZE(entry);
29452945
}
2946+
else if (ZJIT_STACK_MAP_BASE_PTR_P(entry)) {
2947+
RUBY_ASESRT_ALWAYS(0 == i, "base_ptr stack map code only makes sense at 0");
2948+
VALUE *base_ptr = (VALUE *)((VALUE *)cfp->jit_return)[-(ssize_t)ZJIT_STACK_MAP_BASE_PTR_SLOT_INDEX(entry)];
2949+
stack = base_ptr + ZJIT_STACK_MAP_BASE_PTR_STACK_SIZE(entry);
2950+
}
29462951
else {
29472952
stack--;
29482953
*stack = entry;

zjit.h

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,24 @@
1111
# define ZJIT_STATS (USE_ZJIT && RUBY_DEBUG)
1212
#endif
1313

14-
// Stack map entries are either immediate Ruby VALUEs, tagged native-stack
15-
// locations, or tagged skip counts. Stack maps never contain heap VALUEs, so
16-
// these tags are available: they are not Qfalse (0), and their low 3 bits are
17-
// zero, so RB_SPECIAL_CONST_P is false.
14+
// Stack map entries are opcodes for zjit_materialize_frames(), which walks them
15+
// in order while moving a cursor down the VM stack. An untagged entry is an
16+
// immediate Ruby VALUE to store; the tagged forms below copy from the native
17+
// stack, skip slots, or move the cursor. Stack maps never contain heap VALUEs,
18+
// so these tags are available: they are not Qfalse (0), and their low 3 bits
19+
// are zero, so RB_SPECIAL_CONST_P is false. Tags must stay non-zero multiples
20+
// of 8 for that to hold.
1821
#define ZJIT_STACK_MAP_VREG_TAG 0x08
1922
#define ZJIT_STACK_MAP_SKIP_TAG 0x10
23+
#define ZJIT_STACK_MAP_BASE_PTR_TAG 0x18
2024
#define ZJIT_STACK_MAP_TAG_MASK 0xff
2125
#define ZJIT_STACK_MAP_SHIFT 8
2226

27+
// The BASE_PTR payload packs two fields above the tag byte: the slot index in
28+
// bits 8..31 and the operand stack size in bits 32..63.
29+
#define ZJIT_STACK_MAP_BASE_PTR_SIZE_SHIFT 32
30+
#define ZJIT_STACK_MAP_BASE_PTR_INDEX_MASK 0xffffff
31+
2332
static inline bool
2433
ZJIT_STACK_MAP_VREG_P(VALUE entry)
2534
{
@@ -44,6 +53,36 @@ ZJIT_STACK_MAP_SKIP_SIZE(VALUE entry)
4453
return entry >> ZJIT_STACK_MAP_SHIFT;
4554
}
4655

56+
// Anchor the write cursor using the SP register the JIT saved on its native
57+
// stack, instead of cfp->sp. cfp->sp is not a reliable starting point for a
58+
// frame that is in the middle of a non-leaf C call, as e.g. raising
59+
// ArgumentError can push through and move cfp->sp, then use the stack map.
60+
// gen_prepare_non_leaf_call() emits this opcode, always as stack[0], so the
61+
// entries after decode to the right place.
62+
static inline bool
63+
ZJIT_STACK_MAP_BASE_PTR_P(VALUE entry)
64+
{
65+
return (entry & ZJIT_STACK_MAP_TAG_MASK) == ZJIT_STACK_MAP_BASE_PTR_TAG;
66+
}
67+
68+
// VALUE index from cfp->jit_return down to the native stack slot holding the
69+
// saved SP register, i.e. base_ptr is `((VALUE **)cfp->jit_return)[-index]`.
70+
// There is one such slot per compiled function, so the index depends on the
71+
// frame's inlining depth.
72+
static inline size_t
73+
ZJIT_STACK_MAP_BASE_PTR_SLOT_INDEX(VALUE entry)
74+
{
75+
return (entry >> ZJIT_STACK_MAP_SHIFT) & ZJIT_STACK_MAP_BASE_PTR_INDEX_MASK;
76+
}
77+
78+
// Number of VM stack slots above base_ptr, i.e. the frame's operand stack
79+
// depth. The cursor is set to `base_ptr + this`.
80+
static inline size_t
81+
ZJIT_STACK_MAP_BASE_PTR_STACK_SIZE(VALUE entry)
82+
{
83+
return entry >> ZJIT_STACK_MAP_BASE_PTR_SIZE_SHIFT;
84+
}
85+
4786
// JITFrame is defined here as the single source of truth and imported into
4887
// Rust via bindgen. C code reads fields directly; Rust uses an impl block.
4988
typedef struct zjit_jit_frame {
@@ -61,9 +100,8 @@ typedef struct zjit_jit_frame {
61100

62101
// Number of stack map entries in stack[].
63102
uint32_t stack_size;
64-
// Flexible array of stack map entries. Each entry is either an immediate
65-
// VALUE, a tagged native-stack index from cfp->jit_return for a value
66-
// kept by the JIT, or a tagged count of VM stack slots to skip.
103+
// Flexible array of stack map entries, executed in order by
104+
// zjit_materialize_frames(). See the ZJIT_STACK_MAP_* opcodes above.
67105
VALUE stack[];
68106
} zjit_jit_frame_t;
69107

zjit/bindgen/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,9 @@ fn main() {
344344
.allowlist_var("ZJIT_STACK_MAP_SHIFT")
345345
.allowlist_var("ZJIT_STACK_MAP_VREG_TAG")
346346
.allowlist_var("ZJIT_STACK_MAP_SKIP_TAG")
347+
.allowlist_var("ZJIT_STACK_MAP_BASE_PTR_TAG")
348+
.allowlist_var("ZJIT_STACK_MAP_BASE_PTR_SIZE_SHIFT")
349+
.allowlist_var("ZJIT_STACK_MAP_BASE_PTR_INDEX_MASK")
347350
.allowlist_var("ZJIT_JIT_RETURN_C_FRAME")
348351
.allowlist_function("rb_assert_holding_vm_lock")
349352
.allowlist_function("rb_jit_shape_complex_p")

zjit/src/backend/lir.rs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::mem::take;
55
use std::rc::Rc;
66
use crate::bitset::BitSet;
77
use crate::codegen::{perf_symbol_range_start, perf_symbol_range_end, register_with_perf};
8-
use crate::cruby::{IseqPtr, RUBY_OFFSET_CFP_ISEQ, RUBY_OFFSET_CFP_JIT_RETURN, RUBY_OFFSET_CFP_PC, RUBY_OFFSET_CFP_SP, SIZEOF_VALUE_I32, VALUE, ZJIT_STACK_MAP_SHIFT, ZJIT_STACK_MAP_SKIP_TAG, ZJIT_STACK_MAP_VREG_TAG, vm_stack_canary, YarvInsnIdx, zjit_jit_frame, local_size_and_idx_to_ep_offset};
8+
use crate::cruby::{IseqPtr, RUBY_OFFSET_CFP_ISEQ, RUBY_OFFSET_CFP_JIT_RETURN, RUBY_OFFSET_CFP_PC, RUBY_OFFSET_CFP_SP, SIZEOF_VALUE_I32, VALUE, ZJIT_STACK_MAP_BASE_PTR_INDEX_MASK, ZJIT_STACK_MAP_BASE_PTR_SIZE_SHIFT, ZJIT_STACK_MAP_BASE_PTR_TAG, ZJIT_STACK_MAP_SHIFT, ZJIT_STACK_MAP_SKIP_TAG, ZJIT_STACK_MAP_VREG_TAG, vm_stack_canary, YarvInsnIdx, zjit_jit_frame, local_size_and_idx_to_ep_offset};
99
use crate::hir::{Invariant, SideExitReason};
1010
use crate::hir;
1111
use crate::options::{TraceExits, PerfMap, get_option};
@@ -1529,7 +1529,10 @@ const JIT_FRAME_OFFSET_FROM_JIT_RETURN: usize = 1;
15291529
/// | +-------------------------+ |
15301530
/// | | ... | | JITState::jit_frame_size
15311531
/// stack_base_idx | +-------------------------+ |
1532-
/// | | JITFrame slot depth X | v
1532+
/// | | JITFrame slot depth X | |
1533+
/// | +-------------------------+ |
1534+
/// | | saved SP (stack map | | <-- one per function, not per depth
1535+
/// | | anchor, base_ptr) | v see base_ptr_slot_offset()
15331536
/// | +-------------------------+
15341537
/// | | opnds.last() | ^
15351538
/// | +-------------------------+ |
@@ -1656,13 +1659,52 @@ impl StackMap {
16561659
}
16571660
}
16581661

1659-
/// Entry in a JITFrame stack map.
1662+
/// Entry in a JITFrame stack map. These are opcodes for zjit_materialize_frames(),
1663+
/// which walks them in order moving a write cursor down the VM stack.
16601664
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
16611665
pub enum StackMapEntry {
16621666
/// Immediate Ruby VALUE or VReg to materialize.
16631667
Opnd(Opnd),
16641668
/// Number of VM stack slots to skip when materializing across inlined frames.
16651669
Skip(usize),
1670+
/// Anchor the write cursor on the SP register saved on the native stack
1671+
/// at `cfp->jit_return[-slot_index]`, plus `stack_size` VM slots. Emitted as
1672+
/// the first entry by gen_prepare_non_leaf_call(); see zjit.h.
1673+
BasePtr { slot_index: u32, stack_size: u32 },
1674+
}
1675+
1676+
/// The base_ptr payload splits in two above the tag byte, so the slot index has
1677+
/// to occupy exactly the bits between the tag and the stack size field.
1678+
const _: () = assert!(
1679+
ZJIT_STACK_MAP_BASE_PTR_INDEX_MASK as u64
1680+
== (1u64 << (ZJIT_STACK_MAP_BASE_PTR_SIZE_SHIFT - ZJIT_STACK_MAP_SHIFT)) - 1,
1681+
"BASE_PTR index mask must cover the bits between the tag byte and the stack size field",
1682+
);
1683+
1684+
/// The stack size field runs from its shift to the top of the VALUE, so a u32
1685+
/// always fits and StackMapEntry::BasePtr needs no runtime check for it.
1686+
const _: () = assert!(
1687+
ZJIT_STACK_MAP_BASE_PTR_SIZE_SHIFT + u32::BITS <= usize::BITS,
1688+
"BASE_PTR stack size field must be at least 32 bits wide",
1689+
);
1690+
1691+
impl StackMapEntry {
1692+
/// Encode a [`StackMapEntry::BasePtr`] into its tagged VALUE form.
1693+
/// `stack_size` is bound by [`CompileError::IseqStackTooLarge`]
1694+
/// `slot_index` by the max inline iteration.
1695+
// TODO(alan): bounds check max inline iteration as it's user-controlled.
1696+
fn encode_base_ptr(slot_index: u32, stack_size: u32) -> VALUE {
1697+
const INDEX_BITS: u32 = ZJIT_STACK_MAP_BASE_PTR_SIZE_SHIFT - ZJIT_STACK_MAP_SHIFT;
1698+
assert!(
1699+
slot_index <= ZJIT_STACK_MAP_BASE_PTR_INDEX_MASK,
1700+
"StackMap base_ptr slot index {slot_index} does not fit in {INDEX_BITS} bits",
1701+
);
1702+
let encoded = ((stack_size as usize) << ZJIT_STACK_MAP_BASE_PTR_SIZE_SHIFT)
1703+
| ((slot_index as usize) << ZJIT_STACK_MAP_SHIFT)
1704+
| ZJIT_STACK_MAP_BASE_PTR_TAG as usize;
1705+
debug_assert!(!VALUE(encoded).special_const_p(), "encoded StackMap base_ptr should not look like an immediate VALUE");
1706+
VALUE(encoded)
1707+
}
16661708
}
16671709

16681710
/// Initial capacity for asm.insns vector
@@ -2530,6 +2572,10 @@ impl Assembler
25302572
debug_assert!(!VALUE(encoded).special_const_p(), "encoded StackMap skip should not look like an immediate VALUE");
25312573
VALUE(encoded)
25322574
}
2575+
StackMapEntry::BasePtr { slot_index, stack_size } => {
2576+
debug_assert_eq!(idx, 0, "base_ptr must be the first StackMap entry so later entries decode from it");
2577+
StackMapEntry::encode_base_ptr(slot_index, stack_size)
2578+
}
25332579
StackMapEntry::Opnd(Opnd::VReg { idx: vreg, .. }) => {
25342580
let vreg_stack_index = match assignments[vreg].expect("StackMap VReg should have an allocation") {
25352581
Allocation::Reg(_) | Allocation::Fixed(_) => {

zjit/src/codegen.rs

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,10 @@ struct JITState {
6161
iseq_calls: Vec<IseqCallRef>,
6262

6363
/// The number of native stack slots reserved for JITFrame, one per
64-
/// simultaneously live frame (`inlining_depth() + 1`). gen_write_jit_frame()
65-
/// and the inlined frame push write a JITFrame into the slot selected by the
66-
/// current frame's depth.
64+
/// simultaneously live frame (`inlining_depth() + 1`), plus one shared slot
65+
/// for the saved SP register at the bottom. gen_write_jit_frame() and the
66+
/// inlined frame push write a JITFrame into the slot selected by the current
67+
/// frame's depth; gen_prepare_non_leaf_call() writes the SP slot.
6768
jit_frame_size: usize,
6869
}
6970

@@ -107,6 +108,18 @@ impl JITState {
107108
}
108109
}
109110

111+
/// Byte offset from [NATIVE_BASE_PTR] to the slot holding the SP VM stack base pointer.
112+
fn base_ptr_slot_native_base_ptr_offset(&self) -> i32 {
113+
-(i32::try_from(SIZEOF_VALUE * self.jit_frame_size).expect("base_ptr_slot_index overflow"))
114+
}
115+
116+
/// The VALUE index a frame at `depth` uses to read the saved SP register as
117+
/// `((VALUE **)cfp->jit_return)[-index]`. Distance between this inline frame's
118+
/// `jit_return` and first slot past all `(jit_return, jit_frame)` tuples.
119+
/// Encoded into [`StackMapEntry::BasePtr`].
120+
fn base_ptr_slot_index(&self, depth: InlineDepth) -> u32 {
121+
(self.jit_frame_size - depth).try_into().expect("base_ptr slot index overflow")
122+
}
110123
}
111124

112125
impl Assembler {
@@ -406,7 +419,10 @@ fn gen_function(cb: &mut CodeBlock, iseq: IseqPtr, version: IseqVersionRef, func
406419
// frame push select among these slots by the frame's depth, keeping each
407420
// frame's `cfp->jit_return` pointed at its own slot rather than a shared
408421
// one.
409-
let jit_frame_size = function.inlining_depth() + 1;
422+
//
423+
// One more slot below those holds the saved SP register that stack maps
424+
// are anchored on (see base_ptr_slot_offset()).
425+
let jit_frame_size = function.inlining_depth() + 2;
410426
let mut jit = JITState::new(version, function.num_insns(), function.num_blocks(), jit_frame_size);
411427
let mut asm = Assembler::new_with_stack_slots(jit_frame_size);
412428

@@ -3243,8 +3259,9 @@ pub(crate) fn block_iseq_may_throw(iseq: IseqPtr) -> bool {
32433259
/// Byte offset from NATIVE_BASE_PTR of the JITFrame storage slot for a frame at
32443260
/// the given inlining depth. Depth 0 (the top-level frame) lives at
32453261
/// `[NATIVE_BASE_PTR - 8]`; each deeper inlined frame gets the next slot below.
3246-
/// gen_function() reserves `inlining_depth() + 1` slots, so every live frame's
3247-
/// depth maps to a distinct slot inside that reserved region.
3262+
/// gen_function() reserves `inlining_depth() + 1` of these, so every live
3263+
/// frame's depth maps to a distinct slot, followed by the single saved-SP slot
3264+
/// of base_ptr_slot_offset().
32483265
fn jit_frame_slot_offset(depth: InlineDepth) -> i32 {
32493266
-(SIZEOF_VALUE_I32 * (depth as i32 + 1))
32503267
}
@@ -3365,6 +3382,8 @@ fn gen_spill_stack(jit: &JITState, asm: &mut Assembler, function: &Function, sta
33653382
StackMapEntry::Skip(skip) => {
33663383
offset -= skip as i32;
33673384
}
3385+
// Only gen_prepare_non_leaf_call() prepends this, and it doesn't spill.
3386+
StackMapEntry::BasePtr { .. } => unreachable!("build_stack_map() does not emit BasePtr"),
33683387
}
33693388
}
33703389
}
@@ -3416,12 +3435,21 @@ fn inline_frame_stack_gap(iseq: IseqPtr) -> usize {
34163435
/// Prepare for calling a C function that may call an arbitrary method.
34173436
/// Use gen_prepare_leaf_call_with_gc() if the method is leaf but allocates objects.
34183437
fn gen_prepare_non_leaf_call(jit: &JITState, asm: &mut Assembler, function: &Function, state: &FrameState) {
3419-
// TODO: Lazily materialize caller frames when needed
3420-
// Save PC for backtraces and allocation tracing
3421-
// and SP to avoid marking uninitialized stack slots
3422-
let stack_map = build_stack_map(jit, function, state);
3438+
// Anchor the stack map on a private copy of SP rather than on cfp->sp. The callee is free to
3439+
// use the stack map after pushing through and moving cfp->sp (e.g. rb_funcall() + a raise in
3440+
// vm_callee_setup_arg()).
3441+
let mut stack_map = vec![StackMapEntry::BasePtr {
3442+
slot_index: jit.base_ptr_slot_index(state.depth),
3443+
stack_size: state.stack_size().try_into().expect("stack size overflow"),
3444+
}];
3445+
stack_map.extend(build_stack_map(jit, function, state));
34233446
let jit_frame = gen_prepare_call_with_gc(asm, state, false, stack_map.len());
34243447

3448+
// NOTE(alan): This store can be done once on function entry, but analysis is required
3449+
// to avoid the store in functions that make no non-leaf call.
3450+
asm_comment!(asm, "save SP as the stack map anchor");
3451+
asm.mov(Opnd::mem(64, NATIVE_BASE_PTR, jit.base_ptr_slot_native_base_ptr_offset()), SP);
3452+
34253453
// Remember the stack map in case it raises an exception
34263454
// and the interpreter uses the stack for handling the exception
34273455
asm.stack_map(stack_map, jit_frame, state.depth);

zjit/src/cruby_bindings.inc.rs

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

zjit/src/jit_frame.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,55 @@ mod tests {
292292
"#), @r#""no bar""#);
293293
}
294294

295+
// A C function that calls back into Ruby (rb_const_missing here) pushes
296+
// recv + args onto *this* frame's VM stack via vm_call0_body(). A wrong
297+
// arity makes vm_callee_setup_arg() raise before vm_call_iseq_setup_normal()
298+
// restores cfp->sp, so cfp->sp is left 1 + argc slots high while this frame
299+
// is materialized for the rescue. The stack map has to be decoded from the
300+
// JIT's saved SP instead, or the live `1` operand lands above its slot and
301+
// the array comes back as [false, 2].
302+
#[test]
303+
fn test_stack_map_anchor_after_callee_arity_error() {
304+
assert_snapshot!(inspect(r#"
305+
class Holder
306+
def self.const_missing(a, b) = nil # wrong arity: called with 1 arg
307+
end
308+
def jit_entry
309+
[1, (begin # the 1 is live across the const_missing call
310+
Holder::NOPE
311+
rescue ArgumentError
312+
2
313+
end)]
314+
end
315+
jit_entry
316+
jit_entry
317+
"#), @"[1, 2]");
318+
}
319+
320+
// Same displaced cfp->sp, but reaching across an inlined frame: `defined?`
321+
// calls respond_to_missing? with 2 args, which raises on arity. The map for
322+
// the inlined `inner` frame keeps going past its frame gap into `outer`'s
323+
// operand stack, so a displaced anchor writes `outer`'s live 1 above its
324+
// slot and leaves the real one holding garbage. Giving `inner` a local
325+
// pushes that garbage into env data instead, which crashes rather than
326+
// returning a wrong answer.
327+
#[test]
328+
fn test_stack_map_anchor_with_inlined_frame() {
329+
assert_snapshot!(inspect(r#"
330+
class BadResponder
331+
def respond_to_missing?(name) = true # wrong arity: called with 2
332+
end
333+
class Test
334+
def initialize = @o = BadResponder.new
335+
def inner = defined?(@o.nope) # must have 0 locals
336+
def outer = [1, inner] # the 1 is live across the inlined call
337+
end
338+
test = Test.new
339+
test.outer
340+
test.outer
341+
"#), @"[1, nil]");
342+
}
343+
295344
// Proc.new inside a block passed via invokeblock captures the caller's
296345
// block_code. When the JIT compiles the caller, block_code must be
297346
// correctly available for the proc to work.

0 commit comments

Comments
 (0)