expect-pty 0.3.2 → 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/.rubocop.yml +18 -10
- data/CHANGELOG.md +42 -12
- data/CONTRIBUTING.md +3 -1
- data/Gemfile +1 -0
- data/README.md +194 -80
- 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 +95 -24
- data/docs/PERFORMANCE.md +71 -9
- data/docs/RELEASING.md +8 -6
- data/docs/VERIFICATION.md +213 -81
- data/lib/expect/configuration.rb +3 -1
- data/lib/expect/interaction.rb +18 -3
- data/lib/expect/logging.rb +200 -0
- data/lib/expect/matcher.rb +70 -8
- data/lib/expect/pattern.rb +11 -4
- 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 +6 -2
- data/lib/expect/terminal.rb +36 -0
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +84 -152
- data/script/ci +30 -3
- data/script/release.rb +37 -5
- data/test/buffer_accounting_test.rb +83 -0
- data/test/cleanup_test.rb +88 -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
- data/test/pattern_offset_test.rb +3 -1
- data/test/process_test.rb +46 -0
- data/test/release_test.rb +94 -16
- data/test/timeout_test.rb +119 -0
- metadata +11 -2
data/lib/expect/relay_writer.rb
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 向单个转接目标写入数据,维护发送进度与期限;恢复时不重放已成功写出的前缀。
|
|
5
|
+
# 只借用目标,不 dup 描述符,也不负责读取;所有就绪等待统一交给 Relay 调度。
|
|
5
6
|
class RelayWriter
|
|
6
7
|
attr_reader :target, :deadline
|
|
7
8
|
|
|
9
|
+
# data 在本游标存活期间由上层保持不变,offset 始终以实际交付的字节数计量。
|
|
8
10
|
def initialize(target, data)
|
|
9
11
|
@target = target
|
|
10
12
|
@data = data
|
|
@@ -13,17 +15,21 @@ class Expect
|
|
|
13
15
|
restart_timeout
|
|
14
16
|
end
|
|
15
17
|
|
|
18
|
+
# 初次排队或重新进入 interconnect 时开始新的写入预算;普通短写不会刷新期限。
|
|
19
|
+
# 只有 Expect 目标提供 write_timeout,原生 IO 和自定义目标只受转接总期限约束。
|
|
16
20
|
def restart_timeout
|
|
17
21
|
period = target.write_timeout if target.is_a?(Expect)
|
|
18
22
|
@deadline = period && (Expect.monotonic + period)
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
# 真实 IO 可参与共同 select;自定义可写对象返回 nil,沿用其同步 write/flush 协议。
|
|
21
26
|
def io
|
|
22
27
|
return target.writer if target.is_a?(Expect)
|
|
23
28
|
|
|
24
29
|
target if target.is_a?(IO)
|
|
25
30
|
end
|
|
26
31
|
|
|
32
|
+
# 数据交付完但 flush 失败仍未完成;重试只补 flush,不重新发送已接受的数据。
|
|
27
33
|
def done? = @offset == @data.bytesize && @flushed
|
|
28
34
|
|
|
29
35
|
# 每轮每目标至多一次写入;真实 IO 永不在此等待,交给 Relay 的共同 select。
|
|
@@ -52,11 +58,14 @@ class Expect
|
|
|
52
58
|
end
|
|
53
59
|
true
|
|
54
60
|
rescue Errno::EINTR
|
|
61
|
+
# 未获得写入计数就不移动游标;先检查原期限,再让共同循环轮询输入并重试。
|
|
55
62
|
check_timeout! if check_timeout
|
|
56
63
|
true
|
|
57
64
|
end
|
|
65
|
+
|
|
58
66
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
59
67
|
|
|
68
|
+
# 保留本数据块已交付的进度,便于调用方区分未写入和部分写入;不清空恢复用的游标。
|
|
60
69
|
def check_timeout!
|
|
61
70
|
return unless deadline && Expect.monotonic >= deadline
|
|
62
71
|
|
data/lib/expect/result.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 保存一次等待的结果,保留 Struct 原生数组、Hash 转换和模式解构能力。
|
|
5
5
|
# number 仅在文本匹配时存在;error 为 :timeout、:eof 或原始 IO 异常,文本字段均为字节串。
|
|
6
|
+
# before/match/after 是产生结果时的快照,后续读取不追加到旧结果;captures 保留未参与捕获的 nil。
|
|
7
|
+
# 结果对象仍采用普通可变 Struct 语义;session 只引用来源,不取得 IO 或子进程所有权。
|
|
6
8
|
Result = Struct.new(:number, :error, :match, :before, :after, :session, :captures, keyword_init: true) do
|
|
7
9
|
# 是否命中文本模式;事件不会返回模式序号。
|
|
8
10
|
def matched? = !number.nil?
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 独立保存句柄、PID 和日志所有权,让终结器无需直接捕获会话即可清理遗弃资源。
|
|
5
|
+
# IO 是否关闭与子进程是否退出分别记录;不能仅凭句柄状态清空 PID 或伪造退出状态。
|
|
5
6
|
class SessionResources
|
|
7
|
+
# owned_log 只保存库打开的文件;借用的 IO/日志回调留在会话中,不能成为终结器的引用根。
|
|
6
8
|
attr_accessor :pid, :status, :owned_log
|
|
7
9
|
attr_reader :reader, :writer, :slave, :owner, :own
|
|
8
10
|
|
|
@@ -16,12 +18,13 @@ class Expect
|
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
# 只关闭由本库拥有的 IO;借用的 reader、writer 由调用方管理。
|
|
21
|
+
# 常规关闭错误延后到所有句柄尝试完再抛出,失败句柄仍留在账本内供下一次关闭重试。
|
|
19
22
|
def close_handles
|
|
20
23
|
return unless own
|
|
21
24
|
|
|
22
|
-
# PTY
|
|
25
|
+
# 初始化校验失败时可能含无效参数,只关闭真实 IO;PTY 读写端也需要去重。
|
|
23
26
|
failure = nil
|
|
24
|
-
[reader, writer, slave].
|
|
27
|
+
[reader, writer, slave].grep(IO).uniq.each do |io|
|
|
25
28
|
io.close unless io.closed?
|
|
26
29
|
rescue IOError, SystemCallError => error
|
|
27
30
|
failure ||= error
|
|
@@ -40,6 +43,7 @@ class Expect
|
|
|
40
43
|
status
|
|
41
44
|
rescue Errno::ECHILD
|
|
42
45
|
# 子进程可能已被调用方或其他线程回收,不再保留可能被系统复用的 PID。
|
|
46
|
+
# 无法获取别人已取走的 Process::Status,保留未知状态而不是推断成功或失败。
|
|
43
47
|
@pid = nil
|
|
44
48
|
status
|
|
45
49
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
# 会话终端的模式和窗口尺寸接口;人工接管期间的临时恢复由 interaction.rb 负责。
|
|
6
|
+
class Expect
|
|
7
|
+
# 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
|
|
8
|
+
# 非终端返回空字符串;命令不存在或执行失败抛出 IOError,管道在所有退出路径关闭。
|
|
9
|
+
def stty(*modes)
|
|
10
|
+
return "" unless tty?
|
|
11
|
+
|
|
12
|
+
modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
|
|
13
|
+
modes = ["-g"] if modes.empty?
|
|
14
|
+
reader, sink = IO.pipe
|
|
15
|
+
child = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
|
|
16
|
+
sink.close
|
|
17
|
+
output = reader.read
|
|
18
|
+
_, status = Process.waitpid2(child)
|
|
19
|
+
raise IOError, "stty failed: #{output.strip}" unless status.success?
|
|
20
|
+
|
|
21
|
+
output.strip
|
|
22
|
+
rescue Errno::ENOENT
|
|
23
|
+
raise IOError, "stty executable not found in PATH; install the system terminal utilities"
|
|
24
|
+
ensure
|
|
25
|
+
reader&.close unless reader&.closed?
|
|
26
|
+
sink&.close unless sink&.closed?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 读取终端的 [行数, 列数];底层并非终端或句柄已关闭时保留原生 IO 异常。
|
|
30
|
+
def winsize = to_io.winsize
|
|
31
|
+
|
|
32
|
+
# 更新终端尺寸,由内核通知前台进程。
|
|
33
|
+
def winsize=(size)
|
|
34
|
+
to_io.winsize = size
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/expect/version.rb
CHANGED
data/lib/expect.rb
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require "pty"
|
|
4
4
|
require "io/console"
|
|
5
5
|
require "io/wait"
|
|
6
|
-
require "shellwords"
|
|
7
6
|
require "stringio"
|
|
8
7
|
require "forwardable"
|
|
9
8
|
require_relative "expect/version"
|
|
@@ -62,11 +61,19 @@ class Expect
|
|
|
62
61
|
def spawn(*command, env: {}, chdir: nil, **)
|
|
63
62
|
session = new(**)
|
|
64
63
|
session.spawn(*command, env: env, chdir: chdir)
|
|
64
|
+
spawned = true
|
|
65
65
|
return session unless block_given?
|
|
66
66
|
|
|
67
67
|
yield session
|
|
68
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 记录本次作用域的失败,清理后原样传播,包括非 StandardError 异常。
|
|
69
|
+
failed = true
|
|
70
|
+
raise
|
|
68
71
|
ensure
|
|
69
|
-
session
|
|
72
|
+
if session && (block_given? || !spawned)
|
|
73
|
+
session.__send__(:cleanup_preserving_failure, failed) do
|
|
74
|
+
session.close(graceful: spawned && session.graceful_close?)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
70
77
|
end
|
|
71
78
|
|
|
72
79
|
# 适配已有 IO;own: true 接管关闭责任,初始化失败也释放接管的读写端。
|
|
@@ -77,11 +84,14 @@ class Expect
|
|
|
77
84
|
return session unless block_given?
|
|
78
85
|
|
|
79
86
|
yield session
|
|
87
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 初始化和块异常均须保留,清理失败不能替换原始原因。
|
|
88
|
+
failed = true
|
|
89
|
+
raise
|
|
80
90
|
ensure
|
|
81
|
-
if initialized
|
|
82
|
-
session.
|
|
83
|
-
|
|
84
|
-
|
|
91
|
+
if session && (block_given? || !initialized)
|
|
92
|
+
session.__send__(:cleanup_preserving_failure, failed) do
|
|
93
|
+
session.close(graceful: initialized && session.graceful_close?)
|
|
94
|
+
end
|
|
85
95
|
end
|
|
86
96
|
end
|
|
87
97
|
|
|
@@ -89,8 +99,8 @@ class Expect
|
|
|
89
99
|
def expect(...) = expect_result(...).number
|
|
90
100
|
|
|
91
101
|
# 多会话等待的完整结果入口;from: 提供默认来源,块内可分别指定每个模式的来源。
|
|
92
|
-
def expect_result(*patterns, from: [], timeout: configuration.timeout, &)
|
|
93
|
-
run_expect(from, patterns, timeout, &)
|
|
102
|
+
def expect_result(*patterns, from: [], timeout: configuration.timeout, deadline: nil, &)
|
|
103
|
+
run_expect(from, patterns, timeout, deadline: deadline, &)
|
|
94
104
|
end
|
|
95
105
|
|
|
96
106
|
# 返回继续等待的控制符,reset_timeout 决定是否重新计算匹配期限。
|
|
@@ -139,15 +149,17 @@ class Expect
|
|
|
139
149
|
private
|
|
140
150
|
|
|
141
151
|
# 先完成模式声明再启动引擎;无参数块支持简洁 DSL,有参数块保留调用方 self。
|
|
142
|
-
def run_expect(sessions, patterns, timeout, &block)
|
|
152
|
+
def run_expect(sessions, patterns, timeout, deadline: nil, &block)
|
|
143
153
|
timeout = duration(timeout)
|
|
154
|
+
deadline = Float(deadline) unless deadline.nil?
|
|
155
|
+
raise ArgumentError, "deadline must be finite" if deadline && !deadline.finite?
|
|
144
156
|
raise ArgumentError, "provide patterns or a pattern block, not both" if block_given? && !patterns.empty?
|
|
145
157
|
|
|
146
158
|
pattern_list = PatternList.new(sessions, patterns)
|
|
147
159
|
if block
|
|
148
160
|
block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
|
|
149
161
|
end
|
|
150
|
-
Matcher.new(pattern_list.validate!, timeout).run
|
|
162
|
+
Matcher.new(pattern_list.validate!, timeout, deadline: deadline).run
|
|
151
163
|
end
|
|
152
164
|
end
|
|
153
165
|
|
|
@@ -157,7 +169,7 @@ class Expect
|
|
|
157
169
|
def_delegators :@configuration, *Configuration::ATTRIBUTES, *Configuration::PREDICATES
|
|
158
170
|
def_delegators :@configuration, *(Configuration::ATTRIBUTES - [:buffer_limit]).map { |name| :"#{name}=" }
|
|
159
171
|
|
|
160
|
-
attr_reader :command, :last_result, :slave, :tty_name
|
|
172
|
+
attr_reader :command, :last_result, :slave, :tty_name, :buffer_discarded_bytes
|
|
161
173
|
|
|
162
174
|
# 校验并更新缓冲上限后,立即裁剪已接收的内容;校验失败不改变旧缓冲。
|
|
163
175
|
def buffer_limit=(value)
|
|
@@ -173,11 +185,11 @@ class Expect
|
|
|
173
185
|
@tty_name = slave.path
|
|
174
186
|
spawn(*command, env: env, chdir: chdir) unless command.empty?
|
|
175
187
|
initialized = true
|
|
188
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 构造异常时也要关闭已创建的资源并回收已启动的子进程。
|
|
189
|
+
failed = true
|
|
190
|
+
raise
|
|
176
191
|
ensure
|
|
177
|
-
|
|
178
|
-
master&.close unless master&.closed?
|
|
179
|
-
slave&.close unless slave&.closed?
|
|
180
|
-
end
|
|
192
|
+
cleanup_preserving_failure(failed) { close(graceful: false) } if @resources && !initialized
|
|
181
193
|
end
|
|
182
194
|
|
|
183
195
|
# 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
|
|
@@ -226,20 +238,21 @@ class Expect
|
|
|
226
238
|
hard_close
|
|
227
239
|
raise SpawnError, failure
|
|
228
240
|
end
|
|
229
|
-
trace("spawned pid=#{child}")
|
|
241
|
+
trace("spawned pid=#{child}", event: :spawned)
|
|
230
242
|
self
|
|
231
243
|
ensure
|
|
232
244
|
from_child&.close unless from_child&.closed?
|
|
233
245
|
to_parent&.close unless to_parent&.closed?
|
|
234
246
|
end
|
|
247
|
+
|
|
235
248
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
236
249
|
|
|
237
250
|
# 在当前会话等待文本或事件,返回模式序号或 nil。
|
|
238
251
|
def expect(...) = expect_result(...).number
|
|
239
252
|
|
|
240
253
|
# 使用会话默认超时构造一次等待,返回含匹配内容、来源和错误的 Result。
|
|
241
|
-
def expect_result(*patterns, timeout: self.timeout, &)
|
|
242
|
-
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, &)
|
|
243
256
|
end
|
|
244
257
|
|
|
245
258
|
# 供实例回调返回继续控制符,语义与 Expect.continue 相同。
|
|
@@ -296,6 +309,7 @@ class Expect
|
|
|
296
309
|
raise ArgumentError, "buffer must be a String" unless value.is_a?(String)
|
|
297
310
|
|
|
298
311
|
@buffer = value.b
|
|
312
|
+
@buffer_generation += 1
|
|
299
313
|
trim_buffer
|
|
300
314
|
end
|
|
301
315
|
|
|
@@ -303,6 +317,7 @@ class Expect
|
|
|
303
317
|
def clear_buffer
|
|
304
318
|
previous = @buffer
|
|
305
319
|
@buffer = "".b
|
|
320
|
+
@buffer_generation += 1
|
|
306
321
|
previous
|
|
307
322
|
end
|
|
308
323
|
|
|
@@ -312,7 +327,7 @@ class Expect
|
|
|
312
327
|
raise IOError, "closed Expect session" if closed? || writer.closed?
|
|
313
328
|
|
|
314
329
|
data = objects.map { |object| object.to_s.b }.join
|
|
315
|
-
|
|
330
|
+
trace_data(:sending, data, level: 2) if debug_level >= 2
|
|
316
331
|
deadline = write_timeout && (Expect.monotonic + write_timeout)
|
|
317
332
|
offset = 0
|
|
318
333
|
while offset < data.bytesize
|
|
@@ -350,6 +365,7 @@ class Expect
|
|
|
350
365
|
end
|
|
351
366
|
data.bytesize
|
|
352
367
|
end
|
|
368
|
+
|
|
353
369
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
354
370
|
|
|
355
371
|
# 链式写入单个对象,返回当前会话。
|
|
@@ -382,82 +398,6 @@ class Expect
|
|
|
382
398
|
count
|
|
383
399
|
end
|
|
384
400
|
|
|
385
|
-
# 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
|
|
386
|
-
attr_reader :log_output
|
|
387
|
-
|
|
388
|
-
# 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
|
|
389
|
-
def log_output=(target)
|
|
390
|
-
unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
|
|
391
|
-
raise ArgumentError, "log output must support write or call, or be nil"
|
|
392
|
-
end
|
|
393
|
-
|
|
394
|
-
replace_log(target)
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
# 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
|
|
398
|
-
def log_to(target = nil, mode: "a", &block)
|
|
399
|
-
raise ArgumentError, "provide a log target or a block, not both" if block && target
|
|
400
|
-
|
|
401
|
-
target = block if block
|
|
402
|
-
if target.respond_to?(:to_path) || target.is_a?(String)
|
|
403
|
-
raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
|
|
404
|
-
|
|
405
|
-
# 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
|
|
406
|
-
replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
|
|
407
|
-
else
|
|
408
|
-
raise ArgumentError, "provide a log target or a block" unless target
|
|
409
|
-
|
|
410
|
-
self.log_output = target
|
|
411
|
-
end
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
# 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
|
|
415
|
-
def write_log(*objects)
|
|
416
|
-
target = log_output
|
|
417
|
-
return unless target
|
|
418
|
-
|
|
419
|
-
data = objects.map { |object| object.to_s.b }.join
|
|
420
|
-
target.respond_to?(:call) ? target.call(data) : emit(target, data)
|
|
421
|
-
end
|
|
422
|
-
|
|
423
|
-
# 返回监听器列表副本,避免外部原地修改转发关系。
|
|
424
|
-
def listeners = @listeners.dup
|
|
425
|
-
|
|
426
|
-
# 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
|
|
427
|
-
def listeners=(outputs)
|
|
428
|
-
outputs = Array(outputs)
|
|
429
|
-
raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
|
|
430
|
-
|
|
431
|
-
@listeners = outputs.dup
|
|
432
|
-
end
|
|
433
|
-
|
|
434
|
-
# 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
|
|
435
|
-
def stty(*modes)
|
|
436
|
-
return "" unless tty?
|
|
437
|
-
|
|
438
|
-
modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
|
|
439
|
-
modes = ["-g"] if modes.empty?
|
|
440
|
-
reader, sink = IO.pipe
|
|
441
|
-
child = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
|
|
442
|
-
sink.close
|
|
443
|
-
output = reader.read
|
|
444
|
-
_, status = Process.waitpid2(child)
|
|
445
|
-
raise IOError, "stty failed: #{output.strip}" unless status.success?
|
|
446
|
-
|
|
447
|
-
output.strip
|
|
448
|
-
ensure
|
|
449
|
-
reader&.close unless reader&.closed?
|
|
450
|
-
sink&.close unless sink&.closed?
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
# 读取终端的 [行数, 列数]。
|
|
454
|
-
def winsize = to_io.winsize
|
|
455
|
-
|
|
456
|
-
# 更新终端尺寸,由内核通知前台进程。
|
|
457
|
-
def winsize=(size)
|
|
458
|
-
to_io.winsize = size
|
|
459
|
-
end
|
|
460
|
-
|
|
461
401
|
# 轮询回收状态直到进程退出或期限到达;返回 Process::Status 或 nil,超时不丢弃 PID。
|
|
462
402
|
def wait(timeout: nil)
|
|
463
403
|
period = Expect.duration(timeout)
|
|
@@ -501,12 +441,22 @@ class Expect
|
|
|
501
441
|
def close(graceful: graceful_close?)
|
|
502
442
|
soft_close if graceful
|
|
503
443
|
nil
|
|
444
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 软关闭的原始异常在硬关闭兜底后继续传播。
|
|
445
|
+
failed = true
|
|
446
|
+
raise
|
|
504
447
|
ensure
|
|
505
|
-
hard_close
|
|
448
|
+
cleanup_preserving_failure(failed) { hard_close }
|
|
506
449
|
end
|
|
507
450
|
|
|
508
451
|
private
|
|
509
452
|
|
|
453
|
+
# 仅在本次生命周期已有异常时抑制常规清理错误;调用者 rescue 中的旧异常不算本次失败。
|
|
454
|
+
def cleanup_preserving_failure(failed)
|
|
455
|
+
yield
|
|
456
|
+
rescue IOError, SystemCallError
|
|
457
|
+
raise unless failed
|
|
458
|
+
end
|
|
459
|
+
|
|
510
460
|
# 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
|
|
511
461
|
def finish_close(timeout:, term_timeout:, force:)
|
|
512
462
|
failure = nil
|
|
@@ -534,7 +484,11 @@ class Expect
|
|
|
534
484
|
completed = true
|
|
535
485
|
status
|
|
536
486
|
ensure
|
|
537
|
-
|
|
487
|
+
begin
|
|
488
|
+
cleanup.call { flush_diagnostics }
|
|
489
|
+
ensure
|
|
490
|
+
cleanup.call { self.log_output = nil }
|
|
491
|
+
end
|
|
538
492
|
# 用本次流程的完成状态判断异常传播,不能误把调用者 rescue 中的异常当成当前错误。
|
|
539
493
|
raise failure if failure && completed
|
|
540
494
|
end
|
|
@@ -554,19 +508,23 @@ class Expect
|
|
|
554
508
|
end
|
|
555
509
|
|
|
556
510
|
# 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
|
|
557
|
-
def initialize_session(reader, writer:, slave: nil, own: false, **)
|
|
511
|
+
def initialize_session(reader, writer:, slave: nil, own: false, diagnostic_output: nil, **)
|
|
512
|
+
# 先登记所有权,后续校验失败也使用同一个资源对象逐个清理所属 IO。
|
|
513
|
+
@resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
|
|
558
514
|
raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
|
|
559
515
|
raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
|
|
560
516
|
|
|
561
|
-
@resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
|
|
562
517
|
@pty = reader.tty?
|
|
563
518
|
@slave = slave
|
|
564
519
|
@configuration = Configuration.new(**self.class.configuration.to_h, **)
|
|
565
520
|
@buffer = "".b
|
|
521
|
+
@buffer_generation = 0
|
|
522
|
+
@buffer_discarded_bytes = 0
|
|
566
523
|
@listeners = []
|
|
567
524
|
@sequences = {}
|
|
568
525
|
@relay_outputs = []
|
|
569
526
|
@closed = @eof = false
|
|
527
|
+
self.diagnostic_output = diagnostic_output
|
|
570
528
|
ObjectSpace.define_finalizer(self, SessionResources.finalizer(@resources))
|
|
571
529
|
end
|
|
572
530
|
|
|
@@ -582,9 +540,14 @@ class Expect
|
|
|
582
540
|
@last_result = Result.new(number: pattern.number, before: @buffer.byteslice(0, offset),
|
|
583
541
|
match: @buffer.byteslice(offset, length), after: @buffer.byteslice((offset + length)..),
|
|
584
542
|
session: self, captures: captures)
|
|
585
|
-
|
|
543
|
+
unless preserve_buffer?
|
|
544
|
+
@buffer = @last_result.after.dup
|
|
545
|
+
@buffer_generation += 1
|
|
546
|
+
end
|
|
547
|
+
# 诊断回调可能嵌套等待;恢复本次结果后再交给正式模式回调,不能返回内层等待的结果。
|
|
548
|
+
result = @last_result
|
|
586
549
|
trace("matched pattern #{pattern.number}")
|
|
587
|
-
@last_result
|
|
550
|
+
@last_result = result
|
|
588
551
|
end
|
|
589
552
|
|
|
590
553
|
# 记录超时、EOF 或原始 IO 异常,保留当前缓冲快照并清除旧匹配及捕获组。
|
|
@@ -617,74 +580,41 @@ class Expect
|
|
|
617
580
|
# 某些系统用 PTY 的 EIO 表示对端关闭;普通 IO 的同类错误仍按异常处理。
|
|
618
581
|
raise unless @pty
|
|
619
582
|
|
|
620
|
-
|
|
621
|
-
return nil
|
|
583
|
+
return finish_read
|
|
622
584
|
rescue EOFError
|
|
623
|
-
|
|
624
|
-
return nil
|
|
585
|
+
return finish_read
|
|
625
586
|
end
|
|
626
587
|
return nil if data == :wait_readable
|
|
627
588
|
|
|
628
|
-
if data.nil?
|
|
629
|
-
|
|
630
|
-
return nil
|
|
631
|
-
end
|
|
589
|
+
return finish_read if data.nil?
|
|
590
|
+
|
|
632
591
|
data = data.b
|
|
633
592
|
buffer << data
|
|
634
593
|
trim_buffer if trim
|
|
635
|
-
|
|
636
|
-
|
|
594
|
+
trace_data(:received, data, level: 2) if debug_level >= 2
|
|
595
|
+
trace_data(:buffer, @buffer, level: 3) if debug_level >= 3
|
|
637
596
|
# 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
|
|
638
597
|
write_log(data)
|
|
639
598
|
propagate(data) if propagate
|
|
640
599
|
data
|
|
641
600
|
end
|
|
642
601
|
|
|
602
|
+
def finish_read
|
|
603
|
+
@eof = true
|
|
604
|
+
flush_log
|
|
605
|
+
flush_diagnostics(:received)
|
|
606
|
+
nil
|
|
607
|
+
end
|
|
608
|
+
|
|
643
609
|
# 缓冲超过上限时只保留最新尾部字节,不对编码做隐式修改。
|
|
644
610
|
def trim_buffer
|
|
645
611
|
limit = buffer_limit
|
|
646
|
-
|
|
647
|
-
end
|
|
648
|
-
|
|
649
|
-
# 按各自开关将接收字节转发到 stdout 和监听器,不重复写日志。
|
|
650
|
-
def propagate(data)
|
|
651
|
-
emit($stdout, data) if log_stdout?
|
|
652
|
-
@listeners.each { |listener| emit(listener, data) } if log_listeners?
|
|
653
|
-
end
|
|
612
|
+
return unless limit && @buffer.bytesize > limit
|
|
654
613
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
count = target.write(data.byteslice(offset..))
|
|
660
|
-
unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
|
|
661
|
-
raise IOError, "write must return the number of accepted bytes"
|
|
662
|
-
end
|
|
663
|
-
|
|
664
|
-
offset += count
|
|
665
|
-
end
|
|
666
|
-
target.flush if target.respond_to?(:flush)
|
|
667
|
-
end
|
|
668
|
-
|
|
669
|
-
# 按诊断级别向 stderr 输出会话标识和消息。
|
|
670
|
-
def trace(message, level: 1)
|
|
671
|
-
warn("#{inspect}: #{message}") if debug_level >= level
|
|
672
|
-
end
|
|
673
|
-
|
|
674
|
-
# 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
|
|
675
|
-
def replace_log(target, owned: false)
|
|
676
|
-
previous = log_output
|
|
677
|
-
# 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
|
|
678
|
-
return target if previous.equal?(target)
|
|
679
|
-
|
|
680
|
-
previous.close if @resources.owned_log && previous && !previous.closed?
|
|
681
|
-
# 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
|
|
682
|
-
@resources.owned_log = owned ? target : nil
|
|
683
|
-
@log_output = target
|
|
684
|
-
target
|
|
685
|
-
rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
|
|
686
|
-
target.close if owned && target && !target.closed?
|
|
687
|
-
raise
|
|
614
|
+
# 只累计匹配窗口裁剪,消费、清空及转接交接不算丢弃;关闭后仍可读取累计值。
|
|
615
|
+
@buffer_discarded_bytes += @buffer.bytesize - limit
|
|
616
|
+
@buffer = @buffer.byteslice(-limit, limit)
|
|
617
|
+
@buffer_generation += 1
|
|
688
618
|
end
|
|
689
619
|
|
|
690
620
|
# 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
|
|
@@ -697,4 +627,6 @@ class Expect
|
|
|
697
627
|
end
|
|
698
628
|
end
|
|
699
629
|
|
|
630
|
+
require_relative "expect/logging"
|
|
631
|
+
require_relative "expect/terminal"
|
|
700
632
|
require_relative "expect/interaction"
|
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
|
|