expect-pty 0.3.0 → 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 +12 -0
- data/CONTRIBUTING.md +21 -0
- data/Gemfile +22 -5
- data/README.md +31 -8
- 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 +9 -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 +48 -5
- 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/process_test.rb +3 -2
- data/test/relay_recovery_test.rb +37 -0
- data/test/release_test.rb +34 -3
- data/test/scan_reuse_test.rb +109 -0
- metadata +101 -3
data/expect-pty.gemspec
CHANGED
|
@@ -15,9 +15,17 @@ Gem::Specification.new do |spec|
|
|
|
15
15
|
spec.files = Dir[
|
|
16
16
|
"lib/**/*.rb", "examples/**/*.rb", "examples/**/*.md", "docs/**/*.md", "benchmark/**/*.rb",
|
|
17
17
|
"test/**/*.rb", "test/**/*.sh", "test/**/*.md", "Gemfile", "Rakefile",
|
|
18
|
-
".rubocop.yml", "expect-pty.gemspec", "README.md", "
|
|
18
|
+
".rubocop.yml", "expect-pty.gemspec", "README.md", "CONTRIBUTING.md", "LICENSE", "CHANGELOG.md",
|
|
19
|
+
"script/ci", "script/release.rb"
|
|
19
20
|
]
|
|
20
21
|
spec.require_paths = ["lib"]
|
|
22
|
+
# 显式声明可独立升级的标准库 gem,供应用的 Bundler 解析完整运行时依赖。
|
|
23
|
+
# pty 是 Ruby 自带的 POSIX 扩展,不是独立 gem。
|
|
24
|
+
spec.add_dependency "forwardable", "~> 1.3"
|
|
25
|
+
spec.add_dependency "io-console", ">= 0.6", "< 1.0"
|
|
26
|
+
spec.add_dependency "io-wait", ">= 0.3", "< 1.0"
|
|
27
|
+
spec.add_dependency "shellwords", ">= 0.1", "< 1.0"
|
|
28
|
+
spec.add_dependency "stringio", "~> 3.0"
|
|
21
29
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
22
30
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
23
31
|
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
data/lib/expect/interaction.rb
CHANGED
|
@@ -87,14 +87,20 @@ class Expect
|
|
|
87
87
|
|
|
88
88
|
# 转发一个会话的待处理缓冲并剔除转义;返回 false 表示应结束整个转接。
|
|
89
89
|
# 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
|
|
90
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 转义匹配、前缀暂存和回调消费属于同一轮扫描。
|
|
90
91
|
def self.relay_buffer(session, buffers, final: false)
|
|
91
92
|
buffer = buffers.fetch(session)
|
|
92
93
|
loop do
|
|
93
94
|
sequences = session.__send__(:sequences).except(:eof)
|
|
94
95
|
history = session.__send__(:relay_history)
|
|
96
|
+
text = nil
|
|
97
|
+
utf8_regexp = false
|
|
95
98
|
matches = sequences.filter_map do |key, handler|
|
|
96
99
|
if key.is_a?(Regexp)
|
|
97
|
-
|
|
100
|
+
# 仅在本轮扫描复用组合文本;回调可能更换规则或消费缓冲,下一轮必须重新构造。
|
|
101
|
+
text ||= history + buffer
|
|
102
|
+
utf8_regexp ||= key.fixed_encoding? && key.encoding == Encoding::UTF_8
|
|
103
|
+
position = Pattern.new(value: key).locate(text, final: final)
|
|
98
104
|
if position
|
|
99
105
|
offset, length, = position
|
|
100
106
|
raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
|
|
@@ -148,12 +154,12 @@ class Expect
|
|
|
148
154
|
data = buffer.byteslice(0, count)
|
|
149
155
|
block_given? ? yield(data) : session.__send__(:propagate, data)
|
|
150
156
|
end
|
|
151
|
-
if
|
|
157
|
+
if text
|
|
152
158
|
history << buffer.byteslice(0, count)
|
|
153
159
|
limit = session.buffer_limit || REGEXP_ESCAPE_HISTORY_LIMIT
|
|
154
160
|
if history.bytesize > limit
|
|
155
161
|
history.replace(history.byteslice(-limit, limit))
|
|
156
|
-
if
|
|
162
|
+
if utf8_regexp
|
|
157
163
|
# 窗口可能从 UTF-8 续字节开始;丢弃不完整的首字符后再交给固定编码正则。
|
|
158
164
|
history.slice!(0) while (byte = history.getbyte(0)) && (0x80..0xBF).cover?(byte)
|
|
159
165
|
end
|
|
@@ -163,6 +169,7 @@ class Expect
|
|
|
163
169
|
return block_given? && count.positive? ? :pending : true
|
|
164
170
|
end
|
|
165
171
|
end
|
|
172
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
166
173
|
|
|
167
174
|
private_class_method :relay_buffer
|
|
168
175
|
|
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"
|
|
@@ -17,19 +22,44 @@ gem build expect-pty.gemspec --output "$gem_file"
|
|
|
17
22
|
# 在独立目录安装刚构建的包,避免源码路径或 Bundler 掩盖打包遗漏。
|
|
18
23
|
smoke_dir="$(mktemp -d)"
|
|
19
24
|
trap 'rm -rf "$smoke_dir"' EXIT
|
|
20
|
-
|
|
25
|
+
gem_file="$(pwd)/$gem_file"
|
|
26
|
+
bundler_version="$(bundle exec ruby -rbundler -e 'print Bundler::VERSION')"
|
|
27
|
+
bundler_gem="$(bundle exec ruby -e 'print Gem.loaded_specs.fetch("bundler").cache_file')"
|
|
21
28
|
|
|
22
29
|
(
|
|
23
30
|
cd "$smoke_dir"
|
|
24
|
-
|
|
31
|
+
# 清除 Bundler 的自动加载与路径设置;Ruby 4 还会通过 BUNDLER_SETUP 自动启用 Bundler。
|
|
32
|
+
for bundle_variable in ${!BUNDLE_@} ${!BUNDLER_@}; do
|
|
33
|
+
unset "$bundle_variable"
|
|
34
|
+
done
|
|
35
|
+
unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS
|
|
25
36
|
export GEM_HOME="$smoke_dir/gems" GEM_PATH="$smoke_dir/gems"
|
|
26
|
-
|
|
37
|
+
gem install --local --no-document "$gem_file"
|
|
38
|
+
|
|
39
|
+
cat > smoke.rb <<'RUBY'
|
|
40
|
+
require "expect/pty"
|
|
41
|
+
require "rbconfig"
|
|
42
|
+
|
|
27
43
|
spec = Gem.loaded_specs.fetch("expect-pty")
|
|
28
44
|
# 临时目录可能经由符号链接访问,先统一为实际路径再检查。
|
|
29
45
|
install_root = File.realpath(ENV.fetch("GEM_HOME"))
|
|
30
46
|
abort "gem was loaded outside the isolated install" unless File.realpath(spec.full_gem_path).start_with?("#{install_root}/")
|
|
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) }
|
|
49
|
+
abort "development dependencies leaked into the application: #{unexpected.join(', ')}" unless unexpected.empty?
|
|
50
|
+
spec.runtime_dependencies.each do |dependency|
|
|
51
|
+
loaded = Gem.loaded_specs.fetch(dependency.name)
|
|
52
|
+
abort "incompatible dependency: #{dependency}" unless dependency.matches_spec?(loaded)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 检查实际使用的标准库已在包元数据中声明,默认 gem 可加载不能代替依赖声明。
|
|
56
|
+
%w[forwardable io-console io-wait shellwords stringio].each do |name|
|
|
57
|
+
abort "undeclared runtime dependency: #{name}" unless spec.runtime_dependencies.any? { |dependency| dependency.name == name }
|
|
58
|
+
end
|
|
31
59
|
child = 'STDOUT.sync = true; puts "ready"; puts "reply:#{$stdin.gets&.strip}"'
|
|
32
|
-
Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, log_stdout: false) do |session|
|
|
60
|
+
Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_stdout: false) do |session|
|
|
61
|
+
session.winsize = [30, 100]
|
|
62
|
+
abort "io-console terminal size failed" unless session.winsize == [30, 100]
|
|
33
63
|
abort "installed gem did not receive the PTY prompt" unless session.expect("ready", timeout: 5)
|
|
34
64
|
|
|
35
65
|
session.puts("expect-ruby")
|
|
@@ -38,6 +68,19 @@ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, log_stdout: false) do
|
|
|
38
68
|
abort "installed gem child did not exit successfully" unless status&.success?
|
|
39
69
|
end
|
|
40
70
|
|
|
41
|
-
puts "Installed #{spec.full_name}: PTY and
|
|
71
|
+
puts "Installed #{spec.full_name}: #{ARGV.fetch(0)} PTY and runtime dependencies passed"
|
|
42
72
|
RUBY
|
|
73
|
+
ruby smoke.rb plain
|
|
74
|
+
|
|
75
|
+
# 消费方仅声明当前 Gem,不能借用项目 Gemfile 中的测试和发布依赖。
|
|
76
|
+
# 单独安装本次开发检查使用的 Bundler,避免依赖宿主 Ruby 的默认 Bundler 副本。
|
|
77
|
+
if [ -f "$bundler_gem" ]; then
|
|
78
|
+
gem install --local --no-document "$bundler_gem"
|
|
79
|
+
else
|
|
80
|
+
gem install --no-document bundler --version "$bundler_version"
|
|
81
|
+
fi
|
|
82
|
+
export BUNDLE_IGNORE_CONFIG=true
|
|
83
|
+
printf 'source "https://rubygems.org"\ngem "expect-pty", "= %s", require: "expect/pty"\n' "$gem_version" > Gemfile
|
|
84
|
+
bundle lock --local
|
|
85
|
+
bundle exec ruby -rbundler -e 'Bundler.require; load "smoke.rb"' bundler
|
|
43
86
|
)
|
|
@@ -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"
|