expect-pty 0.9.2 → 0.9.4

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: 9d6ae394cf13d882d00a78b0684c158551e08896bf572272790d68cc7850d804
4
- data.tar.gz: 2cada03b8804a8402bec3dfdbc54f705650d1b47d57b410e3e6c83d35af4cd9c
3
+ metadata.gz: bd51e466710a92cea41212f9cb4477f73f586ff44f53fa57676be9562b59f037
4
+ data.tar.gz: a848149d3069fafefffe5618c9293eed72b569dab3879a783edee292271a25c2
5
5
  SHA512:
6
- metadata.gz: 64dbb1fbbed803a50dedda414a59f662121adcfee88de614f563e1de1d2f7c195f82d77f415a3a0fc545b420f5158e252637dada211c8ac35a98fa1728868748
7
- data.tar.gz: d060fd85a2344e4e18d15257506844908f332100538517edbf6b5395e2a6bef0c98e0c12c44d21d26074eccb690ef6d1f81b4508056e85107239c84de3d7a09e
6
+ metadata.gz: 19709a17b0f7a69db8d8eac19e6bec88259bd37475394a4a0d27e1406a33244bef839cc6627696c8b72a1df5b873b27c2ef55004003a07e66b3e82323d5322be
7
+ data.tar.gz: f2806cb9db50e6b82bfe0c72a7affd28f1ab50b716688c1dc027aa75fff5c049fffe2f20348695b32a06cb41f1ec2fba426ccef8b4789a2304a388a1908d0e93
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.4 - 2026-10-19
6
+
7
+ - 将字面未命中进度归一为内部 LiteralScan,供匹配与转接复用;大缓冲分块转接不再反复搜索已排除的范围,保留跨块转义与回调修改规则的行为。
8
+ - 转接活跃来源改用按对象身份维护的有序索引,避免批量 EOF 继续处理时反复遍历剩余来源。
9
+ - 修复句柄关闭和进程回收同时失败时后一个系统错误覆盖首个错误的问题;后续致命中断仍原样传播,失败资源可重试清理。
10
+
11
+ - 新增公开的 `Session#buffer_bytesize`,以常数时间查询当前主缓冲字节数,避免容量检查创建正文快照;原 `buffer` 的防御性复制契约不变。
12
+
13
+ ## 0.9.3 - 2026-10-18
14
+
15
+ - 结果的 inspect 与 to_s 仅输出匹配序号和错误状态,避免诊断、字符串插值泄露匹配文本、捕获内容或异常消息;原始结果字段保持不变。
16
+ - 工厂块异常、取消或非局部退出时跳过 graceful 读尾,直接硬关闭;正常返回(包括 nil/false)仍可收取尾部输出。
17
+
5
18
  ## 0.9.2 - 2026-10-18
6
19
 
7
20
  - 减少显式搜索窗口下字面量匹配的内存分配,保留原始字节偏移、声明优先级和正则窗口语义。
data/docs/API.md CHANGED
@@ -19,6 +19,8 @@ Session 构造器不接收命令或操作参数。同一会话只能启动一次
19
19
  `Expect.open(io, writer: io, own: false, graceful: false, **settings)` 适配真实可 select 的 IO,默认借用。
20
20
  `own: true` 接管关闭责任,包括初始化中已知设置的非法值导致的失败;未知关键字在 Ruby 方法入口拒绝,此时不接管 IO。
21
21
  `spawn`、`open` 无块时返回 Session,有块时返回块结果并确保按 `graceful:` 关闭;非局部返回也清理。
22
+ 工厂块正常返回时才执行 graceful 读尾;异常、线程取消以及 return/break/throw 等非局部退出直接硬关闭,避免读尾延迟退出。
23
+ 块正常返回 nil 或 false 仍属于正常完成;调用方 rescue 中已处理的异常不影响此判断。
22
24
  清理中的 StandardError 不覆盖本次作用域已有的主异常;没有主异常时照常传播。新发生的 Interrupt/SystemExit 不会被清理包装吞掉。
23
25
 
24
26
  `close(graceful: false)` 最终硬关闭兜底,返回 nil;`soft_close(timeout: 15, term_timeout: 1)` 收取尾部、关闭句柄并最多发送
@@ -65,6 +67,8 @@ nil 保留全缓冲扫描;指定时,字符串和正则只搜索最新的尾
65
67
  固定 UTF-8 正则跳过边界切开的合法首字符,等待合法未完成尾字符;窗口内非法字节及 EOF 未完成字符仍报错。
66
68
  二进制正则按原始字节匹配。
67
69
 
70
+ Result 的 `inspect` 与 `to_s` 仅显示匹配序号和错误状态,不打印原始文本、异常消息或来源对象。
71
+ 需要原始结果时显式读取字段;`redact` 不改变这些字段。
68
72
  Result 字段为 `number`、`error`、`match`、`before`、`after`、`session`、`captures`。对象、文本及捕获数组均不可变,未参与捕获为
69
73
  nil。
70
74
  错误为 nil、`:timeout`、`:eof` 或原始 IO 异常;来源会话与异常不复制、不冻结。使用 `matched?`、`timeout?`、`eof?` 查询,
@@ -130,7 +134,7 @@ end
130
134
  | outputs | [] | 复制可写目标数组,原样转发接收字节 |
131
135
 
132
136
  操作参数 `raw`、`consume`、`reset_timeout_on_read`、`graceful` 不持久化为会话设置。
133
- `buffer` 返回副本,`buffer=` 复制并应用上限,`clear_buffer` 移交并清空缓冲;`buffer_discarded_bytes` 累计窗口裁剪量,匹配消费不计入。
137
+ `buffer` 返回副本;只检查容量时使用 `buffer_bytesize`,以常数时间取得当前未消费主缓冲的字节数,不复制正文、不读取 IO,也不包含 Relay 待写数据或历史窗口。`buffer=` 复制并应用上限,`clear_buffer` 移交并清空缓冲;`buffer_discarded_bytes` 累计窗口裁剪量,匹配消费不计入。
134
138
  显式替换/清空、实际裁剪和消费输入会清除正则转义历史;非消费匹配、未裁剪的等待与内部缓冲交接保持连续历史。
135
139
  正则转接历史默认保留至多 65,536 字节,设置 `buffer_limit` 后采用该上限。存在固定 UTF-8 正则时,历史预算至少为 3 字节,以保留四字节字符尚未接收完整的前缀;这不扩大普通匹配缓冲的上限,也不延迟协议转发。
136
140
 
@@ -46,7 +46,8 @@ PID。`hard_close` 不等待输出,必要时 TERM、KILL 并回收子进程。
46
46
 
47
47
  Ruby 使用关键字指定各阶段期限,关闭方法返回 `Process::Status` 或 `nil`。`close(graceful: true)` 或工厂块的
48
48
  `graceful: true`
49
- 先尝试软关闭,再在 `ensure` 中硬关闭;这对应原版销毁时可选软关闭、随后硬关闭的清理策略。`close` 返回 `nil`;GC
49
+ 先尝试软关闭,再在 `ensure` 中硬关闭;工厂块仅正常返回时读尾,异常、取消及非局部退出直接硬关闭。
50
+ 显式 `close(graceful: true)` 仍按调用参数读尾。`close` 返回 `nil`;GC
50
51
  兜底直接强制清理,不阻塞等待。
51
52
 
52
53
  ## 有意采用的 Ruby 语义
@@ -3,6 +3,7 @@
3
3
  require_relative "relay/writer"
4
4
  require_relative "relay"
5
5
  require_relative "literal_prefix"
6
+ require_relative "literal_scan"
6
7
 
7
8
  # 为会话补充人工接管和多路 IO 转接;核心会话定义位于 session.rb。
8
9
  module Expect
@@ -81,14 +82,17 @@ module Expect
81
82
  # 将待处理输入排入唯一的转接发送队列;返回 :ready、:queued、:stopped 或 :timeout。
82
83
  # 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
83
84
  # @api private
84
- def self.queue_input(session, buffer, final: false, deadline: nil)
85
+ def self.queue_input(session, buffer, final: false, deadline: nil, literal_scan: nil)
85
86
  loop do
86
87
  sequences = session.sequences.except(:eof)
87
88
  history = session.relay_history
88
- found, regexp_scanned, utf8_regexp = scan_sequences(buffer, sequences, history, final:)
89
+ found, regexp_scanned, utf8_regexp = scan_sequences(
90
+ buffer, sequences, history, final:, literal_scan:, generation: session.buffer_generation
91
+ )
89
92
  return :timeout if expired?(deadline)
90
93
 
91
94
  if found
95
+ literal_scan&.reset
92
96
  result = handle_escape(session, buffer, history, found)
93
97
  return result unless result == :ready
94
98
  return :timeout if expired?(deadline)
@@ -108,7 +112,10 @@ module Expect
108
112
  end
109
113
  # 排队前缀与尾部可共享原字节;替换窗口而非删除前缀,避免逐块复制整个余量。
110
114
  # 保留缓冲对象身份,嵌套 Matcher 仍把尾部交回 Relay 借用的同一个对象。
