vm.c: defer task switch across C call boundary

A task switch (early return from mrb_vm_exec) is unsafe when execution
has re-entered the VM from C (mrb_funcall, mrb_yield, mrb_vm_run). The
C stack frame between the scheduler's mrb_vm_exec and the current frame
cannot be suspended, and returning early from the inner mrb_vm_exec
leaves the call-info stack drifted, so the enclosing mrb_vm_run trips
its `c->ci == c->cibase || ...` assertion (or corrupts state in a
non-debug build).

This happens when a block yielded from a C function wakes a task, for
example Task::Queue#push from inside a block passed to a C method via
mrb_yield_argv.

Defer the switch via task_across_c_boundary, which walks the call-info
stack for a C frame (cci > 0), mirroring the cooperative guard in
Task.pass. The switch resumes once execution unwinds back to a frame
with no C boundary. The gc.iterating short-circuit from #6862 is kept
as a cheap pre-check.

Fixes #6868.
Refs #6864.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Yukihiro "Matz" Matsumoto
2026-05-28 15:36:21 +09:00
parent dc671f007a
commit d2a1c43a5f
+24 -3
View File
@@ -1588,13 +1588,33 @@ prepare_tagged_break(mrb_state *mrb, uint32_t tag, const mrb_callinfo *return_ci
#define CALL_CODE_HOOKS() do { insn = BYTECODE_DECODER(*ci->pc); CODE_FETCH_HOOK(mrb, irep, ci->pc, regs); } while (0)
#ifdef MRB_USE_TASK_SCHEDULER
/* TRUE when the current context is executing across a C call boundary, i.e.
a C function on the stack re-entered the VM (mrb_funcall / mrb_yield /
mrb_vm_run). A task cannot be suspended at such a point: the C stack
frame between the scheduler's mrb_vm_exec and the current frame cannot
be saved or restored, and returning early from the inner mrb_vm_exec
would leave the call-info stack drifted, tripping the assertion in
mrb_vm_run (issues #6864, #6868). The scheduler defers the switch until
execution unwinds back to a frame with no C boundary. This mirrors the
cooperative guard in Task.pass, which raises rather than defers.
cibase is excluded: it is the entry frame of this mrb_vm_exec. */
static mrb_bool
task_across_c_boundary(mrb_state *mrb)
{
for (mrb_callinfo *ci = mrb->c->ci; ci > mrb->c->cibase; ci--) {
if (ci->cci > 0) return TRUE;
}
return FALSE;
}
/* Defer task switches while a C-level ObjectSpace walk holds gc.iterating
true. The walk runs callbacks (which may call back into mrb_vm_exec via
mrb_yield); returning early from an inner exec while the outer C
iteration is still active drifts the call-info stack and eventually
crashes (issue #6862). Switches resume at the next OP boundary after
the walk releases gc.iterating. A pending MRB_TASK_STOPPED is not
deferred, since the task is going away.
the walk releases gc.iterating. A pending switch is also deferred while
executing across a C call boundary (see task_across_c_boundary). A
pending MRB_TASK_STOPPED is not deferred, since the task is going away.
mrb->jmp is restored to prev_jmp before returning, exactly as the
normal return paths below do. mrb_vm_exec set mrb->jmp to its own
@@ -1604,7 +1624,8 @@ prepare_tagged_break(mrb_state *mrb, uint32_t tag, const mrb_callinfo *return_ci
This macro must only be expanded where prev_jmp is in scope, i.e.
inside mrb_vm_exec (via NEXT / END_DISPATCH). */
#define RETURN_IF_TASK_STOPPED(mrb) do { \
if (((mrb)->task.switching && !(mrb)->gc.iterating) || \
if (((mrb)->task.switching && !(mrb)->gc.iterating && \
!task_across_c_boundary(mrb)) || \
(mrb)->c->status == MRB_TASK_STOPPED) { \
(mrb)->jmp = prev_jmp; \
return mrb_nil_value(); \