cool.io 1.9.3 → 1.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,8 @@
18
18
  #++
19
19
 
20
20
  require 'resolv'
21
+ require 'securerandom'
22
+ require 'socket'
21
23
 
22
24
  module Coolio
23
25
  # A non-blocking DNS resolver. It provides interfaces for querying both
@@ -67,14 +69,22 @@ module Coolio
67
69
  # list of nameservers to query. By default the resolver will
68
70
  # use nameservers listed in /etc/resolv.conf
69
71
  def initialize(hostname, *nameservers)
72
+ nameservers = reject_ipv6_nameservers(nameservers)
70
73
  if nameservers.empty?
71
- nameservers = Resolv::DNS::Config.default_config_hash[:nameserver]
74
+ nameservers = reject_ipv6_nameservers(Resolv::DNS::Config.default_config_hash[:nameserver])
72
75
  raise RuntimeError, "no nameservers found" if nameservers.empty? # TODO just call resolve_failed, not raise [also handle Errno::ENOENT)]
73
76
  end
74
77
 
75
78
  @nameservers = nameservers.dup
76
79
  @question = request_question hostname
77
80
 
81
+ # A guessable ID would let an off-path attacker forge a response
82
+ @request_id = SecureRandom.random_number(1 << 16)
83
+
84
+ # Numeric addresses this query was sent to, and the lookups behind them
85
+ @queried_addresses = []
86
+ @numeric_addresses = {}
87
+
78
88
  @socket = UDPSocket.new
79
89
  @timer = Timeout.new(self)
80
90
 
@@ -113,27 +123,44 @@ module Coolio
113
123
 
114
124
  # Send a request to the DNS server
115
125
  def send_request
116
- nameserver = @nameservers.shift
117
- @nameservers << nameserver # rotate them
126
+ @nameservers.rotate!
127
+
128
+ # Send to the numeric address, so we know where a response must come from
129
+ address = numeric_address(@nameservers.first)
130
+ @queried_addresses << address unless @queried_addresses.include?(address)
131
+
118
132
  begin
119
- @socket.send request_message, 0, @nameservers.first, DNS_PORT
133
+ @socket.send request_message, 0, address, DNS_PORT
120
134
  rescue Errno::EHOSTUNREACH # TODO figure out why it has to be wrapper here, when the other wrapper should be wrapping this one!
121
135
  end
122
136
  end
123
137
 
124
138
  # Called by the subclass when the DNS response is available
125
139
  def on_readable
126
- datagram = nil
140
+ datagram = sender = nil
127
141
  begin
128
- datagram = @socket.recvfrom_nonblock(DATAGRAM_SIZE).first
142
+ datagram, sender = @socket.recvfrom_nonblock(DATAGRAM_SIZE)
129
143
  rescue Errno::ECONNREFUSED
130
144
  end
131
145
 
146
+ # Ignore anything we didn't ask for, rather than resolving or failing on it.
147
+ # The query stays outstanding, so the retry timer still bounds us.
148
+ return if datagram and not solicited_response?(datagram, sender)
149
+
132
150
  address = response_address datagram rescue nil
133
151
  address ? on_success(address) : on_failure
134
152
  detach
135
153
  end
136
154
 
155
+ # Is this a reply to our query, from an address we sent it to?
156
+ # Retries rotate through @nameservers, so any address already queried counts.
157
+ def solicited_response?(datagram, sender)
158
+ return false unless datagram.size >= 12
159
+ return false unless sender and sender[1] == DNS_PORT and @queried_addresses.include?(sender[3])
160
+
161
+ datagram[0..1].unpack('n').first.to_i == @request_id
162
+ end
163
+
137
164
  def request_question(hostname)
138
165
  raise ArgumentError, "hostname cannot be nil" if hostname.nil?
139
166
 
@@ -151,7 +178,7 @@ module Coolio
151
178
 
152
179
  def request_message
153
180
  # Standard query header
154
- message = [2, 1, 0].pack('nCC')
181
+ message = [@request_id, 1, 0].pack('nCC')
155
182
 
156
183
  # One entry
157
184
  qdcount = 1
@@ -166,7 +193,7 @@ module Coolio
166
193
  def response_address(message)
167
194
  # Confirm the ID field
168
195
  id = message[0..1].unpack('n').first.to_i
169
- return unless id == 2
196
+ return unless id == @request_id
170
197
 
171
198
  # Check the QR value and confirm this message is a response
172
199
  qr = message[2..2].unpack('B1').first.to_i
@@ -204,6 +231,23 @@ module Coolio
204
231
  nil
205
232
  end
206
233
 
234
+ private
235
+
236
+ def reject_ipv6_nameservers(nameservers)
237
+ nameservers.reject { |ns| ns.include?(':') }
238
+ end
239
+
240
+ # The address of a nameserver, which may be given as a hostname.
241
+ # Only successful lookups are cached, so a transient failure is looked up again.
242
+ def numeric_address(nameserver)
243
+ @numeric_addresses[nameserver] ||= begin
244
+ addrinfo = Addrinfo.getaddrinfo(nameserver, nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM).first
245
+ raise SocketError, "getaddrinfo: no IPv4 address for #{nameserver}" if addrinfo.nil?
246
+
247
+ addrinfo.ip_address
248
+ end
249
+ end
250
+
207
251
  class Timeout < TimerWatcher
208
252
  def initialize(resolver)
209
253
  @resolver = resolver
@@ -1,6 +1,6 @@
1
1
  module Coolio
2
- VERSION = "1.9.3"
3
-
2
+ VERSION = "1.9.5"
3
+
4
4
  def self.version
5
5
  VERSION
6
6
  end
@@ -1,20 +1,26 @@
1
1
  diff --git a/ext/libev/ev.c b/ext/libev/ev.c
2
- index dae87f1..d15f6bd 100644
2
+ index a59efb2..5c18fd6 100644
3
3
  --- a/ext/libev/ev.c
4
4
  +++ b/ext/libev/ev.c
5
- @@ -207,6 +207,7 @@
5
+ @@ -210,6 +210,13 @@
6
6
  #else
7
7
  # include <io.h>
8
8
  # define WIN32_LEAN_AND_MEAN
9
- +# define FD_SETSIZE 1024
9
+ +/* ruby.h above already pulled in winsock2.h, so fd_set may be dimensioned
10
+ + * already. Defining FD_SETSIZE unconditionally here would only move the bound
11
+ + * used by EV_WIN_FD_SET, not the array it indexes. Take whatever is in effect
12
+ + * and only supply a default when nothing has been decided yet. */
13
+ +# ifndef FD_SETSIZE
14
+ +# define FD_SETSIZE 1024
15
+ +# endif
10
16
  # include <winsock2.h>
11
17
  # include <windows.h>
12
18
  # ifndef EV_SELECT_IS_WINSOCKET
13
19
  diff --git a/ext/libev/ev_select.c b/ext/libev/ev_select.c
14
- index f38d6ca..7050778 100644
20
+ index ed1fc7a..eccff2b 100644
15
21
  --- a/ext/libev/ev_select.c
16
22
  +++ b/ext/libev/ev_select.c
17
- @@ -67,6 +67,54 @@
23
+ @@ -67,6 +67,64 @@
18
24
 
19
25
  #include <string.h>
20
26
 
@@ -58,6 +64,16 @@ index f38d6ca..7050778 100644
58
64
  +#define EV_WIN_FD_ZERO(set) (((fd_set *)(set))->fd_count=0)
59
65
  +#define EV_WIN_FD_ISSET(fd, set) __WSAFDIsSet((SOCKET)(fd), (fd_set *)(set))
60
66
  +#define EV_WIN_FD_COUNT(set) (((fd_set *)(set))->fd_count)
67
+ +
68
+ +/*
69
+ +fd_set is dimensioned by whatever FD_SETSIZE was in effect when winsock2.h was
70
+ +first pulled in, but EV_WIN_FD_SET and select_modify bound-check against the
71
+ +FD_SETSIZE visible here. The two are decided by separate paths, so if the bound
72
+ +ever exceeds the declared array we would write past the end of the allocation in
73
+ +select_init. Catch that at build time instead.
74
+ +*/
75
+ +typedef char coolio_fd_setsize_matches_fd_set[
76
+ + (sizeof (((fd_set *)0)->fd_array) / sizeof (SOCKET) >= (size_t)FD_SETSIZE) ? 1 : -1];
61
77
  +/* ######################################## */
62
78
  +#else
63
79
  +#define EV_WIN_FD_CLR FD_CLR
@@ -69,7 +85,7 @@ index f38d6ca..7050778 100644
69
85
  static void
70
86
  select_modify (EV_P_ int fd, int oev, int nev)
71
87
  {
72
- @@ -91,17 +139,17 @@ select_modify (EV_P_ int fd, int oev, int nev)
88
+ @@ -91,17 +149,17 @@ select_modify (EV_P_ int fd, int oev, int nev)
73
89
  if ((oev ^ nev) & EV_READ)
74
90
  #endif
75
91
  if (nev & EV_READ)
@@ -91,7 +107,7 @@ index f38d6ca..7050778 100644
91
107
 
92
108
  #else
93
109
 
94
- @@ -197,8 +245,8 @@ select_poll (EV_P_ ev_tstamp timeout)
110
+ @@ -197,8 +255,8 @@ select_poll (EV_P_ ev_tstamp timeout)
95
111
  {
96
112
  if (timeout)
97
113
  {
@@ -102,7 +118,7 @@ index f38d6ca..7050778 100644
102
118
  }
103
119
 
104
120
  return;
105
- @@ -230,10 +278,10 @@ select_poll (EV_P_ ev_tstamp timeout)
121
+ @@ -230,10 +288,10 @@ select_poll (EV_P_ ev_tstamp timeout)
106
122
  int handle = fd;
107
123
  #endif
108
124
 
@@ -116,7 +132,7 @@ index f38d6ca..7050778 100644
116
132
  #endif
117
133
 
118
134
  if (expect_true (events))
119
- @@ -279,9 +327,9 @@ select_init (EV_P_ int flags)
135
+ @@ -280,9 +338,9 @@ select_init (EV_P_ int flags)
120
136
  backend_poll = select_poll;
121
137
 
122
138
  #if EV_SELECT_USE_FD_SET
@@ -30,7 +30,9 @@ describe Cool.io::AsyncWatcher, :env => :exclude_win do
30
30
  end
31
31
 
32
32
  # ensure children are ready
33
- nr_fork.times { expect(rd.sysread(1)).to eq('.') }
33
+ # rd may be O_NONBLOCK on macOS Ruby 3.2+ (IO.pipe sets O_NONBLOCK); use read
34
+ # instead of sysread so EAGAIN on an empty pipe is retried transparently.
35
+ nr_fork.times { expect(rd.read(1)).to eq('.') }
34
36
 
35
37
  # send our signals
36
38
  nr_signal.times { aw.signal }
@@ -1,10 +1,13 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe Cool.io::Loop do
4
+ # An IOWatcher that drains its pipe and then runs a user-supplied block,
5
+ # receiving itself as the argument.
4
6
  class Victim < Cool.io::IOWatcher
5
- def initialize(io)
6
- super
7
+ def initialize(io, &on_readable)
8
+ super(io)
7
9
  @io = io
10
+ @on_readable = on_readable
8
11
  end
9
12
 
10
13
  def on_readable
@@ -12,39 +15,103 @@ describe Cool.io::Loop do
12
15
  @io.read_nonblock(1024)
13
16
  rescue IO::WaitReadable, EOFError
14
17
  end
18
+ @on_readable.call(self) if @on_readable
15
19
  end
16
20
  end
17
21
 
18
22
  # https://github.com/socketry/cool.io/issues/87
19
- it "does not raise TypeError when a watcher is detached while an event is pending" do
20
- loop = Cool.io::Loop.default
21
-
23
+ #
24
+ # Several watchers have an event pending in the same loop iteration. When the
25
+ # first one dispatched detaches the others, the loop must skip their now-stale
26
+ # pending events instead of dispatching them to a detached watcher. Before the
27
+ # fix that raised "TypeError: wrong argument type nil (expected Coolio::Loop)"
28
+ # and could crash the VM.
29
+ #
30
+ # This is exercised deterministically within a single thread (a preceding
31
+ # callback detaching another watcher in the same loop cycle). The original
32
+ # reproduction detached from a separate thread while the loop was polling,
33
+ # which is an unsupported concurrent mutation of libev (not thread-safe) and
34
+ # crashed intermittently on macOS.
35
+ it "does not raise when a watcher with a pending event is detached during dispatch" do
22
36
  iterations = 200
23
37
 
24
38
  expect {
25
39
  iterations.times do
26
- r_victim, w_victim = IO.pipe
27
- victim_watcher = Victim.new(r_victim)
28
- victim_watcher.attach(loop)
40
+ coolio_loop = Cool.io::Loop.new
41
+ pipes = []
42
+ watchers = []
29
43
 
30
- t1 = Thread.new do
31
- sleep 0.01
32
- w_victim.write("dummy\n")
33
- end
44
+ 5.times do
45
+ r, w = IO.pipe
46
+ pipes << [r, w]
47
+
48
+ watcher = Victim.new(r) do |fired|
49
+ # Detach every other watcher whose event is already queued for this
50
+ # same loop iteration.
51
+ watchers.each do |other|
52
+ other.detach if !other.equal?(fired) && other.attached?
53
+ end
54
+ end
55
+ watcher.attach(coolio_loop)
56
+ watchers << watcher
34
57
 
35
- t2 = Thread.new do
36
- sleep 0.01
37
- victim_watcher.detach
58
+ w.write("dummy\n") # make the read end readable so an event is pending
38
59
  end
39
60
 
40
- loop.run_once
61
+ coolio_loop.run_once
41
62
 
42
- t1.join
43
- t2.join
63
+ # Only the first dispatched watcher runs; it detaches the other four,
64
+ # whose pending events are then skipped.
65
+ expect(watchers.count(&:attached?)).to eq(1)
44
66
 
45
- r_victim.close
46
- w_victim.close
67
+ watchers.each { |watcher| watcher.detach if watcher.attached? }
68
+ pipes.each { |r, w| r.close; w.close }
47
69
  end
48
70
  }.not_to raise_error
49
71
  end
72
+
73
+ class HttpHandler < Coolio::IO
74
+ RESPONSE = "HTTP/1.1 200 OK\r\nContent-Length: 1024\r\nConnection: close\r\n\r\n" + ("X" * 1024)
75
+
76
+ def on_connect
77
+ end
78
+
79
+ def on_read(data)
80
+ write(RESPONSE)
81
+ end
82
+
83
+ def on_write_complete
84
+ close
85
+ end
86
+ end
87
+
88
+ # https://github.com/socketry/cool.io/issues/89
89
+ it "does not cause memory leaks" do
90
+ port = 18989
91
+ loop = Coolio::Loop.default
92
+
93
+ server = Coolio::TCPServer.new('127.0.0.1', port, HttpHandler)
94
+ server.attach(loop)
95
+
96
+ event_thread = Thread.new { loop.run }
97
+
98
+ request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
99
+
100
+ 10.times do |iteration|
101
+ begin
102
+ sock = TCPSocket.new('127.0.0.1', port)
103
+ sock.write(request)
104
+ sock.read
105
+ sock.close
106
+ rescue => e
107
+ sleep 0.01
108
+ retry
109
+ end
110
+ end
111
+
112
+ server.close
113
+ event_thread.join
114
+
115
+ expect(loop.watchers).to be_empty
116
+ end
50
117
  end
data/spec/dns_spec.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require File.expand_path('../spec_helper', __FILE__)
2
+ require 'tempfile'
2
3
 
3
4
  VALID_DOMAIN = "google.com"
4
5
  INVALID_DOMAIN = "gibidigibigididibitidibigitibidigitidididi.com"
@@ -55,4 +56,189 @@ describe "DNS" do
55
56
  expect( Coolio::DNSResolver.hosts("localhost", file.path)).to eq @preferred_localhost_address
56
57
  end
57
58
  end
59
+
60
+ describe "IPv6 nameserver filtering" do
61
+ it "ignores IPv6 nameservers provided in arguments" do
62
+ resolver = Coolio::DNSResolver.new("example.com", "8.8.8.8", "2001:4860:4860::8888", "1.1.1.1")
63
+
64
+ nameservers = resolver.instance_variable_get(:@nameservers)
65
+ expect(nameservers).to eq(["8.8.8.8", "1.1.1.1"])
66
+ end
67
+
68
+ it "falls back to default IPv4 config if only IPv6 addresses are provided" do
69
+ allow(Resolv::DNS::Config).to receive(:default_config_hash).and_return({
70
+ nameserver: ["8.8.4.4", "2001:4860:4860::8844"]
71
+ })
72
+
73
+ resolver = Coolio::DNSResolver.new("example.com", "2001:4860:4860::8888")
74
+
75
+ nameservers = resolver.instance_variable_get(:@nameservers)
76
+ expect(nameservers).to eq(["8.8.4.4"])
77
+ end
78
+ end
79
+
80
+ describe "nameserver normalization" do
81
+ let(:localhost_address) do
82
+ Addrinfo.getaddrinfo("localhost", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM).first.ip_address
83
+ end
84
+
85
+ it "keeps the nameserver list as given" do
86
+ resolver = Coolio::DNSResolver.new("example.com", "localhost")
87
+
88
+ expect(resolver.instance_variable_get(:@nameservers)).to eq(["localhost"])
89
+ end
90
+
91
+ it "queries the numeric address of a nameserver given as a hostname" do
92
+ resolver = Coolio::DNSResolver.new("example.com", "localhost")
93
+ resolver.__send__(:send_request)
94
+
95
+ expect(resolver.instance_variable_get(:@queried_addresses)).to eq([localhost_address])
96
+ end
97
+
98
+ it "accepts responses from a nameserver which was given as a hostname" do
99
+ resolver = Coolio::DNSResolver.new("example.com", "localhost")
100
+ resolver.__send__(:send_request)
101
+ response = dns_response_for(resolver)
102
+
103
+ expect(
104
+ resolver.__send__(:solicited_response?, response, ["AF_INET", 53, localhost_address, localhost_address])
105
+ ).to be true
106
+ end
107
+
108
+ it "looks a nameserver up once and reuses the result on retries" do
109
+ resolved = Addrinfo.getaddrinfo("127.0.0.1", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM)
110
+ resolver = Coolio::DNSResolver.new("example.com", "127.0.0.1")
111
+
112
+ expect(Addrinfo).to receive(:getaddrinfo).once.and_return(resolved)
113
+
114
+ 3.times { resolver.__send__(:send_request) }
115
+ end
116
+
117
+ it "does not reject an unresolvable nameserver at construction" do
118
+ allow(Addrinfo).to receive(:getaddrinfo).and_raise(SocketError, "getaddrinfo: Name or service not known")
119
+
120
+ expect do
121
+ Coolio::DNSResolver.new("example.com", "no-such-nameserver.invalid")
122
+ end.to_not raise_error
123
+ end
124
+
125
+ it "surfaces an unresolvable nameserver as a SocketError from the request" do
126
+ allow(Addrinfo).to receive(:getaddrinfo).and_raise(SocketError, "getaddrinfo: Name or service not known")
127
+ resolver = Coolio::DNSResolver.new("example.com", "no-such-nameserver.invalid")
128
+
129
+ expect { resolver.attach(@loop) }.to raise_error(SocketError)
130
+ expect(@loop.watchers).to be_empty
131
+ end
132
+
133
+ it "recovers when a nameserver is only transiently unresolvable" do
134
+ resolved = Addrinfo.getaddrinfo("127.0.0.1", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM)
135
+ resolver = Coolio::DNSResolver.new("example.com", "ns.example.test")
136
+
137
+ attempts = 0
138
+ allow(Addrinfo).to receive(:getaddrinfo) do
139
+ attempts += 1
140
+ raise SocketError, "getaddrinfo: Name or service not known" if attempts == 1
141
+
142
+ resolved
143
+ end
144
+
145
+ expect { resolver.__send__(:send_request) }.to raise_error(SocketError)
146
+ expect { resolver.__send__(:send_request) }.to_not raise_error
147
+ expect(resolver.instance_variable_get(:@queried_addresses)).to eq(["127.0.0.1"])
148
+ end
149
+ end
150
+
151
+ describe "response validation" do
152
+ let(:nameserver) { "127.0.0.1" }
153
+ let(:sender) { ["AF_INET", 53, nameserver, nameserver] }
154
+ let(:resolver) do
155
+ Coolio::DNSResolver.new("example.com", nameserver).tap { |r| r.__send__(:send_request) }
156
+ end
157
+
158
+ it "uses an unpredictable transaction ID for each query" do
159
+ ids = 10.times.map do
160
+ request_id_of(Coolio::DNSResolver.new("example.com", nameserver))
161
+ end
162
+
163
+ expect(ids.uniq.size).to be > 1
164
+ end
165
+
166
+ it "accepts a response carrying our transaction ID from the queried nameserver" do
167
+ expect(
168
+ resolver.__send__(:solicited_response?, dns_response_for(resolver), sender)
169
+ ).to be true
170
+ end
171
+
172
+ it "rejects a response carrying a different transaction ID" do
173
+ forged = dns_response_for(resolver, id: (request_id_of(resolver) + 1) % 65536)
174
+
175
+ expect(resolver.__send__(:solicited_response?, forged, sender)).to be false
176
+ end
177
+
178
+ it "rejects a response from a source address we did not query" do
179
+ response = dns_response_for(resolver)
180
+
181
+ expect(
182
+ resolver.__send__(:solicited_response?, response, ["AF_INET", 53, "10.11.12.13", "10.11.12.13"])
183
+ ).to be false
184
+ end
185
+
186
+ it "rejects a response arriving before the request was sent" do
187
+ unsent = Coolio::DNSResolver.new("example.com", nameserver)
188
+
189
+ expect(unsent.__send__(:solicited_response?, dns_response_for(unsent), sender)).to be false
190
+ end
191
+
192
+ it "rejects a response from a source port other than the DNS port" do
193
+ response = dns_response_for(resolver)
194
+
195
+ expect(
196
+ resolver.__send__(:solicited_response?, response, ["AF_INET", 4444, nameserver, nameserver])
197
+ ).to be false
198
+ end
199
+
200
+ it "rejects a truncated datagram" do
201
+ expect(resolver.__send__(:solicited_response?, "\0\0", sender)).to be false
202
+ end
203
+
204
+ it "resolves from a response sent by the queried nameserver" do
205
+ response = dns_response_for(resolver, address: "1.2.3.4")
206
+ allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock).and_return([response, sender])
207
+
208
+ expect(resolver).to receive(:on_success).with("1.2.3.4")
209
+ expect(resolver).to receive(:detach)
210
+
211
+ resolver.__send__(:on_readable)
212
+ end
213
+
214
+ it "ignores a spoofed response instead of resolving or failing it" do
215
+ forged = dns_response_for(resolver, address: "6.6.6.6")
216
+ allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock)
217
+ .and_return([forged, ["AF_INET", 53, "10.11.12.13", "10.11.12.13"]])
218
+
219
+ expect(resolver).to_not receive(:on_success)
220
+ expect(resolver).to_not receive(:on_failure)
221
+ expect(resolver).to_not receive(:detach)
222
+
223
+ resolver.__send__(:on_readable)
224
+ end
225
+ end
226
+
227
+ def request_id_of(resolver)
228
+ resolver.__send__(:request_message)[0..1].unpack('n').first
229
+ end
230
+
231
+ # A response to the resolver's own query: header plus the echoed question,
232
+ # and an A record when an address is given.
233
+ def dns_response_for(resolver, id: request_id_of(resolver), address: nil)
234
+ question = resolver.instance_variable_get(:@question)
235
+ answer = if address
236
+ # Compressed name pointer, type A, class IN, TTL, RDLENGTH, RDATA
237
+ [0xc00c, 1, 1, 60, 4].pack('nnnNn') + address.split('.').map(&:to_i).pack('CCCC')
238
+ else
239
+ ""
240
+ end
241
+
242
+ [id, 0x81, 0x80, 1, answer.empty? ? 0 : 1, 0, 0].pack('nCCnnnn') + question + answer
243
+ end
58
244
  end
@@ -31,6 +31,18 @@ describe Cool.io::Buffer do
31
31
  expect(buffer << "baz").to eq "baz"
32
32
  expect(buffer.read 3).to eq "arb"
33
33
  end
34
+
35
+ it "raises ArgumentError for a length below one" do
36
+ buffer << "foo"
37
+ expect { buffer.read 0 }.to raise_error ArgumentError
38
+ expect { buffer.read(-1) }.to raise_error ArgumentError
39
+ end
40
+
41
+ it "clamps a length which does not fit in a C int to the buffer size" do
42
+ buffer << "foobar"
43
+ expect(buffer.read 2**31).to eq "foobar"
44
+ expect(buffer.size).to eq 0
45
+ end
34
46
  end
35
47
 
36
48
  describe "provides methods for performing non-blocking I/O" do
@@ -142,6 +154,25 @@ describe Cool.io::Buffer do
142
154
  expect(data).to eq "foo\nbarbaz"
143
155
  expect(buffer.to_str).to eq ""
144
156
  end
157
+
158
+ it "raises TypeError instead of crashing when data is not a String" do
159
+ buffer << "hello world"
160
+ expect { buffer.read_frame 12345, " ".ord }.to raise_error(TypeError)
161
+ expect { buffer.read_frame nil, " ".ord }.to raise_error(TypeError)
162
+ expect { buffer.read_frame [], " ".ord }.to raise_error(TypeError)
163
+ end
164
+
165
+ it "raises FrozenError when data is a frozen String" do
166
+ buffer << "hello world"
167
+ expect { buffer.read_frame "frozen".freeze, " ".ord }.to raise_error(FrozenError)
168
+ end
169
+
170
+ it "coerces objects responding to #to_str" do
171
+ buffer << "foo\nbar"
172
+ convertible = Object.new
173
+ def convertible.to_str; +""; end
174
+ expect(buffer.read_frame convertible, "\n".ord).to eq true
175
+ end
145
176
  end
146
177
 
147
178
  end
@@ -18,12 +18,31 @@ class MyStatWatcher < Cool.io::StatWatcher
18
18
  end
19
19
  end
20
20
 
21
- def run_with_file_change(path)
21
+ def run_with_file_change(path, compact: false)
22
22
  reactor = Cool.io::Loop.new
23
23
 
24
24
  sw = MyStatWatcher.new(path)
25
25
  sw.attach(reactor)
26
26
 
27
+ # libev retains the path pointer passed to ev_stat_init() for the lifetime of
28
+ # the watcher. If the watcher held RSTRING_PTR(@path) directly, a compaction
29
+ # that relocates the @path String would leave libev dereferencing a stale
30
+ # address on every stat. Force a maximal relocation here so the watcher keeps
31
+ # operating against a path buffer it owns rather than Ruby-managed memory.
32
+ if compact
33
+ if GC.respond_to?(:verify_compaction_references)
34
+ # verify_compaction_references moves every movable object to a fresh slot.
35
+ # Its keyword arguments have varied across Ruby versions, so fall back.
36
+ begin
37
+ GC.verify_compaction_references(expand_heap: true, toplevel: true)
38
+ rescue ArgumentError
39
+ GC.verify_compaction_references(expand_heap: true)
40
+ end
41
+ elsif GC.respond_to?(:compact)
42
+ GC.compact
43
+ end
44
+ end
45
+
27
46
  tw = Cool.io::TimerWatcher.new(INTERVAL, true)
28
47
  tw.on_timer do
29
48
  reactor.stop if sw.accessed
@@ -69,6 +88,17 @@ describe Cool.io::StatWatcher do
69
88
  expect(watcher.previous.ino).to eq(watcher.current.ino)
70
89
  end
71
90
 
91
+ it "keeps firing on_change after GC compaction relocates objects" do
92
+ skip "GC compaction not available" unless GC.respond_to?(:compact)
93
+
94
+ watcher = run_with_file_change(TEMP_FILE_PATH, compact: true)
95
+ expect(watcher.accessed).to eq(true)
96
+ end
97
+
98
+ it "raises ArgumentError when the path contains a null byte" do
99
+ expect { MyStatWatcher.new("foo\0bar") }.to raise_error(ArgumentError)
100
+ end
101
+
72
102
  it "should raise when the handler does not take 2 parameters" do
73
103
  class MyStatWatcher < Cool.io::StatWatcher
74
104
  remove_method :on_change