Skip to content

Commit 6f645ae

Browse files
p-mongop
authored andcommitted
Fix RUBY-1898 Socket reads may raise Timeout::Error (#1482)
* RUBY-1898 Correctly map select timeouts to Mongo::Error::TimeoutError * RUBY-1898 Handle IO::WaitWritable when reading * RUBY-1898 Raise more informative exceptions * RUBY-1898 Rewrite the code to avoid weird failures * Separate contexts to avoid unintentional connection reuse * RUBY-1898 Handle IO::WaitWritable when reading * Close all clients before * Remove default 5 legacy read retries in the test suite since it interferes with assertions * Remove all read retries for the connection timeout test * Remove unneeded line * Remove obsolete IO::WaitWritable rescue
1 parent 2fed15f commit 6f645ae

File tree

8 files changed

+120
-29
lines changed

8 files changed

+120
-29
lines changed

lib/mongo/socket.rb

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ class Socket
4949
# @return [ Hash ] The options.
5050
attr_reader :options
5151

52+
# @return [ Float ] timeout The socket timeout.
53+
attr_reader :timeout
54+
5255
# Is the socket connection alive?
5356
#
5457
# @example Is the socket alive?
@@ -123,10 +126,14 @@ def initialize(family)
123126
def read(length)
124127
handle_errors do
125128
data = read_from_socket(length)
126-
raise IOError unless (data.length > 0 || length == 0)
129+
unless (data.length > 0 || length == 0)
130+
raise IOError, "Expected to read > 0 bytes but read 0 bytes"
131+
end
127132
while data.length < length
128133
chunk = read_from_socket(length - data.length)
129-
raise IOError unless (chunk.length > 0 || length == 0)
134+
unless (chunk.length > 0 || length == 0)
135+
raise IOError, "Expected to read > 0 bytes but read 0 bytes"
136+
end
130137
data << chunk
131138
end
132139
data
@@ -185,7 +192,9 @@ def read_from_socket(length)
185192
return ''.force_encoding('BINARY')
186193
end
187194

188-
deadline = (Time.now + timeout) if timeout
195+
if _timeout = self.timeout
196+
deadline = Time.now + _timeout
197+
end
189198

190199
# We want to have a fixed and reasonably small size buffer for reads
191200
# because, for example, OpenSSL reads in 16 kb chunks max.
@@ -232,10 +241,22 @@ def read_from_socket(length)
232241
data[retrieved, chunk.length] = chunk
233242
retrieved += chunk.length
234243
end
235-
rescue IO::WaitReadable
236-
select_timeout = (deadline - Time.now) if deadline
237-
if (select_timeout && select_timeout <= 0) || !Kernel::select([@socket], nil, [@socket], select_timeout)
238-
raise Timeout::Error.new("Took more than #{timeout} seconds to receive data.")
244+
# As explained in https://ruby-doc.com/core-trunk/IO.html#method-c-select,
245+
# reading from a TLS socket may require writing which may raise WaitWritable
246+
rescue IO::WaitReadable, IO::WaitWritable => exc
247+
if deadline
248+
select_timeout = deadline - Time.now
249+
if select_timeout <= 0
250+
raise Errno::ETIMEDOUT, "Took more than #{_timeout} seconds to receive data"
251+
end
252+
end
253+
if exc.is_a?(IO::WaitReadable)
254+
select_args = [[@socket], nil, [@socket], select_timeout]
255+
else
256+
select_args = [nil, [@socket], [@socket], select_timeout]
257+
end
258+
unless Kernel::select(*select_args)
259+
raise Errno::ETIMEDOUT, "Took more than #{_timeout} seconds to receive data"
239260
end
240261
retry
241262
end

lib/mongo/socket/ssl.rb

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,6 @@ class SSL < Socket
3333
# @return [ Integer ] port The port to connect to.
3434
attr_reader :port
3535

36-
# @return [ Float ] timeout The socket timeout.
37-
attr_reader :timeout
38-
3936
# Establishes a socket connection.
4037
#
4138
# @example Connect the socket.

lib/mongo/socket/tcp.rb

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,6 @@ class TCP < Socket
2626
# @return [ Integer ] port The port to connect to.
2727
attr_reader :port
2828

29-
# @return [ Float ] timeout The socket timeout.
30-
attr_reader :timeout
31-
3229
# Establishes a socket connection.
3330
#
3431
# @example Connect the socket.

lib/mongo/socket/unix.rb

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,6 @@ class Unix < Socket
2323
# @return [ String ] path The path to connect to.
2424
attr_reader :path
2525

26-
# @return [ Float ] timeout The socket timeout.
27-
attr_reader :timeout
28-
2926
# Initializes a new Unix socket.
3027
#
3128
# @example Create the Unix socket.

spec/mongo/server/connection_spec.rb

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -757,38 +757,42 @@ class ConnectionSpecTestException < Exception; end
757757
end
758758
end
759759

760-
context 'when a socket timeout is set' do
760+
context 'when a socket timeout is set on client' do
761761

762762
let(:connection) do
763763
described_class.new(server, socket_timeout: 10)
764764
end
765765

766-
it 'sets the timeout' do
766+
it 'is propagated to connection timeout' do
767767
expect(connection.timeout).to eq(10)
768768
end
769+
end
769770

771+
context 'when an operation never completes' do
770772
let(:client) do
771-
authorized_client.with(socket_timeout: 1.5)
773+
authorized_client.with(socket_timeout: 1.5,
774+
# Read retries would cause the reads to be attempted twice,
775+
# thus making the find take twice as long to time out.
776+
retry_reads: false, max_read_retries: 0)
772777
end
773778

774779
before do
775-
authorized_collection.delete_many
776-
authorized_collection.insert_one(a: 1)
780+
client.cluster.next_primary
777781
end
778782

779-
it 'raises a timeout when it expires' do
783+
it 'times out and raises SocketTimeoutError' do
780784
start = Time.now
781785
begin
782786
Timeout::timeout(1.5 + 2) do
783787
client[authorized_collection.name].find("$where" => "sleep(2000) || true").first
784788
end
785789
rescue => ex
786790
end_time = Time.now
787-
expect(ex).to be_a(Timeout::Error)
788-
expect(ex.message).to eq("Took more than 1.5 seconds to receive data.")
791+
expect(ex).to be_a(Mongo::Error::SocketTimeoutError)
792+
expect(ex.message).to match(/Took more than 1.5 seconds to receive data/)
789793
end
790-
# Account for wait queue timeout (2s) and rescue
791-
expect(end_time - start).to be_within(2.5).of(1.5)
794+
# allow 1.5 seconds +- 0.5 seconds
795+
expect(end_time - start).to be_within(1).of(2)
792796
end
793797

794798
context 'when the socket_timeout is negative' do
@@ -816,7 +820,7 @@ class ConnectionSpecTestException < Exception; end
816820
it 'raises a timeout error' do
817821
expect {
818822
reply
819-
}.to raise_exception(Timeout::Error)
823+
}.to raise_exception(Mongo::Error::SocketTimeoutError)
820824
end
821825
end
822826
end

