Skip to content

Fix execute_task() so unhandled task exceptions become task results#6871

Merged
matz merged 2 commits into
mruby:masterfrom
hasumikin:fix/execute_task
May 28, 2026
Merged

Fix execute_task() so unhandled task exceptions become task results#6871
matz merged 2 commits into
mruby:masterfrom
hasumikin:fix/execute_task

Conversation

@hasumikin

@hasumikin hasumikin commented May 28, 2026

Copy link
Copy Markdown
Contributor

This patch fixes a bug that c09196c introduced.

Background

mrb_task_run() has two usage patterns:

  1. Called directly from main() as the top-level scheduler (PicoRuby and R2P2). There is no surrounding C exception handler, so mrb->jmp is NULL on entry
  2. Called from Ruby code via Task.run, bootstrapped on top of mruby's regular call chain. mrb->jmp is non-NULL

Historically, an unhandled exception raised inside a task body was turned into the task's result value by mrb_vm_exec(): the L_RAISE path walked callinfo down to cibase, ran fiber_terminate(), and - because c->vmexec was TRUE and prev_jmp was NULL in pattern 1 - took return mrb_obj_value(mrb->exc). That value landed in t->result and could be read back through mrb_task_value() / join().

What c09196c broke

It consider only pattern 2 and wrapped mrb_task_run() in a protect frame (MRB_TRY / mrb_protect_error) to guarantee that loop_running is cleared on exception. As a side effect, mrb->jmp is now always non-NULL while a task body is executing, so the L_RAISE path takes MRB_THROW(prev_jmp) instead of returning the exception value.

In pattern 2 this merely changed the semantics (exceptions started propagating out of Task.run instead of being stored as task results). In pattern 1 it was FATAL: the throw unwound to mrb_task_run's catch handler, which called mrb_exc_raise() to re-propagate, and with no outer jmpbuf this aborted the process. PicoRuby/R2P2 could no longer retrieve task exceptions via mrb_task_value().

Fix

Restore the "task exception becomes task result" contract uniformly for both patterns, independent of mrb->jmp:

  • Add mrb_task_state.exception_as_result. When set, mrb_vm_exec()'s non-root_c L_RAISE branch returns the exception as a value even if prev_jmp is non-NULL, instead of throwing

  • execute_task_vm() raises the flag around mrb_vm_exec(), captures the exception into t->result, and clears mrb->exc

  • Wrap execute_task_vm() in mrb_protect_error() as a safety net for rare paths that still unwind via MRB_THROW (e.g. CINFO_SKIP frames). exception_as_result is reset both at the end of the body and immediately after mrb_protect_error() returns, so a caught throw does not leave the flag set

  • Expose Task#value to retrieve t->result from Ruby, since Task#join cannot deliver the value through its return path under cooperative scheduling

  • Add a test asserting that Task#join on a task that raised returns the exception object, matching the pre-c09196c observable behavior

Notes

The "task exception becomes task result" semantics match the mruby/c's rrt0.c and the spirit of CRuby's Thread (an unhandled exception in a thread does not kill the scheduler / process; it surfaces when the thread is joined). The visible API shape still differs from CRuby - Task#join here returns the exception object rather than re-raising it - but the scheduler is no longer destabilized by task errors in either invocation pattern.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a mechanism to return unhandled task exceptions as task results instead of propagating them directly, ensuring the scheduler protect frame remains intact. It executes tasks via mrb_protect_error and captures exceptions in the VM execution loop. However, the reviewer noted that the fallback mechanism for abnormal cases that bypasses exception_as_result and calls mrb_exc_raise could propagate exceptions to the scheduler's context and cause a crash. The reviewer suggested terminating the task cleanly and storing the exception as the task's result instead of re-raising it.

