We've found an edge case where YJIT can generate an infinite loop (jump to the same address) when it's out-of-memory.
Reproduction:
deffirstsecondenddefsecond::Fileend# Make `second` side exit on its first instructiontrace=TracePoint.new(:line){}trace.enable(target: method(:second))32.timesdo|i|putsifirstifi==29# We've JITed the methods now - trigger the bug# Trigger a constant cache miss in rb_vm_opt_getconstant_path (in `second`) next time it's calledmoduleInvalidateConstantCacheFile=nilend# nb. this only works in yjit dev modeRubyVM::YJIT.simulate_oom!endend
This hangs indefinitely when run with YJIT (./configure --enable-yjit=dev is required for simulate_oom).
If we attach a debugger to the Ruby process at this point, it's stuck in an infinite loop:
$ lldb -p 9753
(lldb) process attach --pid 9753
Process 9753 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGSTOP
frame #0: 0x0000000104b202b8
-> 0x104b202b8: b 0x104b202b8
0x104b202bc: nop
0x104b202c0: nop
0x104b202c4: nop
Target 0: (ruby) stopped.
Executable module set to "/Users/rian/opt/ruby/bin/ruby".
Architecture set to: arm64-apple-macosx-.
the first method is a fallthrough to the second - the branch is BranchGenFn::JumpToTarget0 and BranchShape::Next0, so the branch is effectively empty (see gen_direct_jmp).
the second method exits to the interpreter on its first instruction
Invalidating block from second@infinite-jmp.rb:6, ISEQ offsets [0, 0)
# gen_direct_jmp: fallthrough
# Block: second@infinite-jmp.rb:6
# reg_temps: 00000000
# exit to interpreter on trace_opt_getconstant_path
# regenerate_branch
0x5571d6436224: jmp 0x5571d6436224
invalidate_block_version skips patching block to jump to block.entry_exit, because it exits on entry already:
ifblock_start==block_entry_exit{// Some blocks exit on entry. Patching a jump to the entry at the// entry makes an infinite loop.}else{
It then rewrites the incoming branch from the first method. As we're OOM, gen_branch_stub returns None, and we fall back to using the invalidated block's exit for the branch target, rather than a new stub:
// Create a stub for this branch targetletstub_addr=gen_branch_stub(block.ctx,block.iseq.get(),ocb,branchref.as_ptr()asusize,target_idxasu32);// In case we were unable to generate a stub (e.g. OOM). Use the block's// exit instead of a stub for the block. It's important that we// still patch the branch in this situation so stubs are unique// to branches. Think about what could go wrong if we run out of// memory in the middle of this loop.letstub_addr=stub_addr.unwrap_or(block_entry_exit);
// Check if the invalidated block immediately followslettarget_next=block.start_addr==branch.end_addr.get();iftarget_next{// The new block will no longer be adjacent.// Note that we could be enlarging the branch and writing into the// start of the block being invalidated.branch.gen_fn.set_shape(BranchShape::Default);}
This means when the branch is regenerated, we emit a jmp to the block exit address. The original branch code was zero-length fallthrough, so this jmp is written over the start of the invalidated block (this is allowed). However, because that block exits on entry, the jmp target is the start of address of that block and we end up with an infinite loop.
It feels like the invalid assumption here is that, if target_next is true, the new branch target will no longer be adjacent? This is normally true, as the target is a newly generated stub, but it falls down if gen_branch_stub failed (because we're OOM).
YJIT: Fix potential infinite loop when OOM (GH-13186)
Avoid generating an infinite loop in the case where:
Block first is adjacent to block second, and the branch from first to second is a fallthrough, and
Block second immediately exits to the interpreter, and
Block second is invalidated and YJIT is OOM
While pondering how to fix this, I think I've stumbled on another related edge case:
Block incoming_one and incoming_two both branch to block second. Block incoming_one has a fallthrough
Block second immediately exits to the interpreter (so it starts with its exit)
When Block second is invalidated, the incoming fallthrough branch from incoming_one might be rewritten first, which overwrites the start of block second with a jump to a new branch stub.
YJIT runs of out memory
The incoming branch from incoming_two is then rewritten, but because we're
OOM we can't generate a new stub, so we use second's exit as the branch
target. However second's exit was already overwritten with a jump to the
branch stub for incoming_one, so incoming_two will end up jumping to incoming_one's branch stub.