expect-pty 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee45b8c572aa37af57de1fae7b298e0a73a1b1157bb77949edb3918b100b8a81
4
- data.tar.gz: 6767f2e994b45d693e5701a9f153cc3991c48255c3bd6f0fec8ce5884aa72fb2
3
+ metadata.gz: cbcc16796d1be757cc45df5ed803aa8d3518601923eaa3765d512cd3000baaf7
4
+ data.tar.gz: 47943a69f86cfc7ba7564eea3f2ec7e0f71a30910e398535fbe734cff8cba54f
5
5
  SHA512:
6
- metadata.gz: b2e908dc05179ec58c9cfa11d5105f9e1b1b4089996a588ae2f14dd93b5218e0478ad43e749320200b98d880da54d0381963f7dc59891e943f0d1092bf4b4783
7
- data.tar.gz: ae06bd3ffdac6991bf510d10bf52665bfeaaaac6ea46a78b1e7fdc56f37fb6b4523c29e2a3519f6a374dd675df7cc94607c1b56923cf25bff89603bd95549362
6
+ metadata.gz: b9d0688944e25cd879072059ecdf8556676bc32c0b83634dbac773f002dad67485e9dad95034a52b306dbf0e1a3e38eff1e67efb0fcd382cd45c8b0c06bf79bd
7
+ data.tar.gz: 62aed043158e01a8023111d441fb9c484d697763157e18db97c6c01e9bbb7c55af7fb55ea3e311a4bcae2e191209a1401a42b139523d0e7d4c0873be78c2800c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.2 - 2026-10-11
6
+
7
+ - 修复对象转换、日志回调或背压读取关闭会话后仍继续写入借用 IO 的问题,空写入也会检查关闭状态。
8
+ - 修复转接接收回调耗尽总期限后仍读取后续来源的问题,保留零超时的首次轮询行为。
9
+ - 加快多来源 EOF 连续派发,并减少诊断重新启用后持续脱敏的临时分配,保留来源顺序与秘密过滤。
10
+
11
+ ## 0.8.1 - 2026-10-11
12
+
13
+ - 修复自动应答已在缓冲开头确定获胜模式,却被后续无关正则的编码错误阻断的问题,保留未消费尾部。
14
+ - 修复 EOF 时 transcript 尾部交付失败跳过接收诊断收尾的问题,两个日志通道均尝试冲刷并保留首个常规异常。
15
+ - 降低大量规则继续匹配、转义扫描和单对象日志补记的开销,并让脱敏流结束后的可见日志恢复普通过滤路径。
16
+
5
17
  ## 0.8.0 - 2026-10-11
6
18
 
7
19
  - 新增可复用的自动应答规则,支持重复确认、分页按键、动态回复和次数上限,按输入顺序处理提示且不延长等待期限,并提供批量脚本示例。
data/docs/API.md CHANGED
@@ -126,6 +126,7 @@ end
126
126
  `write(*objects)` 按 to_s 写字节并返回字节数,`<<` 返回 Session,`puts` 遵循 Ruby IO 换行与数组规则并返回 nil。
127
127
  `send_slow(*objects, delay:)` 按字符延迟发送并收取回复。背压超时抛出 `Expect::WriteTimeout`,`bytes_written` 仅标识本次调用已确认进度,
128
128
  嵌套写入异常放在 cause。成功短写不受 write_timeout 的总耗时限制。
129
+ 对象转换、发送诊断或背压读取的回调若关闭会话,后续写入抛出 IOError;借用 IO 仍归调用方所有,空写入同样检查关闭状态。
129
130
  `send_slow` 的进度累计本次调用中此前完整字符与失败字符实际接受的字节;转换对象和记录回复的内层写入进度不计入。
130
131
  首字符 write 的异常无需修正进度时保留原对象;需要累计或排除内层进度时包装并保留 cause。
131
132
  `to_io`、`writer`、`slave` 暴露句柄;`pid`、`command`、`tty_name`、`fileno`、`tty?`、`alive?`、`exit_code` 查询会话属性。
@@ -154,7 +155,8 @@ DEBUG 关闭期间仍推进分片脱敏,但这些字节不会在重新启用
154
155
  `pending_output?` 表示尚未交付数据;再次转接同源会话继续发送,不重放成功前缀;更换 outputs 不改变已排队字节的目标。
155
156
  同源递归转接抛出 ReentrancyError;转义回调可嵌套匹配,所有来源状态按对象身份隔离。
156
157
  接收字节交付期间,logger/transcript/outputs 的回调不能递归读取同一会话的新输入,否则抛出 ReentrancyError;
