Skip to content

Thread#join no longer runs the deferred body (single-threaded hang fix); Process.detach reaps via a dedicated waiter#898

Merged
sisshiki1969 merged 3 commits into
masterfrom
claude/cruby-4-0-2-specs-olt61c
Jul 13, 2026
Merged

Thread#join no longer runs the deferred body (single-threaded hang fix); Process.detach reaps via a dedicated waiter#898
sisshiki1969 merged 3 commits into
masterfrom
claude/cruby-4-0-2-specs-olt61c

Conversation

@sisshiki1969

@sisshiki1969 sisshiki1969 commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary

monoruby is single-threaded and emulates Thread by deferring the block and running it lazily. Thread#join previously 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/thread pattern:

t = Thread.new { loop { Thread.pass } }   # runs until killed
status = Status.new(t)                     # capture status
t.kill                                      # no-op in a single-threaded VM
t.join                                      # ← ran loop { Thread.pass } → HANG

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#join reports completion and returns self without executing the body.

  • A thread whose result is actually needed is still run lazily by #value.
  • Bodies driven cooperatively by Thread.pass (the Thread.pass until flag idiom) are unaffected.
  • The class stays intact, so RubyGems / Bundler keep loading.
  • This trades a hang for a plain failure on "assert a side effect after #join" specs — the correct outcome for a runtime with no real concurrency.

2. Process.detach returns a Thread::Waiter that still reaps on #join/#value.
Making #join a no-op regressed the Process.detach(pid).join reap-and-wait idiom (detach relied on join running its Process.wait2 body). 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 :pid thread-local) and tolerates a missing child (Errno::ECHILDnil), matching CRuby. Open3's wait_thr.value is unaffected.

3. Process.wait/waitpid/wait2 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.

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).
  • No regression to the wait/waitpid specs (their remaining failures are unrelated: waitpid isn't defined as a literal alias of wait, and Process.waitall is missing).

The three core/thread files that still hang — alive_spec, raise_spec, report_on_exception_spec — spawn bodies with an internal busy-wait (Thread.pass until flag, where flag is 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: #value runs the body; #join returns the thread itself and does not run the body.
  • builtins::process::tests::process_spawn_detach: extended to cover #pid / :pid, reap-on-join, and the Errno::ECHILD mapping.
  • All existing Thread / Process unit + integration tests pass; Open3, detach-value, and value-collection verified manually.

🤖 Generated with Claude Code

claude added 3 commits July 13, 2026 00:18
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
@sisshiki1969 sisshiki1969 changed the title Thread#join no longer runs the deferred body (single-threaded hang fix) Thread#join no longer runs the deferred body (single-threaded hang fix); Process.detach reaps via a dedicated waiter Jul 13, 2026
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.42857% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 90.50%. Comparing base (8753ca6) to head (0ebd32d).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
monoruby/src/builtins/process.rs 95.45% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sisshiki1969 sisshiki1969 merged commit afa6aaf into master Jul 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants