expect-pty 0.3.3 → 0.5.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.
@@ -1,9 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "redactor"
4
+
3
5
  # 管理日志目标、监听器及同步输出;资源所有权仍由会话的 SessionResources 统一保存。
6
+ # 接收日志、诊断和协议转发是三条独立通道;脱敏只改变前两条,不改变匹配或转发字节。
4
7
  class Expect
5
8
  # 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
6
- attr_reader :log_output
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
7
36
 
8
37
  # 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
9
38
  def log_output=(target)
@@ -22,6 +51,8 @@ class Expect
22
51
  if target.respond_to?(:to_path) || target.is_a?(String)
23
52
  raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
24
53
 
54
+ # 先交付旧过滤尾部,再允许新路径截断;同一文件不能在截断后被旧句柄写回。
55
+ flush_log
25
56
  # 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
26
57
  replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
27
58
  else
@@ -32,11 +63,17 @@ class Expect
32
63
  end
33
64
 
34
65
  # 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
66
+ # 启用脱敏后可能暂存末尾字节,因此一次调用不保证触发一次日志写入或回调。
35
67
  def write_log(*objects)
36
68
  target = log_output
37
69
  return unless target
38
70
 
39
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
40
77
  target.respond_to?(:call) ? target.call(data) : emit(target, data)
41
78
  end
42
79
 
@@ -60,6 +97,8 @@ class Expect
60
97
  end
61
98
 
62
99
  # 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
100
+ # 这里遵循同步写入协议,不受 Matcher 的 IO 等待期限中断;慢目标转接应使用 Relay。
101
+ # 短写只推进已确认的字节数;目标抛错时不猜测它是否已经产生副作用。
63
102
  def emit(target, data)
64
103
  offset = 0
65
104
  while offset < data.bytesize
@@ -73,21 +112,86 @@ class Expect
73
112
  target.flush if target.respond_to?(:flush)
74
113
  end
75
114
 
76
- # 按诊断级别向 stderr 输出会话标识和消息。
77
- def trace(message, level: 1)
78
- warn("#{inspect}: #{message}") if debug_level >= level
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
79
178
  end
80
179
 
81
180
  # 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
82
181
  def replace_log(target, owned: false)
83
- previous = log_output
84
182
  # 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
85
- return target if previous.equal?(target)
183
+ return target if log_output.equal?(target)
184
+
185
+ flush_log
186
+ # 尾部回调可以重入并替换目标;交接时重新读取目标和所有权,不能关闭旧的回调对象。
187
+ return target if log_output.equal?(target)
86
188
 
87
- previous.close if @resources.owned_log && previous && !previous.closed?
189
+ previous = @resources.owned_log
190
+ previous.close if previous && !previous.closed?
88
191
  # 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
89
192
  @resources.owned_log = owned ? target : nil
90
193
  @log_output = target
194
+ @log_redactor = nil
91
195
  target
92
196
  rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
93
197
  target.close if owned && target && !target.closed?
@@ -2,12 +2,15 @@
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 = {}
@@ -31,11 +34,11 @@ class Expect
31
34
  end
32
35
  loop do
33
36
  # 先消费已缓冲的匹配,再处理 EOF,最后读取;避免进程退出时丢失最后一个匹配。
34
- result = if !@expired_eof_continuation && (matched = find_match)
37
+ result = if !@expired_eof_continuation && !hard_expired? && (matched = find_match)
35
38
  handle_match(*matched)
36
39
  elsif (session = unhandled_eof)
37
40
  handle_eof(session)
38
- elsif @expired_eof_continuation
41
+ elsif @expired_eof_continuation || hard_expired?
39
42
  handle_timeout
40
43
  else
41
44
  read_next
@@ -43,6 +46,7 @@ class Expect
43
46
  return result unless result == :retry
44
47
  end
45
48
  ensure
49
+ # 嵌套 expect 即使异常退出,也要把未消费尾部还给原 Relay 的同一个缓冲对象。
46
50
  @relay_buffers.each do |session, buffer|
47
51
  buffer.replace(session.clear_buffer)
48
52
  session.__send__(:interaction_buffer=, buffer)
@@ -70,17 +74,52 @@ class Expect
70
74
  stalled = nil
71
75
  end
72
76
  patterns.each do |pattern|
77
+ return nil if hard_expired?
73
78
  next if stalled && stalled[:patterns].include?(pattern)
74
79
 
75
- position = pattern.locate(buffer, final: session.eof?)
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?
76
87
  return [session, pattern, position] if position
77
88
  end
78
89
  end
79
90
  end
80
91
  nil
81
92
  end
93
+
82
94
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
83
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
+
84
123
  # 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
85
124
  def handle_match(session, pattern, position)
86
125
  previous_buffer = session.buffer
@@ -94,7 +133,8 @@ class Expect
94
133
  stalled[:patterns] << pattern
95
134
  end
96
135
  @deadline = next_deadline if action == CONTINUE
97
- return handle_timeout if action == CONTINUE_WITHOUT_RESET && expired?
136
+ # 总期限到达时回主循环先派发已知 EOF;仅相对期限延续原有立即超时语义。
137
+ return handle_timeout if action == CONTINUE_WITHOUT_RESET && expired? && !hard_expired?
98
138
 
99
139
  :retry
100
140
  end
@@ -122,7 +162,7 @@ class Expect
122
162
 
123
163
  # 在剩余期限内等待可读 IO;零超时仍允许首次非阻塞轮询,EINTR 重试不重新计时。
124
164
  def read_next
125
- return handle_timeout if @polled && expired?
165
+ return handle_timeout if hard_expired? || (@polled && expired?)
126
166
 
127
167
  readers = active_sessions
128
168
  begin
@@ -147,6 +187,8 @@ class Expect
147
187
  sessions.each { |session| by_io[session.to_io] ||= session }
148
188
  end
149
189
  readable.each do |io|
190
+ break if hard_expired?
191
+
150
192
  session = by_io ? by_io.fetch(io) : sessions.find { |candidate| candidate.to_io.equal?(io) }
151
193
  begin
152
194
  # 转接回调消费匹配内容,余下字节交回 Relay,不能在这里提前转发两次。
@@ -169,7 +211,14 @@ class Expect
169
211
  def continuing?(action) = [CONTINUE, CONTINUE_WITHOUT_RESET].include?(action)
170
212
 
171
213
  # 使用单调时钟计算期限;nil 一直表示无限等待,不受系统时间调整影响。
172
- def next_deadline = @timeout && (Expect.monotonic + @timeout)
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
173
222
 
174
223
  # 计算传给 select 的非负等待秒数,避免计时跨过边界时产生负数。
175
224
  def remaining = @deadline && [@deadline - Expect.monotonic, 0].max
@@ -177,16 +226,20 @@ class Expect
177
226
  # 判断有限期限是否已到达;无限等待不会触发超时。
178
227
  def expired? = @deadline && Expect.monotonic >= @deadline
179
228
 
229
+ # 绝对总期限不受接收数据和 continue 重置;已知 EOF 仍按原顺序派发。
230
+ def hard_expired? = @hard_deadline && Expect.monotonic >= @hard_deadline
231
+
180
232
  # select 失败时无法归属单个源,为本次会话记录同一原始异常并返回首个结果。
181
233
  def record_error(error)
182
234
  @sessions.map { |session| session.__send__(:record_error, error) }.first
183
235
  end
184
236
 
185
237
  # 为活跃会话记录超时,回调接收全部活跃源;只有重置计时的继续符号能重新等待。
238
+ # 已到总期限仍通知超时回调,但不接受继续请求,且不消费尚未匹配的字节。
186
239
  def handle_timeout
187
240
  results = active_sessions.map { |session| session.__send__(:record_error, :timeout) }
188
241
  action = @patterns.timeout_pattern&.call(active_sessions)
189
- return results.first unless action == CONTINUE
242
+ return results.first unless action == CONTINUE && !hard_expired?
190
243
 
191
244
  @deadline = next_deadline
192
245
  @polled = false
@@ -12,10 +12,12 @@ class Expect
12
12
  def eof? = value == :eof
13
13
 
14
14
  # 在缓冲中定位字符串或正则,返回 [字节偏移, 字节长度, 捕获组];事件或未匹配返回 nil。
15
- def locate(buffer, final: false)
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
23
  # 正则只读取输入;只有编码标记不同才复制,避免每个模式额外分配缓冲对象。
@@ -42,6 +44,7 @@ class Expect
42
44
  private
43
45
 
44
46
  # 仅容忍末尾尚未收全的 UTF-8 字符,其他非法编码直接报错,不静默替换接收字节。
47
+ # 转码仅用于区分“不完整尾部”和“非法字节”,结果不回写缓冲,也不做编码归一化。
45
48
  def validate_incomplete_suffix!(text, final:)
46
49
  if !final && text.encoding == Encoding::UTF_8
47
50
  incomplete = begin
