Skip to content

Thread#kill cannot interrupt a thread blocked in a native socket call: no unblock function, and the 8 remaining library/socket hangs #933

Open

Description

@tannevaled

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

  1. tannevaled commented on Nov 13, 2026

    @tannevaled
    ContributorAuthor

    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_sleep with an unblocking function:

    func (vm *VM) interruptibleWait(ch <-chan struct{}) (fired bool) {
    	intr := vm.currentThread.drainInterrupt()
    	vm.threadBlock(func() {
    		select {
    		case <-ch:
    			fired = true
    		case <-intr:          // ← the unblock path
    		}
    	})
    	return fired
    }

    Every wait rbgo owns — Thread#join, Queue#pop, Mutex#lock — goes through
    it and is interruptible. The comment is explicit that this is why a Ruby Mutex
    cannot 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
    , so ioBlock has nothing to select
    on and threadRelease runs fn() 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()), or Close(). So ioBlock wants a sibling that takes
    the canceller its call site already knows:

    // ioBlockCancel runs fn with the GVL released, and calls cancel if this thread
    // is interrupted while fn is blocked — the Go stand-in for MRI's ubf.
    func ioBlockCancel(vm *VM, cancel func(), fn func()) {
    	intr := vm.currentThread.drainInterrupt()
    	done := make(chan struct{})
    	go func() {
    		select {
    		case <-intr:
    			cancel()
    		case <-done:
    		}
    	}()
    	vm.threadBlock(fn)
    	close(done)
    }

    The interrupt channel is the same one interruptibleWait uses, so a kill
    delivers once and reaches both kinds of wait.

    Two details worth knowing before starting

    1. net.Listener has no SetDeadline. The interface is Accept/Close/
      Addr; *net.TCPListener has the method but tcpServer stores the
      interface. So the accept sites either keep the concrete type or cancel with
      Close(), which is coarser — it ends the listener rather than the one
      accept, and TCPServer#accept after a kill should still behave.
    2. The error after cancelling must not surface as a Ruby exception. The
      syscall returns ErrDeadlineExceeded or ErrClosed, and the thread is about
      to unwind on killSignal anyway; whatever fn sets must be discarded on the
      interrupt path rather than raised, or a killed thread gains an exception MRI
      never raises.

    Where it would be measured

    The 8 library/socket files named above, with MRI 4.0.6 as the control, plus
    the direct reproduction in the issue body: alive? 0.2 s after kill must be
    false, as it is in MRI, instead of still true after 3 s.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions