Repository navigation
socket: Thread#kill can interrupt a blocked accept — the unblock function - #935
Open
tannevaled wants to merge 2 commits into
Open
tannevaled wants to merge 2 commits into
tannevaled wants to merge 2 commits into
Conversation
…ion) threadRelease runs fn to completion, so a kill arriving while a thread is parked in accept(2) is only noticed after fn returns -- which, for an accept nobody will connect to, is never. Measured: MRI kills such a thread within 200 ms, rbgo left it alive after 3 full seconds (#933). MRI's answer is the unblock function of rb_thread_call_without_gvl, whose job is to make the call RETURN. rbgo already had the other half: interruptibleWait selects on the thread's interrupt channel for every wait rbgo owns (join, Queue#pop, Mutex#lock). A wait the KERNEL owns cannot go in a select, so ioBlockCancel puts a watcher on the SAME channel and calls a cancel function instead -- here SetDeadline(now), which makes the call return without closing anything. Two things the design had to get right, both written down before coding: - net.Listener does NOT declare SetDeadline; the interface is Accept/Close/Addr. The capability is asked for (socketDeadliner) rather than assumed, and the deadline is cleared afterwards so a listener that outlives the interrupted thread still accepts. - ⛔ AN INTERRUPTED WAIT IS NOT A FAILED ONE. The error after cancelling is the deadline we ourselves set; raising it would hand a killed thread an IOError MRI never raises. accept returns instead, and the thread unwinds at its next safepoint, which is what safepoints are for. The answer travels on a channel, not a shared bool: a flag written by the watcher and read by the caller is a data race, and -race would say so. ⛔ MY OWN GVL GATE REFUSED THIS FIRST, which is the gate working. ioBlockCancel is a new releaser, so the Accept inside it read as unwrapped until the wrapper was NAMED in the one map where that exemption lives. Inferring it would have defeated the point. Verified against the oracle, same program both sides: rbgo ["alive after kill", false, "waited", 0.1] MRI ["alive after kill", false, "waited", 0.1] 112 tests pass under -race, no data race, gate green at 33 calls. Refs #933, #916 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ratchet reported unblockNow at 0.0%, newly below 100%, and it was right in the most useful way: NOTHING IN THE SUITE EVER INTERRUPTS A BLOCKING CALL. I had proved the fix with a script run by hand, so nothing would have noticed if the cancel stopped working. The test kills a thread parked in accept and asserts it dies, and then asserts something I had claimed in a comment and never checked: that the LISTENER SURVIVES. The cancel works by putting the deadline in the past, so without clearing it afterwards every later accept would fail at once — a fix that trades a hang for a broken server. The second half of the test accepts a real connection after the interrupted one. It waits on status == "sleep" rather than on a sleep of its own, which is what ruby/spec does here, so a slow machine makes it slower and not flaky. Measured before pushing with covratchet -require: ioBlockCancel, unblockNow, clearDeadline and registerTCPServer all at 100%. Passes under -race in 0.09s. Refs #933 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.
socket: Thread#kill can interrupt a blocked accept — the unblock function
threadReleaseruns itsfnto completion, so aThread#killarriving while athread is parked in
accept(2)is only noticed afterfnreturns — which,for an accept nobody will connect to, is never.
alive?aftert.killfalseat 0.1 sfalseat 0.1 sSame program both sides, the one in #933.
The mechanism
MRI's answer is the unblock function of
rb_thread_call_without_gvl, whosejob is to make the call return. rbgo already had the other half:
interruptibleWaitselects on the thread's interrupt channel for every waitrbgo owns —
join,Queue#pop,Mutex#lock. A wait the kernel owns cannotgo into a
select, soioBlockCancelputs a watcher on that same channel andcalls a cancel function instead:
SetDeadline(now), which makes the call returnwithout closing anything.
Two details the design had to get right, both written down before any code:
net.Listenerdoes not declareSetDeadline— the interface isAccept/Close/Addr. The capability is asked for (socketDeadliner)rather than assumed, and the deadline is cleared afterwards so a listener that
outlives the interrupted thread still accepts.
the deadline we set ourselves; raising it would hand a killed thread an
IOErrorMRI never raises.acceptreturns instead, and the thread unwinds atits next safepoint — which is what safepoints are for.
The answer travels on a channel, not a shared
bool: a flag written by thewatcher and read by the caller is a data race, and
-racewould say so.⛔ My own GVL gate refused this first
ioBlockCancelis a new releaser, so theAcceptinside it read as unwrappeduntil the wrapper was named in the one map where that exemption lives.
Inferring it would have defeated the point of the map. That is the gate from
#924 catching the author of the gate, one day later.
Measured, and one number deliberately not quoted
library/socket/tcpserver/accept_spec.rb— the exact file whose site thischanges — is gone from the kill list: nine files killed by the clock across
library/socket+library/timeoutbefore, eight after.⛔ The passing-example delta from that sweep is not quoted, because the
control is broken there: MRI scores 7 on those 190 files. The cause is now
known —
library/socket/spec_helper.rbcallsMSpec.enable_feature, and theconformance shim defines no
MSpec, so the reference stops at load. A controlthat scores 7 measures nothing, and
+217against it would mean nothing.Why one site and not twelve
Twelve
ioBlocksites remain (socket.go×6,socket_raw.go×6). Each needsits own canceller — listener, conn, packet conn — and its own decision about the
error it returns. Converting them all in one change, each with its own
concurrency semantics, is how a race gets shipped; this lands the primitive with
one site proven end to end against the reference, which is the same ratchet
argument this repository already uses for the GVL gate itself.
The remaining sites are listed in #933 as the follow-up, and the seven
library/socketfiles still killed name them precisely:recv,send,recvfrom,connect_nonblock,tcp_server_loop,udp_server_loop,unixserver/accept.112 tests pass under
-race, no data race, gate green at 33 calls.Refs #933, #916, #924
(#933 stays open: one site of thirteen is converted. #916 stays open: eight
files still hang. #924 is the merged pull request whose gate refused this one
until the new wrapper was named.)
🤖 Generated with Claude Code