Skip to content

fix(vm): thread blocking and the GVL hand-off — five defects, one subject (#695) - #698

Merged
tannevaled merged 9 commits into
mainfrom
wave42/thread-blocking
Sep 26, 2026
Merged

tannevaled merged 9 commits into
mainfrom
wave42/thread-blocking

Conversation

@tannevaled

@tannevaled tannevaled commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Thread blocking and the GVL hand-off. Closes #695 and three ruby/spec files that
scored zero.

The question I was sent to settle: one defect, or several?

Several — five, with five distinct mechanisms. They share a description ("a
blocking operation that does not yield control as MRI's does") and nothing else,
and no single change fixes two of them. The measurement that settles it is an
ablation matrix: each fix was built alone and run against all seven witnesses.

witness (all MRI 4.0.5 byte-for-byte) main GVL-release only interrupt only all
Thread#kill on a thread in Mutex#lock, then join hang hang ok ok
Thread#kill on a thread in Queue#pop, then join hang hang ok ok
abort_on_exception= must end the main thread's sleep wrong wrong wrong ok
TCPServer#accept on main + client in a Thread hang ok hang ok
TCPSocket#gets on main + client in a Thread hang ok hang ok
Mutex#lock after its owner was killed hang hang hang ok
Mutex#lock after its owner raised hang hang hang ok

The five mechanisms:

  1. A wait that releases the GVL but registers no unblocking function.
    Mutex#lock, Queue#pop, SizedQueue#push and Thread#join each owned a
    private channel, and only Kernel#sleep / Thread.stop registered the wake
    channel that Thread#kill and Thread#raise signal. So a thread killed while
    blocked in one waited for an event its killer had just made impossible.
  2. A stored flag nothing reads. abort_on_exception was written by its setter
    and read by its getter and by nothing else.
  3. A blocking syscall that never releases the GVL at all. accept and the
    socket reads held the lock the peer Thread needed.
  4. State a dying thread never cleans up. A Mutex held by a thread that died
    stayed locked for the rest of the program. This one survives fixing (1): the
    wait became interruptible, and nothing was ever going to interrupt it.
  5. A flag inherited that MRI does not inherit (found while checking an
    expectation, see below).

The C read, and what each function decided

thread.c

  • blocking_region_begin — settles the design question. Entering a blocking
    region is unblock_function_set(th, ubf, arg, fail_if_interrupted); if the
    thread is already interrupted the region is not entered at all. MRI's blocking
    primitives are interruptible by construction, not by convention.
  • threadptr_interrupt_locked — sets RUBY_VM_SET_INTERRUPT and calls
    th->unblock.func. Two separate things: a flag cannot reach a sleeping thread.
    This is the shape the fix copies — a per-thread interrupt channel kept separate
    from the killed / pendingRaise flags, exactly as MRI keeps
    ec->interrupt_flag separate from th->unblock.func.
  • rb_thread_kill — enqueue RUBY_FATAL_THREAD_KILLED, then
    rb_threadptr_interrupt. rbgo did the enqueue and woke only a parked sleep.
  • sleep_forever — RUBY_VM_CHECK_INTS_BLOCKING, then loop on
    th->status == status, native_sleep, check again. The loop, not the wait, is
    where an interrupt lands.
  • thread_join_sleep — while (!thread_finished(target_th)) around
    sleep_forever + RUBY_VM_CHECK_INTS_BLOCKING, with end computed once and
    hrtime_update_expire per turn. Gave Thread#join's loop and its absolute
    deadline.
  • thread_start_func_2 — the whole death sequence, in order:
    report_on_exception warning, the abort test
    (th->vm->thread_abort_on_exception || th->abort_on_exception),
    rb_threadptr_join_list_wakeup, rb_threadptr_unlock_all_locking_mutexes,
    status = THREAD_KILLED, then
    if (RB_TYPE_P(errinfo, T_OBJECT)) rb_threadptr_raise(ractor_main_th, 1, &errinfo).
    Three separate defects came out of this one function.
  • rb_threadptr_unlock_all_locking_mutexes — walks keeping_mutexes and unlocks
    each.
  • rb_thread_s_abort_exc_set / rb_thread_abort_exc_set — the class-level form
    sets vm->thread_abort_on_exception, the instance form sets
    th->abort_on_exception, and nothing copies one into the other at creation.
    Termination tests them independently. report_on_exception is seeded from the
    VM default.
  • rb_threadptr_pending_interrupt_check_mask — the :never / :on_blocking
    timing that decides whether a wait may end on a raise at all.

thread_sync.c

  • do_mutex_lock — while (mutex->fiber != fiber): add to waitq,
    native_sleep(th, NULL) (the general interrupt-aware sleep, not a dedicated
    condvar), ccan_list_del, then, if interruptible_p, "release mutex before
    checking for interrupts...as interrupt checking code might call rb_raise()"
    and RUBY_VM_CHECK_INTS_BLOCKING. Gave the loop, the queue unlink, and the
    order (give the mutex back before the check that may raise).
  • do_mutex_lock's interruptible_p == 0 branch — saves the pending interrupts,
    keeps looping, restores them once the mutex is held. See the regression below.
  • rb_mutex_sleep — rb_ensure(mutex_sleep_begin, ..., mutex_lock_uninterruptible, self),
    then RUBY_VM_CHECK_INTS_BLOCKING. The relock is an ensure clause and it is
    uninterruptible.
  • queue_do_pop — rb_ensure(queue_sleep, ..., queue_sleep_done, ...), where
    queue_sleep reaches rb_thread_sleep_deadly_allow_spurious_wakeup and
    queue_sleep_done is the ccan_list_del. The ensure clause is why the Go
    waiters unlink through a defer: an interrupt leaves the wait by panicking, and
    a waiter left in the queue would swallow a wake meant for a live thread.
  • rb_mutex_unlock_th — hands the mutex to the head of the waitq and
    rb_threadptr_interrupts it; also thread_mutex_remove.

The interruptibility decision

Go's blocking primitives cannot be interrupted and expose no unblocking hook: a
sync.Mutex acquisition and a bare channel receive have no ubf to register.
So a Ruby-level Mutex cannot be a bare sync.Mutex, and a Ruby-level wait
cannot be a bare channel receive
— which is also how MRI builds them: a wait
queue plus the one general, interrupt-aware sleep.

Every Ruby-level wait now selects on its own condition and on the thread's
interrupt channel, and sits in a loop that re-tests its own predicate and reaches
a safepoint — MRI's RUBY_VM_CHECK_INTS_BLOCKING. Three deliberate choices:

  • The interrupt channel is drained under the GVL before each wait. MRI
    tolerates spurious wakeups (SLEEP_ALLOW_SPURIOUS) and its loops re-check
    their predicate; rbgo keeps the loops and removes the spurious edge, so a
    witness for an interruption bug is deterministic instead of timing-dependent.
  • A raise a Thread.handle_interrupt frame defers sends the wait back to
    sleep
    rather than ending it — do_mutex_lock's loop, again.
  • No deadline anywhere in the VM. MRI blocks indefinitely, so a timeout in a
    primitive would be a divergence. Every deadline added here is a test's.

The regression the corpus caught, and why it matters

Making every wait interruptible made one wait too many so: the relock at the end
of Mutex#sleep, which ConditionVariable#wait returns through. MRI keeps that
one uninterruptible on purpose, so a thread killed while blocked on the relock
still gets the lock before it unwinds. core/conditionvariable/wait_spec.rb
asserts it twice and printed the diagnosis itself:

ConditionVariable#wait reacquires the lock even if the thread is killed after being signaled
ThreadError: Attempt to unlock a mutex which is not locked

It cost one example, 6 -> 5, and no test in the Go suite covered it — the
corpus was the only witness. The branch now carries one that reproduces it in
0.1s and passes on unmodified main, which is what a regression guard is.
Mechanism: a per-thread shield count; inside it a queued kill or raise is neither
delivered nor consumed, so it fires at the first safepoint after the region ends.
Leaving the flags set is MRI's save, not consuming them is its restore.

#695 explained

TestServerTLSLoopback does release the GVL for both TLS handshakes, so the
peers complete the handshake. Then both race for the GVL — the client to reach
ssl.write, the server to reach conn.gets. Whichever wins decides the run:

  • client first: the write is buffered, the server's gets is satisfied from the
    buffer, green (as it was for months, including six PRs the same day);
  • server first: it blocks in gets holding the GVL the client needs to write,
    and both stop forever.

Which waiter a sync.Mutex hands to is unspecified, and -race shifts exactly
that timing. That is the whole of it: a real deadlock reachable only on one
interleaving, not a slow suite. The test's own comment ("the server reads the
buffered request afterwards, so no two Threads ever block on a read at once")
describes the lucky interleaving as if it were guaranteed.

Both halves are now fixed and both are witnessed deterministically, by forcing
the losing interleaving with one placed sleep.

Before / after — rates, not passes

Interruption bugs are timing-dependent, so each witness ran 20 times against
MRI 4.0.5's own stdout for the same program:

witness before after
kill in Mutex#lock + join 0/20 20/20
kill in Queue#pop + join 0/20 20/20
abort_on_exception ends main's sleep 0/20 20/20
accept on main + client Thread 0/20 20/20
gets on main + client Thread 0/20 20/20
Mutex#lock after killed owner 0/20 20/20
Mutex#lock after failed owner 0/20 20/20
control: kill a thread that is not blocked 20/20 20/20
control: a lock that succeeds immediately 20/20 20/20
control: an ordinary join and pop 20/20 20/20

ruby/spec: 23179 -> 23198 (+19), nothing down

language + core, all 2206 *_spec.rb, corpus pinned 87b1631,
TIMEOUT=25. Re-measured after merging main (which moved twice while this branch
was open: #684, #693 and parser v0.6.0), so both readings are against the CURRENT
main @ efaea63 rather than the 4f5581a this branch was cut from. Every file
that moved, in either direction:

file before after note
core/thread/abort_on_exception_spec.rb 0 6 = MRI-through-our-shim
core/mutex/synchronize_spec.rb 0 5 = MRI-through-our-shim
core/thread/value_spec.rb 0 4 = MRI-through-our-shim
core/thread/new_spec.rb 4 6
core/mutex/locked_spec.rb 2 3 = MRI-through-our-shim
core/kernel/require_spec.rb 67 68 pre-existing, load-sensitive flake, and it landed HIGH on this side. Standalone it is 68 in 10/10 on both; the 67 appears only inside the 10-way parallel sweep. Not counted as a win.

Nothing moved down. The deterministic gain is +18, all of it in the five
thread/mutex files; the sweep reads +19 because the flaky file happened to land
high in this run.

core/conditionvariable/wait_spec.rb stayed at 6 — it was 5 mid-branch, see the
regression above, and is back where it was.

The four files that went from zero-or-low are the three the mspec-shim repair (#692)
routed here plus mutex/locked, and each now reads exactly what
MRI-through-our-shim reads — measured, not assumed: 6 = 6, 5 = 5, 4 = 4, 3 = 3.
Nowhere above the shim, so there is nothing to inspect. Two files are still short of
it, both for reasons outside this subject: core/thread/new_spec.rb 6 vs 8 (a
Thread subclass whose #initialize calls super with a block — "must be called
with a block"; block propagation through super into a native singleton method) and
core/conditionvariable/wait_spec.rb 6 vs 7 (Thread#backtrace of another thread,
which needs a per-thread frame stack; rbgo keeps one per VM).

core/exception/signo_spec.rb flipped OK/FILEFAIL between sweeps; it scores
0 either way, so the total is unaffected. Also a pre-existing flake.

FLOOR is deliberately not touched in this PR. The measured total is 23198
against a floor of 22475.

Sweep integrity

Row count == input count is not sufficient, and this sweep proved it: the first
run produced 2206 rows for 2206 inputs with every filename empty and a total of
0
, because the {} had been dropped from the xargs command. The guard asserts
no empty key column, distinct filenames == input count, and a floor on the
measurement itself; it caught it, and the real sweep was re-run.

Tests

30 Ruby witnesses, and every expectation is MRI 4.0.5's own stdout for the same
program
— checked by extracting the cases from the test source and running them
through ruby, so the strings judged are the strings asserted rather than retyped
ones. That check is what found defect (5): rbgo agreed with my wrong expectation,
because rbgo had the same bug.

  • 19 fail on unmodified main (counted, not estimated: the file compiles there
    and the run reports 19 failures) and 11 pass;
  • those 11 are controls that must not change: a lock that succeeds
    immediately, a kill on a thread that is not blocked, an ordinary join and pop,
    mutual exclusion under contention (8 threads x 200 increments), a timed join
    that still times out, t.join(0), an unhandled exception without
    abort_on_exception, a raise masked by Thread.handle_interrupt that must send
    the wait back to sleep, a kill delivered while a mask is in effect and the
    thread is inside the uninterruptible relock, a waiter already handed the mutex
    then killed (it must give it back), and the uninterruptible relock itself.
    Fixing a hang by letting a lock spin or time out would pass the 19 and fail
    these.

Two witnesses were added for mutexLock specifically: a :never-masked raise
arriving at a thread blocked in Mutex#lock — the deterministic route to the
continue arm, which until then was reached only through the uninterruptible
relock, whose timing decides whether the waiter is parked — and recursive locking
raising ThreadError, which the suite covered only through an unrelated test.

Every witness carries its own deadline, and TestServerTLSLoopback gains one
(#695). The whole set of new hangs reports in 107 seconds on main where one
unbounded hang cost 80 minutes of a 90-minute lane and reported as
panic: test timed out with no failing assertion anywhere in the job. The
deadlines are the tests' and never the VM's.

Platforms

No build tags and no platform-specific code were added, so nothing here is decided
silently by a tag. On wasm the change is logically identical — goroutines,
channels and timers are what the GVL hand-off is built from, and they all work
there; the socket calls simply raise SocketError as before, since wasip1/js have
no listener. wasip1/wasm, js/wasm, linux/amd64, windows/amd64 and
darwin/arm64 all build.

(js/wasm did not build when this branch was cut, for a reason in
internal/vm/errno.go that had nothing to do with threads; #684 fixed it on main
and this branch carries that fix through the merge below.)

What could not be exercised here

-race is unavailable on this host (the Xcode license is broken, so CGO_ENABLED=0
everywhere) — and -race is precisely the condition under which #695 appeared. So:

  • the 20-run rates, the full suite, the corpus and the coverage profile are all
    without -race;
  • what replaces it is that the -race-only interleaving has been made
    deterministic: each socket witness forces the losing order with one placed
    sleep, so it fails without -race on main and passes without it here. The
    bug no longer needs a timing shift to appear;
  • the CI -race lanes remain the only check that the new interrupt channel and
    the held / shielded bookkeeping are race-free. They are all touched only
    under the GVL, and the one field written outside it (intr, via a buffered
    non-blocking send) is a channel;
  • the coverage gate is measured with the same -coverpkg and the same Go 1.27.1 as
    CI, but without -race. Statement counting does not depend on -race; the
    toolchain version does, and that was the gap that mattered.

Coverage — and a measurement that was wrong until the toolchain matched

The first version of this branch turned the ubuntu gate red, and the reason is
worth more than the fix.
The gate reported
internal/vm/thread.go:1384 mutexLock 89.7% while the same source measured
100.0% here. Cause: CI runs Go 1.27.1 and this host had Go 1.26.4, and they
disagree about how many statements mutexLock contains — 29 against 21. Go 1.27
splits w = m.enqueue(t) into its own basic block, because the
serviceSafepointAt above it can panic; Go 1.26 folded those statements into the
preceding block and counted them covered on the strength of m.leave(t) being
reached.

A tool that counts fewer statements than the gate cannot witness the gate's
complaint, so a local 100.0% was an upper bound, not a measurement. Everything
below is re-measured with Go 1.27.1.

What the gate found was dead code, not a missing test. The block is
unreachable:

threadInterrupted(t) is true  =>  serviceSafepointAt(t, true) raises

A kill unwinds via killSignal; a raise the current Thread.handle_interrupt
frame defers reads as NOT interrupted and takes the continue above instead,
re-waiting on the same queue entry. There is no third case, so the re-registration
could never run. Deleted, with the invariant stated at the site. The arithmetic
ties out: 29 statements, 4 uncovered locally (the 3-statement block plus the
1-statement recursive-locking raise), and the suite covers the raise elsewhere,
leaving 26/29 = the gate's 89.7% on both lanes. After deletion: 27/27.

The four checks, under Go 1.27.1

  1. Rounded totals are not the criterion. main is one below-100 function away
    from failing its own ubuntu gate
    — ubuntu lists 19 below-100 where macOS lists
    17, so ubuntu sits nearer the rounding threshold and one added entry tips it.
    "coverage: 100.0%" is therefore not a safe signal; only an unchanged list is.
  2. Below-100 list, same toolchain and platform on both sides, full suite:
    identical — 16 entries before, 16 after.
    Independently, the two CI ubuntu logs
    were read directly: main 19 entries, this branch 20, the single difference
    being mutexLock; dir.go cwdStillNamed 77.8% is present on both and is
    not from this branch.
  3. Each profile resolved by go tool cover in the tree that produced it.
  4. The full measured function set gained 15 entries and lost none, so
    nothing was orphaned out of measurement.

Verified the way the gate reads it: under Go 1.27.1, running only the three
test functions in thread_blocking_test.go, mutexLock is 100.0%, and no
function in thread.go, queue.go or any socket*.go is below 100% in the
full-suite profile.

Three other statements were deleted rather than tested, for the same reason — no
test could reach them: ioBlock's nil-vm guard, releaseHeldMutexes' fallback for
an entry the thread does not own, and threadJoinLimit's post-loop re-test, which
the loop made unreachable.

Stability of the new tests

These are concurrency tests, so they were run -count=10: green, 121s. Every
witness also holds at 20/20 as a standalone Ruby program against MRI's stdout.
The full suite is green on the merged head (-count=1, all eight packages,
internal/vm 173s).

Merged main in

main moved twice while this was open (#684, #693, parser v0.6.0). The merge is
clean; the single overlapping file, internal/vm/socket_test.go, took both sides
without conflict. Both corpus readings above were taken AFTER the merge, against
main @ efaea63, so no number here is measured against a main that no longer
exists. The two witnesses re-checked on the new baseline still read 0/20 there,
so the defects are present on today's main and not only on the commit this branch
was cut from.

🤖 Generated with Claude Code

tannevaled and others added 7 commits September 26, 2026 20:19
MRI has exactly one blocking primitive, native_sleep, and it is interruptible by
construction: threadptr_interrupt_locked (thread.c) sets the interrupt flag AND
calls a registered unblocking function, because a flag alone cannot reach a
sleeping thread. Every sync object's wait is that primitive plus a wait queue and
an ensure clause that unlinks on a non-local exit -- do_mutex_lock and
queue_sleep/queue_sleep_done (thread_sync.c), thread_join_sleep (thread.c) -- and
each loops around RUBY_VM_CHECK_INTS_BLOCKING.

rbgo had the inverse: each wait owned a private channel, and only Kernel#sleep and
Thread.stop registered the wake channel that Thread#kill and Thread#raise signal.
A thread killed while blocked in Mutex#lock or Queue#pop therefore waited for an
event its killer had just made impossible, so the kill never landed and a join on
it never returned. Two witnesses, both deterministic:

    m = Mutex.new; m.lock; t = Thread.new { m.lock }
    sleep 0.05; t.kill; t.join      # MRI: returns.  rbgo: hangs forever.

    q = Queue.new; t = Thread.new { q.pop }
    sleep 0.05; t.kill; t.join      # MRI: returns.  rbgo: hangs forever.

A Ruby Mutex cannot be a bare sync.Mutex, and a Ruby wait cannot be a bare channel
receive: Go's blocking primitives expose no unblocking hook. So each wait now
selects on the thread's own interrupt channel as well -- the Go counterpart of
MRI's unblock.func, kept separate from the interrupt itself exactly as MRI keeps
ec->interrupt_flag separate from th->unblock.func -- and each wait sits in a loop
that re-tests its own predicate and reaches a safepoint, which is MRI's
RUBY_VM_CHECK_INTS_BLOCKING. The queue and mutex waiters unlink through a defer,
because the interrupt leaves the wait by panicking and a waiter left in the queue
would swallow a wake meant for a live thread -- MRI's rb_ensure, in Go.

Nothing that should not be interrupted is: the interrupt channel is drained under
the GVL before each wait, so no wait can be woken by a stale edge, and a raise
deferred by a Thread.handle_interrupt frame sends the wait back to sleep rather
than ending it, as do_mutex_lock's loop does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TCPServer#accept called net.Listener.Accept, and #gets / #read / #readpartial /
#recv read the connection, all while holding the GVL. MRI releases it around
every blocking socket call (rb_io_blocking_region and rb_thread_io_blocking_call
in io.c, over rb_thread_call_without_gvl in thread.c), and that is not a
throughput choice: a call that keeps the lock DEADLOCKS a program whose peer is
another Thread of the same VM, because the peer needs the lock to send the bytes
this call is waiting for.

Two deterministic witnesses, both of which MRI 4.0.5 prints "hi" for and rbgo
hung forever on -- not a Go deadlock panic, a silent hang, because the blocked
goroutine sits in the netpoller and the runtime does not consider it asleep:

    srv = TCPServer.new("127.0.0.1", 0)
    t = Thread.new { sleep 0.1; s = TCPSocket.new("127.0.0.1", srv.addr[1])
                     s.write("hi\n"); s.close }
    c = srv.accept                     # main blocks in accept holding the GVL
    puts c.gets                        # ... and the client can never run

    t = Thread.new { s = TCPSocket.new(...); sleep 0.1; s.write("hi\n"); s.close }
    c = srv.accept
    puts c.gets                        # main blocks in gets holding the GVL

This is what #695 is. TestServerTLSLoopback does release the GVL for both TLS
handshakes, so the two peers get through the handshake; then BOTH race for the
GVL, the client to reach ssl.write and the server to reach conn.gets. Whichever
wins decides the run: client first and the write is buffered, so the server's
gets is satisfied from the buffer and the test passes, as it did for months;
server first and it blocks in gets holding the lock the client needs to write, and
both stop forever. Which waiter a sync.Mutex hands to is not specified, and -race
shifts exactly that timing -- hence green on six pull requests the same day and a
1h20m hang on one macOS -race lane.

The release is applied at the syscall, not around the Ruby method, and to every
blocking call of the surface, not only the two the witnesses use: dial, accept and
the four reads on TCPSocket/TCPServer, UNIXSocket/UNIXServer, UDPSocket and the
raw Socket. Half of it would have left the same deadlock behind a different
method name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eption

Three things MRI's thread_start_func_2 (thread.c) does as a thread ends, and rbgo
did none of them.

1. rb_threadptr_unlock_all_locking_mutexes walks the thread's keeping_mutexes list
   and unlocks each one, waking its waiters. rbgo kept no such list, so a Mutex
   whose owner was killed -- or died on an unhandled exception -- stayed locked for
   the rest of the program:

     m = Mutex.new
     t = Thread.new { m.lock; sleep }
     sleep 0.05; t.kill; t.join
     m.lock                    # MRI: succeeds.  rbgo: waits forever.

   This survived making the wait interruptible, because nothing was ever going to
   interrupt it. RMutex now keeps the ownership record MRI's mutex_locked /
   thread_mutex_remove maintain, and a dying thread hands each one on.

2. An unhandled exception is raised in the MAIN thread when abort_on_exception is
   set:

     if (RB_TYPE_P(errinfo, T_OBJECT)) rb_threadptr_raise(ractor_main_th, 1, &errinfo);

   rbgo's abort flag was written by its setter and read by its getter and by
   nothing else -- a declared field equal to its own default, which no program
   could tell from the feature being absent. The spec's bare `sleep` on the main
   thread simply ran to completion. The same exception OBJECT propagates, so
   `e.equal?(exc)` holds as it does in MRI.

3. A new thread does NOT inherit Thread.abort_on_exception. Nothing in
   thread_create_core assigns th->abort_on_exception, and termination tests the two
   independently ("th->vm->thread_abort_on_exception || th->abort_on_exception").
   rbgo seeded the per-thread flag from the class-level one, so

     Thread.abort_on_exception = true
     Thread.new { sleep }.abort_on_exception     # MRI: false.  rbgo: true.

   report_on_exception IS inherited, and stays so. This one was found by checking
   an expectation against MRI instead of against rbgo: rbgo agreed with the wrong
   answer, because it had the same bug.

Tests: 20 Ruby witnesses, every expectation taken from MRI 4.0.5's own stdout for
the same program (checked by extracting the cases from the test source and running
them, so the strings judged are the strings asserted, not retyped ones). Nine fail
on unmodified main, seven are controls that must NOT change -- an immediate lock, a
kill on a thread that is not blocked, an ordinary join, mutual exclusion under
contention, a timed join that still times out, an unhandled exception WITHOUT
abort_on_exception, and a raise masked by Thread.handle_interrupt that must send
the wait back to sleep. Fixing a hang by letting a lock spin or time out would pass
the first nine and fail those.

Every witness carries its own deadline and fails in seconds naming the blocked
program, and TestServerTLSLoopback gains one too (#695): the whole suite of new
hangs reports in 107 seconds where one unbounded hang cost 80 minutes. The
deadlines are the TESTS' -- a timeout inside a primitive where MRI blocks
indefinitely would be a divergence, not a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Making every wait interruptible made ONE wait too many so: the relock at the end of
Mutex#sleep, which is what ConditionVariable#wait returns through. MRI keeps that
one uninterruptible on purpose -- rb_mutex_sleep's ensure clause is
mutex_lock_uninterruptible, i.e. do_mutex_lock(self, 0), whose interruptible_p == 0
path saves the pending interrupts, finishes acquiring the mutex, and restores them
(thread_sync.c). It exists so a thread killed while blocked on the relock still gets
the lock before it unwinds.

core/conditionvariable/wait_spec.rb asserts exactly that, twice, and named the
defect in its own failure output:

    ConditionVariable#wait reacquires the lock even if the thread is killed
      after being signaled
    ThreadError: Attempt to unlock a mutex which is not locked

-- the kill landed on the relock, the thread unwound without the mutex, and the
Ruby ensure clause's unlock raised. It cost one example (6 -> 5) and the corpus is
what caught it; no test in the Go suite covered it, so the branch now carries a
witness that reproduces it in 0.1s and PASSES on unmodified main, which is what a
regression guard is.

The mechanism is a per-thread shield count: inside an uninterruptible region a
queued kill or raise is not delivered and not consumed, so it fires at the first
safepoint after the region ends. Leaving the flags set is MRI's save; not consuming
them is its restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…achable statements

The 100% gate is about the below-100 function LIST, not the rounded total, and this
branch had added six entries to it. Four are now covered by witnesses whose
expectations are MRI 4.0.5's own stdout, and two were statements no test could ever
reach, which is a reason to delete them rather than to test them:

- ioBlock's nil-vm guard: every caller is a native Ruby method and always holds the
  GVL, so the guard was a branch nothing could take.
- releaseHeldMutexes' fallback for an entry the thread does not own: `held` is
  appended to only on a successful acquire and pruned by forget whenever ownership
  leaves, so the test now states that invariant instead of handling a case that
  cannot arise. The loop also walks a snapshot, so it cannot spin on a slip.
- abortMainThread's early returns collapse into one positive condition, and MRI's
  RB_TYPE_P(errinfo, T_OBJECT) guard moves to the caller where it reads as one.

The four new witnesses, all verified against MRI:

- `t.join(0)` never waits and reports the thread unfinished -- the absolute deadline
  is already past on the first turn of the loop.
- a kill delivered while a Thread.handle_interrupt mask is in effect AND the thread
  is inside the uninterruptible relock: the kill must stay queued, not fire.
- killing a waiter that is neither head nor tail of the wait queue: both survivors
  still get the mutex.
- killing a waiter that had already been HANDED the mutex but had not run yet: it
  gives the mutex back instead of dying with it, which is why do_mutex_lock releases
  the mutex before the interrupt check.

Also drops a doc comment that went stale: queueBlock no longer takes an `fn`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turning the timed join into a loop (MRI's thread_join_sleep) made the check after
it dead: past the loop the thread has finished, because the only other way out is
the deadline return inside it. It was the one statement keeping the function off
100%, and the honest fix is to delete code the loop made unreachable rather than
to hunt for a test that could reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ongs

It sat among the controls, but it HANGS on unmodified main: killing a waiter that
is neither the head nor the tail of a mutex's wait queue left the survivors
stranded. A case that fails on main is a witness, not a control, and counting it as
a control would have overstated how many controls the branch leaves untouched.

Counted on a clean origin/main checkout: 19 of the 30 witnesses fail there, 11 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… gate)

The ubuntu coverage gate on #698 went red: `coverage: 99.9% < 100%`, with one
function added to the below-100 list against main's, and it is mine --
`internal/vm/thread.go mutexLock`. No DATA RACE, no failing test, no timeout.

The cause is dead code, and the reason I did not see it is worth more than the fix:
**CI runs Go 1.27.1 and this host has 1.26.4, and the two disagree about how many
statements mutexLock contains -- 29 against 21.** Go 1.27 splits
`w = m.enqueue(t)` into its own basic block, because the `serviceSafepointAt` call
above it can panic; Go 1.26 folded those three statements into the preceding block
and therefore counted them as covered on the strength of `m.leave(t)` being
reached. So the local profile reported mutexLock at 100.0% while the gate read
86.2% from the same source. A tool that counts fewer statements than the gate
cannot witness the gate's complaint, and every coverage number measured that way
is an upper bound rather than a measurement.

The block is not merely uncovered, it is unreachable, which is why no test could
have fixed it:

    threadInterrupted(t) is true  =>  serviceSafepointAt(t, true) raises

A kill unwinds through killSignal; a raise the current Thread.handle_interrupt
frame defers reads as NOT interrupted, so it takes the `continue` above instead and
re-waits on the same queue entry. There is no third case, so nothing follows the
safepoint and the re-registration could never run. Deleted, with the invariant
stated at the site, and the entry is now documented as surviving a continue.

Two witnesses added so mutexLock is covered by THIS file's tests rather than by
whatever else the suite happens to run:

- a masked (`:never`) raise arriving at a thread blocked in `Mutex#lock`, which is
  the deterministic route to the `continue` arm. It was previously reached only
  through the uninterruptible relock, whose timing decides whether the waiter is
  parked when the kill lands -- so that arm was *sometimes* covered, and a gate
  reading it on the unlucky run fails. This is the mirror of a coverage gate that
  passes because a goroutine leaked at the right moment.
- recursive locking raising ThreadError, which the suite covered only through an
  unrelated test.

Verified the way the gate reads it: under Go 1.27.1, running ONLY the three test
functions in this file, `mutexLock` is 100.0%.

Both expectations are MRI 4.0.5's own stdout, checked by extracting the cases from
the test source and running them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit eaa52a3 into main Sep 26, 2026
9 checks passed
@tannevaled
tannevaled deleted the wave42/thread-blocking branch September 26, 2026 22:33
tannevaled added a commit that referenced this pull request Oct 7, 2026
…n be served (#794)

Net::HTTP against a server living in one of the program's own Threads hung
WITHOUT BOUND. MRI runs the same program in 81 ms; the probe that first showed
this was killed at a 30-minute limit, still in the same state. ruby.Run takes
no context and exposes no cancellation, so an embedder lost that goroutine
permanently, with no error and no way to interrupt it.

The mechanism, which the issue had left open: nethttpExchangeFramed held the
emulated GVL across the whole round trip. socket.go routes its reads and writes
through ioBlock -- `func ioBlock(vm *VM, fn func()) { vm.threadBlock(fn) }` --
and the Net::HTTP binding routed nothing: `grep -c 'threadBlock(\|threadPass('`
was 0 for nethttp_bind.go and non-zero for socket.go. So the server Thread in
the same VM could never be scheduled while a request was in flight, and both
sides waited forever.

That contrast is also what localised it. A raw TCPSocket client against the
same shape of in-VM server always worked; Net::HTTP against a server in a
SEPARATE process always worked; only the combination hung. One releases the
lock, the other does not.

This is the third of the five mechanisms PR #698 separated in September -- "a
blocking call that never releases the lock at all", distinguished there from "a
wait that releases the lock but registers no unblocking function" because the
two present identically and no single change fixes both. This binding is the
same mechanism where that wave did not reach.

ABLATED RATHER THAN ASSUMED, which is #698's own lesson. Wrapping the exchange
ALONE makes the witness pass, so the dial is deliberately left unwrapped. A
second witness -- the server sleeping 0.5 s before accept, so the client's
connect must wait -- also passes with the exchange wrap alone, 541 ms against
MRI's 554 ms, because the TCP connect completes into the listen backlog without
the Ruby accept having run. One wrap, one defect, and a speculative second
change avoided.

The regression test carries a 60-second deadline on purpose. A regression here
does not fail, it HANGS, and an unbounded hang inside `go test` burns the
package's whole timeout and reports "panic: test timed out" with the cause
buried in a goroutine dump -- which is exactly what the parser's nesting test
did to three OS lanes yesterday. Bounded, it is one legible line instead.

A census of the same shape is recorded on #771: ten files do blocking network
IO with zero threadBlock. They are CANDIDATES, not findings -- the first probed,
socket_raw.go with the most sites, does not hang at all; its raw-Socket API
fails earlier with `undefined method 'read'`, so that row is an unimplemented
method and not this defect. Each needs its own witness.

Refs #771.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

TestServerTLSLoopback hung for 1h20m on macOS, taking the lane's whole 90m budget: a GVL hand-off deadlock, not a slow suite

1 participant