Files
HASUMI Hitoshi f1232334c0 Fix execute_task() so unhandled task exceptions become task results
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.
2026-05-29 01:25:47 +09:00

211 lines
4.9 KiB
Ruby

# Sleep/usleep tests (from mruby-sleep)
# Note: Use minimal sleep times to avoid test slowdown
assert("sleep accepts non-negative values") do
assert_nothing_raised { sleep(0) }
end
assert("sleep accepts non-negative float values") do
skip unless Object.const_defined?(:Float)
assert_nothing_raised { sleep(0.0) }
assert_nothing_raised { sleep(-0.0) }
end
assert("sleep raises ArgumentError for negative integer") do
assert_raise(ArgumentError) { sleep(-1) }
end
assert("sleep raises ArgumentError for negative float") do
skip unless Object.const_defined?(:Float)
assert_raise(ArgumentError) { sleep(-0.1) }
end
assert("usleep accepts non-negative values") do
assert_nothing_raised { usleep(0) }
end
assert("usleep raises ArgumentError for negative value") do
assert_raise(ArgumentError) { usleep(-100) }
end
# Task creation tests
assert("Task.new creates a task") do
task = Task.new { }
assert_kind_of Task, task
end
assert("Task.new accepts name") do
task = Task.new(name: "test") { }
assert_equal "test", task.name
end
assert("Task.new accepts priority") do
task = Task.new(priority: 100) { }
assert_equal 100, task.priority
end
assert("Task.new raises without block") do
assert_raise(ArgumentError) { Task.new }
end
# Task state tests
assert("Task#status returns symbol") do
task = Task.new { }
status = task.status
assert_true [:READY, :RUNNING, :WAITING, :SUSPENDED, :DORMANT, :UNKNOWN].include?(status)
end
assert("new task has READY status") do
task = Task.new { }
assert_equal :READY, task.status
end
assert("Task#inspect returns formatted string") do
task = Task.new(name: "test") { }
inspect_str = task.inspect
assert_kind_of String, inspect_str
assert_true inspect_str.include?("Task")
assert_true inspect_str.include?("test")
end
assert("Task#inspect shows status") do
task = Task.new { }
inspect_str = task.inspect
assert_true inspect_str.include?("READY") || inspect_str.include?("DORMANT")
end
# Task control methods
assert("Task#suspend doesn't raise") do
task = Task.new { }
assert_nothing_raised { task.suspend }
# Clean up: a suspended task left in q_suspended_ keeps a later
# Task.run from terminating (the scheduler idles waiting on it
# instead of exiting).
task.terminate
end
assert("Task#resume doesn't raise") do
task = Task.new { }
assert_nothing_raised { task.resume }
end
assert("Task#terminate doesn't raise") do
task = Task.new { }
assert_nothing_raised { task.terminate }
end
# Task.current tests
assert("Task.current in root context") do
# In root context, Task.current might be nil or a special value
current = Task.current
assert_true current.nil? || current.kind_of?(Task)
end
# Task.pass tests
assert("Task.pass yields control") do
assert_nothing_raised { Task.pass }
end
# Task.stat tests
assert("Task.stat returns hash") do
stat = Task.stat
assert_kind_of Hash, stat
end
assert("Task.stat includes tick") do
stat = Task.stat
assert_true stat.has_key?(:tick)
assert_kind_of Integer, stat[:tick]
end
assert("Task.stat includes wakeup_tick") do
stat = Task.stat
assert_true stat.has_key?(:wakeup_tick)
assert_kind_of Integer, stat[:wakeup_tick]
end
assert("Task.stat includes queue counts") do
stat = Task.stat
[:ready, :waiting, :suspended, :dormant].each do |queue|
assert_true stat.has_key?(queue), "Missing queue: #{queue}"
assert_kind_of Hash, stat[queue]
assert_true stat[queue].has_key?(:count)
assert_kind_of Integer, stat[queue][:count]
assert_true stat[queue].has_key?(:tasks)
assert_kind_of Array, stat[queue][:tasks]
end
end
assert("Task.stat tracks task counts") do
stat_before = Task.stat
ready_before = stat_before[:ready][:count]
task1 = Task.new { sleep 0 }
task2 = Task.new { sleep 0 }
stat_after = Task.stat
ready_after = stat_after[:ready][:count]
assert_equal ready_before + 2, ready_after
end
# Priority tests
assert("Task.new accepts different priorities") do
low = Task.new(priority: 200) { }
high = Task.new(priority: 50) { }
med = Task.new(priority: 128) { }
assert_equal 200, low.priority
assert_equal 50, high.priority
assert_equal 128, med.priority
end
# Name handling
assert("Task with string name") do
task = Task.new(name: "string_name") { }
assert_equal "string_name", task.name
end
assert("Task without name returns (noname)") do
task = Task.new { }
assert_equal "(noname)", task.name
end
# Edge cases
assert("Task.new with block doesn't execute immediately") do
executed = false
task = Task.new { executed = true }
# Block should not execute until scheduler runs
assert_false executed
end
assert("Task.run inside Task.run is a noop") do
assert_nothing_raised do
Task.new { Task.run }
Task.run
end
end
assert("Task#value returns exception object for unhandled task errors") do
child = nil
Task.new do
child = Task.new { raise "boom" }
end
Task.run
result = child.value
assert_kind_of RuntimeError, result
assert_equal "boom", result.message
end