Repository navigation
Description
Thread#kill cannot interrupt a thread blocked in a native socket call — the 8 remaining library/socket hangs
require "socket"
srv = TCPServer.new("127.0.0.1", 0)
t = Thread.new { srv.accept }
sleep 0.3
t.kill
waited = 0.0
while t.alive? and waited < 3.0
sleep 0.1
waited += 0.1
end
p ["alive after kill", t.alive?, "waited", waited.round(1)]| MRI 4.0.6 | rbgo | |
|---|---|---|
alive? 0.2 s after kill |
false | true |
alive? 3.0 s after kill |
— | still true |
The kill never takes effect. It is not slow; nothing will ever deliver it while
the thread sits in accept.
The mechanism, and it is one line of design
internal/vm/thread.go:
func (vm *VM) threadRelease(fn func(), status string) {
…
vm.gvl.Unlock()
t.firstPark()
fn() // ← runs to completion; there is no way in
vm.gvl.Lock()
…
}fn() is the blocking syscall. A kill arriving while it runs is only noticed
after it returns and the GVL is taken back — and for an accept with no
caller, that is never.
MRI solves this with the unblock function:
rb_thread_call_without_gvl(func, data, ubf, data2) takes a ubf whose job is
to make func return — for a socket it interrupts or closes the descriptor.
ioBlock / threadBlock have no equivalent, so there is nothing to call.
The Go-side shape is available and cheap: before running fn, record a cancel
closure on the thread (ln.Close(), or conn.SetDeadline(time.Now()) for a
read/write), and have Thread#kill — and anything else that interrupts —
invoke it. interruptibleWait already shows the pattern for waits rbgo owns;
this is the same idea for waits the kernel owns.
What it accounts for
The 8 library/socket files still killed by the clock in #916, all of which
contain "can be interrupted by Thread#kill" or the same shape:
tcpserver/accept_spec.rb unixserver/accept_spec.rb
basicsocket/recv_spec.rb basicsocket/send_spec.rb
socket/recvfrom_spec.rb socket/connect_nonblock_spec.rb
socket/tcp_server_loop_spec.rb socket/udp_server_loop_spec.rb
Measured with MRI 4.0.6 as the control (#930): 8 killed under rbgo, 0 under
MRI.
Relation to #803
#803 reports that Timeout.timeout runs its block to completion. This is the
layer underneath: even a correct Timeout cannot bound a blocking socket call
while nothing can make that call return. The ninth remaining hang in #916 is
library/timeout/timeout_spec.rb, so the two issues meet there — but #803 is
about the timeout implementation and this is about the blocking primitive, and
fixing either alone leaves the other's specs where they are.
What this is NOT
Not the GVL being held. A goroutine dump of the hung spec shows the accepting
thread correctly inside ioBlock with the GVL released
(net.(*TCPListener).Accept under threadRelease), and the main thread in
threadJoin. Thread#status is also right — it reports "sleep" in both
engines, which is what the specs spin on before killing. Both were my first two
guesses and both were wrong.
Prior art: priorart "Thread#kill cannot interrupt a blocking native call unblock function" at 2026-11-13 17:47 CET reported prior art; checked: priorart --repo go-embedded-ruby/ruby 'Thread#kill cannot interrupt a blocking native call unblock function' returned 868 broad matches. The two that touch it are #803 (Timeout.timeout runs its block to completion) and #916 (the hang symptom). The body states the relation to #803 explicitly: that one is the timeout implementation, this is the blocking primitive underneath, and fixing either alone leaves the other's specs unchanged. #700 and #873 are other Thread-surface gaps, unrelated.
Activity
tannevaled commentedon Nov 13, 2026
The unblock mechanism already exists here — for the waits rbgo owns
interruptibleWait(internal/vm/thread.go:293-315) is already MRI's pattern,and its own comment says so, citing
native_sleepwith an unblocking function:Every wait rbgo owns —
Thread#join,Queue#pop,Mutex#lock— goes throughit and is interruptible. The comment is explicit that this is why a Ruby
Mutexcannot simply be a
sync.Mutex.So the gap is narrower than "rbgo has no unblock function". It is: a wait the
KERNEL owns cannot be put in a
select, soioBlockhas nothing to selecton and
threadReleaserunsfn()to completion.The shape that fits what is already there
The Go equivalent of MRI's ubf for a socket is to make the syscall return:
SetDeadline(time.Now()), orClose(). SoioBlockwants a sibling that takesthe canceller its call site already knows:
The interrupt channel is the same one
interruptibleWaituses, so a killdelivers once and reaches both kinds of wait.
Two details worth knowing before starting
net.Listenerhas noSetDeadline. The interface isAccept/Close/Addr;*net.TCPListenerhas the method buttcpServerstores theinterface. So the accept sites either keep the concrete type or cancel with
Close(), which is coarser — it ends the listener rather than the oneaccept, and
TCPServer#acceptafter a kill should still behave.syscall returns
ErrDeadlineExceededorErrClosed, and the thread is aboutto unwind on
killSignalanyway; whateverfnsets must be discarded on theinterrupt path rather than raised, or a killed thread gains an exception MRI
never raises.
Where it would be measured
The 8
library/socketfiles named above, with MRI 4.0.6 as the control, plusthe direct reproduction in the issue body:
alive?0.2 s afterkillmust befalse, as it is in MRI, instead of stilltrueafter 3 s.README: net-http is now 0 too, measured on the merged main
README: the Net::FTP hang figures describe code that has since changed (
socket: the regression test the coverage ratchet asked for