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/interaction.rb
CHANGED
|
@@ -5,11 +5,13 @@ require_relative "relay"
|
|
|
5
5
|
|
|
6
6
|
# 为会话补充人工接管和多路 IO 转接;核心会话定义位于 lib/expect.rb。
|
|
7
7
|
class Expect
|
|
8
|
+
# 正则没有“潜在部分匹配”接口,只保留有限历史;已转发的历史字节不能撤回。
|
|
8
9
|
REGEXP_ESCAPE_HISTORY_LIMIT = 65_536
|
|
9
10
|
private_constant :REGEXP_ESCAPE_HISTORY_LIMIT
|
|
10
11
|
|
|
11
|
-
#
|
|
12
|
+
# 在 raw 本地终端显示远端文本,补齐 LF 所需的 CR,同时保留已有 CRLF。
|
|
12
13
|
class InteractOutput
|
|
14
|
+
# 包装器借用目标,不复制或关闭其描述符;换行状态属于这个目标的连续显示流。
|
|
13
15
|
def initialize(target)
|
|
14
16
|
@target = target
|
|
15
17
|
@previous_carriage_return = false
|
|
@@ -17,6 +19,7 @@ class Expect
|
|
|
17
19
|
|
|
18
20
|
attr_reader :target
|
|
19
21
|
|
|
22
|
+
# 只改变显示字节,不改写匹配输入;记住上一块的 CR,避免分块的 CRLF 被扩成 CRCRLF。
|
|
20
23
|
def render(data)
|
|
21
24
|
bytes = data.to_s.b
|
|
22
25
|
rendered = bytes.gsub(/(?<!\r)\n/n, "\r\n")
|
|
@@ -25,6 +28,7 @@ class Expect
|
|
|
25
28
|
rendered
|
|
26
29
|
end
|
|
27
30
|
|
|
31
|
+
# 提供普通可写对象接口;Relay 会先 render,再按转换后字节数维护独立发送游标。
|
|
28
32
|
def write(data)
|
|
29
33
|
@target.write(render(data))
|
|
30
34
|
end
|
|
@@ -169,10 +173,12 @@ class Expect
|
|
|
169
173
|
return block_given? && count.positive? ? :pending : true
|
|
170
174
|
end
|
|
171
175
|
end
|
|
176
|
+
|
|
172
177
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
173
178
|
|
|
174
179
|
private_class_method :relay_buffer
|
|
175
180
|
|
|
181
|
+
# 同一输入 IO 重用一个借用会话,以保留上次接管预读的尾部并避免不断积累包装器。
|
|
176
182
|
def interact_source(input)
|
|
177
183
|
return input if input.is_a?(Expect)
|
|
178
184
|
|
|
@@ -181,6 +187,8 @@ class Expect
|
|
|
181
187
|
@interact_inputs[input] ||= Expect.open(input)
|
|
182
188
|
end
|
|
183
189
|
|
|
190
|
+
# 为当前数据块冻结目标选择并各建一个发送游标;此后修改 listeners 只影响后续数据。
|
|
191
|
+
# 调用方须先排空旧游标;显示转换也只做一次,短写重试时不能重复转换 CRLF。
|
|
184
192
|
def queue_output(data)
|
|
185
193
|
targets = []
|
|
186
194
|
targets << $stdout if log_stdout?
|
|
@@ -196,7 +204,7 @@ class Expect
|
|
|
196
204
|
|
|
197
205
|
private :interact_source, :queue_output
|
|
198
206
|
|
|
199
|
-
#
|
|
207
|
+
# 只有 interact 知道哪个流是本地键盘;通用 interconnect 不修改终端模式。
|
|
200
208
|
def prepare_interact_terminal(source)
|
|
201
209
|
return unless source.raw_terminal? && source.tty?
|
|
202
210
|
|
|
@@ -204,11 +212,12 @@ class Expect
|
|
|
204
212
|
state = [io, io.console_mode]
|
|
205
213
|
io.raw!
|
|
206
214
|
state
|
|
207
|
-
rescue Exception # rubocop:disable Lint/RescueException --
|
|
215
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 中断时也恢复可能已部分修改的终端。
|
|
208
216
|
restore_interact_terminal(state)
|
|
209
217
|
raise
|
|
210
218
|
end
|
|
211
219
|
|
|
220
|
+
# 还原 prepare 保存的完整终端模式;没有切换过或句柄已关闭时无需恢复。
|
|
212
221
|
def restore_interact_terminal(state)
|
|
213
222
|
return unless state
|
|
214
223
|
|
|
@@ -216,6 +225,7 @@ class Expect
|
|
|
216
225
|
io.console_mode = mode unless io.closed?
|
|
217
226
|
end
|
|
218
227
|
|
|
228
|
+
# 仅为已切为 raw 的同一个本地终端补齐换行,文件、管道和其他终端保留原字节。
|
|
219
229
|
def interact_display(source, output, terminal_state)
|
|
220
230
|
@interact_output = nil unless terminal_state && @interact_output&.target.equal?(output)
|
|
221
231
|
return output unless terminal_state
|
|
@@ -238,7 +248,11 @@ class Expect
|
|
|
238
248
|
attr_accessor :sequences
|
|
239
249
|
# 让同步写入的背压读取遵守当前转接的数据所有权,退出后恢复普通匹配缓冲。
|
|
240
250
|
attr_accessor :interaction_buffer
|
|
251
|
+
# 只有裁剪、替换和消费才改变代次;同一代次只会追加,供字面扫描复用已排除的前缀。
|
|
252
|
+
attr_reader :buffer_generation
|
|
253
|
+
# 待交付游标随源会话保存,Relay 的超时或异常退出不会丢失各目标已经写出的进度。
|
|
241
254
|
attr_reader :relay_outputs
|
|
255
|
+
# 数组包装区分“没有待执行回调”与“已识别无处理器的停止转义”,前缀交付后只派发一次。
|
|
242
256
|
attr_accessor :relay_callback
|
|
243
257
|
|
|
244
258
|
# 历史属于产生它的转义规则;同规则重入继续匹配,换规则不能重放已转发输入。
|
|
@@ -252,5 +266,6 @@ class Expect
|
|
|
252
266
|
# 转接尚未处理的输入不能被匹配窗口上限裁掉;下次 expect 会重新应用该上限。
|
|
253
267
|
def restore_relay_buffer(buffer)
|
|
254
268
|
@buffer = buffer + @buffer
|
|
269
|
+
@buffer_generation += 1
|
|
255
270
|
end
|
|
256
271
|
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "redactor"
|
|
4
|
+
|
|
5
|
+
# 管理日志目标、监听器及同步输出;资源所有权仍由会话的 SessionResources 统一保存。
|
|
6
|
+
# 接收日志、诊断和协议转发是三条独立通道;脱敏只改变前两条,不改变匹配或转发字节。
|
|
7
|
+
class Expect
|
|
8
|
+
# 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
|
|
9
|
+
attr_reader :log_output, :diagnostic_output
|
|
10
|
+
|
|
11
|
+
# 诊断目标仅借用;nil 沿用 stderr,Logger 使用 info/debug,回调接收冻结的事件 Hash。
|
|
12
|
+
# 先校验再冲刷旧流;校验或冲刷失败时保留旧目标,避免把暂存尾部交给错误的接收方。
|
|
13
|
+
def diagnostic_output=(target)
|
|
14
|
+
unless target.nil? || (target.respond_to?(:info) && target.respond_to?(:debug)) ||
|
|
15
|
+
target.respond_to?(:write) || target.respond_to?(:call)
|
|
16
|
+
raise ArgumentError, "diagnostic output must support info/debug, write or call, or be nil"
|
|
17
|
+
end
|
|
18
|
+
return if @diagnostic_output.equal?(target)
|
|
19
|
+
|
|
20
|
+
flush_diagnostics
|
|
21
|
+
@diagnostic_output = target
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 秘密仅作用于本会话的日志和诊断,不改写匹配、stdout 显示或 listeners 的协议字节。
|
|
25
|
+
# 先校验全部值再发布;注册是追加操作,应在首次通信前完成,不能追溯已交付的日志。
|
|
26
|
+
def redact(*secrets)
|
|
27
|
+
unless secrets.any? && secrets.all? { |secret| secret.is_a?(String) && !secret.empty? }
|
|
28
|
+
raise ArgumentError, "secrets must be nonempty Strings"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@secrets = ((@secrets || []) + secrets.map { |secret| secret.b.freeze }).uniq.freeze
|
|
32
|
+
@log_redactor.patterns = @secrets if @log_redactor
|
|
33
|
+
@diagnostic_redactors&.each_value { |redactor| redactor.patterns = @secrets }
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
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"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
replace_log(target)
|
|
44
|
+
end
|
|
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
|
|
49
|
+
|
|
50
|
+
target = block if block
|
|
51
|
+
if target.respond_to?(:to_path) || target.is_a?(String)
|
|
52
|
+
raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
|
|
53
|
+
|
|
54
|
+
# 先交付旧过滤尾部,再允许新路径截断;同一文件不能在截断后被旧句柄写回。
|
|
55
|
+
flush_log
|
|
56
|
+
# 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
|
|
57
|
+
replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
|
|
58
|
+
else
|
|
59
|
+
raise ArgumentError, "provide a log target or a block" unless target
|
|
60
|
+
|
|
61
|
+
self.log_output = target
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
|
|
66
|
+
# 启用脱敏后可能暂存末尾字节,因此一次调用不保证触发一次日志写入或回调。
|
|
67
|
+
def write_log(*objects)
|
|
68
|
+
target = log_output
|
|
69
|
+
return unless target
|
|
70
|
+
|
|
71
|
+
data = objects.map { |object| object.to_s.b }.join
|
|
72
|
+
if @secrets
|
|
73
|
+
@log_redactor ||= Redactor.new(@secrets)
|
|
74
|
+
data = @log_redactor.append(data)
|
|
75
|
+
return if data.empty?
|
|
76
|
+
end
|
|
77
|
+
target.respond_to?(:call) ? target.call(data) : emit(target, data)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# 返回监听器列表副本,避免外部原地修改转发关系。
|
|
81
|
+
def listeners = @listeners.dup
|
|
82
|
+
|
|
83
|
+
# 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
|
|
84
|
+
def listeners=(outputs)
|
|
85
|
+
outputs = Array(outputs)
|
|
86
|
+
raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
|
|
87
|
+
|
|
88
|
+
@listeners = outputs.dup
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
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
|
|
98
|
+
|
|
99
|
+
# 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
|
|
100
|
+
# 这里遵循同步写入协议,不受 Matcher 的 IO 等待期限中断;慢目标转接应使用 Relay。
|
|
101
|
+
# 短写只推进已确认的字节数;目标抛错时不猜测它是否已经产生副作用。
|
|
102
|
+
def emit(target, data)
|
|
103
|
+
offset = 0
|
|
104
|
+
while offset < data.bytesize
|
|
105
|
+
count = target.write(data.byteslice(offset..))
|
|
106
|
+
unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
|
|
107
|
+
raise IOError, "write must return the number of accepted bytes"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
offset += count
|
|
111
|
+
end
|
|
112
|
+
target.flush if target.respond_to?(:flush)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# 字节诊断在 inspect 转义之前过滤;缓冲快照可能只含秘密中间片段,启用脱敏时不展开。
|
|
116
|
+
def trace_data(event, data, level:)
|
|
117
|
+
if @secrets
|
|
118
|
+
return trace("buffer [FILTERED]", level: level, event: event) if event == :buffer
|
|
119
|
+
|
|
120
|
+
@diagnostic_redactors ||= {}
|
|
121
|
+
redactor = (@diagnostic_redactors[event] ||= Redactor.new(@secrets))
|
|
122
|
+
data = redactor.append(data)
|
|
123
|
+
return if data.empty?
|
|
124
|
+
end
|
|
125
|
+
trace("#{event} #{data.inspect}", level: level, event: event)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# 诊断与接收字节日志分开;回调元数据不含会话对象,避免格式化时意外展开原始缓冲。
|
|
129
|
+
def trace(message, level: 1, event: :matched)
|
|
130
|
+
return unless debug_level >= level
|
|
131
|
+
|
|
132
|
+
severity = level == 1 ? :info : :debug
|
|
133
|
+
target = diagnostic_output
|
|
134
|
+
if target.respond_to?(:info) && target.respond_to?(:debug)
|
|
135
|
+
target.public_send(severity, "#{inspect}: #{message}")
|
|
136
|
+
elsif target.respond_to?(:call)
|
|
137
|
+
target.call({ event: event, level: severity, pid: pid, fd: fileno, message: message.freeze }.freeze)
|
|
138
|
+
elsif target
|
|
139
|
+
emit(target, "#{inspect}: #{message}\n")
|
|
140
|
+
else
|
|
141
|
+
warn("#{inspect}: #{message}")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 结束当前日志片段并交付过滤器尾部;重复调用不会重放已释放字节,写入异常原样传播。
|
|
146
|
+
def flush_log
|
|
147
|
+
loop do
|
|
148
|
+
return unless @log_redactor && log_output
|
|
149
|
+
|
|
150
|
+
data = @log_redactor.finish
|
|
151
|
+
return if data.empty?
|
|
152
|
+
|
|
153
|
+
log_output.respond_to?(:call) ? log_output.call(data) : emit(log_output, data)
|
|
154
|
+
# 回调可能轮换目标并追加新尾部;先结束它,再允许外层截断文件或关闭日志。
|
|
155
|
+
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
|
+
|
|
172
|
+
delivered = true
|
|
173
|
+
trace("#{name} #{data.inspect}", level: 2, event: name)
|
|
174
|
+
end
|
|
175
|
+
# 目标尚未交接,新产生的尾部仍归旧诊断流;排空后才结束这次冲刷。
|
|
176
|
+
break unless delivered
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
|
|
181
|
+
def replace_log(target, owned: false)
|
|
182
|
+
# 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
|
|
183
|
+
return target if log_output.equal?(target)
|
|
184
|
+
|
|
185
|
+
flush_log
|
|
186
|
+
# 尾部回调可以重入并替换目标;交接时重新读取目标和所有权,不能关闭旧的回调对象。
|
|
187
|
+
return target if log_output.equal?(target)
|
|
188
|
+
|
|
189
|
+
previous = @resources.owned_log
|
|
190
|
+
previous.close if previous && !previous.closed?
|
|
191
|
+
# 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
|
|
192
|
+
@resources.owned_log = owned ? target : nil
|
|
193
|
+
@log_output = target
|
|
194
|
+
@log_redactor = nil
|
|
195
|
+
target
|
|
196
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
|
|
197
|
+
target.close if owned && target && !target.closed?
|
|
198
|
+
raise
|
|
199
|
+
end
|
|
200
|
+
end
|
data/lib/expect/matcher.rb
CHANGED
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 驱动一次单会话或多会话匹配,管理模式优先级、EOF 和共享期限,不接管 IO 所有权。
|
|
5
|
+
# 调度只在扫描、回调和 IO 操作之间检查期限,不强行中断用户代码或单次正则计算。
|
|
5
6
|
class Matcher
|
|
6
7
|
# 固定本次参与的会话及初始期限;已处理 EOF 的会话仅从本次等待中移除。
|
|
7
|
-
def initialize(patterns, timeout)
|
|
8
|
+
def initialize(patterns, timeout, deadline: nil)
|
|
8
9
|
@patterns = patterns
|
|
9
10
|
@sessions = patterns.sessions
|
|
10
11
|
@timeout = Expect.duration(timeout)
|
|
12
|
+
# 相对期限可因接收或 continue 重算,总期限始终固定;两者共用单调时钟。
|
|
13
|
+
@hard_deadline = deadline
|
|
11
14
|
@deadline = next_deadline
|
|
12
15
|
@handled_eof = []
|
|
13
16
|
@stalled_matches = {}
|
|
14
17
|
@polled = false
|
|
18
|
+
@expired_eof_continuation = false
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
# 运行匹配状态机;内部 :retry 表示继续循环,最终返回一个 Result。
|
|
@@ -30,16 +34,19 @@ class Expect
|
|
|
30
34
|
end
|
|
31
35
|
loop do
|
|
32
36
|
# 先消费已缓冲的匹配,再处理 EOF,最后读取;避免进程退出时丢失最后一个匹配。
|
|
33
|
-
result = if (matched = find_match)
|
|
37
|
+
result = if !@expired_eof_continuation && !hard_expired? && (matched = find_match)
|
|
34
38
|
handle_match(*matched)
|
|
35
39
|
elsif (session = unhandled_eof)
|
|
36
40
|
handle_eof(session)
|
|
41
|
+
elsif @expired_eof_continuation || hard_expired?
|
|
42
|
+
handle_timeout
|
|
37
43
|
else
|
|
38
44
|
read_next
|
|
39
45
|
end
|
|
40
46
|
return result unless result == :retry
|
|
41
47
|
end
|
|
42
48
|
ensure
|
|
49
|
+
# 嵌套 expect 即使异常退出,也要把未消费尾部还给原 Relay 的同一个缓冲对象。
|
|
43
50
|
@relay_buffers.each do |session, buffer|
|
|
44
51
|
buffer.replace(session.clear_buffer)
|
|
45
52
|
session.__send__(:interaction_buffer=, buffer)
|
|
@@ -67,17 +74,52 @@ class Expect
|
|
|
67
74
|
stalled = nil
|
|
68
75
|
end
|
|
69
76
|
patterns.each do |pattern|
|
|
77
|
+
return nil if hard_expired?
|
|
70
78
|
next if stalled && stalled[:patterns].include?(pattern)
|
|
71
79
|
|
|
72
|
-
position = pattern.
|
|
80
|
+
position = if pattern.value.is_a?(String)
|
|
81
|
+
locate_literal(session, pattern, buffer)
|
|
82
|
+
else
|
|
83
|
+
pattern.locate(buffer, final: session.eof?)
|
|
84
|
+
end
|
|
85
|
+
# 正则本身不可由 IO 期限中断;恢复控制后也不能消费已过总期限的匹配。
|
|
86
|
+
return nil if hard_expired?
|
|
73
87
|
return [session, pattern, position] if position
|
|
74
88
|
end
|
|
75
89
|
end
|
|
76
90
|
end
|
|
77
91
|
nil
|
|
78
92
|
end
|
|
93
|
+
|
|
79
94
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
80
95
|
|
|
96
|
+
# 仅复用同一缓冲代次中的字面未命中。保留模式长度减一的重叠区,覆盖跨读取命中。
|
|
97
|
+
# 不保存整个文本,也不对正则推断扫描窗口;回调替换、消费或转接恢复会改变代次。
|
|
98
|
+
def locate_literal(session, pattern, buffer)
|
|
99
|
+
@literal_misses ||= {}.compare_by_identity
|
|
100
|
+
misses = (@literal_misses[session] ||= {}.compare_by_identity)
|
|
101
|
+
previous = misses[pattern]
|
|
102
|
+
generation = session.__send__(:buffer_generation)
|
|
103
|
+
# 缓冲代次未变意味着只有尾部追加;模式对象的 value 被替换时也不能沿用旧扫描位置。
|
|
104
|
+
offset = if previous && previous[0] == generation && previous[2].equal?(pattern.value)
|
|
105
|
+
return nil if previous[1] == buffer.bytesize
|
|
106
|
+
|
|
107
|
+
[previous[1] - pattern.value.bytesize + 1, 0].max
|
|
108
|
+
else
|
|
109
|
+
0
|
|
110
|
+
end
|
|
111
|
+
position = pattern.locate(buffer, offset: offset)
|
|
112
|
+
if position
|
|
113
|
+
misses.delete(pattern)
|
|
114
|
+
else
|
|
115
|
+
entry = (misses[pattern] ||= [])
|
|
116
|
+
entry[0] = generation
|
|
117
|
+
entry[1] = buffer.bytesize
|
|
118
|
+
entry[2] = pattern.value
|
|
119
|
+
end
|
|
120
|
+
position
|
|
121
|
+
end
|
|
122
|
+
|
|
81
123
|
# 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
|
|
82
124
|
def handle_match(session, pattern, position)
|
|
83
125
|
previous_buffer = session.buffer
|
|
@@ -91,7 +133,8 @@ class Expect
|
|
|
91
133
|
stalled[:patterns] << pattern
|
|
92
134
|
end
|
|
93
135
|
@deadline = next_deadline if action == CONTINUE
|
|
94
|
-
|
|
136
|
+
# 总期限到达时回主循环先派发已知 EOF;仅相对期限延续原有立即超时语义。
|
|
137
|
+
return handle_timeout if action == CONTINUE_WITHOUT_RESET && expired? && !hard_expired?
|
|
95
138
|
|
|
96
139
|
:retry
|
|
97
140
|
end
|
|
@@ -109,12 +152,17 @@ class Expect
|
|
|
109
152
|
return result unless actions.any? { |action| continuing?(action) }
|
|
110
153
|
|
|
111
154
|
@deadline = next_deadline if actions.include?(CONTINUE)
|
|
112
|
-
@sessions.all? { |candidate| @handled_eof.include?(candidate) }
|
|
155
|
+
return result if @sessions.all? { |candidate| @handled_eof.include?(candidate) }
|
|
156
|
+
|
|
157
|
+
# 期限已过时不再扫描文本,但先派发已知 EOF;最后一个源结束不能被误报为超时。
|
|
158
|
+
@expired_eof_continuation = !actions.include?(CONTINUE) && expired?
|
|
159
|
+
|
|
160
|
+
:retry
|
|
113
161
|
end
|
|
114
162
|
|
|
115
163
|
# 在剩余期限内等待可读 IO;零超时仍允许首次非阻塞轮询,EINTR 重试不重新计时。
|
|
116
164
|
def read_next
|
|
117
|
-
return handle_timeout if @polled && expired?
|
|
165
|
+
return handle_timeout if hard_expired? || (@polled && expired?)
|
|
118
166
|
|
|
119
167
|
readers = active_sessions
|
|
120
168
|
begin
|
|
@@ -139,6 +187,8 @@ class Expect
|
|
|
139
187
|
sessions.each { |session| by_io[session.to_io] ||= session }
|
|
140
188
|
end
|
|
141
189
|
readable.each do |io|
|
|
190
|
+
break if hard_expired?
|
|
191
|
+
|
|
142
192
|
session = by_io ? by_io.fetch(io) : sessions.find { |candidate| candidate.to_io.equal?(io) }
|
|
143
193
|
begin
|
|
144
194
|
# 转接回调消费匹配内容,余下字节交回 Relay,不能在这里提前转发两次。
|
|
@@ -161,7 +211,14 @@ class Expect
|
|
|
161
211
|
def continuing?(action) = [CONTINUE, CONTINUE_WITHOUT_RESET].include?(action)
|
|
162
212
|
|
|
163
213
|
# 使用单调时钟计算期限;nil 一直表示无限等待,不受系统时间调整影响。
|
|
164
|
-
|
|
214
|
+
# 每次重置都重新与总期限取较早者,避免连续输入或继续回调无限推迟结束。
|
|
215
|
+
def next_deadline
|
|
216
|
+
relative = @timeout && (Expect.monotonic + @timeout)
|
|
217
|
+
return @hard_deadline unless relative
|
|
218
|
+
return relative unless @hard_deadline
|
|
219
|
+
|
|
220
|
+
[relative, @hard_deadline].min
|
|
221
|
+
end
|
|
165
222
|
|
|
166
223
|
# 计算传给 select 的非负等待秒数,避免计时跨过边界时产生负数。
|
|
167
224
|
def remaining = @deadline && [@deadline - Expect.monotonic, 0].max
|
|
@@ -169,19 +226,24 @@ class Expect
|
|
|
169
226
|
# 判断有限期限是否已到达;无限等待不会触发超时。
|
|
170
227
|
def expired? = @deadline && Expect.monotonic >= @deadline
|
|
171
228
|
|
|
229
|
+
# 绝对总期限不受接收数据和 continue 重置;已知 EOF 仍按原顺序派发。
|
|
230
|
+
def hard_expired? = @hard_deadline && Expect.monotonic >= @hard_deadline
|
|
231
|
+
|
|
172
232
|
# select 失败时无法归属单个源,为本次会话记录同一原始异常并返回首个结果。
|
|
173
233
|
def record_error(error)
|
|
174
234
|
@sessions.map { |session| session.__send__(:record_error, error) }.first
|
|
175
235
|
end
|
|
176
236
|
|
|
177
237
|
# 为活跃会话记录超时,回调接收全部活跃源;只有重置计时的继续符号能重新等待。
|
|
238
|
+
# 已到总期限仍通知超时回调,但不接受继续请求,且不消费尚未匹配的字节。
|
|
178
239
|
def handle_timeout
|
|
179
240
|
results = active_sessions.map { |session| session.__send__(:record_error, :timeout) }
|
|
180
241
|
action = @patterns.timeout_pattern&.call(active_sessions)
|
|
181
|
-
return results.first unless action == CONTINUE
|
|
242
|
+
return results.first unless action == CONTINUE && !hard_expired?
|
|
182
243
|
|
|
183
244
|
@deadline = next_deadline
|
|
184
245
|
@polled = false
|
|
246
|
+
@expired_eof_continuation = false
|
|
185
247
|
:retry
|
|
186
248
|
end
|
|
187
249
|
end
|
data/lib/expect/pattern.rb
CHANGED
|
@@ -12,14 +12,20 @@ class Expect
|
|
|
12
12
|
def eof? = value == :eof
|
|
13
13
|
|
|
14
14
|
# 在缓冲中定位字符串或正则,返回 [字节偏移, 字节长度, 捕获组];事件或未匹配返回 nil。
|
|
15
|
-
|
|
15
|
+
# buffer 应为二进制字符串,offset 仅用于字面扫描;正则始终看到完整窗口以保留锚点语义。
|
|
16
|
+
# final 表示不会再有新输入,此时不完整的编码尾部也必须报错,不能永远当作等待分片。
|
|
17
|
+
def locate(buffer, final: false, offset: 0)
|
|
16
18
|
case value
|
|
17
19
|
when String
|
|
18
|
-
offset = buffer.index(value)
|
|
20
|
+
offset = buffer.index(value, offset)
|
|
19
21
|
return [offset, value.bytesize, []] if offset
|
|
20
22
|
when Regexp
|
|
21
|
-
|
|
22
|
-
text.
|
|
23
|
+
# 正则只读取输入;只有编码标记不同才复制,避免每个模式额外分配缓冲对象。
|
|
24
|
+
text = if value.fixed_encoding? && value.encoding != buffer.encoding
|
|
25
|
+
buffer.dup.force_encoding(value.encoding)
|
|
26
|
+
else
|
|
27
|
+
buffer
|
|
28
|
+
end
|
|
23
29
|
unless text.valid_encoding?
|
|
24
30
|
# 不完整的尾字符可能改变锚点或前瞻结果,必须等字符收齐后再匹配。
|
|
25
31
|
validate_incomplete_suffix!(text, final: final)
|
|
@@ -38,6 +44,7 @@ class Expect
|
|
|
38
44
|
private
|
|
39
45
|
|
|
40
46
|
# 仅容忍末尾尚未收全的 UTF-8 字符,其他非法编码直接报错,不静默替换接收字节。
|
|
47
|
+
# 转码仅用于区分“不完整尾部”和“非法字节”,结果不回写缓冲,也不做编码归一化。
|
|
41
48
|
def validate_incomplete_suffix!(text, final:)
|
|
42
49
|
if !final && text.encoding == Encoding::UTF_8
|
|
43
50
|
incomplete = begin
|
data/lib/expect/pattern_list.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 将 Ruby 模式和块回调整理为有序会话组;注册阶段不读取 IO、不执行匹配回调。
|
|
5
|
+
# 分组结构直接决定 Matcher 的“声明组 → 会话 → 模式”优先级,不能按匹配位置重排。
|
|
5
6
|
class PatternList
|
|
6
7
|
attr_reader :groups, :timeout_pattern
|
|
7
8
|
|
|
@@ -34,6 +35,7 @@ class Expect
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
# 注册一次等待的唯一超时回调,重复定义直接报错,避免悄悄覆盖业务处理。
|
|
38
|
+
# 超时是整次等待的事件,回调收到全部活跃会话,不归属于某一个来源组。
|
|
37
39
|
def timeout(&block)
|
|
38
40
|
raise ArgumentError, "timeout callback already registered" if @timeout_pattern
|
|
39
41
|
|
|
@@ -42,6 +44,7 @@ class Expect
|
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
# 汇总并去重读取源,同一会话出现在多个模式组时仍只读取一次。
|
|
47
|
+
# 这里只去重会话对象;不同会话包装同一 IO 时的读取归属由 Matcher 决定。
|
|
45
48
|
def sessions = groups.flat_map(&:first).uniq
|
|
46
49
|
|
|
47
50
|
# 收集指定会话的所有 EOF 处理器,保留原注册顺序。
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Expect
|
|
4
|
+
# 单个日志字节流的过滤器。保留最长秘密长度减一的尾部,跨 write/read 分片仍可识别。
|
|
5
|
+
# 掩码与原字节一起留存;重叠命中的区间取并集,已经输出的掩码不重复生成。
|
|
6
|
+
# 上层负责传入已复制的非空二进制秘密,每个日志目标或诊断方向使用独立实例。
|
|
7
|
+
class Redactor
|
|
8
|
+
# pending 保存尚不能安全输出的原字节,hidden 的对应字节用 0/1 表示是否需要遮盖。
|
|
9
|
+
def initialize(patterns)
|
|
10
|
+
self.patterns = patterns
|
|
11
|
+
@pending = "".b
|
|
12
|
+
@hidden = "".b
|
|
13
|
+
@masking = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# 更新后续匹配规则并保留已有尾部与掩码;不能追溯修改已经交付给日志目标的内容。
|
|
17
|
+
def patterns=(patterns)
|
|
18
|
+
@patterns = patterns
|
|
19
|
+
@lookbehind = patterns.map(&:bytesize).max - 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 追加一个原始字节块,返回已经可以确定的安全前缀;新秘密可能跨越此前保留的尾部。
|
|
23
|
+
def append(data)
|
|
24
|
+
@pending << data
|
|
25
|
+
@hidden << ("\0" * data.bytesize)
|
|
26
|
+
mark_secrets
|
|
27
|
+
release([@pending.bytesize - @lookbehind, 0].max)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# EOF、日志目标替换及关闭是流边界;尾部疑似秘密前缀也遮盖,不能因 flush 泄露片段。
|
|
31
|
+
def finish
|
|
32
|
+
mark_secrets
|
|
33
|
+
@patterns.each do |pattern|
|
|
34
|
+
[pattern.bytesize - 1, @pending.bytesize].min.downto(1) do |length|
|
|
35
|
+
next unless @pending.end_with?(pattern.byteslice(0, length))
|
|
36
|
+
|
|
37
|
+
@hidden[-length, length] = "\1" * length
|
|
38
|
+
break
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
output = release(@pending.bytesize)
|
|
42
|
+
@masking = false
|
|
43
|
+
output
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# 每次只将命中区域标为隐藏,不清除旧掩码;偏移逐字节推进以识别相互重叠的秘密。
|
|
49
|
+
def mark_secrets
|
|
50
|
+
@patterns.each do |pattern|
|
|
51
|
+
offset = -1
|
|
52
|
+
while (offset = @pending.index(pattern, offset + 1))
|
|
53
|
+
@hidden[offset, pattern.bytesize] = "\1" * pattern.bytesize
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# 按连续区间输出,避免逐字节构造字符串;只保存尚可能与下一块组成秘密的后缀。
|
|
59
|
+
# masking 跨 append 保留,使被分成多个块的同一隐藏区间只输出一次 [FILTERED]。
|
|
60
|
+
def release(length)
|
|
61
|
+
output = "".b
|
|
62
|
+
cursor = 0
|
|
63
|
+
while cursor < length
|
|
64
|
+
hidden = @hidden.getbyte(cursor) == 1
|
|
65
|
+
ending = [@hidden.index(hidden ? "\0" : "\1", cursor) || length, length].min
|
|
66
|
+
if hidden
|
|
67
|
+
output << "[FILTERED]" unless @masking
|
|
68
|
+
else
|
|
69
|
+
output << @pending.byteslice(cursor, ending - cursor)
|
|
70
|
+
end
|
|
71
|
+
@masking = hidden
|
|
72
|
+
cursor = ending
|
|
73
|
+
end
|
|
74
|
+
@pending = @pending.byteslice(length..)
|
|
75
|
+
@hidden = @hidden.byteslice(length..)
|
|
76
|
+
output
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private_constant :Redactor
|
|
81
|
+
end
|
data/lib/expect/relay.rb
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 一次转接的共同读写循环;发送游标留在源会话中,调用结束后仍可继续。
|
|
5
|
+
# 本对象只暂借未处理输入;读缓冲、发送进度与转义回调分开保存,避免重入时重复交付。
|
|
5
6
|
class Relay
|
|
7
|
+
# 将普通匹配缓冲移入本轮转接,并记住上层交互缓冲;不关闭或接管任何外部 IO。
|
|
6
8
|
def initialize(sessions, timeout)
|
|
7
9
|
@sessions = sessions.uniq
|
|
8
10
|
@active = @sessions.dup
|
|
@@ -12,6 +14,8 @@ class Expect
|
|
|
12
14
|
@previous = @sessions.to_h { |session| [session, session.__send__(:interaction_buffer)] }
|
|
13
15
|
end
|
|
14
16
|
|
|
17
|
+
# 每轮先处理转义和已排队输出,再共同选择读写;停止返回来源会话,总期限到达返回 nil。
|
|
18
|
+
# 真实 IO 的写入由非阻塞游标推进,用户日志和回调仍同步执行,须由调用方保证及时返回。
|
|
15
19
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 共享读写循环统一维护来源和目标期限。
|
|
16
20
|
def run
|
|
17
21
|
@sessions.each { |session| session.__send__(:interaction_buffer=, @buffers.fetch(session)) }
|
|
@@ -85,15 +89,19 @@ class Expect
|
|
|
85
89
|
end
|
|
86
90
|
end
|
|
87
91
|
ensure
|
|
92
|
+
# 未处理输入还给会话,已排队字节仍归发送游标;两者不能合并,否则恢复会重放前缀。
|
|
88
93
|
@previous.each { |session, buffer| session.__send__(:interaction_buffer=, buffer) }
|
|
89
94
|
@buffers.each { |session, buffer| session.__send__(:restore_relay_buffer, buffer) }
|
|
90
95
|
end
|
|
96
|
+
|
|
91
97
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
92
98
|
|
|
93
99
|
private
|
|
94
100
|
|
|
101
|
+
# 只收集尚未完成的目标,供共同 select 以及最早写入期限计算使用。
|
|
95
102
|
def outputs = @sessions.flat_map { |session| session.__send__(:relay_outputs) }.reject(&:done?)
|
|
96
103
|
|
|
104
|
+
# 每个目标每轮最多推进一个片段;移除已完成游标,并告知主循环是否值得立即继续轮询。
|
|
97
105
|
def advance_outputs(check_timeout: true)
|
|
98
106
|
progress = false
|
|
99
107
|
@sessions.each do |session|
|
|
@@ -104,8 +112,10 @@ class Expect
|
|
|
104
112
|
progress
|
|
105
113
|
end
|
|
106
114
|
|
|
115
|
+
# 转接总期限不因持续输入或某个目标的写入进展而重置。
|
|
107
116
|
def expired? = @deadline && Expect.monotonic >= @deadline
|
|
108
117
|
|
|
118
|
+
# 背压源暂停吸收新输入,限制排队增长;作为写入目标的会话仍需读取以解除双向等待。
|
|
109
119
|
def read_sources(pending)
|
|
110
120
|
# 输出目标可能也在等待我们读取;这些来源即使有待发送数据也必须继续排空。
|
|
111
121
|
targets = pending.map(&:target).grep(Expect)
|
|
@@ -113,6 +123,8 @@ class Expect
|
|
|
113
123
|
(sources + targets).uniq(&:to_io).reject(&:eof?)
|
|
114
124
|
end
|
|
115
125
|
|
|
126
|
+
# 总期限结束时将可交付尾部转为发送游标,至多尝试一轮交付;余量由源会话保存。
|
|
127
|
+
# 目标的写期限更早到达时保留 WriteTimeout 语义,不能被转接的普通超时掩盖。
|
|
116
128
|
def finish_timeout(idle)
|
|
117
129
|
# 调度延迟可能让两种期限均已到达,仍按先到的期限决定结果。
|
|
118
130
|
outputs.each { |output| output.check_timeout! if output.deadline && output.deadline < @deadline }
|