111
- buffer.replace(buffer.byteslice(count..)) if count.positive?
115
+ if count.positive?
116
+ buffer.replace(buffer.byteslice(count..))
117
+ literal_scan&.advance(count)
118
+ end
112
119
  return count.positive? ? :queued : :ready
113
120
  end
114
121
  end
@@ -134,7 +141,7 @@ module Expect
134
141
  end
135
142
 
136
143
  # 单轮正则共用一个组合文本;回调返回后下一轮重新取得规则和历史。
137
- def self.scan_sequences(buffer, sequences, history, final:)
144
+ def self.scan_sequences(buffer, sequences, history, final:, literal_scan: nil, generation: nil)
138
145
  text = nil
139
146
  utf8_regexp = false
140
147
  earliest = nil
@@ -153,7 +160,7 @@ module Expect
153
160
  # 历史前缀已实时转发,不能撤回;负偏移表示本次只需消费转义尚未转发的部分。
154
161
  offset -= history.bytesize
155
162
  else
156
- offset = buffer.index(key)
163
+ offset = literal_scan ? literal_scan.index(buffer, key, generation:) : buffer.index(key)
157
164
  next unless offset
158
165
 
159
166
  length = key.bytesize
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expect
4
+ # 一个连续字节窗口的字面未命中进度;不持有输入、会话或 IO。
5
+ # Matcher 的窗口只追加,Relay 还会移出安全前缀,两者共用绝对字节坐标。
6
+ # @api private
7
+ class LiteralScan
8
+ def initialize
9
+ @misses = {}.compare_by_identity
10
+ @consumed = 0
11
+ @generation = nil
12
+ end
13
+
14
+ # 返回相对当前窗口的字节位置;新输入只重扫可能跨越旧尾部的重叠区。
15
+ # literal 须为冻结的二进制 String,generation 变化表示窗口被替换、裁剪或外部消费。
16
+ def index(buffer, literal, generation:)
17
+ if @generation != generation
18
+ reset
19
+ @generation = generation
20
+ end
21
+ ending = @consumed + buffer.bytesize
22
+ previous = @misses[literal]
23
+ return nil if previous == ending
24
+
25
+ offset = previous ? [previous - @consumed - literal.bytesize + 1, 0].max : 0
26
+ position = buffer.index(literal, offset)
27
+ if position
28
+ @misses.delete(literal)
29
+ else
30
+ @misses[literal] = ending
31
+ end
32
+ position
33
+ end
34
+
35
+ # Relay 仅移出已扫描的安全前缀;未命中范围仍有效,无须逐规则调整偏移。
36
+ def advance(count)
37
+ @consumed += count
38
+ end
39
+
40
+ # 转义消费或外部修改建立新边界,后续扫描不能复用旧输入的结论。
41
+ def reset
42
+ @misses.clear
43
+ @consumed = 0
44
+ @generation = nil
45
+ end
46
+ end
47
+ private_constant :LiteralScan
48
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "literal_scan"
4
+
3
5
  module Expect
4
6
  # 驱动一次单会话或多会话匹配,管理模式优先级、EOF 和共享期限,不接管 IO 所有权。
5
7
  # 调度只在扫描、回调和 IO 操作之间检查期限,不强行中断用户代码或单次正则计算。
@@ -124,27 +126,10 @@ module Expect
124
126
  # 仅复用同一缓冲代次中的字面未命中。保留模式长度减一的重叠区,覆盖跨读取命中。
125
127
  # 不保存整个文本,也不对正则推断扫描窗口;回调替换、消费或转接恢复会改变代次。
126
128
  def locate_literal(session, pattern, buffer)
127
- @literal_misses ||= {}.compare_by_identity
128
- misses = (@literal_misses[session] ||= {}.compare_by_identity)
129
- previous = misses[pattern]
130
- generation = session.buffer_generation
131
- # 模式已冻结;缓冲代次未变意味着只有尾部追加。
132
- offset = if previous && previous[0] == generation
133
- return nil if previous[1] == buffer.bytesize
134
-
135
- [previous[1] - pattern.value.bytesize + 1, 0].max
136
- else
137
- 0
138
- end
139
- position = pattern.locate(buffer, offset:)
140
- if position
141
- misses.delete(pattern)
142
- else
143
- entry = (misses[pattern] ||= [])
144
- entry[0] = generation
145
- entry[1] = buffer.bytesize
146
- end
147
- position
129
+ @literal_scans ||= {}.compare_by_identity
130
+ scan = (@literal_scans[session] ||= LiteralScan.new)
131
+ offset = scan.index(buffer, pattern.value, generation: session.buffer_generation)
132
+ [offset, pattern.value.bytesize, []] if offset
148
133
  end