157
- 匹配已有缓冲及读取独立会话仍可用。正数转接总期限会在扫描和继续回调返回后检查;零超时仍处理缓冲并首次轮询。
158
+ 匹配已有缓冲及读取独立会话仍可用。正数转接总期限会在扫描、继续回调返回后及读取每个就绪来源前检查;接收回调耗尽预算后不再读取后续来源。
159
+ 已读分片的交付仍完整完成,零超时仍处理缓冲并首次轮询。
158
160
 
159
161
  `interact(input: $stdin, escape: nil, output: nil, timeout: nil, raw: true)` 临时建立双向转接,退出后恢复
160
162
  outputs、转义和本地终端模式。
@@ -137,25 +137,30 @@ module Expect
137
137
  def self.scan_sequences(buffer, sequences, history, final:)
138
138
  text = nil
139
139
  utf8_regexp = false
140
- matches = sequences.filter_map do |key, handler|
140
+ earliest = nil
141
+ # 只保存最早候选,同位置不替换先注册者;仍扫描全部正则以校验零宽规则并收集编码状态。
142
+ sequences.each do |key, handler|
141
143
  if key.is_a?(Regexp)
142
144
  # 仅在本轮扫描复用组合文本;回调可能更换规则或消费缓冲,下一轮必须重新构造。
143
145
  text ||= history + buffer
144
146
  utf8_regexp ||= key.fixed_encoding? && key.encoding == Encoding::UTF_8
145
147
  position = Pattern.new(value: key).locate(text, final:, captures: false)
146
- if position
147
- offset, length, = position
148
- raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
148
+ next unless position
149
149
 
150
- # 历史前缀已实时转发,不能撤回;负偏移表示本次只需消费转义尚未转发的部分。
151
- [offset - history.bytesize, length, handler]
152
- end
150
+ offset, length, = position
151
+ raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
152
+
153
+ # 历史前缀已实时转发,不能撤回;负偏移表示本次只需消费转义尚未转发的部分。
154
+ offset -= history.bytesize
153
155
  else
154
- position = buffer.index(key)
155
- [position, key.bytesize, handler] if position
156
+ offset = buffer.index(key)
157
+ next unless offset
158
+
159
+ length = key.bytesize
156
160
  end
161
+ earliest = [offset, length, handler] if !earliest || offset < earliest.first
157
162
  end
158
- [matches.min_by(&:first), !text.nil?, utf8_regexp]
163
+ [earliest, !text.nil?, utf8_regexp]
159
164
  end
160
165
 
161
166
  # 普通转发和超时排出的字面前缀都属于正则历史,跨次转接时必须接续同一字节流。
@@ -53,7 +53,8 @@ module Expect
53
53
  def write_transcript(*objects)
54
54
  return unless transcript
55
55
 
56
- data = objects.map { |object| object.to_s.b }.join
56
+ # 单对象沿用 Session#write 的转换路径,省去数组与 join;转换后仍重新取得目标。
57
+ data = objects.size == 1 ? objects.first.to_s.b : objects.map { |object| object.to_s.b }.join
57
58
  # to_s 可切换或禁用记录目标;整条记录与其过滤尾部必须属于转换完成后的同一目标。
58
59
  target = transcript
59
60
  return unless target
@@ -18,7 +18,8 @@ module Expect
18
18
  # 相对期限可因接收或 continue 重算,总期限始终固定;两者共用单调时钟。
19
19
  @hard_deadline = deadline
20
20
  @deadline = next_deadline
21
- @handled_eof = {}.compare_by_identity
21
+ # 按声明顺序维护仍参与本轮等待的来源;EOF 删除后不再从完整列表重复跳过已处理项。
22
+ @active_sessions = @sessions.each_with_object({}.compare_by_identity) { |session, active| active[session] = true }
22
23
  @stalled_matches = {}.compare_by_identity
23
24
  @polled = false
24
25
  @expired_eof_continuation = false
@@ -84,7 +85,7 @@ module Expect
84
85
  earliest = nil
85
86
  groups.each do |sessions, patterns|
86
87
  sessions.each do |session|
87
- next if @handled_eof.key?(session)
88
+ next unless @active_sessions.key?(session)
88
89
 
89
90
  buffer = buffers ? (buffers[session] ||= session.scan_buffer) : session.scan_buffer
90
91
  stalled = @stalled_matches[session]
@@ -94,7 +95,7 @@ module Expect
94
95
  end
95
96
  patterns.each do |pattern|
