expect-pty 0.7.8 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad46afe78b754e37c5aa6995b9b1235d732a1f9faf372077b572e584ad186613
4
- data.tar.gz: 7a3165503b1543396abde0d77713134296b7f2d818911c8bbca9e4b505d85227
3
+ metadata.gz: f85e305b108edbac5d96859d034af4221948535a6f160296072eb1c8189c8cfc
4
+ data.tar.gz: 83fa7073bac3d7b17ad16f8fb6bf0bbf34d14379378d9813585b64be5ffd2e52
5
5
  SHA512:
6
- metadata.gz: 78b96691fcf4e0ab916286bfec7a978c3bb1de6fdc082af0ac3e5408eba3f2119c06789fd867b6f3552460ef0acee1aa45302ebb4081c21943197bd124f714c5
7
- data.tar.gz: 9c7f6b06eb593608b4474fac1b0a06464cda52965221e815fd921677af1d889cdbed29aefc801d9e08383685517df293a290dc97de92e724b473608b21165c94
6
+ metadata.gz: 3b4cd1a931adbe60f6d12be3fa49adc43ba9605bbd9c7730661ed435f49b68d3cbaac64a2b3a753134a1350a1edaeaa8c363a27eba837af2ac38663dc336d4ac
7
+ data.tar.gz: 74de2bb39b847334e691ea6d6e47f0bce3417ac6755c1aa91ccdc85cf5b00c21924df9a60a2ad09b525038200d6c4877bad2af826fdff5f8998093b5b7ee457f
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.1 - 2026-10-11
6
+
7
+ - 修复自动应答已在缓冲开头确定获胜模式,却被后续无关正则的编码错误阻断的问题,保留未消费尾部。
8
+ - 修复 EOF 时 transcript 尾部交付失败跳过接收诊断收尾的问题,两个日志通道均尝试冲刷并保留首个常规异常。
9
+ - 降低大量规则继续匹配、转义扫描和单对象日志补记的开销,并让脱敏流结束后的可见日志恢复普通过滤路径。
10
+
11
+ ## 0.8.0 - 2026-10-11
12
+
13
+ - 新增可复用的自动应答规则,支持重复确认、分页按键、动态回复和次数上限,按输入顺序处理提示且不延长等待期限,并提供批量脚本示例。
14
+ - 修复直接软关闭在读尾、日志交付失败或取消时跳过资源清理的问题,保留原始异常和不发送 KILL 的约定,失败时跳过自然退出等待。
15
+ - 减少结果快照、继续匹配、单对象写入和正则转义的临时分配,保留不可变结果、捕获、编码校验及部分写入进度。
16
+
5
17
  ## 0.7.8 - 2026-10-11
6
18
 
7
19
  - 将内部资源所有权对象命名为 ResourceOwner,并统一源码、示例和测试辅助代码的中文注释。
data/README.md CHANGED
@@ -38,6 +38,7 @@ The block closes the session and reaps its child process, including when the blo
38
38
 
39
39
  ## Features
40
40
 
41
+ - Reusable automatic response rules for repeated confirmations, paging, and dynamic replies.
41
42
  - Exact byte-string and Ruby Regexp matching, immutable results, callbacks, timeouts, and EOF handling.
42
43
  - Explicit PTY sessions or borrowed IO sessions, with scoped cleanup and process status.
43
44
  - Multi-session waits, IO forwarding, and interactive terminal handoff.
data/README.zh-CN.md CHANGED
@@ -43,6 +43,12 @@ end
43
43
  - 多会话等待、IO 转发和人工终端接管。
44
44
  - 可选的 Logger 诊断、接收记录和流式脱敏。
45
45
 