spec/mongo/socket_spec.rb

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
require 'lite_spec_helper'
1+
require 'spec_helper'
22

33
describe Mongo::Socket do
44

@@ -51,4 +51,66 @@
5151
end.to raise_error(Mongo::Error::SocketError, 'OpenSSL::SSL::SSLError: Test error (for fake-address) (MongoDB may not be configured with SSL support)')
5252
end
5353
end
54+
55+
describe '#read' do
56+
let(:target_host) do
57+
host = ClusterConfig.instance.primary_address_host
58+
# Take ipv4 address
59+
Socket.getaddrinfo(host, 0).detect { |ai| ai.first == 'AF_INET' }[3]
60+
end
61+
62+
let(:socket) do
63+
Mongo::Socket::TCP.new(target_host, ClusterConfig.instance.primary_address_port, 1, Socket::PF_INET)
64+
end
65+
66+
let(:raw_socket) { socket.instance_variable_get('@socket') }
67+
68+
let(:wait_readable_class) do
69+
Class.new(Exception) do
70+
include IO::WaitReadable
71+
end
72+
end
73+
74+
context 'timeout' do
75+
clean_slate_for_all
76+
77+
shared_examples_for 'times out' do
78+
it 'times out' do
79+
expect(socket).to receive(:timeout).at_least(:once).and_return(0.2)
80+
# When we raise WaitWritable, the socket object is ready for
81+
# writing which makes the read method invoke read_nonblock many times
82+
expect(raw_socket).to receive(:read_nonblock).at_least(:once) do |len, buf|
83+
sleep 0.01
84+
raise exception_class
85+
end
86+
87+
expect do
88+
socket.read(10)
89+
end.to raise_error(Mongo::Error::SocketTimeoutError, /Took more than .* seconds to receive data \(for /)
90+
end
91+
end
92+
93+
context 'with WaitReadable' do
94+
95+
let(:exception_class) do
96+
Class.new(Exception) do
97+
include IO::WaitReadable
98+
end
99+
end
100+
101+
it_behaves_like 'times out'
102+
end
103+
104+
context 'with WaitWritable' do
105+
106+
let(:exception_class) do
107+
Class.new(Exception) do
108+
include IO::WaitWritable
109+
end
110+
end
111+
112+
it_behaves_like 'times out'
113+
end
114+
end
115+
end
54116
end

spec/support/cluster_config.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,20 @@ def primary_address
7474
end
7575
end
7676

77+
def primary_address_str
78+
primary_address
79+
end
80+
81+
def primary_address_host
82+
both = primary_address_str
83+
both.split(':').first
84+
end
85+
86+
def primary_address_port
87+
both = primary_address_str
88+
both.split(':')[1] || 27017
89+
end
90+
7791
# Try running a command on the admin database to see if the mongod was
7892
# started with auth.
7993
def auth_enabled?

spec/support/spec_config.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,6 @@ def base_test_options
328328
{
329329
max_pool_size: 1,
330330
heartbeat_frequency: 20,
331-
max_read_retries: 5,
332331
# The test suite seems to perform a number of operations
333332
# requiring server selection. Hence a timeout of 1 here,
334333
# together with e.g. a misconfigured replica set,

0 commit comments

Comments
 (0)