Repository navigation
fix(vm): thread blocking and the GVL hand-off — five defects, one subject (#695) - #698
Merged
Merged
Conversation
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
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>
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.
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.
Thread#killon a thread inMutex#lock, thenjoinThread#killon a thread inQueue#pop, thenjoinabort_on_exception=must end the main thread'ssleepTCPServer#accepton main + client in aThreadTCPSocket#getson main + client in aThreadMutex#lockafter its owner was killedMutex#lockafter its owner raisedThe five mechanisms:
Mutex#lock,Queue#pop,SizedQueue#pushandThread#joineach owned aprivate channel, and only
Kernel#sleep/Thread.stopregistered the wakechannel that
Thread#killandThread#raisesignal. So a thread killed whileblocked in one waited for an event its killer had just made impossible.
abort_on_exceptionwas written by its setterand read by its getter and by nothing else.
acceptand thesocket reads held the lock the peer
Threadneeded.Mutexheld by a thread that diedstayed locked for the rest of the program. This one survives fixing (1): the
wait became interruptible, and nothing was ever going to interrupt it.
expectation, see below).
The C read, and what each function decided
thread.cblocking_region_begin— settles the design question. Entering a blockingregion is
unblock_function_set(th, ubf, arg, fail_if_interrupted); if thethread is already interrupted the region is not entered at all. MRI's blocking
primitives are interruptible by construction, not by convention.
threadptr_interrupt_locked— setsRUBY_VM_SET_INTERRUPTand callsth->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/pendingRaiseflags, exactly as MRI keepsec->interrupt_flagseparate fromth->unblock.func.rb_thread_kill— enqueueRUBY_FATAL_THREAD_KILLED, thenrb_threadptr_interrupt. rbgo did the enqueue and woke only a parked sleep.sleep_forever—RUBY_VM_CHECK_INTS_BLOCKING, then loop onth->status == status,native_sleep, check again. The loop, not the wait, iswhere an interrupt lands.
thread_join_sleep—while (!thread_finished(target_th))aroundsleep_forever+RUBY_VM_CHECK_INTS_BLOCKING, withendcomputed once andhrtime_update_expireper turn. GaveThread#join's loop and its absolutedeadline.
thread_start_func_2— the whole death sequence, in order:report_on_exceptionwarning, 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, thenif (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— walkskeeping_mutexesand unlockseach.
rb_thread_s_abort_exc_set/rb_thread_abort_exc_set— the class-level formsets
vm->thread_abort_on_exception, the instance form setsth->abort_on_exception, and nothing copies one into the other at creation.Termination tests them independently.
report_on_exceptionis seeded from theVM default.
rb_threadptr_pending_interrupt_check_mask— the:never/:on_blockingtiming that decides whether a wait may end on a raise at all.
thread_sync.cdo_mutex_lock—while (mutex->fiber != fiber): add towaitq,native_sleep(th, NULL)(the general interrupt-aware sleep, not a dedicatedcondvar),
ccan_list_del, then, ifinterruptible_p, "release mutex beforechecking for interrupts...as interrupt checking code might call
rb_raise()"and
RUBY_VM_CHECK_INTS_BLOCKING. Gave the loop, the queue unlink, and theorder (give the mutex back before the check that may raise).
do_mutex_lock'sinterruptible_p == 0branch — 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 isuninterruptible.
queue_do_pop—rb_ensure(queue_sleep, ..., queue_sleep_done, ...), wherequeue_sleepreachesrb_thread_sleep_deadly_allow_spurious_wakeupandqueue_sleep_doneis theccan_list_del. The ensure clause is why the Gowaiters unlink through a
defer: an interrupt leaves the wait by panicking, anda 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 andrb_threadptr_interrupts it; alsothread_mutex_remove.The interruptibility decision
Go's blocking primitives cannot be interrupted and expose no unblocking hook: a
sync.Mutexacquisition and a bare channel receive have noubfto register.So a Ruby-level
Mutexcannot be a baresync.Mutex, and a Ruby-level waitcannot 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:tolerates spurious wakeups (
SLEEP_ALLOW_SPURIOUS) and its loops re-checktheir predicate; rbgo keeps the loops and removes the spurious edge, so a
witness for an interruption bug is deterministic instead of timing-dependent.
Thread.handle_interruptframe defers sends the wait back tosleep rather than ending it —
do_mutex_lock's loop, again.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, whichConditionVariable#waitreturns through. MRI keeps thatone uninterruptible on purpose, so a thread killed while blocked on the relock
still gets the lock before it unwinds.
core/conditionvariable/wait_spec.rbasserts it twice and printed the diagnosis itself:
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
TestServerTLSLoopbackdoes release the GVL for both TLS handshakes, so thepeers complete the handshake. Then both race for the GVL — the client to reach
ssl.write, the server to reachconn.gets. Whichever wins decides the run:getsis satisfied from thebuffer, green (as it was for months, including six PRs the same day);
getsholding the GVL the client needs to write,and both stop forever.
Which waiter a
sync.Mutexhands to is unspecified, and-raceshifts exactlythat 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:
Mutex#lock+ joinQueue#pop+ joinabort_on_exceptionends main's sleepaccepton main + client Threadgetson main + client ThreadMutex#lockafter killed ownerMutex#lockafter failed ownerruby/spec: 23179 -> 23198 (+19), nothing down
language+core, all 2206*_spec.rb, corpus pinned87b1631,TIMEOUT=25. Re-measured after mergingmain(which moved twice while this branchwas open: #684, #693 and parser v0.6.0), so both readings are against the CURRENT
main@efaea63rather than the4f5581athis branch was cut from. Every filethat moved, in either direction:
core/thread/abort_on_exception_spec.rbcore/mutex/synchronize_spec.rbcore/thread/value_spec.rbcore/thread/new_spec.rbcore/mutex/locked_spec.rbcore/kernel/require_spec.rbNothing 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.rbstayed at 6 — it was 5 mid-branch, see theregression 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 whatMRI-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.rb6 vs 8 (aThreadsubclass whose#initializecallssuperwith a block — "must be calledwith a block"; block propagation through
superinto a native singleton method) andcore/conditionvariable/wait_spec.rb6 vs 7 (Thread#backtraceof another thread,which needs a per-thread frame stack; rbgo keeps one per VM).
core/exception/signo_spec.rbflippedOK/FILEFAILbetween sweeps; it scores0 either way, so the total is unaffected. Also a pre-existing flake.
FLOORis deliberately not touched in this PR. The measured total is 23198against 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 thexargscommand. The guard assertsno 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 retypedones. That check is what found defect (5): rbgo agreed with my wrong expectation,
because rbgo had the same bug.
main(counted, not estimated: the file compiles thereand the run reports 19 failures) and 11 pass;
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 withoutabort_on_exception, a raise masked byThread.handle_interruptthat must sendthe 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
mutexLockspecifically: a:never-masked raisearriving at a thread blocked in
Mutex#lock— the deterministic route to thecontinuearm, which until then was reached only through the uninterruptiblerelock, 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
TestServerTLSLoopbackgains one(#695). The whole set of new hangs reports in 107 seconds on
mainwhere oneunbounded hang cost 80 minutes of a 90-minute lane and reported as
panic: test timed outwith no failing assertion anywhere in the job. Thedeadlines 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
SocketErroras before, since wasip1/js haveno listener.
wasip1/wasm,js/wasm,linux/amd64,windows/amd64anddarwin/arm64all build.(
js/wasmdid not build when this branch was cut, for a reason ininternal/vm/errno.gothat had nothing to do with threads; #684 fixed it onmainand this branch carries that fix through the merge below.)
What could not be exercised here
-raceis unavailable on this host (the Xcode license is broken, soCGO_ENABLED=0everywhere) — and
-raceis precisely the condition under which #695 appeared. So:without
-race;-race-only interleaving has been madedeterministic: each socket witness forces the losing order with one placed
sleep, so it fails without-raceonmainand passes without it here. Thebug no longer needs a timing shift to appear;
-racelanes remain the only check that the new interrupt channel andthe
held/shieldedbookkeeping are race-free. They are all touched onlyunder the GVL, and the one field written outside it (
intr, via a bufferednon-blocking send) is a channel;
-coverpkgand the same Go 1.27.1 asCI, but without
-race. Statement counting does not depend on-race; thetoolchain 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 measured100.0% here. Cause: CI runs Go 1.27.1 and this host had Go 1.26.4, and they
disagree about how many statements
mutexLockcontains — 29 against 21. Go 1.27splits
w = m.enqueue(t)into its own basic block, because theserviceSafepointAtabove it can panic; Go 1.26 folded those statements into thepreceding block and counted them covered on the strength of
m.leave(t)beingreached.
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:
A kill unwinds via
killSignal; a raise the currentThread.handle_interruptframe defers reads as NOT interrupted and takes the
continueabove 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
mainis one below-100 function awayfrom 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.
identical — 16 entries before, 16 after. Independently, the two CI ubuntu logs
were read directly:
main19 entries, this branch 20, the single differencebeing
mutexLock;dir.go cwdStillNamed 77.8%is present on both and isnot from this branch.
go tool coverin the tree that produced it.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,mutexLockis 100.0%, and nofunction in
thread.go,queue.goor anysocket*.gois below 100% in thefull-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 foran entry the thread does not own, and
threadJoinLimit's post-loop re-test, whichthe loop made unreachable.
Stability of the new tests
These are concurrency tests, so they were run
-count=10: green, 121s. Everywitness 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/vm173s).Merged
maininmainmoved twice while this was open (#684, #693, parser v0.6.0). The merge isclean; the single overlapping file,
internal/vm/socket_test.go, took both sideswithout conflict. Both corpus readings above were taken AFTER the merge, against
main@efaea63, so no number here is measured against amainthat no longerexists. The two witnesses re-checked on the new baseline still read 0/20 there,
so the defects are present on today's
mainand not only on the commit this branchwas cut from.
🤖 Generated with Claude Code