Skip to content

TCPSocket.open with a block does not close the socket, which is all 27 net-http hangs in #916 #931

Open

Description

@tannevaled

TCPSocket.open with a block does not close the socket — and that is all 27 net-http hangs

require "socket"
srv  = TCPServer.new("127.0.0.1", 0)
port = srv.addr[1]
t = Thread.new { c = srv.accept; p ["server read:", c.gets("\r\n\r\n")] }

r = TCPSocket.open("127.0.0.1", port) { |s| s.write "CLOSE"; s }
p ["closed?", r.closed?]
t.join
p :joined
MRI 4.0.6 rbgo
closed? after the block returns true false
the server's gets returns "CLOSE" blocks for ever
join :joined hangs

MRI closes the socket when the block returns, as every *.open-with-a-block in
the standard library does. rbgo leaves it open, so a peer waiting for a
separator that will never arrive — or simply for EOF — waits for ever.

Why this is the whole of the net-http column in #916

library/net-http/http/fixtures/http_server.rb serves one client at a time:

def listen
  until @server.closed?
    client = @server.accept
    handle_client(client)      # loops on client.gets("\r\n\r\n") until EOF
  end
end

and it shuts down by opening a new connection and writing an unterminated
"CLOSE":

def close
  TCPSocket.open(ip, port) { |socket| socket.write "CLOSE" }
  @thread.join
end

Under rbgo that socket stays open, so the server's gets never returns, the
listen loop never comes back to accept, and @thread.join never completes.
Every file in library/net-http/http/ ends on that join.

Measured against the restored MRI oracle (#930), 30 s per file: 27 of 27
net-http files killed under rbgo, none under MRI
, which is exactly the
count #916 recorded.

What it is not

Not the GVL. A goroutine dump of a hung get_spec.rb shows the server thread
correctly inside ioBlock with the GVL released
(readUntil → bufio.ReadByte → net.(*conn).Read), and the main thread in
threadJoin. The emulated GVL is behaving; the socket is simply still open.

Nor is it Net::HTTP leaking its connection: the same reproduction shows
peer closed (gets returned nil) after the HTTP request under both
engines. rbgo closes the HTTP connection correctly. The divergence is only in
the block form of TCPSocket.open.

Shape of the fix

TCPSocket.open (and the same block form on UNIXSocket, Socket,
TCPServer, UDPSocket — worth auditing together, this is a family) must
close the socket when the block returns, including on an exception, and return
the block's value.

A second divergence found on the way, worth its own check

MRI's gets(sep) returns the partial data at EOF — "CLOSE" above, with no
"\r\n\r\n" in sight. Whether rbgo's readUntil does the same at EOF, rather
than discarding what it has read, is not settled by this reproduction and should
be asserted separately.

Prior art: priorart "TCPSocket.open block form does not close the socket" at 2026-11-13 16:04 CET reported prior art; checked: priorart --repo go-embedded-ruby/ruby 'TCPSocket.open block form does not close the socket' returned 648 broad keyword matches. The nearest is #643 (the VM closes no IO at interpreter SHUTDOWN) which is a different moment and a different mechanism: this is the block form failing to close on normal return, mid-program, with the peer still waiting. #916 is the symptom issue this explains one column of.

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