Comment thread mrbgems/mruby-task/src/task.c Outdated
Comment on lines +414 to +416
if (error) {
mrb_exc_raise(mrb, t->result);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If an abnormal case (such as a CINFO_SKIP frame) bypasses exception_as_result and unwinds via MRB_THROW, calling mrb_exc_raise(mrb, t->result) here will propagate the exception into the scheduler's context. In pattern 1 (where the scheduler is called directly from main()), there is no outer jmpbuf to catch this, which will cause the entire scheduler/process to abort.

To make the scheduler more robust and prevent fatal crashes in these abnormal cases, we should terminate the task cleanly and store the exception as the task's result instead of re-raising it to the scheduler.

  if (error) {
    terminate_task_internal(mrb, t);
  }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix in a502be4

@hasumikin hasumikin marked this pull request as draft May 28, 2026 16:08
This patch fixes a bug that c09196c introduced.

## Background

`mrb_task_run()` has two usage patterns:

1. Called directly from `main()` as the top-level scheduler (PicoRuby and R2P2). There is no surrounding C exception handler, so mrb->jmp is NULL on entry
2. Called from Ruby code via Task.run, bootstrapped on top of mruby's regular call chain. mrb->jmp is non-NULL

Historically, an unhandled exception raised inside a task body was turned into the task's result value by `mrb_vm_exec()`: the L_RAISE path walked callinfo down to cibase, ran `fiber_terminate()`, and - because c->vmexec was TRUE and prev_jmp was NULL in pattern 1 - took `return mrb_obj_value(mrb->exc)`.
That value landed in t->result and could be read back through `mrb_task_value()` / `join()`.

## What c09196c broke

It consider only pattern 2 and wrapped `mrb_task_run()` in a protect frame (MRB_TRY / mrb_protect_error) to guarantee that loop_running is cleared on exception.
As a side effect, mrb->jmp is now always non-NULL while a task body is executing, so the L_RAISE path takes `MRB_THROW(prev_jmp)` instead of returning the exception value.

In pattern 2 this merely changed the semantics (exceptions started propagating out of `Task.run` instead of being stored as task results).
In pattern 1 it was FATAL: the throw unwound to mrb_task_run's catch handler, which called `mrb_exc_raise()` to re-propagate, and with no outer jmpbuf this aborted the process.
PicoRuby/R2P2 could no longer retrieve task exceptions via `mrb_task_value()`.

## Fix

Restore the "task exception becomes task result" contract uniformly for both patterns, independent of mrb->jmp:

* Add `mrb_task_state.exception_as_result`. When set, `mrb_vm_exec()`'s non-root_c L_RAISE branch returns the exception as a value even if prev_jmp is non-NULL, instead of throwing

* `execute_task_vm()` raises the flag around `mrb_vm_exec()`, captures the exception into `t->result`, and clears `mrb->exc`

* Wrap `execute_task_vm()` in `mrb_protect_error()` as a safety net for rare paths that still unwind via MRB_THROW (e.g. CINFO_SKIP frames). exception_as_result is reset both at the end of the body and immediately after `mrb_protect_error()` returns, so a caught throw does not leave the llag set

* Expose `Task#value` to retrieve t->result from Ruby, since Task#join cannot deliver the value through its return path under cooperative scheduling

* Add a test asserting that `Task#join` on a task that raised returns the exception object, matching the pre-c09196c observable behavior

## Notes

The "task exception becomes task result" semantics match the mruby/c's rrt0.c and the spirit of CRuby's Thread (an unhandled exception in a thread does not kill the scheduler / process; it surfaces when the thread is joined).
The visible API shape still differs from CRuby - `Task#join` here returns the exception object rather than re-raising it - but the scheduler is no longer destabilized by task errors in either invocation pattern.
It is not likely happens but if happened, in pattern 1, the whole
process abort when mrb->jmp is NULL.
Instead, make the status MRB_TASK_STOPPED and delegate following
logic of "Handle task termination"
@hasumikin hasumikin marked this pull request as ready for review May 28, 2026 16:44
@matz matz merged commit 94bd329 into mruby:master May 28, 2026
19 checks passed
@hasumikin hasumikin deleted the fix/execute_task branch May 28, 2026 23:35
@matz matz mentioned this pull request May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants