dnsruby 1.73.1 → 2.0.0

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.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +16 -4
  3. data/DNSSEC +3 -2
  4. data/EXAMPLES +1 -1
  5. data/README.md +370 -64
  6. data/RELEASE_NOTES.md +314 -1
  7. data/Rakefile +0 -3
  8. data/demo/axfr.rb +24 -6
  9. data/dnsruby.gemspec +6 -4
  10. data/lib/dnsruby/cache.rb +279 -49
  11. data/lib/dnsruby/code_mapper.rb +35 -8
  12. data/lib/dnsruby/code_mappers.rb +63 -49
  13. data/lib/dnsruby/config.rb +77 -4
  14. data/lib/dnsruby/deadline_queue.rb +75 -0
  15. data/lib/dnsruby/dnssec.rb +148 -45
  16. data/lib/dnsruby/ipv6.rb +45 -5
  17. data/lib/dnsruby/key_cache.rb +202 -35
  18. data/lib/dnsruby/message/decoder.rb +85 -19
  19. data/lib/dnsruby/message/encoder.rb +27 -16
  20. data/lib/dnsruby/message/header.rb +8 -12
  21. data/lib/dnsruby/message/message.rb +53 -22
  22. data/lib/dnsruby/message/section.rb +12 -34
  23. data/lib/dnsruby/name.rb +5 -2
  24. data/lib/dnsruby/packet_sender.rb +664 -207
  25. data/lib/dnsruby/presentation.rb +147 -0
  26. data/lib/dnsruby/query_control.rb +132 -0
  27. data/lib/dnsruby/recursor.rb +798 -341
  28. data/lib/dnsruby/resolver.rb +472 -176
  29. data/lib/dnsruby/resource/CAA.rb +67 -16
  30. data/lib/dnsruby/resource/DLV.rb +6 -0
  31. data/lib/dnsruby/resource/DNSKEY.rb +108 -16
  32. data/lib/dnsruby/resource/DS.rb +27 -28
  33. data/lib/dnsruby/resource/HTTPS.rb +16 -0
  34. data/lib/dnsruby/resource/IN.rb +21 -2
  35. data/lib/dnsruby/resource/MX.rb +2 -2
  36. data/lib/dnsruby/resource/NSEC.rb +6 -10
  37. data/lib/dnsruby/resource/NSEC3.rb +55 -19
  38. data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
  39. data/lib/dnsruby/resource/OPT.rb +2 -3
  40. data/lib/dnsruby/resource/RR.rb +108 -65
  41. data/lib/dnsruby/resource/RRSIG.rb +5 -5
  42. data/lib/dnsruby/resource/RRSet.rb +38 -22
  43. data/lib/dnsruby/resource/SRV.rb +2 -2
  44. data/lib/dnsruby/resource/SVCB.rb +775 -0
  45. data/lib/dnsruby/resource/TSIG.rb +266 -231
  46. data/lib/dnsruby/resource/TXT.rb +34 -42
  47. data/lib/dnsruby/resource/domain_name.rb +1 -1
  48. data/lib/dnsruby/resource/generic.rb +31 -0
  49. data/lib/dnsruby/resource/generic_cache.rb +384 -0
  50. data/lib/dnsruby/resource/generic_constants.rb +79 -0
  51. data/lib/dnsruby/select_thread.rb +986 -291
  52. data/lib/dnsruby/serial_time.rb +25 -0
  53. data/lib/dnsruby/single_resolver.rb +25 -6
  54. data/lib/dnsruby/single_verifier.rb +1620 -530
  55. data/lib/dnsruby/transport_clock.rb +9 -0
  56. data/lib/dnsruby/update.rb +8 -6
  57. data/lib/dnsruby/validation_budget.rb +143 -0
  58. data/lib/dnsruby/validator_thread.rb +139 -58
  59. data/lib/dnsruby/version.rb +1 -1
  60. data/lib/dnsruby/zone_reader.rb +196 -129
  61. data/lib/dnsruby/zone_transfer.rb +157 -25
  62. data/lib/dnsruby.rb +7 -2
  63. data/test/ci_watchdog.rb +33 -0
  64. data/test/fixtures/org-ds-2026-11-05.zone +5 -0
  65. data/test/fixtures/root-dnskey-2026-11-05.zone +7 -0
  66. data/test/fork_test_helper.rb +37 -0
  67. data/test/local_dns_server.rb +57 -0
  68. data/test/soak_server.rb +152 -0
  69. data/test/socket_helpers.rb +26 -0
  70. data/test/spec_helper.rb +8 -13
  71. data/test/tc_attempt_configuration.rb +137 -0
  72. data/test/tc_caa.rb +2 -2
  73. data/test/tc_cache_keys.rb +36 -0
  74. data/test/tc_cancelled_send_capacity.rb +158 -0
  75. data/test/tc_dnssec_alias_validation.rb +289 -0
  76. data/test/tc_dnssec_budget.rb +489 -0
  77. data/test/tc_dnssec_cache_bounds.rb +197 -0
  78. data/test/tc_dnssec_cache_lifetime.rb +615 -0
  79. data/test/tc_dnssec_denial_proofs.rb +285 -0
  80. data/test/tc_dnssec_recursion_limits.rb +186 -0
  81. data/test/tc_dnssec_recursor_review_fixes.rb +351 -0
  82. data/test/tc_dnssec_trust_anchors.rb +286 -0
  83. data/test/tc_dnssec_trust_races.rb +147 -0
  84. data/test/tc_dnssec_wildcard_proofs.rb +249 -0
  85. data/test/tc_ds.rb +5 -5
  86. data/test/tc_edns_retry_regressions.rb +255 -0
  87. data/test/tc_escapedchars.rb +8 -0
  88. data/test/tc_final_review_fixes.rb +167 -0
  89. data/test/tc_final_transport_contracts.rb +104 -0
  90. data/test/tc_generic_cache.rb +852 -0
  91. data/test/tc_header.rb +26 -0
  92. data/test/tc_ipv6.rb +120 -0
  93. data/test/tc_key_cache_identity.rb +382 -0
  94. data/test/tc_malformed_tcp.rb +712 -0
  95. data/test/tc_name.rb +99 -5
  96. data/test/tc_nameserver_bootstrap.rb +322 -0
  97. data/test/tc_nsec.rb +4 -45
  98. data/test/tc_nsec3.rb +17 -1
  99. data/test/tc_pipeline_retirement.rb +358 -0
  100. data/test/tc_pipeline_retry.rb +462 -0
  101. data/test/tc_pipeline_send.rb +663 -0
  102. data/test/tc_protocol_review_fixes.rb +95 -0
  103. data/test/tc_query_control.rb +90 -0
  104. data/test/tc_record_codec_contracts.rb +107 -0
  105. data/test/tc_record_regressions.rb +225 -0
  106. data/test/tc_recur.rb +1 -0
  107. data/test/tc_recursor_address_fallback.rb +169 -0
  108. data/test/tc_recursor_limits.rb +139 -0
  109. data/test/tc_release_auxiliary_fixes.rb +288 -0
  110. data/test/tc_release_configuration_fixes.rb +612 -0
  111. data/test/tc_release_dnssec_completion.rb +405 -0
  112. data/test/tc_release_dnssec_fixes.rb +305 -0
  113. data/test/tc_release_negative_cache.rb +327 -0
  114. data/test/tc_release_recursor_cache.rb +212 -0
  115. data/test/tc_release_review_transport.rb +372 -0
  116. data/test/tc_release_review_transport_native.rb +56 -0
  117. data/test/tc_release_transport.rb +906 -0
  118. data/test/tc_release_transport_fixes.rb +196 -0
  119. data/test/tc_release_zone_defaults.rb +157 -0
  120. data/test/tc_release_zone_ttls.rb +39 -0
  121. data/test/tc_res_config.rb +53 -2
  122. data/test/tc_res_opt.rb +21 -1
  123. data/test/tc_resolver.rb +34 -19
  124. data/test/tc_resolver_concurrency.rb +383 -0
  125. data/test/tc_resolver_configuration_contracts.rb +31 -0
  126. data/test/tc_resolver_validation.rb +211 -0
  127. data/test/tc_review_cache.rb +163 -0
  128. data/test/tc_review_code_mapper.rb +279 -0
  129. data/test/tc_review_control_deadlines.rb +169 -0
  130. data/test/tc_review_followup_transport.rb +583 -0
  131. data/test/tc_review_packet_contracts.rb +369 -0
  132. data/test/tc_review_presentation.rb +213 -0
  133. data/test/tc_review_record_compatibility.rb +101 -0
  134. data/test/tc_review_transport_fixes.rb +195 -0
  135. data/test/tc_review_transport_native.rb +415 -0
  136. data/test/tc_review_validation_priming.rb +178 -0
  137. data/test/tc_rr-opt.rb +5 -2
  138. data/test/tc_rr-unknown.rb +19 -0
  139. data/test/tc_rr.rb +14 -0
  140. data/test/tc_rrset_grouping.rb +50 -0
  141. data/test/tc_rrset_review_fixes.rb +58 -0
  142. data/test/tc_rrsig.rb +21 -1
  143. data/test/tc_scoped_ipv6.rb +153 -0
  144. data/test/tc_scoped_ipv6_select.rb +157 -0
  145. data/test/tc_scoped_ipv6_transport.rb +329 -0
  146. data/test/tc_selector_wakeup.rb +42 -0
  147. data/test/tc_soak.rb +118 -196
  148. data/test/tc_socket_helpers.rb +90 -0
  149. data/test/tc_supporting_lookup_limits.rb +252 -0
  150. data/test/tc_svcb.rb +1043 -0
  151. data/test/tc_svcb_signed_target.rb +145 -0
  152. data/test/tc_svcb_transport.rb +120 -0
  153. data/test/tc_tcp.rb +69 -43
  154. data/test/tc_tcp_pipelining.rb +95 -204
  155. data/test/tc_transport_fork.rb +179 -0
  156. data/test/tc_tsig.rb +2 -2
  157. data/test/tc_tsig_api.rb +251 -0
  158. data/test/tc_tsig_rfc8945.rb +357 -0
  159. data/test/tc_v2_release_dnssec.rb +442 -0
  160. data/test/tc_v2_release_transport.rb +304 -0
  161. data/test/tc_v2_zone_transfer_native.rb +58 -0
  162. data/test/tc_zone_reader.rb +217 -1
  163. data/test/tcp_pipelining_server.rb +150 -0
  164. data/test/ts_offline.rb +77 -6
  165. data/test/ts_online.rb +2 -1
  166. metadata +127 -14
  167. data/.coveralls.yml +0 -2
@@ -0,0 +1,358 @@
1
+ require_relative 'spec_helper'
2
+ require 'open3'
3
+ require 'rbconfig'
4
+
5
+ class TestPipelineRetirement < Minitest::Test
6
+ # Selector tables are class variables. Every scenario owns a subprocess so
7
+ # neither its fake sockets nor its queued events can reach a live singleton.
8
+ SUBPROCESS_SETUP = <<~'RUBY'
9
+ require 'dnsruby'
10
+ require 'timeout'
11
+
12
+ class RetirementFixture
13
+ attr_reader :selector
14
+
15
+ def initialize
16
+ @klass = Dnsruby::SelectThread
17
+ {
18
+ mutex: Mutex.new, sockets: Set.new, query_hash: {}, queries_by_queue: {}.compare_by_identity, socket_hash: {}, timeouts: {},
19
+ socket_is_persistent: {}, socket_remaining_queries: {}, tcp_buffers: {},
20
+ queued_exceptions: [], queued_responses: [], queued_validation_responses: [],
21
+ observers: {}, observer_queue_counts: {}.compare_by_identity, tick_observers: [], select_thread: Thread.current
22
+ }.each { |name, value| @klass.class_variable_set("@@#{name}", value) }
23
+ @selector = @klass.send(:allocate)
24
+ @selector.define_singleton_method(:wake_up_select_thread) {}
25
+ @selector.define_singleton_method(:check_select_thread_synchronized) {}
26
+ @selector.define_singleton_method(:notify_queue_observers) { |*| }
27
+ end
28
+
29
+ def table(name)
30
+ @klass.class_variable_get("@@#{name}")
31
+ end
32
+
33
+ def socket
34
+ closed = false
35
+ socket = Object.new
36
+ socket.define_singleton_method(:closed?) { closed }
37
+ socket.define_singleton_method(:close) { closed = true }
38
+ type = Struct.new(:data).new([Socket::SOCK_STREAM].pack('i'))
39
+ socket.define_singleton_method(:getsockopt) { |*| type }
40
+ socket
41
+ end
42
+
43
+ def settings(socket, id: :query, persistent: true, endtime: Dnsruby::TransportClock.now + 60, register: true)
44
+ query = Dnsruby::Message.new('retirement.example.')
45
+ query.do_validation = query.do_caching = false
46
+ settings = @klass::QuerySettings.new(query.encode, query, false, Queue.new, id,
47
+ socket, '127.0.0.1', 53, endtime, 512, Dnsruby::PacketSender.allocate)
48
+ settings.is_persistent_socket = persistent
49
+ settings.tcp_pipelining_max_queries = :infinite
50
+ @selector.add_to_select(settings) if register
51
+ settings
52
+ end
53
+
54
+ def response(settings)
55
+ settings.query.clone.tap { |message| message.header.qr = true }
56
+ end
57
+
58
+ def events(settings)
59
+ @selector.send_queued_exceptions
60
+ @selector.send_queued_validation_responses
61
+ events = []
62
+ events << settings.client_queue.pop(true) until settings.client_queue.empty?
63
+ events
64
+ end
65
+
66
+ def assert_retired(socket)
67
+ raise 'socket still open' unless socket.closed?
68
+ raise 'retained socket' if table(:sockets).include?(socket)
69
+ [:socket_hash, :tcp_buffers, :socket_is_persistent, :socket_remaining_queries].each do |name|
70
+ raise "retained #{name}" if table(name).key?(socket)
71
+ end
72
+ raise 'retained socket registration' if table(:query_hash).values.any? { |settings| settings.socket == socket }
73
+ end
74
+
75
+ def assert_send_error(settings, error)
76
+ actual = events(settings)
77
+ expected = [[settings.client_query_id, Dnsruby::Resolver::EventType::ERROR, nil, error]]
78
+ raise "unexpected send failure deliveries: #{actual.inspect}" unless actual == expected
79
+ raise 'retained query' if table(:query_hash)[settings.client_query_id].equal?(settings)
80
+ raise 'retained timeout' if table(:timeouts).key?(settings.client_query_id)
81
+ assert_retired(settings.socket)
82
+ end
83
+
84
+ def assert_response(settings, response)
85
+ actual = events(settings)
86
+ expected = [Dnsruby::Resolver::EventType::RECEIVED, Dnsruby::Resolver::EventType::VALIDATED].map do |type|
87
+ [settings.client_query_id, type, response, nil]
88
+ end
89
+ raise "unexpected response deliveries: #{actual.inspect}" unless actual == expected
90
+ raise 'retained response registration' if table(:query_hash)[settings.client_query_id].equal?(settings)
91
+ raise 'retained response timeout' if table(:timeouts).key?(settings.client_query_id)
92
+ end
93
+ end
94
+ RUBY
95
+
96
+ def test_closed_descriptor_during_select_keeps_the_same_worker_processing_queries
97
+ assert_isolated(<<~'RUBY')
98
+ fixture = RetirementFixture.new
99
+ selector = fixture.selector
100
+ klass = Dnsruby::SelectThread
101
+ broken = fixture.settings(fixture.socket, id: :broken)
102
+ healthy = fixture.settings(fixture.socket, id: :healthy, persistent: false)
103
+ response = fixture.response(healthy)
104
+ bytes = response.encode
105
+ incoming = [bytes.bytesize].pack('n') + bytes
106
+ healthy.socket.define_singleton_method(:recv_nonblock) { |length| incoming.slice!(0, length) }
107
+ wakeup = fixture.socket
108
+ fixture.table(:sockets) << wakeup
109
+ klass.class_variable_set(:@@wakeup_sockets, [wakeup, wakeup])
110
+ notifications = []
111
+ selector.define_singleton_method(:notify_queue_observers) do |_queue, id|
112
+ raise 'callback under selector mutex' if fixture.table(:mutex).owned?
113
+ notifications << [id, Thread.current]
114
+ end
115
+ # Use real startup logic: a missing queued failure must not be rescued by
116
+ # a later producer creating a replacement selector.
117
+ selector.singleton_class.remove_method(:check_select_thread_synchronized)
118
+ entered, release, start = Queue.new, Queue.new, Queue.new
119
+ original_select = IO.method(:select)
120
+ calls = 0
121
+ IO.define_singleton_method(:select) do |readers, *_|
122
+ calls += 1
123
+ if calls == 1
124
+ raise 'query sockets missing from selected snapshot' unless
125
+ readers.include?(broken.socket) && readers.include?(healthy.socket)
126
+ entered << true
127
+ release.pop # The native call is waiting on the snapshotted read set.
128
+ raise 'writer did not retire descriptor' unless broken.socket.closed?
129
+ raise Errno::EBADF, 'descriptor closed while select was waiting'
130
+ elsif calls == 2
131
+ [[healthy.socket], [], []]
132
+ end
133
+ end
134
+ worker = Thread.new { start.pop; selector.do_select }
135
+ worker.report_on_exception = false
136
+ klass.class_variable_set(:@@select_thread, worker)
137
+ begin
138
+ start << true
139
+ entered.pop
140
+ failure = Dnsruby::SocketEofResolvError.new('concurrent writer failed')
141
+ selector.fail_tcp_send(broken, failure)
142
+ raise 'writer replaced active selector' unless fixture.table(:select_thread).equal?(worker)
143
+ release << true
144
+ worker.value
145
+ # Do not call any queue-draining helper: the original worker must have
146
+ # delivered both this failure and the unrelated, already registered reply.
147
+ raise 'failure not delivered once' unless broken.client_queue.length == 1 &&
148
+ broken.client_queue.pop(true) == [:broken, Dnsruby::Resolver::EventType::ERROR, nil, failure]
149
+ expected = [Dnsruby::Resolver::EventType::RECEIVED, Dnsruby::Resolver::EventType::VALIDATED]
150
+ actual = 2.times.map { healthy.client_queue.pop(true) }
151
+ raise 'unrelated response lost' unless actual.map { |event| event[1] } == expected &&
152
+ actual.all? { |event| event[0] == :healthy && event.last.nil? }
153
+ raise 'duplicate unrelated response' unless healthy.client_queue.empty?
154
+ raise 'a replacement worker delivered events' unless notifications.length == 3 &&
155
+ notifications.all? { |_id, thread| thread.equal?(worker) }
156
+ fixture.assert_retired(broken.socket)
157
+ raise 'queries retained' unless fixture.table(:query_hash).empty? && fixture.table(:timeouts).empty?
158
+ ensure
159
+ IO.define_singleton_method(:select, original_select)
160
+ worker.kill if worker.alive?
161
+ worker.join
162
+ end
163
+ RUBY
164
+ end
165
+
166
+ def test_retirement_during_incoming_socket_classification_is_harmless
167
+ assert_isolated(<<~'RUBY')
168
+ fixture = RetirementFixture.new
169
+ selector = fixture.selector
170
+ socket = fixture.socket
171
+ settings = fixture.settings(socket)
172
+ error = Dnsruby::SocketEofResolvError.new('write failed during classification')
173
+ classified = false
174
+ socket.define_singleton_method(:getsockopt) do |*|
175
+ classified = true
176
+ selector.fail_tcp_send(settings, error)
177
+ raise IOError, 'closed stream'
178
+ end
179
+ raise 'unexpected incoming data' unless selector.get_incoming_data(socket, 0).nil?
180
+ raise 'classification hook not reached' unless classified
181
+ fixture.assert_send_error(settings, error)
182
+ RUBY
183
+ end
184
+
185
+ def test_retirement_after_response_settings_lookup_does_not_duplicate_error
186
+ assert_isolated(<<~'RUBY')
187
+ fixture = RetirementFixture.new
188
+ selector = fixture.selector
189
+ socket = fixture.socket
190
+ # Nonpersistent settings exercise socket classification after lookup;
191
+ # persistent queries use their recorded TCP type instead.
192
+ settings = fixture.settings(socket, persistent: false)
193
+ response = fixture.response(settings)
194
+ error = Dnsruby::SocketEofResolvError.new('write failed after response lookup')
195
+ classified = false
196
+ socket.define_singleton_method(:getsockopt) do |*|
197
+ classified = true
198
+ selector.fail_tcp_send(settings, error)
199
+ raise IOError, 'closed stream'
200
+ end
201
+ selector.send_response_to_client(response, response.encode, socket)
202
+ raise 'response classification hook not reached' unless classified
203
+ fixture.assert_send_error(settings, error)
204
+ RUBY
205
+ end
206
+
207
+ def test_recvfrom_failure_after_socket_retirement_is_harmless
208
+ assert_isolated(<<~'RUBY')
209
+ fixture = RetirementFixture.new
210
+ settings = fixture.settings(fixture.socket)
211
+ error = Dnsruby::SocketEofResolvError.new('write failed before recvfrom failure')
212
+ fixture.selector.fail_tcp_send(settings, error)
213
+ raise 'socket map was not retired' if fixture.table(:socket_hash).key?(settings.socket)
214
+ fixture.selector.handle_recvfrom_failure(settings.socket, IOError.new('closed stream'))
215
+ fixture.assert_send_error(settings, error)
216
+ RUBY
217
+ end
218
+
219
+ def test_tcp_bytes_returning_after_retirement_do_not_restore_framing_buffers
220
+ assert_isolated(<<~'RUBY')
221
+ [:partial_prefix, :complete_prefix, :partial_body].each do |stage|
222
+ fixture = RetirementFixture.new
223
+ selector = fixture.selector
224
+ socket = fixture.socket
225
+ settings = fixture.settings(socket)
226
+ error = Dnsruby::SocketEofResolvError.new("write failed during #{stage}")
227
+ if stage == :partial_body
228
+ # The prefix was already consumed and one body byte was buffered.
229
+ fixture.table(:tcp_buffers)[socket] = [+'a', 4, false]
230
+ end
231
+ read = false
232
+ socket.define_singleton_method(:recv_nonblock) do |_length|
233
+ read = true
234
+ selector.fail_tcp_send(settings, error)
235
+ stage == :complete_prefix ? [4].pack('n') : "\0".b
236
+ end
237
+ raise "#{stage}: unexpected complete frame" unless selector.tcp_read(socket) == false
238
+ raise "#{stage}: read hook not reached" unless read
239
+ fixture.assert_send_error(settings, error)
240
+ end
241
+ RUBY
242
+ end
243
+
244
+ def test_response_and_send_failure_have_only_one_terminal_owner
245
+ assert_isolated(<<~'RUBY')
246
+ [:failure_first, :response_first].each do |order|
247
+ fixture = RetirementFixture.new
248
+ selector = fixture.selector
249
+ settings = fixture.settings(fixture.socket)
250
+ response = fixture.response(settings)
251
+ error = Dnsruby::SocketEofResolvError.new("write failed #{order}")
252
+ interrupted = false
253
+ if order == :failure_first
254
+ sender = settings.single_resolver
255
+ check = sender.method(:check_response)
256
+ sender.define_singleton_method(:check_response) do |*args|
257
+ result = check.call(*args)
258
+ interrupted = true
259
+ selector.fail_tcp_send(settings, error)
260
+ result
261
+ end
262
+ else
263
+ push = selector.method(:push_to_client)
264
+ selector.define_singleton_method(:push_to_client) do |*args|
265
+ interrupted = true
266
+ raise 'response had not claimed its settings' if query_settings_for_id(settings.client_query_id).equal?(settings)
267
+ fail_tcp_send(settings, error)
268
+ push.call(*args)
269
+ end
270
+ end
271
+ selector.send_response_to_client(response, response.encode, settings.socket)
272
+ raise "#{order}: ownership hook not reached" unless interrupted
273
+ if order == :failure_first
274
+ fixture.assert_send_error(settings, error)
275
+ else
276
+ fixture.assert_response(settings, response)
277
+ fixture.assert_retired(settings.socket)
278
+ end
279
+ end
280
+ RUBY
281
+ end
282
+
283
+ def test_stale_response_cannot_claim_a_replacement_with_the_same_client_id
284
+ assert_isolated(<<~'RUBY')
285
+ fixture = RetirementFixture.new
286
+ selector = fixture.selector
287
+ original = fixture.settings(fixture.socket)
288
+ replacement = fixture.settings(fixture.socket, register: false)
289
+ response = fixture.response(original)
290
+ sender = original.single_resolver
291
+ check = sender.method(:check_response)
292
+ replaced = false
293
+ sender.define_singleton_method(:check_response) do |*args|
294
+ result = check.call(*args)
295
+ selector.add_to_select(replacement)
296
+ selector.fail_tcp_send(original, Dnsruby::SocketEofResolvError.new('old write failed'))
297
+ replaced = true
298
+ result
299
+ end
300
+ selector.send_response_to_client(response, response.encode, original.socket)
301
+ raise 'replacement hook not reached' unless replaced
302
+ raise 'stale response was delivered' unless fixture.events(original).empty?
303
+ raise 'replacement was consumed' unless selector.query_settings_for_id(:query).equal?(replacement)
304
+ raise 'replacement received stale failure' unless fixture.events(replacement).empty?
305
+ fixture.assert_retired(original.socket)
306
+ response = fixture.response(replacement)
307
+ selector.send_response_to_client(response, response.encode, replacement.socket)
308
+ fixture.assert_response(replacement, response)
309
+ RUBY
310
+ end
311
+
312
+ def test_timeout_snapshot_cannot_expire_a_replacement_with_the_same_client_id
313
+ assert_isolated(<<~'RUBY')
314
+ fixture = RetirementFixture.new
315
+ selector = fixture.selector
316
+ deadline = Dnsruby::TransportClock.now - 1
317
+ original = fixture.settings(fixture.socket, endtime: deadline)
318
+ replacement = fixture.settings(fixture.socket, register: false)
319
+ replaced = false
320
+ claim = selector.method(:claim_query)
321
+ selector.define_singleton_method(:claim_query) do |settings, *args|
322
+ # The due heap entry has been popped and the selector lock released.
323
+ # Another sender replaces the ID before the old settings are claimed.
324
+ unless replaced
325
+ add_to_select(replacement)
326
+ replaced = true
327
+ end
328
+ claim.call(settings, *args)
329
+ end
330
+ selector.process_timeouts
331
+ raise 'timeout snapshot hook not reached' unless replaced
332
+ raise 'old attempt received timeout' unless fixture.events(original).empty?
333
+ raise 'replacement received old timeout' unless fixture.events(replacement).empty?
334
+ raise 'replacement registration was consumed' unless selector.query_settings_for_id(:query).equal?(replacement)
335
+ raise 'replacement deadline was removed' unless fixture.table(:timeouts)[:query] == replacement.endtime
336
+ # The replacement can still expire on its own deadline.
337
+ fixture.table(:timeouts)[:query] = Dnsruby::TransportClock.now - 1
338
+ selector.instance_variable_get(:@packet_deadlines)[:query] = fixture.table(:timeouts)[:query]
339
+ selector.process_timeouts
340
+ events = fixture.events(replacement)
341
+ raise 'replacement did not receive one timeout' unless events.length == 1 &&
342
+ events.first[0, 3] == [:query, Dnsruby::Resolver::EventType::ERROR, nil] &&
343
+ events.first.last.is_a?(Dnsruby::ResolvTimeout)
344
+ raise 'timed-out replacement retained' if selector.query_settings_for_id(:query)
345
+ RUBY
346
+ end
347
+
348
+ private
349
+
350
+ def assert_isolated(body)
351
+ options = ''.frozen? ? ['--enable-frozen-string-literal'] : []
352
+ script = SUBPROCESS_SETUP + "\nTimeout.timeout(10) do\n" + body + "\nend\n"
353
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, *options, '-I',
354
+ File.expand_path('../lib', __dir__), '-e', script)
355
+ assert(status.success?, "#{stdout}\n#{stderr}")
356
+ assert_empty(stderr)
357
+ end
358
+ end