mirror of
https://github.com/mruby/mruby
synced 2026-06-08 16:11:16 +00:00
f1232334c0
This patch fixes a bug thatc09196cintroduced. ## 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()`. ## Whatc09196cbroke 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.
211 lines
4.9 KiB
Ruby
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
|