149
134
 
150
135
  # 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
data/lib/expect/pty.rb CHANGED
@@ -66,12 +66,12 @@ module Expect
66
66
  timeout: nil, write_timeout: nil, buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
67
67
  session = nil
68
68
  spawned = false
69
- cleanup = lambda do
69
+ cleanup = lambda do |completed|
70
70
  if session && (block_given? || !spawned)
71
- session.cleanup_session(nil, writer: nil, own: false, graceful: spawned && graceful)
71
+ session.cleanup_session(nil, writer: nil, own: false, graceful: spawned && graceful && completed)
72
72
  end
73
73
  end
74
- Cleanup.always(cleanup) do
74
+ Cleanup.always(cleanup, report_completion: true) do
75
75
  # 工厂在构造期间处理失败;成功对象的返回与登记不可被中断拆开。
76
76
  Thread.handle_interrupt(Object => :never) do
77
77
  session = SessionFactory.pty(timeout:, write_timeout:, buffer_limit:, logger:, transcript:, outputs:)
@@ -89,14 +89,14 @@ module Expect
89
89
  session = nil
90
90
  initialized = false
91
91
  constructing = false
92
- cleanup = lambda do
92
+ cleanup = lambda do |completed|
93
93
  if session && (block_given? || !initialized)
94
- session.cleanup_session(io, writer:, own:, graceful: initialized && graceful)
94
+ session.cleanup_session(io, writer:, own:, graceful: initialized && graceful && completed)
95
95
  elsif !session && own && !constructing
96
96
  ResourceOwner.close_handles(io, writer)
97
97
  end
98
98
  end
99
- Cleanup.always(cleanup) do
99
+ Cleanup.always(cleanup, report_completion: true) do
100
100
  Thread.handle_interrupt(Object => :never) do
101
101
  constructing = true
102
102
  session = SessionFactory.io(io, writer:, own:, timeout:, write_timeout:, buffer_limit:,
data/lib/expect/relay.rb CHANGED
@@ -12,7 +12,8 @@ module Expect
12
12
  # 这里只校验参数;run 取得所有来源后才转移缓冲,不关闭或接管任何外部 IO。
13
13
  def initialize(sessions, timeout)
14
14
  @sessions = sessions.uniq(&:object_id)
15
- @active = @sessions.dup
15
+ # 按对象身份保留声明顺序;每个 EOF 仅删除自身,避免逐个重扫剩余来源。
16
+ @active = @sessions.each_with_object({}.compare_by_identity) { |session, active| active[session] = true }
16
17
  period = Expect.duration(timeout)
17
18
  @deadline = period && (Expect.monotonic + period)
18
19
  # 零超时保留首轮缓冲及一次轮询;正预算在扫描和每次回调之间重新检查。
@@ -20,6 +21,7 @@ module Expect
20
21
  @token = Object.new
21
22
  @buffers = {}.compare_by_identity
22
23
  @previous = {}.compare_by_identity
24
+ @literal_scans = {}.compare_by_identity
23
25
  end
24
26
 
25
27
  # 每轮先处理转义和已排队输出,再共同选择读写;停止返回来源会话,总期限到达返回 nil。
@@ -52,7 +54,7 @@ module Expect
52
54
  def dispatch_sources
53
55
  idle = []
54
56
  queued = false
55
- @active.dup.each do |session|
57
+ @active.each_key do |session|
56
58
  return [:timeout, idle, queued] if dispatch_expired?
57
59
 
58
60
  next if session.pending_output?
@@ -72,7 +74,7 @@ module Expect
72
74
  callback = session.sequences[:eof]
73
75
  return [session, idle, queued] unless callback&.call
74
76
 
75
- @active.delete_if { |active| active.equal?(session) }
77
+ @active.delete(session)
76
78
  return [:timeout, idle, queued] if dispatch_expired?
77
79
  end
78
80
  [nil, idle, queued]
@@ -82,11 +84,18 @@ module Expect
82
84
  def dispatch_input(session)
83
85
  if (callback = session.relay_callback)
84
86
  session.relay_callback = nil
87
+ @literal_scans[session]&.reset
85
88
  return :stopped unless callback.call
86
89
  return :timeout if dispatch_expired?
87
90
  end
88
91
 
89
- Interaction.queue_input(session, @buffers.fetch(session), final: session.eof?, deadline: @dispatch_deadline)
92
+ # 正则与空规则不分配字面缓存;回调可追加规则,每轮按当前声明选择。
93
+ literal_scan = @literal_scans[session]
94
+ if !literal_scan && session.sequences.any? { |key, _handler| key.is_a?(String) }
95
+ literal_scan = (@literal_scans[session] ||= LiteralScan.new)
96
+ end
97
+ Interaction.queue_input(session, @buffers.fetch(session), final: session.eof?, deadline: @dispatch_deadline,
98
+ literal_scan:)
90
99
  end
91
100
 
92
101
  # 共同等待来源与目标,每轮读每个就绪来源一次;EINTR 返回原期限循环。
@@ -198,7 +207,7 @@ module Expect
198
207
  targets = pending.each_with_object({}.compare_by_identity) do |output, index|
199
208
  index[output.target] = true if output.target.is_a?(Session)
200
209
  end
201
- sources = @active.reject do |session|
210
+ sources = @active.each_key.reject do |session|
202
211
  session.pending_output? && !targets.key?(session)
203
212
  end
204
213
  (sources + targets.keys).uniq { |session| session.to_io.object_id }.reject(&:eof?)
data/lib/expect/result.rb CHANGED
@@ -30,6 +30,22 @@ module Expect
30
30
  captures: captures.map { |value| value&.dup&.freeze }.freeze)
