Issue #20215 has been updated by ioquatix (Samuel Williams).
After considering the various use cases I have, I think the easiest to describe problem is
knowing whether a connection is still "connected" or not, i.e. whether read will
definitely fail or might succeed.
I added a full working example of the problem here:
<https://github.com/socketry/protocol-http1/blob/540551bdbdbca06d746b4c4545af2d73ebcc7dcc/examples/http1/client.rb#L70>.
You can try different implementation of `IO#readable?` to see the behaviour.
The example demonstrates HTTP/1 persistent connection handling, where the remote server
may at any time disconnect. In `server.rb`, it has a 50% chance of disconnecting.
`client.rb` makes 10 connections, and tries to use persistent connections.
The key problem that I'm trying to address, is that there is no protocol-level
mechanism to advertise that the remote server is closing the connection (in contrast,
HTTP/2 has such a feature). So, what that means, is in the request loop, when we want to
write the request, we want to ensure, with the best effort possible, that the connection
is still alive and working. That is the purpose of `IO#readable?` in this context -
whether there is a significantly good chance that writing an HTTP request will be
successful.
In practice, persistent connections may sit in a connection pool for minutes or hours, and
thus when you come to write a request, there is no easy operation to check "Is this
connection still working?". That is the purpose of `IO#readable?`. Specifically,
before writing a request, we check if the connection is still readable.
The logic for "Is the connection still readable?" depends on the situation and
the underlying IO. As you know there are many different semantics for handling Sockets,
Pipes, and so on, and we even provide our own blended semantics in `StringIO`. I'd
like to introduce `IO#readable?`, `BasicSocket#readable?` based on `recv_nonblock` and
`StringIO#readable?` which is similar to non-blocking `eof?`.
In other words, in the case of sockets, `BasicSocket#readable?` is querying the operating
system to find out if the TCP connection is still working (i.e. not closed explicitly).
It's true that this can be a race condition, for example the TCP reset/shutdown could
be delayed or received while writing the request. However, it's still better to
prevent writing the request entirely if possible. That's because not all requests are
idempotent e.g. POST requests for handling payments. It's much better to know ahead of
time that the request will fail because the persistent connection has been shut down, than
to find out half way through writing the non-idempotent request.
Example output from the client:
```
bundle exec client.rb
Connected to
#<Socket:0x0000754fc63d8b60> #<Addrinfo: 127.0.0.1:8080 TCP>
Writing request...
Reading response...
Got response: ["HTTP/1.1", 200, "OK", #<Protocol::HTTP::Headers
[["Content-Type", "text/plain"]]>,
#<Protocol::HTTP1::Body::Fixed length=11 remaining=11>]
Hello World
Writing request...
Reading response...
Got response: ["HTTP/1.1", 200, "OK", #<Protocol::HTTP::Headers
[["Content-Type", "text/plain"]]>,
#<Protocol::HTTP1::Body::Fixed length=11 remaining=11>]
Hello World
Writing request...
Reading response...
Got response: ["HTTP/1.1", 200, "OK", #<Protocol::HTTP::Headers
[["Content-Type", "text/plain"]]>,
#<Protocol::HTTP1::Body::Fixed length=11 remaining=11>]
Hello World
Writing request...
Reading response...
Got response: ["HTTP/1.1", 200, "OK", #<Protocol::HTTP::Headers
[["Content-Type", "text/plain"]]>,
#<Protocol::HTTP1::Body::Fixed length=11 remaining=11>]
Hello World
Writing request...
Client is not readable, closing...
Reconnecting...
Connected to #<Socket:0x0000754fc63d0fa0> #<Addrinfo: 127.0.0.1:8080 TCP>
Reading response...
Got response: ["HTTP/1.1", 200, "OK", #<Protocol::HTTP::Headers
[["Content-Type", "text/plain"]]>,
#<Protocol::HTTP1::Body::Fixed length=11 remaining=11>]
Hello World
Writing request...
Client is not readable, closing...
Reconnecting...
Connected to #<Socket:0x0000754fc644d2d0> #<Addrinfo: 127.0.0.1:8080 TCP>
Reading response...
Got response: ["HTTP/1.1", 200, "OK", #<Protocol::HTTP::Headers
[["Content-Type", "text/plain"]]>,
#<Protocol::HTTP1::Body::Fixed length=11 remaining=11>]
Hello World
Writing request...
Client is not readable, closing...
Reconnecting...
Connected to #<Socket:0x0000754fc6448cf8> #<Addrinfo: 127.0.0.1:8080 TCP>
Reading response...
Got response: ["HTTP/1.1", 200, "OK", #<Protocol::HTTP::Headers
[["Content-Type", "text/plain"]]>,
#<Protocol::HTTP1::Body::Fixed length=11 remaining=11>]
Hello World
Writing request...
Client is not readable, closing...
Reconnecting...
Connected to #<Socket:0x0000754fc6443f00> #<Addrinfo: 127.0.0.1:8080 TCP>
Reading response...
Got response: ["HTTP/1.1", 200, "OK", #<Protocol::HTTP::Headers
[["Content-Type", "text/plain"]]>,
#<Protocol::HTTP1::Body::Fixed length=11 remaining=11>]
Hello World
Writing request...
Client is not readable, closing...
Reconnecting...
Connected to #<Socket:0x0000754fc64bfe20> #<Addrinfo: 127.0.0.1:8080 TCP>
Reading response...
Got response: ["HTTP/1.1", 200, "OK", #<Protocol::HTTP::Headers
[["Content-Type", "text/plain"]]>,
#<Protocol::HTTP1::Body::Fixed length=11 remaining=11>]
Hello World
Writing request...
Client is not readable, closing...
Reconnecting...
Connected to #<Socket:0x0000754fc64bc2e8> #<Addrinfo: 127.0.0.1:8080 TCP>
Reading response...
Got response: ["HTTP/1.1", 200, "OK", #<Protocol::HTTP::Headers
[["Content-Type", "text/plain"]]>,
#<Protocol::HTTP1::Body::Fixed length=11 remaining=11>]
Hello World
Closing client...
Exiting.
```
Note that `Client is not readable, closing...` indicates that the client was closed before
the request was written, which is the ideal case.
----------------------------------------
Feature #20215: Introduce `IO#readable?`
https://bugs.ruby-lang.org/issues/20215#change-107937
* Author: ioquatix (Samuel Williams)
* Status: Open
----------------------------------------
There are some cases where, as an optimisation, it's useful to know whether more data
is potentially available.
We already have `IO#eof?` but the problem with using `IO#eof?` is that it can block
indefinitely for sockets.
Therefore, code which uses `IO#eof?` to determine if there is potentially more data, may
hang.
```ruby
def make_request(path = "/")
client = connect_remote_host
# HTTP/1.0 request:
client.write("GET #{path} HTTP/1.0\r\n\r\n")
# Read response
client.gets("\r\n") # => "HTTP/1.0 200 OK\r\n"
# Assuming connection close, there are two things the server can do:
# 1. peer.close
# 2. peer.write(...); peer.close
if client.eof? # <--- Can hang here!
puts "Connection closed"
# Avoid yielding as we know there definitely won't be any data.
else
puts "Connection open, data may be available..."
# There might be data available, so yield.
yield(client)
end
ensure
client&.close
end
make_request do |client|
puts client.read # <--- Prefer to wait here.
end
```
The proposed `IO#readable?` is similar to `IO#eof?` but rather than blocking, would simply
return false. The expectation is the user will subsequently call `read` which may then
wait.
The proposed implementation would look something like this:
```ruby
class IO
def readable?
!self.closed?
end
end
class BasicSocket
# Is it likely that the socket is still connected?
# May return false positive, but won't return false negative.
def readable?
return false unless super
# If we can wait for the socket to become readable, we know that the socket may still
be open.
result = self.recv_nonblock(1, MSG_PEEK, exception: false)
# No data was available - newer Ruby can return nil instead of empty string:
return false if result.nil?
# Either there was some data available, or we can wait to see if there is data
avaialble.
return !result.empty? || result == :wait_readable
rescue Errno::ECONNRESET
# This might be thrown by recv_nonblock.
return false
end
end
```
For `IO` itself, when there is buffered data, `readable?` would also return true
immediately, similar to `eof?`. This is not shown in the above implementation as I'm
not sure if there is any Ruby method which exposes "there is buffered data".
--
https://bugs.ruby-lang.org/