From d2a1c43a5f766a18dcb609e80839483caf8fabad Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Thu, 28 May 2026 15:36:21 +0900 Subject: [PATCH] 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 --- src/vm.c | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/vm.c b/src/vm.c index 10efd5b70..bb0c877f1 100644 --- a/src/vm.c +++ b/src/vm.c @@ -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(); \