96
97
  return nil if hard_expired?
97
- next if stalled && stalled[:patterns].include?(pattern)
98
+ next if stalled && stalled[:patterns].key?(pattern)
98
99
 
99
100
  position = if pattern.value.is_a?(String)
100
101
  locate_literal(session, pattern, buffer)
@@ -105,7 +106,8 @@ module Expect
105
106
  return nil if hard_expired?
106
107
  next unless position
107
108
 
108
- return [session, pattern, position] unless @match_by_position
109
+ # 字节 0 已是最早位置,同位置仍由先声明者胜出;不再执行无法胜出的规则。
110
+ return [session, pattern, position] if !@match_by_position || position.first.zero?
109
111
 
110
112
  earliest = [session, pattern, position] if !earliest || position.first < earliest.last.first
111
113
  end
@@ -153,8 +155,11 @@ module Expect
153
155
 
154
156
  # 同值赋回仍须防止空转;嵌套读取到的新字节即使内容相同,也能恢复匹配。
155
157
  if session.received_bytes == received_bytes && session.scan_buffer == previous_buffer
156
- stalled = (@stalled_matches[session] ||= { buffer: previous_buffer, received_bytes:, patterns: [] })
157
- stalled[:patterns] << pattern
158
+ # 声明模式各有独立身份;恒定成本查询,避免重复继续时遍历不断增长的停滞数组。
159
+ stalled = (@stalled_matches[session] ||= {
160
+ buffer: previous_buffer, received_bytes:, patterns: {}.compare_by_identity
161
+ })
162
+ stalled[:patterns][pattern] = true
158
163
  end
159
164
  @deadline = next_deadline if CONTINUE.equal?(action)
160
165
  # 总期限到达时回主循环先派发已知 EOF;仅相对期限延续原有立即超时语义。
@@ -165,19 +170,19 @@ module Expect
165
170
 
166
171
  # 找出尚未派发 EOF 事件的会话,保证每个源只处理一次结束事件。
167
172
  def unhandled_eof
168
- @sessions.find { |session| session.eof? && !@handled_eof.key?(session) }
173
+ @active_sessions.each_key.find(&:eof?)
169
174
  end
170
175
 
171
176
  # 将剩余字节交给 EOF 回调;需要继续时等待其他源,全部结束则立即返回。
172
177
  def handle_eof(session)
173
178
  result = session.record_eof
174
- @handled_eof[session] = true
179
+ @active_sessions.delete(session)
175
180
  actions = @patterns.eof_patterns_for(session).map { |pattern| pattern.call(session) }
176
181
  return result unless actions.any? { |action| continuing?(action) }
177
182
 
178
183
  reset_timeout = actions.any? { |action| CONTINUE.equal?(action) }
179
184
  @deadline = next_deadline if reset_timeout
180
- return result if @handled_eof.size == @sessions.size
185
+ return result if @active_sessions.empty?
181
186
 
182
187
  # 期限已过时不再扫描文本,但先派发已知 EOF;最后一个源结束不能被误报为超时。
183
188
  @expired_eof_continuation = !reset_timeout && expired?
@@ -230,7 +235,7 @@ module Expect
230
235
  end
231
236
 
232
237
  # 返回本次仍需监听的会话,供读取选择和超时回调使用。
233
- def active_sessions = @sessions.reject { |session| @handled_eof.key?(session) }
238
+ def active_sessions = @active_sessions.keys
234
239
 
235
240
  # 只有约定的继续符号会驱动下一轮,普通回调返回值不会改变等待流程。
236
241
  def continuing?(action) = CONTINUE.equal?(action) || CONTINUE_WITHOUT_RESET.equal?(action)
@@ -57,6 +57,9 @@ module Expect
57
57
  mark_secrets
58
58
  mark_partial_secrets if partial
59
59
  output = release(@pending.bytesize)
60
+ # 已结束的流没有尚待抑制的字节,后续可见分片恢复普通路径;再次 suppress 时重新建立字节图。
61
+ @suppressed = nil
62
+ @suppressed_until = nil
60
63
  @masking = false
61
64
  output
62
65
  end
@@ -71,7 +74,10 @@ module Expect
71
74
  def append_chunk(data, visible:)
72
75
  raise ArgumentError, "data must be a String" unless data.is_a?(String)
73
76
 
74
- @suppressed ||= "\0" * @pending.bytesize unless visible
77
+ unless visible || data.empty?
78
+ @suppressed ||= "\0" * @pending.bytesize
79
+ @suppressed_until = @pending.bytesize + data.bytesize
80
+ end
75
81
  @suppressed << ((visible ? "\0" : "\1") * data.bytesize) if @suppressed