46
+ ## 批量脚本自动应答
47
+
48
+ `Expect::Responder` 支持复用自定义规则、重复确认、分页按键和正则捕获动态回复。
49
+ 每条命令按输入位置处理提示,规则可设置应答次数上限,应答不会延长等待期限。
50
+ 详见 [API 自动应答](docs/API.md#自动应答),可运行 `ruby examples/auto_response.rb` 验证本地设备模拟。
51
+
46
52
  ## 文档
47
53
 
48
54
  - [API 参考](docs/API.md)
data/docs/API.md CHANGED
@@ -22,6 +22,8 @@ Session 构造器不接收命令或操作参数。同一会话只能启动一次
22
22
  TERM;
23
23
  `hard_close(timeout: 0.2)` 关闭句柄后分阶段等待、TERM、KILL。后两者和 `wait(timeout: nil)` 返回真实 `Process::Status` 或
24
24
  nil。
25
+ 直接 `soft_close` 在读尾、日志或输出交付失败时仍尝试软关闭并保留原始异常;失败路径跳过剩余自然退出等待,
26
+ 尤其不会因 `timeout: nil` 阻塞异常传播,TERM 阶段仍使用 `term_timeout`,不会升级为 KILL。参数校验失败不关闭会话。
25
27
  借用 IO 不随会话关闭。输入 EOF、IO 关闭与子进程退出分别由 `eof?`、`closed?`、`process_status` 表示。
26
28
 
27
29
  ## 匹配与回调
@@ -63,6 +65,48 @@ nil。
63
65
  EncodingError。
64
66
  成功默认消费匹配前缀,超时保留缓冲,EOF 将余下内容放入 before 并清空缓冲。EOF 不表示子进程已经退出。
65
67
 
68
+ ## 自动应答
69
+
70
+ 已有 `PatternList#on` 配合 `Expect.continue` 可手写应答循环。批量脚本使用 `Expect::Responder` 复用规则,
71
+ 不必每条命令重新拼接回调;普通 `expect` 的声明优先级保持不变。
72
+
73
+ ```ruby
74
+ rules = Expect::Responder.new do |responses|
75
+ responses.on("Apply change? [y/N] ", reply: "y\n", limit: 5)
76
+ responses.on("--More--", reply: " ", limit: 100)
77
+ responses.on(/Repeat code (\d+): /, limit: 1) { |session| session.puts(session.captures.first) }
78
+ end.freeze
79
+
80
+ # 登录并收取设备初始提示后,再逐条发送配置。
81
+ commands.each do |command|
82
+ session.puts(command)
83
+ result = rules.expect(session, "device>", timeout: 30, deadline: Expect.monotonic + 60)
84
+ raise "command incomplete: #{result.error}" unless result.matched?
85
+ end
86
+ ```
87
+
88
+ - `on(pattern, reply: nil, limit: nil)` 只接受非空 String 或 Regexp;必须提供 String 回复或动作块,二者互斥。
89
+ 静态字符串复制并冻结,块收到带 `before`、`match`、`captures` 的 Session;块返回值忽略,应答后继续等待。
90
+ - `reply` 原样发送,行确认自行包含 `\n` 或 `\r`,分页可发送空格;空回复合法但不产生字节。
91
+ 不内置通用“全部确认”规则,调用方应按目标设备及业务选择精确提示和回复。
92
+ - `expect(session, *patterns, timeout: session.timeout, deadline: nil)` 仅处理一个 Session;也可使用与 `expect` 相同的
93
+ PatternList 块声明完成模式、EOF 和 timeout 回调。位置参数模式与块不能同时提供。
94
+ - 自动应答与完成模式按最早的字节位置处理;同位置时完成模式先于应答,多个应答按声明顺序。
95
+ 不按正则长度推测未完成提示,不在未知提示上猜测回复。仅将成功匹配的文本视为完成,业务错误输出需由调用方注册为终止模式并检查。
96
+ - 每条规则的 `limit` 是一次等待内的最大应答次数,必须为正 Integer,nil 不限;每次调用独立计数。
97
+ 超限或正则匹配空文本时,抛出 `Expect::Responder::ResponseError`,且不执行本次应答。触发该异常的匹配已经按普通匹配语义消费,
98
+ 未读尾部留在会话中。应答发送失败原样传播,不自动重试可能已经部分交付的确认。
99
+ - 应答不重置相对 timeout,deadline 仍是不可延长的总期限;沿用 Matcher 的协作式期限检查。
100
+ 动作块、单次正则计算和写入不由匹配期限强行中断;应为会话配置 `write_timeout`。
101
+ timeout 和 EOF 返回普通 Result,默认未知提示等到超时。显式完成回调仍遵循 `Expect.continue` 契约。
102
+ - 中途应答消费 `before` 和 `match`;最后 Result 仅表示完成、EOF 或超时,不汇总整条脚本输出。
103
+ 需要全文时在发送命令前设置 `session.transcript`;逐段展示可在动作块打印 `before/match`,完成后打印最终 `before`。
104
+ 完成后的尾部保留给下一轮。发送秘密前调用 `session.redact(secret)`,回复会经过正常写入诊断,规则摘要不展示内容。
105
+ - 注册阶段不读写 IO;每次等待快照规则,回调中的规则修改仅影响以后等待。不接管会话资源,
106
+ 不保证同一会话并发操作安全;设备的 SSH 登录和配置成功判定仍属于调用方。
107
+
108
+ 本地设备模拟见 `examples/auto_response.rb`;`examples/ssh_auto.rb` 接入精确规则并保留分段输出和完整 transcript。
109
+
66
110
  ## 会话属性与读写
67
111
 
68
112
  不提供全局配置或配置对象。构造器、工厂显式接受以下设置,会话可通过同名 reader/writer 修改;非法更新保留原值。
@@ -6,14 +6,21 @@ module Expect
6
6
  module Cleanup
7
7
  # 包括非局部返回在内的所有退出均清理;StandardError 只在没有原异常时传播。
8
8
  # 清理期间新发生的 Interrupt、SystemExit 等致命异常仍原样传播。
9
- def self.always(on_exit)
10
- yield
9
+ # report_completion 仅供关闭策略区分正常结束、取消及非局部退出,不改变异常优先级。
10
+ def self.always(on_exit, report_completion: false)
11
+ result = yield
12
+ completed = true
13
+ result
11
14
  rescue Exception # rubocop:disable Lint/RescueException -- 中断也必须清理,然后原样传播。
12
15
  failed = true
13
16
  raise
14
17
  ensure
15
18
  begin
16
- on_exit.call
19
+ if report_completion
20
+ on_exit.call(completed && !failed && Thread.current.status != "aborting")
21
+ else
22
+ on_exit.call
23
+ end
17
24
  rescue StandardError
18
25
  raise unless failed
19
26
  end
@@ -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
- position = Pattern.new(value: key).locate(text, final:)
146
- if position
147
- offset, length, = position
148
- raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
149
-
150
- # 历史前缀已实时转发,不能撤回;负偏移表示本次只需消费转义尚未转发的部分。
151
- [offset - history.bytesize, length, handler]
152
- end
147
+ position = Pattern.new(value: key).locate(text, final:, captures: false)
148
+ next unless position
149
+
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
  # 普通转发和超时排出的字面前缀都属于正则历史,跨次转接时必须接续同一字节流。
@@ -10,7 +10,7 @@ module Expect
10
10
  # 宿主 Session 初始化目标与过滤器状态,并提供 pid、fileno 作为诊断元数据。
11
11
  # 三种输出各有边界:Logger 接收事件,transcript 接收脱敏字节,outputs 接收原协议字节。
12
12
  module Logging
13
- # 调用方提供的诊断 Logger 与原始接收记录 writer,均由调用方管理生命周期。
13
+ # 调用方提供的诊断 Logger 与接收记录 writer,均由调用方管理生命周期。
14
14
  attr_reader :logger, :transcript
15
15
 
16
16
  # Logger 自己决定级别、格式与失败策略;nil 禁用诊断,不隐式写入 stderr。
@@ -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
@@ -6,11 +6,13 @@ module Expect
6
6
  # @api private
7
7
  class Matcher
8
8
  # 固定本次参与的会话及初始期限;已处理 EOF 的会话仅从本次等待中移除。
9
- def initialize(patterns, timeout, deadline: nil, consume: true, reset_timeout_on_read: false)
9
+ def initialize(patterns, timeout, deadline: nil, consume: true, reset_timeout_on_read: false,
10
+ match_by_position: false)
10
11
  @patterns = patterns.finalize!
11
12
  @sessions = patterns.sessions
12
13
  @groups = patterns.text_groups
13
14
  @consume = consume
15
+ @match_by_position = match_by_position
14
16
  @reset_timeout_on_read = reset_timeout_on_read
15
17
  @timeout = Expect.duration(timeout)
16
18
  # 相对期限可因接收或 continue 重算,总期限始终固定;两者共用单调时钟。
@@ -73,12 +75,13 @@ module Expect
73
75
  end
74
76
  end
75
77
 
76
- # 按声明组、会话、模式的顺序寻找首个匹配,不按文本中的出现位置重新排序。
78
+ # 普通等待按声明组、会话、模式选择;单会话自动应答选最早字节位置,同位置保留声明顺序。
77
79
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 声明优先级与单轮缓冲须在同一次扫描保持一致。
78
80
  def find_match
79
81
  # 编译后的组内来源已按身份去重;借用仅活在本次无回调扫描,不跨越 IO 或用户回调。
80
82
  groups = @groups
81
83
  buffers = {}.compare_by_identity if groups.size > 1
84
+ earliest = nil
82
85
  groups.each do |sessions, patterns|
83
86
  sessions.each do |session|
84
87
  next if @handled_eof.key?(session)
@@ -91,7 +94,7 @@ module Expect
91
94
  end
92
95
  patterns.each do |pattern|
93
96
  return nil if hard_expired?
94
- next if stalled && stalled[:patterns].include?(pattern)
97
+ next if stalled && stalled[:patterns].key?(pattern)
95
98
 
96
99
  position = if pattern.value.is_a?(String)
97
100
  locate_literal(session, pattern, buffer)
@@ -100,11 +103,16 @@ module Expect
100
103
  end
101
104
  # 正则本身不可由 IO 期限中断;恢复控制后也不能消费已过总期限的匹配。
102
105
  return nil if hard_expired?
103
- return [session, pattern, position] if position
106
+ next unless position
107
+
108
+ # 字节 0 已是最早位置,同位置仍由先声明者胜出;不再执行无法胜出的规则。
109
+ return [session, pattern, position] if !@match_by_position || position.first.zero?
110
+
111
+ earliest = [session, pattern, position] if !earliest || position.first < earliest.last.first
104
112
  end
105
113
  end
106
114
  end
107
- nil
115
+ earliest
108
116
  end
109
117
 
110
118
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -137,16 +145,20 @@ module Expect
137
145
 
138
146
  # 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
139
147
  def handle_match(session, pattern, position)
140
- previous_buffer = session.buffer
148
+ # 没有回调的终止匹配不可能继续,无须复制完整缓冲建立防空转快照。
149
+ previous_buffer = session.buffer if pattern.callback
141
150
  received_bytes = session.received_bytes
142
151
  result = session.record_match(pattern, position, consume: @consume)
143
152
  action = pattern.call(session)
144
153
  return result unless continuing?(action)
145
154
 
146
155
  # 同值赋回仍须防止空转;嵌套读取到的新字节即使内容相同,也能恢复匹配。
147
- if session.received_bytes == received_bytes && session.buffer == previous_buffer
148
- stalled = (@stalled_matches[session] ||= { buffer: previous_buffer, received_bytes:, patterns: [] })
149
- stalled[:patterns] << pattern
156
+ if session.received_bytes == received_bytes && session.scan_buffer == previous_buffer
157
+ # 声明模式各有独立身份;恒定成本查询,避免重复继续时遍历不断增长的停滞数组。
158
+ stalled = (@stalled_matches[session] ||= {
159
+ buffer: previous_buffer, received_bytes:, patterns: {}.compare_by_identity
160
+ })
161
+ stalled[:patterns][pattern] = true
150
162
  end
151
163
  @deadline = next_deadline if CONTINUE.equal?(action)
152
164
  # 总期限到达时回主循环先派发已知 EOF;仅相对期限延续原有立即超时语义。
@@ -19,8 +19,9 @@ module Expect
19
19
 
20
20
  # 在缓冲中定位字符串或正则,返回 [字节偏移, 字节长度, 捕获组];事件或未匹配返回 nil。
21
21
  # buffer 应为二进制字符串,offset 仅用于字面扫描;正则始终看到完整窗口以保留锚点语义。
22
+ # captures 为 false 时只求字节范围,转义扫描不分配随后丢弃的捕获字符串。
22
23
  # final 表示不会再有新输入,此时不完整的编码尾部也必须报错,不能永远当作等待分片。
23
- def locate(buffer, final: false, offset: 0)
24
+ def locate(buffer, final: false, offset: 0, captures: true)
24
25
  case value
25
26
  when String
26
27
  offset = buffer.index(value, offset)
@@ -42,7 +43,7 @@ module Expect
42
43
 
43
44
  # 直接使用正则的字节范围,避免为转换字符偏移创建前缀切片;捕获组也返回字节串。
44
45
  offset, finish = found.byteoffset(0)
45
- return [offset, finish - offset, found.captures.map { |capture| capture&.b }]
46
+ return [offset, finish - offset, captures ? found.captures.map { |capture| capture&.b } : []]
46
47
  end
47
48
  nil
48
49
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Expect
4
4
  # 将 Ruby 模式和块回调整理为有序会话组;注册阶段不读取 IO、不执行匹配回调。
5
- # 分组结构直接决定 Matcher 的“声明组 → 会话 → 模式”优先级,不能按匹配位置重排。
5
+ # 普通等待按“声明组 → 会话 → 模式”选择;单会话自动应答另按字节位置选择,同位置沿用声明顺序。
6
6
  class PatternList
7
7
  # 供匹配器读取的已编译分组和超时规则。
8
8
  # @api private
@@ -57,6 +57,8 @@ module Expect
57
57
  mark_secrets
58
58
  mark_partial_secrets if partial
59
59
  output = release(@pending.bytesize)
60
+ # 已结束的流没有尚待抑制的字节,后续可见分片恢复普通路径;再次 suppress 时重新建立字节图。
61
+ @suppressed = nil
60
62
  @masking = false
61
63
  output
62
64
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expect
4
+ # 可复用的自动应答规则;声明不持有会话,每次等待独立统计应答次数。
5
+ # 输入按字节位置处理,同位置的完成模式优先,其余按规则声明顺序。
6
+ class Responder
7
+ # 规则超限或匹配空文本时停止自动发送,避免反复确认和零宽应答循环。
8
+ class ResponseError < StandardError; end
9
+
10
+ # 建立规则集;块保留调用方 self,通过参数注册规则。
11
+ def initialize
12
+ @rules = []
13
+ yield self if block_given?
14
+ end
15
+
16
+ # 注册字面或正则提示;reply 原样写入,换行须显式包含,也可用块操作匹配会话。
17
+ # limit 是本规则在一次 expect 中允许的应答次数,nil 不限;规则文本和静态回复复制冻结。
18
+ def on(pattern, reply: nil, limit: nil, &block)
19
+ raise FrozenError, "responder is frozen" if frozen?
20
+ unless (pattern.is_a?(String) && !pattern.empty?) || pattern.is_a?(Regexp)
21
+ raise ArgumentError, "response pattern must be a nonempty String or Regexp"
22
+ end
23
+
24
+ validate_action!(reply, limit, block)
25
+
26
+ pattern = pattern.b.freeze if pattern.is_a?(String)
27
+ @rules << [pattern, reply&.b&.freeze, limit, block].freeze
28
+ self
29
+ end
30
+
31
+ # 等待完成模式或 EOF,同时自动处理提示;timeout 沿用会话默认值,应答不重置期限。
32
+ # 完成块使用 PatternList DSL;Result 只描述最后一次匹配,全文请用会话 transcript 保存。
33
+ # 每次等待快照规则,回调修改规则仅影响后续等待;不接管或关闭会话。
34
+ def expect(session, *patterns, timeout: (session.timeout if session.is_a?(Session)), deadline: nil, &block)
35
+ raise ArgumentError, "responder requires a Session" unless session.is_a?(Session)
36
+
37
+ raise ArgumentError, "provide patterns or a pattern block, not both" if block && !patterns.empty?
38
+
39
+ rules = @rules.dup.freeze
40
+ Expect.__send__(
41
+ :run_expect, [session], [], timeout, deadline:, consume: true,
42
+ reset_timeout_on_read: false, match_by_position: true
43
+ ) do |list|
44
+ patterns.each do |pattern|
45
+ case pattern
46
+ when :eof then list.eof
47
+ when :timeout then list.timeout
48
+ else list.on(pattern)
49
+ end
50
+ end
51
+ if block
52
+ block.parameters.empty? ? list.instance_exec(&block) : block.call(list)
53
+ end
54
+ unless list.sessions.all? { |source| source.equal?(session) }
55
+ raise ArgumentError, "response completion patterns must use the same Session"
56
+ end
57
+
58
+ register(list, rules)
59
+ end
60
+ end
61
+
62
+ # 摘要不展示模式、回复或回调闭包,避免意外记录凭据。
63
+ def inspect = "#<#{self.class} rules=#{@rules.size}>"
64
+
65
+ private
66
+
67
+ # 静态回复与动作块互斥,次数必须是明确的正整数,不做宽松转换。
68
+ def validate_action!(reply, limit, block)
69
+ unless (reply.is_a?(String) && !block) || (reply.nil? && block)
70
+ raise ArgumentError, "provide a String reply or a response block, not both"
71
+ end
72
+ return if limit.nil? || (limit.is_a?(Integer) && limit.positive?)
73
+
74
+ raise ArgumentError, "response limit must be a positive Integer"
75
+ end
76
+
77
+ # 计数只属于本轮;先检查再发送,不重试写入或回调失败的部分副作用。
78
+ def register(list, rules)
79
+ rules.each_with_index do |(pattern, reply, limit, block), index|
80
+ count = 0
81
+ list.on(pattern) do |session|
82
+ raise ResponseError, "response rule #{index + 1} matched empty text" if session.match.empty?
83
+ raise ResponseError, "response rule #{index + 1} exceeded its limit" if limit && count >= limit
84
+
85
+ count += 1
86
+ block ? block.call(session) : session.write(reply)
87
+ CONTINUE_WITHOUT_RESET
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -181,7 +181,8 @@ module Expect
181
181
  raise IOError, "closed Expect session" if closed? || writer.closed?
182
182
 
183
183
  begin
184
- data = objects.map { |object| object.to_s.b }.join
184
+ # 单对象不需要中间数组与 join 副本;to_s、诊断和部分写入仍经过同一条路径。
185
+ data = objects.size == 1 ? objects.first.to_s.b : objects.map { |object| object.to_s.b }.join
185
186
  trace_data(:sending, data)
186
187
  rescue WriteTimeout
187
188
  # 转换或诊断中的嵌套写入不属于当前命令;此时尚未向 writer 发送任何字节。
@@ -252,20 +253,28 @@ module Expect
252
253
 
253
254
  # 先在自然退出期限内收集尾部输出,再关闭句柄并最多发送 TERM;不会发送 KILL。
254
255
  # 尚未退出时返回 nil 并保留 PID,调用方可以继续等待或随后硬关闭。
256
+ # 读尾或交付失败仍完成软关闭,保留主异常;失败路径跳过自然等待,不升级为 KILL。
255
257
  def soft_close(timeout: 15, term_timeout: 1)
256
258
  period = Expect.duration(timeout)
257
259
  term_timeout = Expect.duration(term_timeout)
258
260
  raise ArgumentError, "term_timeout must be finite" unless term_timeout
259
261
 
260
262
  deadline = period && (Expect.monotonic + period)
261
- until eof?
262
- remaining = deadline && [deadline - Expect.monotonic, 0].max
263
- break if remaining&.zero? || !to_io.wait_readable(remaining)
263
+ status = nil
264
+ cleanup = lambda do |completed|
265
+ # 读尾失败后不再等待自然退出,尤其不能用 nil 预算无限阻塞异常传播。
266
+ remaining = completed ? (deadline && [deadline - Expect.monotonic, 0].max) : 0
267
+ status = close_resources(timeout: remaining, term_timeout:, force: false)
268
+ end
269
+ Cleanup.always(cleanup, report_completion: true) do
270
+ until eof?
271
+ remaining = deadline && [deadline - Expect.monotonic, 0].max
272
+ break if remaining&.zero? || !to_io.wait_readable(remaining)
264
273
 
265
- read_available
274
+ read_available
275
+ end
266
276
  end
267
- close_resources(timeout: deadline ? [deadline - Expect.monotonic, 0].max : nil,
268
- term_timeout:, force: false)
277
+ status
269
278
  end
270
279
 
271
280
  # 立即关闭句柄,再分阶段等待、TERM、KILL;不收集剩余输出,返回已回收状态或 nil。
@@ -323,7 +332,8 @@ module Expect
323
332
  # 记录超时、EOF 或原始 IO 异常,保留当前缓冲快照并清除旧匹配及捕获组。
324
333
  # @api private
325
334
  def record_error(error)
326
- @last_result = Result.new(error:, before: buffer, session: self, captures: [])
335
+ # Result 自行复制冻结文本,无须先通过公开 buffer 再复制一遍。
336
+ @last_result = Result.new(error:, before: @buffer, session: self, captures: [])
327
337
  end
328
338
 
329
339
  # 输入结束时将剩余缓冲放入 before 并清空,尝试回收但不终止仍活跃的子进程。
@@ -566,8 +576,8 @@ module Expect
566
576
  # 只结束读取方向并冲刷接收记录;EOF 不代表子进程已经退出或写端已经关闭。
567
577
  def mark_eof
568
578
  @eof = true
569
- flush_transcript
570
- flush_diagnostics(:received)
579
+ # EOF 只登记一次;任一日志尾部失败也必须尝试另一通道,保持首个常规异常。
580
+ Cleanup.always(-> { flush_diagnostics(:received) }) { flush_transcript }
571
581
  nil
572
582
  end
573
583
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.7.8"
5
+ VERSION = "0.8.1"
6
6
  end
data/lib/expect.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "expect/pattern_list"
11
11
  require_relative "expect/matcher"
12
12
  require_relative "expect/cleanup"
13
13
  require_relative "expect/session"
14
+ require_relative "expect/responder"
14
15
 
15
16
  # PTY 会话的创建、匹配与转接入口;可变状态只属于返回的 Session。
16
17
  module Expect
@@ -149,7 +150,8 @@ module Expect
149
150
  end
150
151
 
151
152
  # 声明完成后固定模式;有参数块保留调用方 self,无参数块使用模式 DSL。
152
- def run_expect(sessions, patterns, timeout, deadline:, consume:, reset_timeout_on_read:, &block)
153
+ def run_expect(sessions, patterns, timeout, deadline:, consume:, reset_timeout_on_read:,
154
+ match_by_position: false, &block)
153
155
  timeout = duration(timeout)
154
156
  deadline = Float(deadline) unless deadline.nil?
155
157
  raise ArgumentError, "deadline must be finite" if deadline && !deadline.finite?
@@ -159,7 +161,7 @@ module Expect
159
161
  if block
160
162
  block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
161
163
  end
162
- Matcher.new(pattern_list, timeout, deadline:, consume:, reset_timeout_on_read:).run
164
+ Matcher.new(pattern_list, timeout, deadline:, consume:, reset_timeout_on_read:, match_by_position:).run
163
165
  end
164
166
  end
165
167
  end
data/sig/expect.rbs CHANGED
@@ -263,6 +263,21 @@ module Expect
263
263
  def timeout: () ?{ (Array[Session]) -> untyped } -> PatternList
264
264
  end
265
265
 
266
+ class Responder
267
+ class ResponseError < StandardError
268
+ end
269
+
270
+ def initialize: () ?{ (Responder) -> untyped } -> void
271
+
272
+ def on: ((String | Regexp) pattern, ?reply: String?, ?limit: Integer?)
273
+ ?{ (Session) -> untyped } -> Responder
274
+
275
+ def expect: (Session session, *pattern patterns, ?timeout: duration?, ?deadline: duration?)
276
+ ?{ (PatternList) -> untyped } -> Result
277
+
278
+ def inspect: () -> String
279
+ end
280
+
266
281
  class Redactor
267
282
  def self.redact: (String data, Array[String] patterns, ?replacement: String) -> String
268
283
 
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.7.8
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
@@ -77,6 +77,7 @@ files:
77
77
  - lib/expect/relay.rb
78
78
  - lib/expect/relay_writer.rb
79
79
  - lib/expect/resource_owner.rb
80
+ - lib/expect/responder.rb
80
81
  - lib/expect/result.rb
81
82
  - lib/expect/session.rb
82
83
  - lib/expect/version.rb