expect-pty 0.5.3 → 0.6.1
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 +28 -0
- data/README.md +28 -19
- data/docs/API.md +88 -0
- data/docs/MIGRATION.md +15 -0
- data/lib/expect/cleanup.rb +33 -0
- data/lib/expect/configuration.rb +87 -66
- data/lib/expect/interaction.rb +226 -209
- data/lib/expect/logging.rb +157 -156
- data/lib/expect/matcher.rb +30 -29
- data/lib/expect/pattern.rb +8 -2
- data/lib/expect/pattern_list.rb +35 -5
- data/lib/expect/redactor.rb +11 -4
- data/lib/expect/relay.rb +97 -73
- data/lib/expect/relay_writer.rb +2 -1
- data/lib/expect/result.rb +32 -8
- data/lib/expect/session.rb +508 -0
- data/lib/expect/session_resources.rb +10 -2
- data/lib/expect/terminal.rb +78 -78
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +333 -520
- data/sig/expect.rbs +355 -0
- metadata +13 -106
- data/.rubocop.yml +0 -66
- data/CONTRIBUTING.md +0 -29
- data/Gemfile +0 -28
- data/Rakefile +0 -38
- data/benchmark/matching.rb +0 -98
- data/benchmark/redactor.rb +0 -64
- data/benchmark/relay.rb +0 -48
- data/benchmark/scaling.rb +0 -70
- data/benchmark/send_slow.rb +0 -41
- data/benchmark/support.rb +0 -114
- data/docs/COMPATIBILITY.md +0 -98
- data/docs/INTERNAL_CONTRACTS.md +0 -154
- data/docs/PERFORMANCE.md +0 -165
- data/docs/RELEASING.md +0 -84
- data/docs/VERIFICATION.md +0 -456
- data/examples/dialogue.rb +0 -30
- data/examples/kibitz/README.md +0 -81
- data/examples/kibitz/kibitz.rb +0 -142
- data/examples/kibitz/test_kibitz.rb +0 -37
- data/examples/ssh_auto.rb +0 -94
- data/examples/ssh_interact.rb +0 -159
- data/examples/ssh_login.rb +0 -64
- data/expect-pty.gemspec +0 -33
- data/script/ci +0 -121
- data/script/release.rb +0 -319
- data/test/buffer_accounting_test.rb +0 -83
- data/test/cleanup_test.rb +0 -251
- data/test/compare_upstream.rb +0 -157
- data/test/configuration_test.rb +0 -133
- data/test/deadline_test.rb +0 -237
- data/test/diagnostics_test.rb +0 -353
- data/test/edge_case_test.rb +0 -262
- data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
- data/test/fixtures/ssh_scripts/02_output.sh +0 -5
- data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
- data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
- data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
- data/test/initialization_failure_test.rb +0 -92
- data/test/integration/README.md +0 -109
- data/test/integration/ssh_scripts.rb +0 -94
- data/test/interact_test.rb +0 -253
- data/test/interconnect_test.rb +0 -425
- data/test/io_test.rb +0 -321
- data/test/kibitz_test.rb +0 -45
- data/test/lifecycle_contract_test.rb +0 -71
- data/test/literal_scan_test.rb +0 -97
- data/test/matching_test.rb +0 -211
- data/test/multi_session_test.rb +0 -66
- data/test/ownership_sequence_test.rb +0 -208
- data/test/pattern_offset_test.rb +0 -43
- data/test/process_interruption_test.rb +0 -296
- data/test/process_test.rb +0 -291
- data/test/redactor_test.rb +0 -183
- data/test/relay_recovery_test.rb +0 -451
- data/test/relay_reentrancy_test.rb +0 -159
- data/test/release_test.rb +0 -375
- data/test/ruby_api_test.rb +0 -515
- data/test/scan_reuse_test.rb +0 -109
- data/test/script_logging_test.rb +0 -124
- data/test/support/interact_probe.rb +0 -125
- data/test/support/kibitz_probe.rb +0 -177
- data/test/support/script_probe.rb +0 -158
- data/test/terminal_cleanup_test.rb +0 -349
- data/test/test_helper.rb +0 -58
- data/test/timeout_test.rb +0 -289
- data/test/write_contract_test.rb +0 -105
data/lib/expect/logging.rb
CHANGED
|
@@ -5,196 +5,197 @@ require_relative "redactor"
|
|
|
5
5
|
# 管理日志目标、监听器及同步输出;资源所有权仍由会话的 SessionResources 统一保存。
|
|
6
6
|
# 接收日志、诊断和协议转发是三条独立通道;脱敏只改变前两条,不改变匹配或转发字节。
|
|
7
7
|
class Expect
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
# @api private
|
|
9
|
+
module Logging
|
|
10
|
+
# 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
|
|
11
|
+
attr_reader :log_output, :diagnostic_output
|
|
12
|
+
|
|
13
|
+
# 诊断目标仅借用;nil 沿用 stderr,Logger 使用 info/debug,回调接收冻结的事件 Hash。
|
|
14
|
+
# 先校验再冲刷旧流;校验或冲刷失败时保留旧目标,避免把暂存尾部交给错误的接收方。
|
|
15
|
+
def diagnostic_output=(target)
|
|
16
|
+
unless target.nil? || (target.respond_to?(:info) && target.respond_to?(:debug)) ||
|
|
17
|
+
target.respond_to?(:write) || target.respond_to?(:call)
|
|
18
|
+
raise ArgumentError, "diagnostic output must support info/debug, write or call, or be nil"
|
|
19
|
+
end
|
|
20
|
+
return if @diagnostic_output.equal?(target)
|
|
21
|
+
|
|
22
|
+
flush_diagnostics
|
|
23
|
+
@diagnostic_output = target
|
|
17
24
|
end
|
|
18
|
-
return if @diagnostic_output.equal?(target)
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
# 秘密仅作用于本会话的日志和诊断,不改写匹配、stdout 显示或 listeners 的协议字节。
|
|
27
|
+
# 先校验全部值再发布;注册是追加操作,应在首次通信前完成,不能追溯已交付的日志。
|
|
28
|
+
def redact(*secrets)
|
|
29
|
+
unless secrets.any? && secrets.all? { |secret| secret.is_a?(String) && !secret.empty? }
|
|
30
|
+
raise ArgumentError, "secrets must be nonempty Strings"
|
|
31
|
+
end
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
raise ArgumentError, "secrets must be nonempty Strings"
|
|
33
|
+
@secrets = ((@secrets || []) + secrets.map { |secret| secret.b.freeze }).uniq.freeze
|
|
34
|
+
@log_redactor.patterns = @secrets if @log_redactor
|
|
35
|
+
@diagnostic_redactors.each_value { |redactor| redactor.patterns = @secrets }
|
|
36
|
+
connection
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
# 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
|
|
40
|
+
def log_output=(target)
|
|
41
|
+
unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
|
|
42
|
+
raise ArgumentError, "log output must support write or call, or be nil"
|
|
43
|
+
end
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
def log_output=(target)
|
|
39
|
-
unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
|
|
40
|
-
raise ArgumentError, "log output must support write or call, or be nil"
|
|
45
|
+
replace_log(target)
|
|
41
46
|
end
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
|
|
47
|
-
def log_to(target = nil, mode: "a", &block)
|
|
48
|
-
raise ArgumentError, "provide a log target or a block, not both" if block && target
|
|
48
|
+
# 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
|
|
49
|
+
def log_to(target = nil, mode: "a", &block)
|
|
50
|
+
raise ArgumentError, "provide a log target or a block, not both" if block && target
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
target = block if block
|
|
53
|
+
if target.respond_to?(:to_path) || target.is_a?(String)
|
|
54
|
+
raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
# 先交付旧过滤尾部,再允许新路径截断;同一文件不能在截断后被旧句柄写回。
|
|
57
|
+
flush_log
|
|
58
|
+
# 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
|
|
59
|
+
replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError, "provide a log target or a block" unless target
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
self.log_output = target
|
|
64
|
+
end
|
|
62
65
|
end
|
|
63
|
-
end
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
# 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
|
|
68
|
+
# 启用脱敏后可能暂存末尾字节,因此一次调用不保证触发一次日志写入或回调。
|
|
69
|
+
def write_log(*objects)
|
|
70
|
+
target = log_output
|
|
71
|
+
return unless target
|
|
72
|
+
|
|
73
|
+
data = objects.map { |object| object.to_s.b }.join
|
|
74
|
+
if @secrets
|
|
75
|
+
@log_redactor ||= Redactor.new(@secrets)
|
|
76
|
+
data = @log_redactor.append(data)
|
|
77
|
+
return if data.empty?
|
|
78
|
+
end
|
|
79
|
+
target.respond_to?(:call) ? target.call(data) : emit(target, data)
|
|
76
80
|
end
|
|
77
|
-
target.respond_to?(:call) ? target.call(data) : emit(target, data)
|
|
78
|
-
end
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
# 返回监听器列表副本,避免外部原地修改转发关系。
|
|
83
|
+
def listeners = @listeners.dup
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
# 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
|
|
86
|
+
def listeners=(outputs)
|
|
87
|
+
outputs = Array(outputs)
|
|
88
|
+
raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
@listeners = outputs.dup
|
|
91
|
+
end
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
# 按各自开关将接收字节转发到 stdout 和监听器,不重复写日志。
|
|
94
|
+
def propagate(data)
|
|
95
|
+
emit($stdout, data) if log_stdout?
|
|
96
|
+
@listeners.each { |listener| emit(listener, data) } if log_listeners?
|
|
97
|
+
end
|
|
92
98
|
|
|
93
|
-
|
|
94
|
-
def propagate(data)
|
|
95
|
-
emit($stdout, data) if log_stdout?
|
|
96
|
-
@listeners.each { |listener| emit(listener, data) } if log_listeners?
|
|
97
|
-
end
|
|
99
|
+
private
|
|
98
100
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
101
|
+
# 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
|
|
102
|
+
# 这里遵循同步写入协议,不受 Matcher 的 IO 等待期限中断;慢目标转接应使用 Relay。
|
|
103
|
+
# 短写只推进已确认的字节数;目标抛错时不猜测它是否已经产生副作用。
|
|
104
|
+
def emit(target, data)
|
|
105
|
+
offset = 0
|
|
106
|
+
while offset < data.bytesize
|
|
107
|
+
count = target.write(data.byteslice(offset..))
|
|
108
|
+
unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
|
|
109
|
+
raise IOError, "write must return the number of accepted bytes"
|
|
110
|
+
end
|
|
109
111
|
|
|
110
|
-
|
|
112
|
+
offset += count
|
|
113
|
+
end
|
|
114
|
+
target.flush if target.respond_to?(:flush)
|
|
111
115
|
end
|
|
112
|
-
target.flush if target.respond_to?(:flush)
|
|
113
|
-
end
|
|
114
116
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
# 字节诊断在 inspect 转义之前过滤;缓冲快照可能只含秘密中间片段,启用脱敏时不展开。
|
|
118
|
+
def trace_data(event, data, level:)
|
|
119
|
+
if @secrets
|
|
120
|
+
return trace("buffer [FILTERED]", level:, event:) if event == :buffer
|
|
119
121
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
redactor = (@diagnostic_redactors[event] ||= Redactor.new(@secrets))
|
|
123
|
+
data = redactor.append(data)
|
|
124
|
+
return if data.empty?
|
|
125
|
+
end
|
|
126
|
+
trace("#{event} #{data.inspect}", level:, event:)
|
|
124
127
|
end
|
|
125
|
-
trace("#{event} #{data.inspect}", level: level, event: event)
|
|
126
|
-
end
|
|
127
128
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
129
|
+
# 诊断与接收字节日志分开;回调元数据不含会话对象,避免格式化时意外展开原始缓冲。
|
|
130
|
+
def trace(message, level: 1, event: :matched)
|
|
131
|
+
return unless debug_level >= level
|
|
132
|
+
|
|
133
|
+
severity = level == 1 ? :info : :debug
|
|
134
|
+
target = diagnostic_output
|
|
135
|
+
if target.respond_to?(:info) && target.respond_to?(:debug)
|
|
136
|
+
target.public_send(severity, "#{inspect}: #{message}")
|
|
137
|
+
elsif target.respond_to?(:call)
|
|
138
|
+
target.call({ event:, level: severity, pid:, fd: fileno, message: message.freeze }.freeze)
|
|
139
|
+
elsif target
|
|
140
|
+
emit(target, "#{inspect}: #{message}\n")
|
|
141
|
+
else
|
|
142
|
+
warn("#{inspect}: #{message}")
|
|
143
|
+
end
|
|
142
144
|
end
|
|
143
|
-
end
|
|
144
145
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
# 结束当前日志片段并交付过滤器尾部;重复调用不会重放已释放字节,写入异常原样传播。
|
|
147
|
+
def flush_log
|
|
148
|
+
loop do
|
|
149
|
+
return unless @log_redactor && log_output
|
|
149
150
|
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
data = @log_redactor.finish
|
|
152
|
+
return if data.empty?
|
|
152
153
|
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
log_output.respond_to?(:call) ? log_output.call(data) : emit(log_output, data)
|
|
155
|
+
# 回调可能轮换目标并追加新尾部;先结束它,再允许外层截断文件或关闭日志。
|
|
156
|
+
end
|
|
155
157
|
end
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# EOF 只结束接收诊断;目标替换或显式关闭结束全部方向,发送与接收不能拼成一个秘密。
|
|
159
|
-
# GC 终结器不进入本方法,避免在回收阶段调用用户回调或持有会话对象。
|
|
160
|
-
def flush_diagnostics(event = nil)
|
|
161
|
-
return unless @diagnostic_redactors
|
|
162
|
-
|
|
163
|
-
loop do
|
|
164
|
-
delivered = false
|
|
165
|
-
# 回调可能首次创建另一个方向,也可能写入本轮已经处理的方向。
|
|
166
|
-
@diagnostic_redactors.to_a.each do |name, redactor|
|
|
167
|
-
next if event && name != event
|
|
168
|
-
|
|
169
|
-
data = redactor.finish
|
|
170
|
-
next if data.empty?
|
|
171
158
|
|
|
172
|
-
|
|
173
|
-
|
|
159
|
+
# EOF 只结束接收诊断;目标替换或显式关闭结束全部方向,发送与接收不能拼成一个秘密。
|
|
160
|
+
# GC 终结器不进入本方法,避免在回收阶段调用用户回调或持有会话对象。
|
|
161
|
+
def flush_diagnostics(event = nil)
|
|
162
|
+
return unless @diagnostic_redactors
|
|
163
|
+
|
|
164
|
+
loop do
|
|
165
|
+
delivered = false
|
|
166
|
+
# 回调可能首次创建另一个方向,也可能写入本轮已经处理的方向。
|
|
167
|
+
@diagnostic_redactors.to_a.each do |name, redactor|
|
|
168
|
+
next if event && name != event
|
|
169
|
+
|
|
170
|
+
data = redactor.finish
|
|
171
|
+
next if data.empty?
|
|
172
|
+
|
|
173
|
+
delivered = true
|
|
174
|
+
trace("#{name} #{data.inspect}", level: 2, event: name)
|
|
175
|
+
end
|
|
176
|
+
# 目标尚未交接,新产生的尾部仍归旧诊断流;排空后才结束这次冲刷。
|
|
177
|
+
break unless delivered
|
|
174
178
|
end
|
|
175
|
-
# 目标尚未交接,新产生的尾部仍归旧诊断流;排空后才结束这次冲刷。
|
|
176
|
-
break unless delivered
|
|
177
179
|
end
|
|
178
|
-
end
|
|
179
180
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
181
|
+
# 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
|
|
182
|
+
def replace_log(target, owned: false)
|
|
183
|
+
# 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
|
|
184
|
+
return target if log_output.equal?(target)
|
|
185
|
+
|
|
186
|
+
Cleanup.on_failure(-> { target.close if owned && target && !target.closed? }) do
|
|
187
|
+
flush_log
|
|
188
|
+
# 尾部回调可以重入并替换目标;交接时重新读取目标和所有权,不能关闭旧的回调对象。
|
|
189
|
+
next target if log_output.equal?(target)
|
|
190
|
+
|
|
191
|
+
previous = @resources.owned_log
|
|
192
|
+
previous.close if previous && !previous.closed?
|
|
193
|
+
# 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
|
|
194
|
+
@resources.owned_log = owned ? target : nil
|
|
195
|
+
@log_output = target
|
|
196
|
+
@log_redactor = nil
|
|
197
|
+
target
|
|
198
|
+
end
|
|
199
|
+
end
|
|
199
200
|
end
|
|
200
201
|
end
|
data/lib/expect/matcher.rb
CHANGED
|
@@ -3,16 +3,18 @@
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 驱动一次单会话或多会话匹配,管理模式优先级、EOF 和共享期限,不接管 IO 所有权。
|
|
5
5
|
# 调度只在扫描、回调和 IO 操作之间检查期限,不强行中断用户代码或单次正则计算。
|
|
6
|
+
# @api private
|
|
6
7
|
class Matcher
|
|
7
8
|
# 固定本次参与的会话及初始期限;已处理 EOF 的会话仅从本次等待中移除。
|
|
8
9
|
def initialize(patterns, timeout, deadline: nil)
|
|
9
|
-
@patterns = patterns
|
|
10
|
-
@sessions = patterns.sessions
|
|
10
|
+
@patterns = patterns.finalize!
|
|
11
|
+
@sessions = patterns.sessions.map { |connection| Session.for(connection) }
|
|
12
|
+
@groups = patterns.groups.map { |connections, entries| [connections.map { |c| Session.for(c) }, entries] }
|
|
11
13
|
@timeout = Expect.duration(timeout)
|
|
12
14
|
# 相对期限可因接收或 continue 重算,总期限始终固定;两者共用单调时钟。
|
|
13
15
|
@hard_deadline = deadline
|
|
14
16
|
@deadline = next_deadline
|
|
15
|
-
@handled_eof =
|
|
17
|
+
@handled_eof = {}.compare_by_identity
|
|
16
18
|
@stalled_matches = {}
|
|
17
19
|
@polled = false
|
|
18
20
|
@expired_eof_continuation = false
|
|
@@ -22,15 +24,15 @@ class Expect
|
|
|
22
24
|
def run
|
|
23
25
|
@relay_buffers = {}
|
|
24
26
|
@sessions.each do |session|
|
|
25
|
-
buffer = session.
|
|
27
|
+
buffer = session.interaction_buffer
|
|
26
28
|
if buffer
|
|
27
29
|
# 转义回调中的显式匹配临时接管读取,先消费转接已经预读的尾部。
|
|
28
30
|
@relay_buffers[session] = buffer
|
|
29
|
-
session.
|
|
30
|
-
session.
|
|
31
|
+
session.interaction_buffer = nil
|
|
32
|
+
session.restore_relay_buffer(buffer)
|
|
31
33
|
buffer.clear
|
|
32
34
|
end
|
|
33
|
-
session.
|
|
35
|
+
session.reset_result
|
|
34
36
|
end
|
|
35
37
|
loop do
|
|
36
38
|
# 先消费已缓冲的匹配,再处理 EOF,最后读取;避免进程退出时丢失最后一个匹配。
|
|
@@ -49,7 +51,7 @@ class Expect
|
|
|
49
51
|
# 嵌套 expect 即使异常退出,也要把未消费尾部还给原 Relay 的同一个缓冲对象。
|
|
50
52
|
@relay_buffers.each do |session, buffer|
|
|
51
53
|
buffer.replace(session.clear_buffer)
|
|
52
|
-
session.
|
|
54
|
+
session.interaction_buffer = buffer
|
|
53
55
|
end
|
|
54
56
|
end
|
|
55
57
|
|
|
@@ -59,13 +61,13 @@ class Expect
|
|
|
59
61
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 声明优先级与单轮快照须在同一次扫描保持一致。
|
|
60
62
|
def find_match
|
|
61
63
|
# 单组且来源不重复时无需缓存;重复来源才为本轮扫描建立快照表。
|
|
62
|
-
groups = @
|
|
64
|
+
groups = @groups
|
|
63
65
|
if groups.size > 1 || (groups.first && groups.first.first.size > @sessions.size)
|
|
64
66
|
snapshots = {}.compare_by_identity
|
|
65
67
|
end
|
|
66
68
|
groups.each do |sessions, patterns|
|
|
67
69
|
sessions.each do |session|
|
|
68
|
-
next if @handled_eof.
|
|
70
|
+
next if @handled_eof.key?(session)
|
|
69
71
|
|
|
70
72
|
buffer = snapshots ? (snapshots[session] ||= session.buffer) : session.buffer
|
|
71
73
|
stalled = @stalled_matches[session]
|
|
@@ -99,23 +101,22 @@ class Expect
|
|
|
99
101
|
@literal_misses ||= {}.compare_by_identity
|
|
100
102
|
misses = (@literal_misses[session] ||= {}.compare_by_identity)
|
|
101
103
|
previous = misses[pattern]
|
|
102
|
-
generation = session.
|
|
103
|
-
#
|
|
104
|
-
offset = if previous && previous[0] == generation
|
|
104
|
+
generation = session.buffer_generation
|
|
105
|
+
# 模式已冻结;缓冲代次未变意味着只有尾部追加。
|
|
106
|
+
offset = if previous && previous[0] == generation
|
|
105
107
|
return nil if previous[1] == buffer.bytesize
|
|
106
108
|
|
|
107
109
|
[previous[1] - pattern.value.bytesize + 1, 0].max
|
|
108
110
|
else
|
|
109
111
|
0
|
|
110
112
|
end
|
|
111
|
-
position = pattern.locate(buffer, offset:
|
|
113
|
+
position = pattern.locate(buffer, offset:)
|
|
112
114
|
if position
|
|
113
115
|
misses.delete(pattern)
|
|
114
116
|
else
|
|
115
117
|
entry = (misses[pattern] ||= [])
|
|
116
118
|
entry[0] = generation
|
|
117
119
|
entry[1] = buffer.bytesize
|
|
118
|
-
entry[2] = pattern.value
|
|
119
120
|
end
|
|
120
121
|
position
|
|
121
122
|
end
|
|
@@ -123,8 +124,8 @@ class Expect
|
|
|
123
124
|
# 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
|
|
124
125
|
def handle_match(session, pattern, position)
|
|
125
126
|
previous_buffer = session.buffer
|
|
126
|
-
result = session.
|
|
127
|
-
action = pattern.call(session)
|
|
127
|
+
result = session.record_match(pattern, position)
|
|
128
|
+
action = pattern.call(session.connection)
|
|
128
129
|
return result unless continuing?(action)
|
|
129
130
|
|
|
130
131
|
# 回调未改变缓冲时暂停当前模式,等待缓冲变化后再匹配,避免原地空转。
|
|
@@ -141,18 +142,18 @@ class Expect
|
|
|
141
142
|
|
|
142
143
|
# 找出尚未派发 EOF 事件的会话,保证每个源只处理一次结束事件。
|
|
143
144
|
def unhandled_eof
|
|
144
|
-
@sessions.find { |session| session.eof? && !@handled_eof.
|
|
145
|
+
@sessions.find { |session| session.eof? && !@handled_eof.key?(session) }
|
|
145
146
|
end
|
|
146
147
|
|
|
147
148
|
# 将剩余字节交给 EOF 回调;需要继续时等待其他源,全部结束则立即返回。
|
|
148
149
|
def handle_eof(session)
|
|
149
|
-
result = session.
|
|
150
|
-
@handled_eof
|
|
151
|
-
actions = @patterns.eof_patterns_for(session).map { |pattern| pattern.call(session) }
|
|
150
|
+
result = session.record_eof
|
|
151
|
+
@handled_eof[session] = true
|
|
152
|
+
actions = @patterns.eof_patterns_for(session.connection).map { |pattern| pattern.call(session.connection) }
|
|
152
153
|
return result unless actions.any? { |action| continuing?(action) }
|
|
153
154
|
|
|
154
155
|
@deadline = next_deadline if actions.include?(CONTINUE)
|
|
155
|
-
return result if @
|
|
156
|
+
return result if @handled_eof.size == @sessions.size
|
|
156
157
|
|
|
157
158
|
# 期限已过时不再扫描文本,但先派发已知 EOF;最后一个源结束不能被误报为超时。
|
|
158
159
|
@expired_eof_continuation = !actions.include?(CONTINUE) && expired?
|
|
@@ -192,11 +193,11 @@ class Expect
|
|
|
192
193
|
session = by_io ? by_io.fetch(io) : sessions.find { |candidate| candidate.to_io.equal?(io) }
|
|
193
194
|
begin
|
|
194
195
|
# 转接回调消费匹配内容,余下字节交回 Relay,不能在这里提前转发两次。
|
|
195
|
-
data = session.
|
|
196
|
+
data = session.read_available(propagate: !@relay_buffers.key?(session))
|
|
196
197
|
rescue Errno::EINTR
|
|
197
198
|
next
|
|
198
199
|
rescue IOError, SystemCallError => error
|
|
199
|
-
return session.
|
|
200
|
+
return session.record_error(error)
|
|
200
201
|
end
|
|
201
202
|
@stalled_matches.delete(session) if data
|
|
202
203
|
@deadline = next_deadline if data && session.reset_timeout_on_read?
|
|
@@ -205,7 +206,7 @@ class Expect
|
|
|
205
206
|
end
|
|
206
207
|
|
|
207
208
|
# 返回本次仍需监听的会话,供读取选择和超时回调使用。
|
|
208
|
-
def active_sessions = @sessions.reject { |session| @handled_eof.
|
|
209
|
+
def active_sessions = @sessions.reject { |session| @handled_eof.key?(session) }
|
|
209
210
|
|
|
210
211
|
# 只有约定的继续符号会驱动下一轮,普通回调返回值不会改变等待流程。
|
|
211
212
|
def continuing?(action) = [CONTINUE, CONTINUE_WITHOUT_RESET].include?(action)
|
|
@@ -229,16 +230,16 @@ class Expect
|
|
|
229
230
|
# 绝对总期限不受接收数据和 continue 重置;已知 EOF 仍按原顺序派发。
|
|
230
231
|
def hard_expired? = @hard_deadline && Expect.monotonic >= @hard_deadline
|
|
231
232
|
|
|
232
|
-
# select
|
|
233
|
+
# select 失败时无法归属单个源,只更新仍监听的会话;已派发 EOF 的结果保持不变。
|
|
233
234
|
def record_error(error)
|
|
234
|
-
|
|
235
|
+
active_sessions.map { |session| session.record_error(error) }.first
|
|
235
236
|
end
|
|
236
237
|
|
|
237
238
|
# 为活跃会话记录超时,回调接收全部活跃源;只有重置计时的继续符号能重新等待。
|
|
238
239
|
# 已到总期限仍通知超时回调,但不接受继续请求,且不消费尚未匹配的字节。
|
|
239
240
|
def handle_timeout
|
|
240
|
-
results = active_sessions.map { |session| session.
|
|
241
|
-
action = @patterns.timeout_pattern&.call(active_sessions)
|
|
241
|
+
results = active_sessions.map { |session| session.record_error(:timeout) }
|
|
242
|
+
action = @patterns.timeout_pattern&.call(active_sessions.map(&:connection))
|
|
242
243
|
return results.first unless action == CONTINUE && !hard_expired?
|
|
243
244
|
|
|
244
245
|
@deadline = next_deadline
|
data/lib/expect/pattern.rb
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 一个已编号的文本模式或事件及其回调;匹配结果统一使用字节偏移,便于精确消费 IO 缓冲。
|
|
5
|
-
|
|
5
|
+
# @api private
|
|
6
|
+
Pattern = Data.define(:number, :value, :callback) do
|
|
7
|
+
def initialize(value:, number: nil, callback: nil)
|
|
8
|
+
super(value: value.is_a?(String) ? value.b.freeze : value, number:, callback:)
|
|
9
|
+
end
|
|
10
|
+
|
|
6
11
|
# 将匹配会话或超时会话数组交给回调;额外上下文由调用方闭包保存。
|
|
7
12
|
def call(subject)
|
|
8
13
|
callback&.call(subject)
|
|
@@ -28,7 +33,7 @@ class Expect
|
|
|
28
33
|
end
|
|
29
34
|
unless text.valid_encoding?
|
|
30
35
|
# 不完整的尾字符可能改变锚点或前瞻结果,必须等字符收齐后再匹配。
|
|
31
|
-
validate_incomplete_suffix!(text, final:
|
|
36
|
+
validate_incomplete_suffix!(text, final:)
|
|
32
37
|
return nil
|
|
33
38
|
end
|
|
34
39
|
found = value.match(text)
|
|
@@ -58,4 +63,5 @@ class Expect
|
|
|
58
63
|
raise EncodingError, "received invalid #{text.encoding} data; use a binary regexp (/.../n) for binary streams"
|
|
59
64
|
end
|
|
60
65
|
end
|
|
66
|
+
private_constant :Pattern
|
|
61
67
|
end
|
data/lib/expect/pattern_list.rb
CHANGED
|
@@ -4,6 +4,8 @@ class Expect
|
|
|
4
4
|
# 将 Ruby 模式和块回调整理为有序会话组;注册阶段不读取 IO、不执行匹配回调。
|
|
5
5
|
# 分组结构直接决定 Matcher 的“声明组 → 会话 → 模式”优先级,不能按匹配位置重排。
|
|
6
6
|
class PatternList
|
|
7
|
+
# 供匹配器读取的已编译分组和超时规则。
|
|
8
|
+
# @api private
|
|
7
9
|
attr_reader :groups, :timeout_pattern
|
|
8
10
|
|
|
9
11
|
# 建立默认来源,并将位置参数中的文本、正则、:eof、:timeout 转成统一模式。
|
|
@@ -26,7 +28,7 @@ class Expect
|
|
|
26
28
|
def on(value, from: @default_sessions, &block)
|
|
27
29
|
raise ArgumentError, "pattern must be a String or Regexp" unless value.is_a?(String) || value.is_a?(Regexp)
|
|
28
30
|
|
|
29
|
-
add(value
|
|
31
|
+
add(value, from, block)
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
# 为指定来源注册 EOF 回调;实例 DSL 默认使用当前会话。
|
|
@@ -37,6 +39,8 @@ class Expect
|
|
|
37
39
|
# 注册一次等待的唯一超时回调,重复定义直接报错,避免悄悄覆盖业务处理。
|
|
38
40
|
# 超时是整次等待的事件,回调收到全部活跃会话,不归属于某一个来源组。
|
|
39
41
|
def timeout(&block)
|
|
42
|
+
raise FrozenError, "patterns are finalized" if frozen?
|
|
43
|
+
|
|
40
44
|
raise ArgumentError, "timeout callback already registered" if @timeout_pattern
|
|
41
45
|
|
|
42
46
|
@timeout_pattern = build(:timeout, block)
|
|
@@ -45,22 +49,42 @@ class Expect
|
|
|
45
49
|
|
|
46
50
|
# 汇总并去重读取源,同一会话出现在多个模式组时仍只读取一次。
|
|
47
51
|
# 这里只去重会话对象;不同会话包装同一 IO 时的读取归属由 Matcher 决定。
|
|
48
|
-
|
|
52
|
+
# @api private
|
|
53
|
+
def sessions
|
|
54
|
+
groups.flat_map(&:first).each_with_object({}.compare_by_identity) do |session, unique|
|
|
55
|
+
unique[session] = true
|
|
56
|
+
end.keys
|
|
57
|
+
end
|
|
49
58
|
|
|
50
59
|
# 收集指定会话的所有 EOF 处理器,保留原注册顺序。
|
|
60
|
+
# @api private
|
|
51
61
|
def eof_patterns_for(session)
|
|
52
62
|
groups.flat_map do |sessions, patterns|
|
|
53
|
-
sessions.
|
|
63
|
+
sessions.any? { |candidate| candidate.equal?(session) } ? patterns.select(&:eof?) : []
|
|
54
64
|
end
|
|
55
65
|
end
|
|
56
66
|
|
|
57
67
|
# 启动引擎前确保存在读取源;类级等待必须通过 from: 明确来源。
|
|
68
|
+
# @api private
|
|
58
69
|
def validate!
|
|
59
70
|
raise ArgumentError, "at least one session is required" if groups.empty?
|
|
60
71
|
|
|
61
72
|
self
|
|
62
73
|
end
|
|
63
74
|
|
|
75
|
+
# 固定本次等待的规则;只冻结声明容器,不冻结借用的会话与回调。
|
|
76
|
+
# @api private
|
|
77
|
+
def finalize!
|
|
78
|
+
validate!
|
|
79
|
+
groups.each do |sessions, patterns|
|
|
80
|
+
sessions.freeze
|
|
81
|
+
patterns.freeze
|
|
82
|
+
end
|
|
83
|
+
groups.each(&:freeze).freeze
|
|
84
|
+
@default_sessions.freeze
|
|
85
|
+
freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
64
88
|
private
|
|
65
89
|
|
|
66
90
|
# 校验并复制来源列表,将模式加入相邻的相同来源组或新建组。
|
|
@@ -69,7 +93,7 @@ class Expect
|
|
|
69
93
|
validate_sessions!(sessions)
|
|
70
94
|
pattern = build(value, callback)
|
|
71
95
|
# 仅合并相邻的相同来源,保持声明顺序;跨组复用会话由引擎去重读取。
|
|
72
|
-
if groups.last&.first
|
|
96
|
+
if same_sessions?(groups.last&.first, sessions)
|
|
73
97
|
groups.last.last << pattern
|
|
74
98
|
else
|
|
75
99
|
groups << [sessions, [pattern]]
|
|
@@ -77,10 +101,16 @@ class Expect
|
|
|
77
101
|
self
|
|
78
102
|
end
|
|
79
103
|
|
|
104
|
+
# 会话的业务相等性不能合并不同读取源;来源对象及排列都必须相同。
|
|
105
|
+
def same_sessions?(previous, sessions)
|
|
106
|
+
previous && previous.size == sessions.size &&
|
|
107
|
+
previous.each_with_index.all? { |session, index| session.equal?(sessions[index]) }
|
|
108
|
+
end
|
|
109
|
+
|
|
80
110
|
# 按注册顺序分配从 1 开始的序号,文本模式与事件共用编号。
|
|
81
111
|
def build(value, callback)
|
|
82
112
|
@number += 1
|
|
83
|
-
Pattern.new(number: @number, value
|
|
113
|
+
Pattern.new(number: @number, value:, callback:)
|
|
84
114
|
end
|
|
85
115
|
|
|
86
116
|
# 拒绝空来源和非 Expect 对象,在任何 IO 读取之前暴露调用错误。
|