76
82
  @pending << data.b
77
83
  @hidden << ("\0" * data.bytesize)
@@ -142,7 +148,11 @@ module Expect
142
148
  remaining = @pending.bytesize - length
143
149
  @pending = @pending.byteslice(length, remaining)
144
150
  @hidden = @hidden.byteslice(length, remaining)
145
- @suppressed = @suppressed.byteslice(length, remaining) if @suppressed
151
+ if @suppressed
152
+ # 只追踪最后一个抑制字节的排空边界,避免每轮扫描字节图;跨块秘密掩码不受影响。
153
+ @suppressed_until -= length
154
+ @suppressed = @suppressed_until.positive? ? @suppressed.byteslice(length, remaining) : nil
155
+ end
146
156
  @scanned_bytes = remaining
147
157
  output
148
158
  end
data/lib/expect/relay.rb CHANGED
@@ -122,6 +122,8 @@ module Expect
122
122
  end
123
123
  readers.each do |session|
124
124
  next unless ready ? ready.key?(session.to_io) : readable.first.equal?(session.to_io)
125
+ # 前一个接收回调可耗尽总预算;本块交付完成后暂停新的读取,零超时仍允许首次批量轮询。
126
+ break if dispatch_expired?
125
127
 
126
128
  begin
127
129
  if @buffers.key?(session)
@@ -178,16 +178,19 @@ module Expect
178
178
 
179
179
  # 按 Ruby to_s 规则原样写入所有字节,返回字节数;背压等待受 write_timeout 限制。
180
180
  def write(*objects)
181
- raise IOError, "closed Expect session" if closed? || writer.closed?
181
+ check_writable!
182
182
 
183
183
  begin
184
184
  # 单对象不需要中间数组与 join 副本;to_s、诊断和部分写入仍经过同一条路径。
185
185
  data = objects.size == 1 ? objects.first.to_s.b : objects.map { |object| object.to_s.b }.join
186
+ check_writable!
186
187
  trace_data(:sending, data)
187
188
  rescue WriteTimeout
188
189
  # 转换或诊断中的嵌套写入不属于当前命令;此时尚未向 writer 发送任何字节。
189
190
  raise WriteTimeout.new("write interrupted before sending data", bytes_written: 0)
190
191
  end
192
+ # 转换与诊断均可关闭借用会话;即使底层 IO 仍打开,也不能把关闭后的发送报告为成功。
193
+ check_writable!
191
194
  deadline = write_timeout && (Expect.monotonic + write_timeout)
192
195
  offset = 0
193
196
  while offset < data.bytesize
@@ -467,6 +470,8 @@ module Expect
467
470
 
468
471
  # EINTR 未确认交付时不移动游标;其他写入计数必须落在当前块范围内。
469
472
  def write_chunk(data, offset, deadline)
473
+ # 背压读取会执行接收回调;每次重试重新检查,关闭会话不等于关闭调用方拥有的 writer。
474
+ check_writable!
470
475
  chunk = data.byteslice(offset, READ_SIZE)
471
476
  count = writer.write_nonblock(chunk, exception: false)
472
477
  return count if count == :wait_writable
@@ -481,6 +486,11 @@ module Expect
481
486
  nil
482
487
  end
483
488
 
489
+ # 同一状态检查覆盖入口、用户转换/诊断之后和背压恢复;只检查,不取得借用 IO 所有权。
490
+ def check_writable!
491
+ raise IOError, "closed Expect session" if closed? || writer.closed?
492
+ end
493
+
484
494
  # 背压等待同时排空读端,期限不因 EINTR 重算,异常始终报告外层写入进度。
485
495
  def wait_writable(offset, deadline)
486
496
  remaining = deadline && [deadline - Expect.monotonic, 0].max
@@ -576,8 +586,8 @@ module Expect
576
586
  # 只结束读取方向并冲刷接收记录;EOF 不代表子进程已经退出或写端已经关闭。
577
587
  def mark_eof
578
588
  @eof = true
579
- flush_transcript
580
- flush_diagnostics(:received)
589
+ # EOF 只登记一次;任一日志尾部失败也必须尝试另一通道,保持首个常规异常。
590
+ Cleanup.always(-> { flush_diagnostics(:received) }) { flush_transcript }
581
591
  nil
582
592
  end
583
593
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.8.0"
5
+ VERSION = "0.8.2"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: expect-pty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors