Thread#join no longer runs the deferred body (single-threaded hang fix); Process.detach reaps via a dedicated waiter#898
Merged
Conversation
Document monoruby's raise/unwind/rescue pipeline and how it differs from CRuby, centered on the laziness that keeps the raise path cheap: - MonorubyErr (in-flight Rust error) vs the lazily-materialized Ruby exception object (take_ex_obj). - The two families of MonorubyErrKind: real exceptions vs control-flow pseudo-exceptions (return/break/throw/retry/redo/fatal), and why the latter are dispatched before any trace capture. - handle_error as the per-frame unwinder, the per-method exception table, ensure deferral, and $! restoration. - Backtrace construction: incremental capture on unwind, the catch-time caller walk (complete_backtrace_for_rescue) as the last coherent snapshot point, lazy string formatting + memoization, and why loop's StopIteration pays almost nothing. - Point-by-point CRuby contrast (eager-at-raise snapshot vs deferred/incremental) and a hot-path cost table. Captures the findings from the Exception#backtrace work (#896). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
monoruby is single-threaded and emulates Thread by deferring the body and
running it lazily. `#join` previously ran that body synchronously, which
hangs the whole VM whenever a worker's body loops until another thread
stops it — the standard `Thread.new { loop { Thread.pass } }` + capture
status + `#kill` + `#join` pattern used throughout ruby/spec's
core/thread fixtures (status/stop/kill specs). There is no other thread to
break the loop, so the join spins forever.
Make `#join` report completion and return self without executing the body.
A thread whose result is actually needed is still run lazily by `#value`
(which the internal Open3 / Process.detach reaping path depends on), and
bodies driven cooperatively by `Thread.pass` are unaffected. This trades a
hang for a plain failure on side-effect-via-join specs, which is the
correct outcome for a single-threaded runtime and stops core/thread (and
any category that incidentally joins a worker) from hanging the runner:
core/thread now completes 42/45 files instead of deadlocking.
The three files that still hang (alive/raise/report_on_exception) spawn
bodies with an *internal* busy-wait (`Thread.pass until flag`, where the
flag is only set by the main thread after the body is spawned) and are
unsolvable without real concurrency or deadlock detection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
…rrno::ECHILD
The previous commit made Thread#join a no-op (it must not run a possibly
looping body, or the single-threaded VM hangs). That regressed the
`Process.detach(pid).join` reap-and-wait idiom, since detach relied on
join running its `Process.wait2` body.
Fix it without reintroducing the hang: `Process.detach` now returns a
`Thread::Waiter` (a Thread subclass) whose #join/#value DO run the reaper.
Reaping one specific child is a terminating operation, so it is safe to
run synchronously — unlike an arbitrary user thread body. The waiter also
carries the child pid (#pid and the :pid thread-local) and tolerates a
missing child (Errno::ECHILD -> nil), matching CRuby. Open3's
`wait_thr.value` is unaffected (it never joined).
Also fix Process.wait/waitpid/wait2 to raise the errno-mapped
`Errno::E*` (a SystemCallError) instead of a bare RuntimeError — so
`Process.waitpid` on an already-reaped child raises `Errno::ECHILD`
("No child processes") as CRuby does, and the waiter's `rescue
SystemCallError` catches it.
core/process/detach_spec: 2/9 -> 6/9 passing (0 errors). No regression to
the wait/waitpid specs (their remaining failures are unrelated: waitpid is
not defined as a literal alias of wait, and Process.waitall is missing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #898 +/- ##
=======================================
Coverage 90.50% 90.50%
=======================================
Files 189 189
Lines 123227 123253 +26
=======================================
+ Hits 111521 111547 +26
Misses 11706 11706 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
monoruby is single-threaded and emulates
Threadby deferring the block and running it lazily.Thread#joinpreviously ran that deferred body synchronously — which hangs the entire VM whenever a worker's body loops until another thread stops it.This is the standard ruby/spec
core/threadpattern:There is no other thread to break the loop, so the join spins forever and takes the whole mspec run down with it. Root cause: pseudo-simulating multithreading in a runtime that has none.
Changes
1.
Thread#joinreports completion and returnsselfwithout executing the body.#value.Thread.pass(theThread.pass until flagidiom) are unaffected.#join" specs — the correct outcome for a runtime with no real concurrency.2.
Process.detachreturns aThread::Waiterthat still reaps on#join/#value.Making
#joina no-op regressed theProcess.detach(pid).joinreap-and-wait idiom (detach relied on join running itsProcess.wait2body). Reaping one specific child is a terminating operation, so the waiter safely runs it synchronously — unlike an arbitrary user body. The waiter also carries the child pid (#pid/ the:pidthread-local) and tolerates a missing child (Errno::ECHILD→nil), matching CRuby.Open3'swait_thr.valueis unaffected.3.
Process.wait/waitpid/wait2raise the errno-mappedErrno::E*(aSystemCallError) instead of a bareRuntimeError.So
Process.waitpidon an already-reaped child raisesErrno::ECHILD("No child processes") as CRuby does — and the waiter'srescue SystemCallErrorcatches it.Impact
core/thread: now completes 42 of 45 files (213 examples run) instead of deadlocking the runner. The join-driven hangs (status_spec,stop_spec, kill-then-join) are gone.core/process/detach_spec: 2/9 → 6/9 passing (0 errors).wait/waitpidspecs (their remaining failures are unrelated:waitpidisn't defined as a literal alias ofwait, andProcess.waitallis missing).The three
core/threadfiles that still hang —alive_spec,raise_spec,report_on_exception_spec— spawn bodies with an internal busy-wait (Thread.pass until flag, whereflagis only set by the main thread after the body is spawned). These are unsolvable without real concurrency or deadlock detection and would need to be excluded or addressed in a separate follow-up.Tests
builtins::class::tests::thread_value_runs_block_lazily:#valueruns the body;#joinreturns the thread itself and does not run the body.builtins::process::tests::process_spawn_detach: extended to cover#pid/:pid, reap-on-join, and theErrno::ECHILDmapping.🤖 Generated with Claude Code