Skip to content

socket: Thread#kill can interrupt a blocked accept — the unblock function - #935

Open
tannevaled wants to merge 2 commits into
mainfrom
socket-unblock
Open

tannevaled wants to merge 2 commits into
mainfrom
socket-unblock

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

socket: Thread#kill can interrupt a blocked accept — the unblock function

threadRelease runs its fn to completion, so a Thread#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.

MRI 4.0.6 rbgo before rbgo after
alive? after t.kill false at 0.1 s still true at 3.0 s false at 0.1 s

Same program both sides, the one in #933.

The mechanism

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 into a select, so ioBlockCancel puts a watcher on that same channel and
calls a cancel function instead: SetDeadline(now), which makes the call return
without closing anything.

Two details the design had to get right, both written down before any code:

  • 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 set ourselves; 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

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 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 this
changes — is gone from the kill list: nine files killed by the clock across
library/socket + library/timeout before, 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.rb calls MSpec.enable_feature, and the
conformance shim defines no MSpec, so the reference stops at load. A control
that scores 7 measures nothing, and +217 against it would mean nothing.

Why one site and not twelve

Twelve ioBlock sites remain (socket.go ×6, socket_raw.go ×6). Each needs
its 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/socket files 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

tannevaled and others added 2 commits November 14, 2026 08:37
…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>
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.

1 participant