expect-pty 0.3.3 → 0.4.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.
data/lib/expect.rb CHANGED
@@ -99,8 +99,8 @@ class Expect
99
99
  def expect(...) = expect_result(...).number
100
100
 
101
101
  # 多会话等待的完整结果入口;from: 提供默认来源,块内可分别指定每个模式的来源。
102
- def expect_result(*patterns, from: [], timeout: configuration.timeout, &)
103
- run_expect(from, patterns, timeout, &)
102
+ def expect_result(*patterns, from: [], timeout: configuration.timeout, deadline: nil, &)
103
+ run_expect(from, patterns, timeout, deadline: deadline, &)
104
104
  end
105
105
 
106
106
  # 返回继续等待的控制符,reset_timeout 决定是否重新计算匹配期限。
@@ -149,15 +149,17 @@ class Expect
149
149
  private
150
150
 
151
151
  # 先完成模式声明再启动引擎;无参数块支持简洁 DSL,有参数块保留调用方 self。
152
- def run_expect(sessions, patterns, timeout, &block)
152
+ def run_expect(sessions, patterns, timeout, deadline: nil, &block)
153
153
  timeout = duration(timeout)
154
+ deadline = Float(deadline) unless deadline.nil?
155
+ raise ArgumentError, "deadline must be finite" if deadline && !deadline.finite?
154
156
  raise ArgumentError, "provide patterns or a pattern block, not both" if block_given? && !patterns.empty?
155
157
 
156
158
  pattern_list = PatternList.new(sessions, patterns)
157
159
  if block
158
160
  block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
159
161
  end
160
- Matcher.new(pattern_list.validate!, timeout).run
162
+ Matcher.new(pattern_list.validate!, timeout, deadline: deadline).run
161
163
  end
162
164
  end
163
165
 
@@ -167,7 +169,7 @@ class Expect
167
169
  def_delegators :@configuration, *Configuration::ATTRIBUTES, *Configuration::PREDICATES
168
170
  def_delegators :@configuration, *(Configuration::ATTRIBUTES - [:buffer_limit]).map { |name| :"#{name}=" }
169
171
 
170
- attr_reader :command, :last_result, :slave, :tty_name
172
+ attr_reader :command, :last_result, :slave, :tty_name, :buffer_discarded_bytes
171
173
 
172
174
  # 校验并更新缓冲上限后,立即裁剪已接收的内容;校验失败不改变旧缓冲。
173
175
  def buffer_limit=(value)
@@ -236,20 +238,21 @@ class Expect
236
238
  hard_close
237
239
  raise SpawnError, failure
238
240
  end
239
- trace("spawned pid=#{child}")
241
+ trace("spawned pid=#{child}", event: :spawned)
240
242
  self
241
243
  ensure
242
244
  from_child&.close unless from_child&.closed?
243
245
  to_parent&.close unless to_parent&.closed?
244
246
  end
247
+
245
248
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
246
249
 
247
250
  # 在当前会话等待文本或事件,返回模式序号或 nil。
248
251
  def expect(...) = expect_result(...).number
249
252
 
250
253
  # 使用会话默认超时构造一次等待,返回含匹配内容、来源和错误的 Result。
251
- def expect_result(*patterns, timeout: self.timeout, &)
252
- self.class.__send__(:run_expect, [self], patterns, timeout, &)
254
+ def expect_result(*patterns, timeout: self.timeout, deadline: nil, &)
255
+ self.class.__send__(:run_expect, [self], patterns, timeout, deadline: deadline, &)
253
256
  end
254
257
 
255
258
  # 供实例回调返回继续控制符,语义与 Expect.continue 相同。
@@ -306,6 +309,7 @@ class Expect
306
309
  raise ArgumentError, "buffer must be a String" unless value.is_a?(String)
307
310
 
308
311
  @buffer = value.b
312
+ @buffer_generation += 1
309
313
  trim_buffer
310
314
  end
311
315
 
@@ -313,6 +317,7 @@ class Expect
313
317
  def clear_buffer
314
318
  previous = @buffer
315
319
  @buffer = "".b
320
+ @buffer_generation += 1
316
321
  previous
317
322
  end
318
323
 
@@ -322,7 +327,7 @@ class Expect
322
327
  raise IOError, "closed Expect session" if closed? || writer.closed?
323
328
 
324
329
  data = objects.map { |object| object.to_s.b }.join
325
- trace("sending #{data.inspect}", level: 2) if debug_level >= 2
330
+ trace_data(:sending, data, level: 2) if debug_level >= 2
326
331
  deadline = write_timeout && (Expect.monotonic + write_timeout)
327
332
  offset = 0
328
333
  while offset < data.bytesize
@@ -360,6 +365,7 @@ class Expect
360
365
  end
361
366
  data.bytesize
362
367
  end
368
+
363
369
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
364
370
 
365
371
  # 链式写入单个对象,返回当前会话。
@@ -478,7 +484,11 @@ class Expect
478
484
  completed = true
479
485
  status
480
486
  ensure
481
- cleanup.call { self.log_output = nil }
487
+ begin
488
+ cleanup.call { flush_diagnostics }
489
+ ensure
490
+ cleanup.call { self.log_output = nil }
491
+ end
482
492
  # 用本次流程的完成状态判断异常传播,不能误把调用者 rescue 中的异常当成当前错误。
483
493
  raise failure if failure && completed
484
494
  end
@@ -498,7 +508,7 @@ class Expect
498
508
  end
499
509
 
500
510
  # 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
501
- def initialize_session(reader, writer:, slave: nil, own: false, **)
511
+ def initialize_session(reader, writer:, slave: nil, own: false, diagnostic_output: nil, **)
502
512
  # 先登记所有权,后续校验失败也使用同一个资源对象逐个清理所属 IO。
503
513
  @resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
504
514
  raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
@@ -508,10 +518,13 @@ class Expect
508
518
  @slave = slave
509
519
  @configuration = Configuration.new(**self.class.configuration.to_h, **)
510
520
  @buffer = "".b
521
+ @buffer_generation = 0
522
+ @buffer_discarded_bytes = 0
511
523
  @listeners = []
512
524
  @sequences = {}
513
525
  @relay_outputs = []
514
526
  @closed = @eof = false
527
+ self.diagnostic_output = diagnostic_output
515
528
  ObjectSpace.define_finalizer(self, SessionResources.finalizer(@resources))
516
529
  end
517
530
 
@@ -527,9 +540,14 @@ class Expect
527
540
  @last_result = Result.new(number: pattern.number, before: @buffer.byteslice(0, offset),
528
541
  match: @buffer.byteslice(offset, length), after: @buffer.byteslice((offset + length)..),
529
542
  session: self, captures: captures)
530
- @buffer = @last_result.after.dup unless preserve_buffer?
543
+ unless preserve_buffer?
544
+ @buffer = @last_result.after.dup
545
+ @buffer_generation += 1
546
+ end
547
+ # 诊断回调可能嵌套等待;恢复本次结果后再交给正式模式回调,不能返回内层等待的结果。
548
+ result = @last_result
531
549
  trace("matched pattern #{pattern.number}")
532
- @last_result
550
+ @last_result = result
533
551
  end
534
552
 
535
553
  # 记录超时、EOF 或原始 IO 异常,保留当前缓冲快照并清除旧匹配及捕获组。
@@ -562,33 +580,41 @@ class Expect
562
580
  # 某些系统用 PTY 的 EIO 表示对端关闭;普通 IO 的同类错误仍按异常处理。
563
581
  raise unless @pty
564
582
 
565
- @eof = true
566
- return nil
583
+ return finish_read
567
584
  rescue EOFError
568
- @eof = true
569
- return nil
585
+ return finish_read
570
586
  end
571
587
  return nil if data == :wait_readable
572
588
 
573
- if data.nil?
574
- @eof = true
575
- return nil
576
- end
589
+ return finish_read if data.nil?
590
+
577
591
  data = data.b
578
592
  buffer << data
579
593
  trim_buffer if trim
580
- trace("received #{data.inspect}", level: 2) if debug_level >= 2
581
- trace("buffer #{@buffer.inspect}", level: 3) if debug_level >= 3
594
+ trace_data(:received, data, level: 2) if debug_level >= 2
595
+ trace_data(:buffer, @buffer, level: 3) if debug_level >= 3
582
596
  # 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
583
597
  write_log(data)
584
598
  propagate(data) if propagate
585
599
  data
586
600
  end
587
601
 
602
+ def finish_read
603
+ @eof = true
604
+ flush_log
605
+ flush_diagnostics(:received)
606
+ nil
607
+ end
608
+
588
609
  # 缓冲超过上限时只保留最新尾部字节,不对编码做隐式修改。
589
610
  def trim_buffer
590
611
  limit = buffer_limit
591
- @buffer = @buffer.byteslice(-limit, limit) if limit&.positive? && @buffer.bytesize > limit
612
+ return unless limit && @buffer.bytesize > limit
613
+
614
+ # 只累计匹配窗口裁剪,消费、清空及转接交接不算丢弃;关闭后仍可读取累计值。
615
+ @buffer_discarded_bytes += @buffer.bytesize - limit
616
+ @buffer = @buffer.byteslice(-limit, limit)
617
+ @buffer_generation += 1
592
618
  end
593
619
 
594
620
  # 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
data/script/ci CHANGED
@@ -7,7 +7,7 @@ bundle exec rake
7
7
  bundle exec ruby examples/dialogue.rb
8
8
 
9
9
  # 只检查小规模工作负载的正确性,不为共享 CI 机器设置性能阈值。
10
- for benchmark in matching relay send_slow; do
10
+ for benchmark in matching relay send_slow scaling; do
11
11
  bundle exec ruby "benchmark/${benchmark}.rb" --smoke
12
12
  done
13
13
 
@@ -45,7 +45,7 @@ spec = Gem.loaded_specs.fetch("expect-pty")
45
45
  install_root = File.realpath(ENV.fetch("GEM_HOME"))
46
46
  abort "gem was loaded outside the isolated install" unless File.realpath(spec.full_gem_path).start_with?("#{install_root}/")
47
47
  abort "Bundler leaked into the plain gem smoke" if ARGV.first == "plain" && defined?(Bundler)
48
- unexpected = %w[minitest rake rubocop parallel].select { |name| Gem.loaded_specs.key?(name) }
48
+ unexpected = %w[minitest rake rubocop parallel logger].select { |name| Gem.loaded_specs.key?(name) }
49
49
  abort "development dependencies leaked into the application: #{unexpected.join(', ')}" unless unexpected.empty?
50
50
  spec.runtime_dependencies.each do |dependency|
51
51
  loaded = Gem.loaded_specs.fetch(dependency.name)
@@ -68,7 +68,34 @@ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_st
68
68
  abort "installed gem child did not exit successfully" unless status&.success?
69
69
  end
70
70
 
71
- puts "Installed #{spec.full_name}: #{ARGV.fetch(0)} PTY and runtime dependencies passed"
71
+ # 使用已安装包验证内部过滤器随包分发,且结构化诊断无需加载可选的 Logger。
72
+ reader, producer = IO.pipe
73
+ events = []
74
+ transcript = StringIO.new
75
+ begin
76
+ Expect.open(reader, own: true, buffer_limit: 5, debug_level: 2,
77
+ diagnostic_output: ->(event) { events << event }) do |session|
78
+ session.redact("secret")
79
+ session.log_to(transcript)
80
+ session.buffer = "ready"
81
+ expired = session.expect_result("ready", deadline: Expect.monotonic)
82
+ abort "expired deadline consumed buffered text" unless expired.timeout? && session.buffer == "ready"
83
+ abort "zero timeout lost buffered text" unless session.expect("ready", timeout: 0) == 1
84
+ producer.write("secret!")
85
+ producer.close
86
+ result = session.expect_result(:eof, deadline: Expect.monotonic + 5)
87
+ abort "installed gem did not preserve capped input" unless result.eof? && result.before == "cret!"
88
+ abort "incorrect installed buffer accounting" unless session.buffer_discarded_bytes == 2
89
+ abort "installed redaction failed" unless transcript.string == "[FILTERED]!"
90
+ abort "diagnostics leaked secret" if events.any? { |event| event[:message].include?("secret") }
91
+ abort "filtered diagnostic missing" unless events.any? { |event| event[:message].include?("[FILTERED]") }
92
+ end
93
+ ensure
94
+ reader.close unless reader.closed?
95
+ producer.close unless producer.closed?
96
+ end
97
+
98
+ puts "Installed #{spec.full_name}: #{ARGV.fetch(0)} PTY, core contracts and runtime dependencies passed"
72
99
  RUBY
73
100
  ruby smoke.rb plain
74
101
 
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class BufferAccountingTest < ExpectTest
6
+ def test_limit_discards_are_cumulative_and_distinct_from_consumption
7
+ session, = pipe_session(buffer_limit: 4)
8
+ assert_equal 0, session.buffer_discarded_bytes
9
+ session.buffer = "abcdef"
10
+ assert_equal "cdef", session.buffer
11
+ assert_equal 2, session.buffer_discarded_bytes
12
+ session.buffer_limit = 2
13
+ assert_equal "ef", session.buffer
14
+ assert_equal 4, session.buffer_discarded_bytes
15
+ assert_equal 1, session.expect("e", timeout: 0)
16
+ assert_equal "f", session.clear_buffer
17
+ session.close
18
+ assert_equal 4, session.buffer_discarded_bytes
19
+ refute_respond_to session, :buffer_discarded_bytes=
20
+ end
21
+
22
+ def test_invalid_limit_and_buffer_leave_accounting_unchanged
23
+ session, = pipe_session(buffer_limit: 4)
24
+ session.buffer = "abcdef"
25
+ assert_raises(ArgumentError) { session.buffer_limit = 0 }
26
+ assert_raises(ArgumentError) { session.buffer = nil }
27
+ assert_equal "cdef", session.buffer
28
+ assert_equal 2, session.buffer_discarded_bytes
29
+ session.buffer_limit = nil
30
+ session.buffer = "unlimited"
31
+ assert_equal 2, session.buffer_discarded_bytes
32
+ end
33
+
34
+ def test_continuous_output_keeps_a_bounded_window_and_complete_log
35
+ session, writer = pipe_session(buffer_limit: 64)
36
+ logged = 0
37
+ session.log_to { |data| logged += data.bytesize }
38
+ 32.times do
39
+ writer.write("x" * 4096)
40
+ assert_nil session.expect("missing", timeout: 0)
41
+ assert_equal 64, session.buffer.bytesize
42
+ end
43
+ assert_equal 131_072, logged
44
+ assert_equal logged - 64, session.buffer_discarded_bytes
45
+ end
46
+
47
+ def test_preserved_match_does_not_count_as_discard
48
+ session, = pipe_session(buffer_limit: 4, preserve_buffer: true)
49
+ session.buffer = "abcdef"
50
+ 2.times { assert_equal 1, session.expect("ef", timeout: 0) }
51
+ assert_equal "cdef", session.buffer
52
+ assert_equal 2, session.buffer_discarded_bytes
53
+ end
54
+
55
+ def test_relay_tail_is_only_counted_when_matching_applies_the_limit
56
+ session, writer = pipe_session(buffer_limit: 2)
57
+ output = StringIO.new
58
+ session.listeners = [output]
59
+ session.on_sequence("STOP")
60
+ writer.write("prefixSTOPtail")
61
+ assert_same session, Expect.interconnect(session, timeout: 1)
62
+ assert_equal "prefix", output.string
63
+ assert_equal "tail", session.buffer
64
+ assert_equal 0, session.buffer_discarded_bytes
65
+ assert_equal 1, session.expect("il", timeout: 0)
66
+ assert_equal 2, session.buffer_discarded_bytes
67
+ session.expect(timeout: 0)
68
+ assert_equal 2, session.buffer_discarded_bytes
69
+ end
70
+
71
+ def test_logging_failure_preserves_the_retained_input_and_discard_count
72
+ session, writer = pipe_session(buffer_limit: 4)
73
+ failure = IOError.new("log failed")
74
+ session.log_to { raise failure }
75
+ writer.write("abcdef")
76
+ assert_same failure, session.expect_result("ef", timeout: 1).error
77
+ assert_equal "cdef", session.buffer
78
+ assert_equal 2, session.buffer_discarded_bytes
79
+ session.log_output = nil
80
+ assert_equal 1, session.expect("ef", timeout: 0)
81
+ assert_equal 2, session.buffer_discarded_bytes
82
+ end
83
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class DeadlineTest < ExpectTest
6
+ def test_absolute_deadline_caps_timeout_resets_from_incoming_data
7
+ session, writer = pipe_session(reset_timeout_on_read: true)
8
+ with_clock([[4, writer, "."], [8, writer, "."], [12, writer, "done"]]) do |clock|
9
+ result = session.expect_result("done", timeout: 5, deadline: 10)
10
+ assert result.timeout?
11
+ assert_equal 10, clock[0]
12
+ assert_equal "..", session.buffer
13
+ end
14
+ end
15
+
16
+ def test_class_api_and_nil_timeout_use_the_absolute_deadline
17
+ session, writer = pipe_session
18
+ with_clock([[4, writer, "ready"]]) do |clock|
19
+ assert_equal 1, Expect.expect("ready", from: session, timeout: nil, deadline: 5)
20
+ result = Expect.expect_result("missing", from: session, timeout: nil, deadline: 5)
21
+ assert result.timeout?
22
+ assert_equal 5, clock[0]
23
+ end
24
+ end
25
+
26
+ def test_shorter_relative_timeout_still_wins
27
+ session, = pipe_session
28
+ with_clock do |clock|
29
+ assert session.expect_result("missing", timeout: 2, deadline: 10).timeout?
30
+ assert_equal 2, clock[0]
31
+ end
32
+ end
33
+
34
+ def test_continuing_match_cannot_extend_the_hard_deadline
35
+ session, = pipe_session
36
+ session.buffer = "first second"
37
+ with_clock do |clock|
38
+ result = session.expect_result(timeout: 10, deadline: 5) do
39
+ on("first") do
40
+ clock[0] = 6
41
+ Expect.continue
42
+ end
43
+ on("second") { flunk "must not consume text after the deadline" }
44
+ end
45
+ assert result.timeout?
46
+ assert_equal " second", session.buffer
47
+ end
48
+ end
49
+
50
+ def test_timeout_callback_cannot_restart_an_expired_hard_deadline
51
+ session, = pipe_session
52
+ calls = 0
53
+ with_clock do |clock|
54
+ result = session.expect_result(timeout: 3, deadline: 5) do
55
+ timeout do
56
+ calls += 1
57
+ Expect.continue
58
+ end
59
+ end
60
+ assert result.timeout?
61
+ assert_equal 2, calls
62
+ assert_equal 5, clock[0]
63
+ end
64
+ end
65
+
66
+ def test_expired_deadline_does_not_scan_or_read_and_preserves_input
67
+ session, writer = pipe_session
68
+ session.buffer = "buffered"
69
+ writer.write("unread")
70
+ with_clock do |clock|
71
+ clock[0] = 5
72
+ assert session.expect_result("buffered", timeout: 0, deadline: 5).timeout?
73
+ assert_equal "buffered", session.buffer
74
+ assert_equal "unread", session.to_io.read_nonblock(6)
75
+ end
76
+ end
77
+
78
+ def test_zero_relative_timeout_still_polls_with_a_future_hard_deadline
79
+ session, writer = pipe_session
80
+ writer.write("ready")
81
+ assert_equal 1, session.expect("ready", timeout: 0, deadline: Expect.monotonic + 1)
82
+ end
83
+
84
+ def test_repeated_interruptions_cannot_extend_a_deadline
85
+ session, = pipe_session
86
+ clock = [0]
87
+ Expect.stub(:monotonic, -> { clock[0] }) do
88
+ IO.stub(:select, lambda { |*|
89
+ clock[0] += 1
90
+ raise Errno::EINTR
91
+ }) do
92
+ assert session.expect_result("missing", deadline: 3).timeout?
93
+ end
94
+ assert_equal 3, clock[0]
95
+ end
96
+ end
97
+
98
+ def test_known_eof_events_are_delivered_before_timeout_of_live_sources
99
+ ended, = pipe_session
100
+ live, = pipe_session
101
+ ended.close
102
+ live.buffer = "ready"
103
+ seen = []
104
+ with_clock do
105
+ result = Expect.expect_result(from: [ended, live], deadline: 0) do
106
+ on("ready") { flunk "deadline already expired" }
107
+ eof(from: ended) do
108
+ seen << :eof
109
+ Expect.continue
110
+ end
111
+ timeout { |sessions| seen << sessions }
112
+ end
113
+ assert result.timeout?
114
+ assert_same live, result.session
115
+ assert_equal [:eof, [live]], seen
116
+ assert_equal "ready", live.buffer
117
+ end
118
+ end
119
+
120
+ def test_all_known_eof_returns_eof_even_at_the_hard_deadline
121
+ first, = pipe_session
122
+ second, = pipe_session
123
+ [first, second].each(&:close)
124
+ seen = []
125
+ with_clock do
126
+ result = Expect.expect_result(from: [first, second], deadline: 0) do
127
+ eof do |session|
128
+ seen << session
129
+ Expect.continue
130
+ end
131
+ timeout { flunk "all sources ended" }
132
+ end
133
+ assert result.eof?
134
+ assert_equal [first, second], seen
135
+ end
136
+ end
137
+
138
+ def test_deadline_reached_during_regexp_does_not_consume_the_match
139
+ session, = pipe_session
140
+ session.buffer = "ready"
141
+ pattern = Regexp.new("ready")
142
+ match = pattern.method(:match)
143
+ with_clock do |clock|
144
+ pattern.stub(:match, lambda { |text|
145
+ clock[0] = 5
146
+ match.call(text)
147
+ }) do
148
+ assert session.expect_result(pattern, deadline: 5).timeout?
149
+ end
150
+ assert_equal "ready", session.buffer
151
+ end
152
+ end
153
+
154
+ def test_preserved_timeout_at_hard_deadline_still_dispatches_all_known_eof
155
+ first, = pipe_session
156
+ second, = pipe_session
157
+ [first, second].each(&:close)
158
+ first.buffer = "ready"
159
+ seen = []
160
+ with_clock do |clock|
161
+ result = Expect.expect_result(from: [first, second], deadline: 5) do
162
+ on("ready") do
163
+ clock[0] = 5
164
+ Expect.continue(reset_timeout: false)
165
+ end
166
+ eof do |session|
167
+ seen << session
168
+ Expect.continue(reset_timeout: false)
169
+ end
170
+ timeout { seen << :timeout }
171
+ end
172
+ assert result.eof?
173
+ assert_equal [first, second], seen
174
+ end
175
+ end
176
+
177
+ def test_preserved_timeout_at_hard_deadline_only_times_out_live_sources
178
+ ended, = pipe_session
179
+ live, = pipe_session
180
+ ended.close
181
+ ended.buffer = "ready"
182
+ seen = []
183
+ with_clock do |clock|
184
+ result = Expect.expect_result(from: [ended, live], deadline: 5) do
185
+ on("ready") do
186
+ clock[0] = 5
187
+ Expect.continue(reset_timeout: false)
188
+ end
189
+ eof(from: ended) do
190
+ seen << :eof
191
+ Expect.continue(reset_timeout: false)
192
+ end
193
+ timeout { |sessions| seen << sessions }
194
+ end
195
+ assert result.timeout?
196
+ assert_same live, result.session
197
+ assert_equal [:eof, [live]], seen
198
+ end
199
+ end
200
+
201
+ def test_invalid_deadlines_are_rejected_before_the_definition_or_io
202
+ session, writer = pipe_session
203
+ writer.write("ready")
204
+ [Float::INFINITY, -Float::INFINITY, Float::NAN, "invalid"].each do |deadline|
205
+ assert_raises(ArgumentError) do
206
+ session.expect_result(deadline: deadline) { flunk "invalid deadline must reject before registration" }
207
+ end
208
+ end
209
+ assert_equal "ready", session.to_io.read_nonblock(5)
210
+ end
211
+
212
+ private
213
+
214
+ # 虚拟时间只控制等待,数据仍经真实管道;精确断言上限,不依赖线程调度和 sleep。
215
+ def with_clock(events = [])
216
+ clock = [0.0]
217
+ pending = events.dup
218
+ poll = IO.method(:select)
219
+ select = lambda do |readers, writers, errors, timeout|
220
+ due = timeout && (clock[0] + timeout)
221
+ if pending.any? && (!due || pending.first[0] <= due)
222
+ at, writer, data = pending.shift
223
+ clock[0] = at
224
+ writer.write(data)
225
+ poll.call(readers, writers, errors, 0)
226
+ else
227
+ raise "unbounded wait without input" unless due
228
+
229
+ clock[0] = due
230
+ nil
231
+ end
232
+ end
233
+ Expect.stub(:monotonic, -> { clock[0] }) do
234
+ IO.stub(:select, select) { yield clock }
235
+ end
236
+ end
237
+ end