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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -14
- data/Gemfile +1 -0
- data/README.md +191 -83
- data/benchmark/matching.rb +26 -0
- data/benchmark/scaling.rb +70 -0
- data/benchmark/support.rb +23 -3
- data/docs/COMPATIBILITY.md +68 -57
- data/docs/INTERNAL_CONTRACTS.md +89 -39
- data/docs/PERFORMANCE.md +65 -19
- data/docs/VERIFICATION.md +206 -90
- data/lib/expect/configuration.rb +3 -1
- data/lib/expect/interaction.rb +15 -0
- data/lib/expect/logging.rb +111 -7
- data/lib/expect/matcher.rb +61 -8
- data/lib/expect/pattern.rb +5 -2
- data/lib/expect/pattern_list.rb +3 -0
- data/lib/expect/redactor.rb +81 -0
- data/lib/expect/relay.rb +12 -0
- data/lib/expect/relay_writer.rb +9 -0
- data/lib/expect/result.rb +2 -0
- data/lib/expect/session_resources.rb +4 -0
- data/lib/expect/terminal.rb +2 -1
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +50 -24
- data/script/ci +30 -3
- data/test/buffer_accounting_test.rb +83 -0
- data/test/deadline_test.rb +237 -0
- data/test/diagnostics_test.rb +353 -0
- data/test/lifecycle_contract_test.rb +71 -0
- data/test/literal_scan_test.rb +97 -0
- metadata +8 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
return nil
|
|
583
|
+
return finish_read
|
|
567
584
|
rescue EOFError
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|