Repository navigation
Description
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
endand 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
endUnder 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.
Activity
socket: nonblock? on the socket classes — the vacuous pass the open f…
socket: write the class guard so it has no unreachable statement
socket: open with a block must close, which is 27 of the hangs in #916 (