expect-pty 0.3.1 → 0.3.2
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/.rubocop.yml +32 -3
- data/CHANGELOG.md +6 -0
- data/CONTRIBUTING.md +21 -0
- data/Gemfile +2 -1
- data/README.md +15 -4
- data/benchmark/matching.rb +72 -0
- data/benchmark/relay.rb +48 -0
- data/benchmark/send_slow.rb +41 -0
- data/benchmark/support.rb +94 -0
- data/docs/INTERNAL_CONTRACTS.md +44 -0
- data/docs/PERFORMANCE.md +44 -0
- data/docs/RELEASING.md +6 -6
- data/docs/VERIFICATION.md +8 -0
- data/examples/kibitz/kibitz.rb +2 -0
- data/expect-pty.gemspec +2 -1
- data/lib/expect/interaction.rb +10 -3
- data/lib/expect/matcher.rb +15 -3
- data/lib/expect/pattern.rb +3 -3
- data/lib/expect/relay.rb +3 -1
- data/lib/expect/relay_writer.rb +2 -0
- data/lib/expect/session_resources.rb +28 -10
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +50 -16
- data/script/ci +5 -0
- data/test/cleanup_test.rb +163 -0
- data/test/configuration_test.rb +43 -0
- data/test/io_test.rb +35 -0
- data/test/pattern_offset_test.rb +41 -0
- data/test/relay_recovery_test.rb +37 -0
- data/test/scan_reuse_test.rb +109 -0
- metadata +12 -2
data/lib/expect/matcher.rb
CHANGED
|
@@ -49,12 +49,18 @@ class Expect
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
51
|
# 按声明组、会话、模式的顺序寻找首个匹配,不按文本中的出现位置重新排序。
|
|
52
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 声明优先级与单轮快照须在同一次扫描保持一致。
|
|
52
53
|
def find_match
|
|
53
|
-
|
|
54
|
+
# 单组且来源不重复时无需缓存;重复来源才为本轮扫描建立快照表。
|
|
55
|
+
groups = @patterns.groups
|
|
56
|
+
if groups.size > 1 || (groups.first && groups.first.first.size > @sessions.size)
|
|
57
|
+
snapshots = {}.compare_by_identity
|
|
58
|
+
end
|
|
59
|
+
groups.each do |sessions, patterns|
|
|
54
60
|
sessions.each do |session|
|
|
55
61
|
next if @handled_eof.include?(session)
|
|
56
62
|
|
|
57
|
-
buffer = session.buffer
|
|
63
|
+
buffer = snapshots ? (snapshots[session] ||= session.buffer) : session.buffer
|
|
58
64
|
stalled = @stalled_matches[session]
|
|
59
65
|
if stalled && stalled[:buffer] != buffer
|
|
60
66
|
@stalled_matches.delete(session)
|
|
@@ -70,6 +76,7 @@ class Expect
|
|
|
70
76
|
end
|
|
71
77
|
nil
|
|
72
78
|
end
|
|
79
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
73
80
|
|
|
74
81
|
# 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
|
|
75
82
|
def handle_match(session, pattern, position)
|
|
@@ -126,8 +133,13 @@ class Expect
|
|
|
126
133
|
|
|
127
134
|
# 每个就绪 IO 读取一次,将异常归属到对应会话;仅显式启用时按接收数据刷新期限。
|
|
128
135
|
def read_ready(readable, sessions)
|
|
136
|
+
# select 返回 IO 对象本身;共享同一 IO 时仍选择声明顺序中的首个会话。
|
|
137
|
+
if readable.size > 1
|
|
138
|
+
by_io = {}.compare_by_identity
|
|
139
|
+
sessions.each { |session| by_io[session.to_io] ||= session }
|
|
140
|
+
end
|
|
129
141
|
readable.each do |io|
|
|
130
|
-
session = sessions.find { |candidate| candidate.to_io.equal?(io) }
|
|
142
|
+
session = by_io ? by_io.fetch(io) : sessions.find { |candidate| candidate.to_io.equal?(io) }
|
|
131
143
|
begin
|
|
132
144
|
# 转接回调消费匹配内容,余下字节交回 Relay,不能在这里提前转发两次。
|
|
133
145
|
data = session.__send__(:read_available, propagate: !@relay_buffers.key?(session))
|
data/lib/expect/pattern.rb
CHANGED
|
@@ -28,9 +28,9 @@ class Expect
|
|
|
28
28
|
found = value.match(text)
|
|
29
29
|
return unless found
|
|
30
30
|
|
|
31
|
-
#
|
|
32
|
-
offset =
|
|
33
|
-
return [offset,
|
|
31
|
+
# 直接使用正则的字节范围,避免为转换字符偏移创建前缀切片;捕获组也返回字节串。
|
|
32
|
+
offset, finish = found.byteoffset(0)
|
|
33
|
+
return [offset, finish - offset, found.captures.map { |capture| capture&.b }]
|
|
34
34
|
end
|
|
35
35
|
nil
|
|
36
36
|
end
|
data/lib/expect/relay.rb
CHANGED
|
@@ -12,6 +12,7 @@ class Expect
|
|
|
12
12
|
@previous = @sessions.to_h { |session| [session, session.__send__(:interaction_buffer)] }
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 共享读写循环统一维护来源和目标期限。
|
|
15
16
|
def run
|
|
16
17
|
@sessions.each { |session| session.__send__(:interaction_buffer=, @buffers.fetch(session)) }
|
|
17
18
|
outputs.each(&:restart_timeout)
|
|
@@ -66,7 +67,7 @@ class Expect
|
|
|
66
67
|
|
|
67
68
|
begin
|
|
68
69
|
if @buffers.key?(session)
|
|
69
|
-
session.__send__(:read_available, propagate: false, buffer: @buffers.fetch(session))
|
|
70
|
+
session.__send__(:read_available, propagate: false, buffer: @buffers.fetch(session), trim: false)
|
|
70
71
|
else
|
|
71
72
|
session.__send__(:read_available, propagate: false)
|
|
72
73
|
end
|
|
@@ -87,6 +88,7 @@ class Expect
|
|
|
87
88
|
@previous.each { |session, buffer| session.__send__(:interaction_buffer=, buffer) }
|
|
88
89
|
@buffers.each { |session, buffer| session.__send__(:restore_relay_buffer, buffer) }
|
|
89
90
|
end
|
|
91
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
90
92
|
|
|
91
93
|
private
|
|
92
94
|
|
data/lib/expect/relay_writer.rb
CHANGED
|
@@ -27,6 +27,7 @@ class Expect
|
|
|
27
27
|
def done? = @offset == @data.bytesize && @flushed
|
|
28
28
|
|
|
29
29
|
# 每轮每目标至多一次写入;真实 IO 永不在此等待,交给 Relay 的共同 select。
|
|
30
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 游标、短写、flush 和期限按单个目标推进。
|
|
30
31
|
def advance(check_timeout: true)
|
|
31
32
|
return false if done?
|
|
32
33
|
raise IOError, "closed Expect session" if target.is_a?(Expect) && target.closed?
|
|
@@ -54,6 +55,7 @@ class Expect
|
|
|
54
55
|
check_timeout! if check_timeout
|
|
55
56
|
true
|
|
56
57
|
end
|
|
58
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
57
59
|
|
|
58
60
|
def check_timeout!
|
|
59
61
|
return unless deadline && Expect.monotonic >= deadline
|
|
@@ -20,7 +20,13 @@ class Expect
|
|
|
20
20
|
return unless own
|
|
21
21
|
|
|
22
22
|
# PTY 的读写端可能是同一个对象,先去重,重复关闭也保持安全。
|
|
23
|
-
|
|
23
|
+
failure = nil
|
|
24
|
+
[reader, writer, slave].compact.uniq.each do |io|
|
|
25
|
+
io.close unless io.closed?
|
|
26
|
+
rescue IOError, SystemCallError => error
|
|
27
|
+
failure ||= error
|
|
28
|
+
end
|
|
29
|
+
raise failure if failure
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
# 非阻塞回收直属子进程,缓存 Process::Status,成功后清空 PID 以支持重复查询。
|
|
@@ -42,15 +48,27 @@ class Expect
|
|
|
42
48
|
def finalize
|
|
43
49
|
return unless owner == Process.pid
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
begin
|
|
52
|
+
close_handles
|
|
53
|
+
ensure
|
|
54
|
+
begin
|
|
55
|
+
owned_log.close if owned_log && !owned_log.closed?
|
|
56
|
+
ensure
|
|
57
|
+
# 每个阶段独立收尾;句柄或日志关闭失败不能跳过进程回收。
|
|
58
|
+
reap
|
|
59
|
+
if pid
|
|
60
|
+
begin
|
|
61
|
+
Process.kill("KILL", pid)
|
|
62
|
+
rescue Errno::ESRCH
|
|
63
|
+
# 子进程可能刚好退出,仍需尝试 wait,不能留下僵尸进程。
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
# 将最终 wait 交给后台回收线程,避免在 GC 终结器中阻塞等待。
|
|
67
|
+
Process.detach(pid)
|
|
68
|
+
@pid = nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
54
72
|
rescue IOError, SystemCallError
|
|
55
73
|
nil
|
|
56
74
|
end
|
data/lib/expect/version.rb
CHANGED
data/lib/expect.rb
CHANGED
|
@@ -21,6 +21,8 @@ class Expect
|
|
|
21
21
|
CONTINUE = :continue
|
|
22
22
|
CONTINUE_WITHOUT_RESET = :continue_without_reset
|
|
23
23
|
READ_SIZE = 16_384
|
|
24
|
+
CONFIGURATION_MUTEX = Mutex.new
|
|
25
|
+
private_constant :CONFIGURATION_MUTEX
|
|
24
26
|
|
|
25
27
|
class SpawnError < StandardError; end
|
|
26
28
|
|
|
@@ -40,14 +42,20 @@ class Expect
|
|
|
40
42
|
return @configuration if defined?(@configuration)
|
|
41
43
|
return superclass.configuration unless self == Expect
|
|
42
44
|
|
|
43
|
-
@configuration
|
|
45
|
+
CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new.freeze }
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
# 基于旧快照构造可修改副本,全部赋值与配置块成功后才发布,异常时保留原配置。
|
|
47
49
|
def configure(**)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
raise ThreadError, "nested configure is not supported" if CONFIGURATION_MUTEX.owned?
|
|
51
|
+
|
|
52
|
+
# 初始化默认快照后,将整个读改写过程串行化,避免并发配置丢失更新。
|
|
53
|
+
configuration
|
|
54
|
+
CONFIGURATION_MUTEX.synchronize do
|
|
55
|
+
updated = Configuration.new(**configuration.to_h, **)
|
|
56
|
+
yield updated if block_given?
|
|
57
|
+
@configuration = updated.freeze
|
|
58
|
+
end
|
|
51
59
|
end
|
|
52
60
|
|
|
53
61
|
# 创建并启动会话;有块时返回块结果并确保关闭,无块时由调用方负责生命周期。
|
|
@@ -173,6 +181,7 @@ class Expect
|
|
|
173
181
|
end
|
|
174
182
|
|
|
175
183
|
# 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
|
|
184
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- PTY 子进程启动与失败回传共用一次生命周期。
|
|
176
185
|
def spawn(*command, env: {}, chdir: nil)
|
|
177
186
|
raise SpawnError, "cannot reuse a spawned session" if @command
|
|
178
187
|
raise SpawnError, "only a new PTY session can spawn" unless @slave && !@slave.closed? && !closed?
|
|
@@ -223,6 +232,7 @@ class Expect
|
|
|
223
232
|
from_child&.close unless from_child&.closed?
|
|
224
233
|
to_parent&.close unless to_parent&.closed?
|
|
225
234
|
end
|
|
235
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
226
236
|
|
|
227
237
|
# 在当前会话等待文本或事件,返回模式序号或 nil。
|
|
228
238
|
def expect(...) = expect_result(...).number
|
|
@@ -297,6 +307,7 @@ class Expect
|
|
|
297
307
|
end
|
|
298
308
|
|
|
299
309
|
# 按 Ruby to_s 规则原样写入所有字节,返回字节数;背压等待受 write_timeout 限制。
|
|
310
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 写入、背压排空和同一期限必须同步推进。
|
|
300
311
|
def write(*objects)
|
|
301
312
|
raise IOError, "closed Expect session" if closed? || writer.closed?
|
|
302
313
|
|
|
@@ -339,6 +350,7 @@ class Expect
|
|
|
339
350
|
end
|
|
340
351
|
data.bytesize
|
|
341
352
|
end
|
|
353
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
342
354
|
|
|
343
355
|
# 链式写入单个对象,返回当前会话。
|
|
344
356
|
def <<(object)
|
|
@@ -364,7 +376,7 @@ class Expect
|
|
|
364
376
|
object.to_s.each_char do |character|
|
|
365
377
|
sleep(pause) if pause.positive?
|
|
366
378
|
count += write(character)
|
|
367
|
-
read_available if !eof? && to_io.wait_readable(0
|
|
379
|
+
read_available if !eof? && to_io.wait_readable(0)
|
|
368
380
|
end
|
|
369
381
|
end
|
|
370
382
|
count
|
|
@@ -497,27 +509,48 @@ class Expect
|
|
|
497
509
|
|
|
498
510
|
# 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
|
|
499
511
|
def finish_close(timeout:, term_timeout:, force:)
|
|
512
|
+
failure = nil
|
|
513
|
+
# 预期的清理错误延后传播,保证其余所属资源和直属子进程仍能完成清理。
|
|
514
|
+
cleanup = lambda do |&step|
|
|
515
|
+
step.call
|
|
516
|
+
rescue IOError, SystemCallError => error
|
|
517
|
+
failure ||= error
|
|
518
|
+
nil
|
|
519
|
+
end
|
|
500
520
|
# IO 关闭与进程退出独立记录:软关闭可能已经 closed?,但仍保留活跃 PID。
|
|
501
|
-
@resources.close_handles
|
|
521
|
+
cleanup.call { @resources.close_handles }
|
|
502
522
|
@closed = true
|
|
503
|
-
@interact_inputs&.
|
|
504
|
-
|
|
523
|
+
@interact_inputs&.delete_if do |_io, input|
|
|
524
|
+
cleanup.call do
|
|
525
|
+
input.close(graceful: false)
|
|
526
|
+
true
|
|
527
|
+
end
|
|
528
|
+
end
|
|
505
529
|
@interact_output = nil
|
|
506
530
|
@relay_outputs&.clear
|
|
507
531
|
@relay_history&.clear
|
|
508
532
|
@relay_callback = nil
|
|
533
|
+
status = finish_child_close(timeout: timeout, term_timeout: term_timeout, force: force)
|
|
534
|
+
completed = true
|
|
535
|
+
status
|
|
536
|
+
ensure
|
|
537
|
+
cleanup.call { self.log_output = nil }
|
|
538
|
+
# 用本次流程的完成状态判断异常传播,不能误把调用者 rescue 中的异常当成当前错误。
|
|
539
|
+
raise failure if failure && completed
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# 句柄清理失败不改变进程策略;未回收 PID 保留给重复关闭或终结器继续处理。
|
|
543
|
+
def finish_child_close(timeout:, term_timeout:, force:)
|
|
509
544
|
return process_status unless @resources.owner == Process.pid && pid
|
|
510
545
|
return process_status if wait(timeout: timeout)
|
|
511
546
|
|
|
512
547
|
signal_child("TERM")
|
|
513
548
|
return process_status if wait(timeout: term_timeout)
|
|
514
549
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
ensure
|
|
520
|
-
self.log_output = nil
|
|
550
|
+
return unless force
|
|
551
|
+
|
|
552
|
+
signal_child("KILL")
|
|
553
|
+
wait(timeout: 1)
|
|
521
554
|
end
|
|
522
555
|
|
|
523
556
|
# 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
|
|
@@ -568,13 +601,14 @@ class Expect
|
|
|
568
601
|
end
|
|
569
602
|
|
|
570
603
|
# 先将读取字节交给匹配或转接缓冲,再记录日志;日志失败也能恢复输入。
|
|
571
|
-
def read_available(propagate: true, buffer: @buffer)
|
|
604
|
+
def read_available(propagate: true, buffer: @buffer, trim: true)
|
|
572
605
|
return nil if eof?
|
|
573
606
|
|
|
574
607
|
# 写入背压也会读取;转接期间统一交给转义处理器,不能直接转发或另存匹配缓冲。
|
|
575
608
|
if @interaction_buffer
|
|
576
609
|
buffer = @interaction_buffer
|
|
577
610
|
propagate = false
|
|
611
|
+
trim = false
|
|
578
612
|
end
|
|
579
613
|
|
|
580
614
|
begin
|
|
@@ -597,7 +631,7 @@ class Expect
|
|
|
597
631
|
end
|
|
598
632
|
data = data.b
|
|
599
633
|
buffer << data
|
|
600
|
-
trim_buffer if
|
|
634
|
+
trim_buffer if trim
|
|
601
635
|
trace("received #{data.inspect}", level: 2) if debug_level >= 2
|
|
602
636
|
trace("buffer #{@buffer.inspect}", level: 3) if debug_level >= 3
|
|
603
637
|
# 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
|
data/script/ci
CHANGED
|
@@ -6,6 +6,11 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
|
|
6
6
|
bundle exec rake
|
|
7
7
|
bundle exec ruby examples/dialogue.rb
|
|
8
8
|
|
|
9
|
+
# 只检查小规模工作负载的正确性,不为共享 CI 机器设置性能阈值。
|
|
10
|
+
for benchmark in matching relay send_slow; do
|
|
11
|
+
bundle exec ruby "benchmark/${benchmark}.rb" --smoke
|
|
12
|
+
done
|
|
13
|
+
|
|
9
14
|
mkdir -p tmp/ci
|
|
10
15
|
gem_version="$(ruby -Ilib -rexpect/version -e 'print Expect::VERSION')"
|
|
11
16
|
gem_file="tmp/ci/expect-pty-${gem_version}.gem"
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class CleanupTest < ExpectTest
|
|
6
|
+
def test_close_handles_attempts_every_owned_handle_and_preserves_first_error
|
|
7
|
+
reader, writer = IO.pipe
|
|
8
|
+
extra, peer = IO.pipe
|
|
9
|
+
@ios.push(reader, writer, extra, peer)
|
|
10
|
+
resources = Expect::SessionResources.new(reader, writer: writer, slave: extra, own: true)
|
|
11
|
+
failure = IOError.new("reader close failed")
|
|
12
|
+
reader.stub(:close, -> { raise failure }) do
|
|
13
|
+
assert_same failure, assert_raises(IOError) { resources.close_handles }
|
|
14
|
+
assert writer.closed?
|
|
15
|
+
assert extra.closed?
|
|
16
|
+
end
|
|
17
|
+
resources.close_handles
|
|
18
|
+
assert reader.closed?
|
|
19
|
+
resources.close_handles
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def test_hard_close_finishes_wrappers_log_and_child_after_handle_failure
|
|
23
|
+
session = stubborn_child
|
|
24
|
+
pid = session.pid
|
|
25
|
+
first, = pipe_session
|
|
26
|
+
second, = pipe_session
|
|
27
|
+
session.instance_variable_set(:@interact_inputs, { first.to_io => first, second.to_io => second })
|
|
28
|
+
Dir.mktmpdir do |dir|
|
|
29
|
+
log = session.log_to(File.join(dir, "owned.log"))
|
|
30
|
+
failure = IOError.new("handle close failed")
|
|
31
|
+
session.to_io.stub(:close, -> { raise failure }) do
|
|
32
|
+
first.stub(:close, ->(**) { raise IOError, "wrapper close failed" }) do
|
|
33
|
+
error = assert_raises(IOError) { bounded { session.hard_close(timeout: 0.01) } }
|
|
34
|
+
assert_same failure, error
|
|
35
|
+
assert second.closed?
|
|
36
|
+
refute second.to_io.closed?, "borrowed wrapper IO must remain open"
|
|
37
|
+
assert log.closed?
|
|
38
|
+
assert_nil session.log_output
|
|
39
|
+
assert_nil session.pid
|
|
40
|
+
assert_equal Signal.list.fetch("KILL"), session.process_status.termsig
|
|
41
|
+
assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
ensure
|
|
46
|
+
session&.hard_close(timeout: 0)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_owned_log_close_failure_is_visible_after_child_reaping_and_retryable
|
|
50
|
+
session = stubborn_child
|
|
51
|
+
Dir.mktmpdir do |dir|
|
|
52
|
+
log = session.log_to(File.join(dir, "owned.log"))
|
|
53
|
+
log.stub(:close, -> { raise IOError, "log close failed" }) do
|
|
54
|
+
assert_raises(IOError) { bounded { session.hard_close(timeout: 0.01) } }
|
|
55
|
+
assert_nil session.pid
|
|
56
|
+
assert_same log, session.log_output
|
|
57
|
+
end
|
|
58
|
+
status = session.hard_close(timeout: 0)
|
|
59
|
+
assert_same session.process_status, status
|
|
60
|
+
assert log.closed?
|
|
61
|
+
assert_nil session.log_output
|
|
62
|
+
end
|
|
63
|
+
ensure
|
|
64
|
+
session&.hard_close(timeout: 0)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_explicit_close_inside_rescue_does_not_hide_a_new_cleanup_error
|
|
68
|
+
session, = pipe_session(own: true)
|
|
69
|
+
failure = IOError.new("close failed inside caller rescue")
|
|
70
|
+
begin
|
|
71
|
+
raise ArgumentError, "already handled"
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
session.to_io.stub(:close, -> { raise failure }) do
|
|
74
|
+
assert_same failure, assert_raises(IOError) { session.hard_close(timeout: 0) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_unexpected_process_error_is_not_replaced_by_cleanup_failure
|
|
80
|
+
session = stubborn_child
|
|
81
|
+
failure = RuntimeError.new("process wait failed")
|
|
82
|
+
session.to_io.stub(:close, -> { raise IOError, "close failed" }) do
|
|
83
|
+
session.stub(:wait, ->(**) { raise failure }) do
|
|
84
|
+
assert_same failure, assert_raises(RuntimeError) { session.hard_close(timeout: 0) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
ensure
|
|
88
|
+
session&.hard_close(timeout: 0)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_soft_close_handle_failure_still_sends_term_but_never_kill
|
|
92
|
+
session = stubborn_child
|
|
93
|
+
signals = []
|
|
94
|
+
original = Process.method(:kill)
|
|
95
|
+
Process.stub(:kill, lambda { |signal, pid|
|
|
96
|
+
signals << signal
|
|
97
|
+
original.call(signal, pid)
|
|
98
|
+
}) do
|
|
99
|
+
session.to_io.stub(:close, -> { raise Errno::EIO, "close failed" }) do
|
|
100
|
+
assert_raises(Errno::EIO) { bounded { session.soft_close(timeout: 0, term_timeout: 0) } }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
assert_equal ["TERM"], signals
|
|
104
|
+
assert session.alive?
|
|
105
|
+
assert session.closed?
|
|
106
|
+
ensure
|
|
107
|
+
session&.hard_close(timeout: 0)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def test_finalizer_reaps_after_both_handle_and_log_close_fail
|
|
111
|
+
session = stubborn_child
|
|
112
|
+
pid = session.pid
|
|
113
|
+
resources = session.instance_variable_get(:@resources)
|
|
114
|
+
log_attempted = false
|
|
115
|
+
Dir.mktmpdir do |dir|
|
|
116
|
+
log = session.log_to(File.join(dir, "owned.log"))
|
|
117
|
+
session.to_io.stub(:close, -> { raise IOError, "handle failed" }) do
|
|
118
|
+
log.stub(:close, lambda {
|
|
119
|
+
log_attempted = true
|
|
120
|
+
raise Errno::EIO, "log failed"
|
|
121
|
+
}) do
|
|
122
|
+
bounded { resources.finalize }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
assert log_attempted
|
|
126
|
+
assert_nil resources.pid
|
|
127
|
+
bounded do
|
|
128
|
+
loop do
|
|
129
|
+
Process.kill(0, pid)
|
|
130
|
+
sleep 0.005
|
|
131
|
+
rescue Errno::ESRCH
|
|
132
|
+
break
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
ensure
|
|
137
|
+
session&.hard_close(timeout: 0)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def test_non_owner_cleanup_never_signals_and_finalizer_leaves_handles_alone
|
|
141
|
+
session = stubborn_child
|
|
142
|
+
resources = session.instance_variable_get(:@resources)
|
|
143
|
+
Process.stub(:pid, -1) do
|
|
144
|
+
Process.stub(:kill, ->(*) { flunk "non-owner sent a signal" }) do
|
|
145
|
+
resources.finalize
|
|
146
|
+
refute session.to_io.closed?
|
|
147
|
+
assert_nil session.hard_close(timeout: 0)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
assert session.alive?
|
|
151
|
+
ensure
|
|
152
|
+
session&.hard_close(timeout: 0)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def stubborn_child
|
|
158
|
+
session = child('Signal.trap("HUP", "IGNORE"); Signal.trap("TERM", "IGNORE"); puts "ready"; sleep 60',
|
|
159
|
+
raw_pty: true)
|
|
160
|
+
assert_equal 1, session.expect("ready", timeout: 2)
|
|
161
|
+
session
|
|
162
|
+
end
|
|
163
|
+
end
|
data/test/configuration_test.rb
CHANGED
|
@@ -56,6 +56,49 @@ class ConfigurationTest < ExpectTest
|
|
|
56
56
|
assert_equal 2, Expect.configuration.timeout
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
def test_concurrent_configure_updates_are_serialized
|
|
60
|
+
subclass = Class.new(Expect)
|
|
61
|
+
entered = Queue.new
|
|
62
|
+
second_started = Queue.new
|
|
63
|
+
release = Queue.new
|
|
64
|
+
first = Thread.new do
|
|
65
|
+
subclass.configure do |config|
|
|
66
|
+
entered << true
|
|
67
|
+
release.pop
|
|
68
|
+
config.timeout = 1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
entered.pop
|
|
72
|
+
second = Thread.new do
|
|
73
|
+
second_started << true
|
|
74
|
+
subclass.configure(debug_level: 2)
|
|
75
|
+
end
|
|
76
|
+
second_started.pop
|
|
77
|
+
# 第一轮尚未发布时,第二轮不能从同一旧快照完成更新。
|
|
78
|
+
refute second.join(0.05), "concurrent configure published before the first update finished"
|
|
79
|
+
release << true
|
|
80
|
+
first.value
|
|
81
|
+
second.value
|
|
82
|
+
assert_equal 1, subclass.configuration.timeout
|
|
83
|
+
assert_equal 2, subclass.configuration.debug_level
|
|
84
|
+
ensure
|
|
85
|
+
release&.push(true)
|
|
86
|
+
first&.join
|
|
87
|
+
second&.join
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_nested_configure_fails_without_publishing_partial_changes
|
|
91
|
+
subclass = Class.new(Expect)
|
|
92
|
+
previous = subclass.configuration
|
|
93
|
+
assert_raises(ThreadError) do
|
|
94
|
+
subclass.configure do |config|
|
|
95
|
+
config.timeout = 1
|
|
96
|
+
subclass.configure(debug_level: 2)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
assert_same previous, subclass.configuration
|
|
100
|
+
end
|
|
101
|
+
|
|
59
102
|
def test_attribute_validation_preserves_the_current_value_and_buffer
|
|
60
103
|
session, = pipe_session(buffer_limit: 8, timeout: 1, write_timeout: 2, debug_level: 1)
|
|
61
104
|
session.buffer = "contents"
|
data/test/io_test.rb
CHANGED
|
@@ -133,6 +133,41 @@ class IOTest < ExpectTest
|
|
|
133
133
|
assert_equal 1, session.expect("ABC", timeout: 1)
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
def test_send_slow_zero_delay_only_polls_and_later_reply_remains_readable
|
|
137
|
+
client, peer = Socket.pair(:UNIX, :STREAM, 0)
|
|
138
|
+
@ios.push(client, peer)
|
|
139
|
+
session = Expect.open(client, log_stdout: false)
|
|
140
|
+
@sessions << session
|
|
141
|
+
waits = []
|
|
142
|
+
original = client.method(:wait_readable)
|
|
143
|
+
client.stub(:wait_readable, lambda { |timeout|
|
|
144
|
+
waits << timeout
|
|
145
|
+
original.call(0)
|
|
146
|
+
}) do
|
|
147
|
+
assert_equal 8, session.send_slow("a中😀", delay: 0)
|
|
148
|
+
end
|
|
149
|
+
assert_equal [0, 0, 0], waits
|
|
150
|
+
assert_equal("a中😀".b, bounded { peer.read(8) })
|
|
151
|
+
peer.write("late reply")
|
|
152
|
+
assert_equal 1, session.expect("late reply", timeout: 1)
|
|
153
|
+
assert_empty session.buffer
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def test_send_slow_preserves_character_delay_and_write_timeout
|
|
157
|
+
session, = pipe_session
|
|
158
|
+
calls = []
|
|
159
|
+
failure = Expect::WriteTimeout.new(bytes_written: 0)
|
|
160
|
+
session.stub(:sleep, ->(duration) { calls << [:sleep, duration] }) do
|
|
161
|
+
session.stub(:write, lambda { |data|
|
|
162
|
+
calls << [:write, data]
|
|
163
|
+
raise failure
|
|
164
|
+
}) do
|
|
165
|
+
assert_same failure, assert_raises(Expect::WriteTimeout) { session.send_slow("中b", delay: 0.25) }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
assert_equal [[:sleep, 0.25], [:write, "中"]], calls
|
|
169
|
+
end
|
|
170
|
+
|
|
136
171
|
def test_large_bidirectional_write_does_not_deadlock
|
|
137
172
|
session = child("STDIN.binmode; STDOUT.binmode; loop { print STDIN.readpartial(4096) }", raw_pty: true,
|
|
138
173
|
write_timeout: 3)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class PatternOffsetTest < ExpectTest
|
|
6
|
+
def test_byte_offsets_captures_and_buffer_consumption
|
|
7
|
+
cases = [
|
|
8
|
+
["abENDtail", /END/, 2, 3, []],
|
|
9
|
+
["中文终点尾", /(终)(点)/, 6, 6, ["终".b, "点".b]],
|
|
10
|
+
["😀中😀尾", /(😀)(x)?/, 0, 4, ["😀".b, nil]],
|
|
11
|
+
["😀中END尾", /END/, 7, 3, []],
|
|
12
|
+
["a\0b", /(\0)/n, 1, 1, ["\0".b]],
|
|
13
|
+
["\xFF\x00\xFE".b, /(\xFE)/n, 2, 1, ["\xFE".b]],
|
|
14
|
+
["中文尾", /(?=尾)/, 6, 0, []],
|
|
15
|
+
["😀", /$/u, 4, 0, []]
|
|
16
|
+
]
|
|
17
|
+
session, = pipe_session
|
|
18
|
+
cases.each do |text, regexp, offset, length, captures|
|
|
19
|
+
bytes = text.b
|
|
20
|
+
assert_equal [offset, length, captures], Expect::Pattern.new(value: regexp).locate(bytes)
|
|
21
|
+
session.buffer = bytes
|
|
22
|
+
result = session.expect_result(regexp, timeout: 0)
|
|
23
|
+
assert_equal bytes.byteslice(0, offset), result.before
|
|
24
|
+
assert_equal bytes.byteslice(offset, length), result.match
|
|
25
|
+
assert_equal bytes.byteslice((offset + length)..), result.after
|
|
26
|
+
assert_equal captures, result.captures
|
|
27
|
+
assert_equal result.after, session.buffer
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_incomplete_utf8_defers_matching_and_eof_still_rejects_it
|
|
32
|
+
pattern = Expect::Pattern.new(value: /中(?=😀)/)
|
|
33
|
+
bytes = "前中😀".b
|
|
34
|
+
(1..3).each do |missing|
|
|
35
|
+
partial = bytes.byteslice(0, bytes.bytesize - missing)
|
|
36
|
+
assert_nil pattern.locate(partial)
|
|
37
|
+
assert_raises(EncodingError) { pattern.locate(partial, final: true) }
|
|
38
|
+
end
|
|
39
|
+
assert_equal [3, 3, []], pattern.locate(bytes)
|
|
40
|
+
end
|
|
41
|
+
end
|
data/test/relay_recovery_test.rb
CHANGED
|
@@ -234,6 +234,43 @@ class RelayRecoveryTest < ExpectTest
|
|
|
234
234
|
end
|
|
235
235
|
end
|
|
236
236
|
|
|
237
|
+
def test_split_literal_and_short_write_failures_preserve_delivery_and_callback_order
|
|
238
|
+
[1, 2, 3].product([1, 2, 3], [0, 2]).each do |split, step, failure_offset|
|
|
239
|
+
source, writer = pipe_session
|
|
240
|
+
fast = StringIO.new
|
|
241
|
+
received = "".b
|
|
242
|
+
failed = false
|
|
243
|
+
slow = Object.new
|
|
244
|
+
slow.define_singleton_method(:write) do |data|
|
|
245
|
+
if !failed && received.bytesize == failure_offset
|
|
246
|
+
failed = true
|
|
247
|
+
raise IOError, "injected partial delivery"
|
|
248
|
+
end
|
|
249
|
+
count = [step, data.bytesize].min
|
|
250
|
+
count = [count, failure_offset - received.bytesize].min unless failed
|
|
251
|
+
received << data.byteslice(0, count)
|
|
252
|
+
count
|
|
253
|
+
end
|
|
254
|
+
callbacks = []
|
|
255
|
+
source.listeners = [fast, slow]
|
|
256
|
+
source.on_sequence("STOP") do
|
|
257
|
+
callbacks << [fast.string.dup, received.dup]
|
|
258
|
+
false
|
|
259
|
+
end
|
|
260
|
+
source.buffer = "head#{"STOP".byteslice(0, split)}"
|
|
261
|
+
writer.write("#{"STOP".byteslice(split..)}tail")
|
|
262
|
+
assert_raises(IOError) { bounded { Expect.interconnect(source, timeout: 1) } }
|
|
263
|
+
assert_empty callbacks
|
|
264
|
+
assert source.pending_output?
|
|
265
|
+
assert_same(source, bounded { Expect.interconnect(source, timeout: 1) })
|
|
266
|
+
assert_equal [%w[head head]], callbacks
|
|
267
|
+
assert_equal "tail", source.buffer
|
|
268
|
+
assert_equal "head", fast.string
|
|
269
|
+
assert_equal "head", received
|
|
270
|
+
refute source.pending_output?
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
237
274
|
def test_flush_failure_retries_flush_without_replaying_data
|
|
238
275
|
source, = pipe_session
|
|
239
276
|
output = StringIO.new
|