31
31
  end
32
32
 
33
+ # 诊断只显示匹配序号和错误状态,不检查原始文本、异常或来源对象。
34
+ # @return [String] 不包含协议内容的结果摘要。
35
+ def inspect
36
+ state = if error.nil? then "none"
37
+ elsif error.equal?(:eof) then "eof"
38
+ elsif error.equal?(:timeout) then "timeout"
39
+ else "exception"
40
+ end
41
+ index = number.is_a?(Integer) ? number.to_s : "nil"
42
+ "#<#{self.class} number=#{index} error=#{state}>"
43
+ end
44
+
45
+ # Data 的默认 to_s 独立于 inspect;字符串插值同样只使用安全摘要。
46
+ # @return [String] 与 inspect 相同的结果摘要。
47
+ def to_s = inspect
48
+
33
49
  # 是否命中文本;EOF 和超时没有模式序号。
34
50
  def matched? = !number.nil?
35
51
 
@@ -130,6 +130,10 @@ module Expect
130
130
  # 返回缓冲副本,防止调用方原地修改绕过裁剪规则。
131
131
  def buffer = @buffer.dup
132
132
 
133
+ # 读取当前未消费主缓冲的字节数,不复制正文、不读取 IO,也不包含转接中的待写数据。
134
+ # @return [Integer] 当前主缓冲占用的字节数。
135
+ def buffer_bytesize = @buffer.bytesize
136
+
133
137
  # Matcher 仅在无回调的当前扫描借用原字节,避免副本使下一次追加复制整个窗口。
134
138
  # 不得修改或跨读取、回调保留;公开 buffer 和继续回调的快照仍独立复制。
135
139
  # @api private
@@ -508,7 +512,7 @@ module Expect
508
512
  @pending_writes&.clear
509
513
  @relay_history&.clear
510
514
  @relay_callback = nil
511
- status = close_child(timeout:, term_timeout:, force:)
515
+ status = cleanup.call { close_child(timeout:, term_timeout:, force:) }
512
516
  raise failure if failure
513
517
 
514
518
  status
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.9.2"
5
+ VERSION = "0.9.4"
6
6
  end
data/sig/expect.rbs CHANGED
@@ -102,6 +102,8 @@ module Expect
102
102
 
103
103
  def buffer: () -> String
104
104
 
105
+ def buffer_bytesize: () -> Integer
106
+
105
107
  def buffer=: (String value) -> void
106
108
 
107
109
  def clear_buffer: () -> String
@@ -238,6 +240,10 @@ module Expect
238
240
 
239
241
  def eof?: () -> bool
240
242
 
243
+ def inspect: () -> String
244
+
245
+ def to_s: () -> String
246
+
241
247
  def deconstruct: () -> [Integer?, result_error, String?, String?, String?, Session?, Array[String?]]
242
248
 
243
249
  def with: (
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: expect-pty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-18 00:00:00.000000000 Z
10
+ date: 2026-10-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: io-console
@@ -82,6 +82,7 @@ files:
82
82
  - lib/expect/cleanup.rb
83
83
  - lib/expect/interaction.rb
84
84
  - lib/expect/literal_prefix.rb
85
+ - lib/expect/literal_scan.rb
85
86
  - lib/expect/logging.rb
86
87
  - lib/expect/matcher.rb
87
88
  - lib/expect/pattern.rb