@@ -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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Expect
4
+ # 单个日志字节流的过滤器。保留最长秘密长度减一的尾部,跨 write/read 分片仍可识别。
5
+ # 掩码与原字节一起留存;重叠命中的区间取并集,已经输出的掩码不重复生成。
6
+ # 不依赖会话或 IO;每个日志目标或诊断方向使用独立实例。
7
+ class Redactor
8
+ # 完整诊断文本只匹配完整秘密;流边界的疑似秘密前缀由 finish 的默认策略保护。
9
+ def self.redact(data, patterns, replacement: "[FILTERED]")
10
+ filter = new(patterns, replacement: replacement)
11
+ filter.append(data) + filter.finish(partial: false)
12
+ end
13
+
14
+ # pending 保存尚不能安全输出的原字节,hidden 的对应字节用 0/1 表示是否需要遮盖。
15
+ def initialize(patterns, replacement: "[FILTERED]")
16
+ unless replacement.is_a?(String) && !replacement.empty?
17
+ raise ArgumentError, "replacement must be a nonempty String"
18
+ end
19
+
20
+ @replacement = replacement.b.freeze
21
+ self.patterns = patterns
22
+ @pending = "".b
23
+ @hidden = "".b
24
+ @masking = false
25
+ end
26
+
27
+ # 更新后续匹配规则并保留已有尾部与掩码;不能追溯修改已经交付给日志目标的内容。
28
+ def patterns=(patterns)
29
+ unless patterns.is_a?(Array) && patterns.all? { |pattern| pattern.is_a?(String) && !pattern.empty? }
30
+ raise ArgumentError, "patterns must be an Array of nonempty Strings"
31
+ end
32
+
33
+ @patterns = patterns.map { |pattern| pattern.b.freeze }.uniq.freeze
34
+ @lookbehind = [(@patterns.map(&:bytesize).max || 0) - 1, 0].max
35
+ end
36
+
37
+ # 追加一个原始字节块,返回已经可以确定的安全前缀;新秘密可能跨越此前保留的尾部。
38
+ def append(data)
39
+ raise ArgumentError, "data must be a String" unless data.is_a?(String)
40
+
41
+ @pending << data.b
42
+ @hidden << ("\0" * data.bytesize)
43
+ mark_secrets
44
+ release([@pending.bytesize - @lookbehind, 0].max)
45
+ end
46
+
47
+ # EOF、日志目标替换及关闭是流边界;尾部疑似秘密前缀也遮盖,不能因 flush 泄露片段。
48
+ def finish(partial: true)
49
+ raise ArgumentError, "partial must be true or false" unless [true, false].include?(partial)
50
+
51
+ mark_secrets
52
+ mark_partial_secrets if partial
53
+ output = release(@pending.bytesize)
54
+ @masking = false
55
+ output
56
+ end
57
+
58
+ # 过滤器公开后仍不在诊断摘要中展开注册秘密或尚未交付的原始字节。
59
+ def inspect = "#<#{self.class}>"
60
+
61
+ private
62
+
63
+ # 流关闭时无法再等待后续字节,默认隐藏与秘密开头一致的未完成尾部。
64
+ def mark_partial_secrets
65
+ @patterns.each do |pattern|
66
+ [pattern.bytesize - 1, @pending.bytesize].min.downto(1) do |length|
67
+ next unless @pending.end_with?(pattern.byteslice(0, length))
68
+
69
+ @hidden[-length, length] = "\1" * length
70
+ break
71
+ end
72
+ end
73
+ end
74
+
75
+ # 每次只将命中区域标为隐藏,不清除旧掩码;偏移逐字节推进以识别相互重叠的秘密。
76
+ def mark_secrets
77
+ @patterns.each do |pattern|
78
+ offset = -1
79
+ while (offset = @pending.index(pattern, offset + 1))
80
+ @hidden[offset, pattern.bytesize] = "\1" * pattern.bytesize
81
+ end
82
+ end
83
+ end
84
+
85
+ # 按连续区间输出,避免逐字节构造字符串;只保存尚可能与下一块组成秘密的后缀。
86
+ # masking 跨 append 保留,使被分成多个块的同一隐藏区间只输出一次替换标记。
87
+ def release(length)
88
+ output = "".b
89
+ cursor = 0
90
+ while cursor < length
91
+ hidden = @hidden.getbyte(cursor) == 1
92
+ ending = [@hidden.index(hidden ? "\0" : "\1", cursor) || length, length].min
93
+ if hidden
94
+ output << @replacement unless @masking
95
+ else
96
+ output << @pending.byteslice(cursor, ending - cursor)
97
+ end
98
+ @masking = hidden
99
+ cursor = ending
100
+ end
101
+ @pending = @pending.byteslice(length..)
102
+ @hidden = @hidden.byteslice(length..)
103
+ output
104
+ end
105
+ end
106
+ 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 }
@@ -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,6 +18,7 @@ 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
 
@@ -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
@@ -5,6 +5,7 @@ require "shellwords"
5
5
  # 会话终端的模式和窗口尺寸接口;人工接管期间的临时恢复由 interaction.rb 负责。
6
6
  class Expect
7
7
  # 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
8
+ # 非终端返回空字符串;命令不存在或执行失败抛出 IOError,管道在所有退出路径关闭。
8
9
  def stty(*modes)
9
10
  return "" unless tty?
10
11
 
@@ -25,7 +26,7 @@ class Expect
25
26
  sink&.close unless sink&.closed?
26
27
  end
27
28
 
28
- # 读取终端的 [行数, 列数]。
29
+ # 读取终端的 [行数, 列数];底层并非终端或句柄已关闭时保留原生 IO 异常。
29
30
  def winsize = to_io.winsize
30
31
 
31
32
  # 更新终端尺寸,由内核通知前台进程。
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.3.3"
5
+ VERSION = "0.5.0"
6
6
  end