expect-pty 0.7.7 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45e6676261fec4b5bcd7db41298fd513a47da86308a29f53702311a6b5219f59
4
- data.tar.gz: 19354431d871147ebd778424288ea2d3a2f788d92e8674f6c779b8c560b23f1b
3
+ metadata.gz: ee45b8c572aa37af57de1fae7b298e0a73a1b1157bb77949edb3918b100b8a81
4
+ data.tar.gz: 6767f2e994b45d693e5701a9f153cc3991c48255c3bd6f0fec8ce5884aa72fb2
5
5
  SHA512:
6
- metadata.gz: efdb068466acf441e74d839e9830d31f59ab18bf989726319dc6f97fb049d34a913ef53fdc399b6287bebc94d3993a5b97fb8fe41965c3c1a5e6ed3fb9aba924
7
- data.tar.gz: c4c4d20ef93293c8958da3ffcf6f2ebc0245b37b07caea6fe5f2af3b9ef73ba29d365134f5c7be1a693a43a3c163724143a805ad4f19d27266df7ec7e9485717
6
+ metadata.gz: b2e908dc05179ec58c9cfa11d5105f9e1b1b4089996a588ae2f14dd93b5218e0478ad43e749320200b98d880da54d0381963f7dc59891e943f0d1092bf4b4783
7
+ data.tar.gz: ae06bd3ffdac6991bf510d10bf52665bfeaaaac6ea46a78b1e7fdc56f37fb6b4523c29e2a3519f6a374dd675df7cc94607c1b56923cf25bff89603bd95549362
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.0 - 2026-10-11
6
+
7
+ - 新增可复用的自动应答规则,支持重复确认、分页按键、动态回复和次数上限,按输入顺序处理提示且不延长等待期限,并提供批量脚本示例。
8
+ - 修复直接软关闭在读尾、日志交付失败或取消时跳过资源清理的问题,保留原始异常和不发送 KILL 的约定,失败时跳过自然退出等待。
9
+ - 减少结果快照、继续匹配、单对象写入和正则转义的临时分配,保留不可变结果、捕获、编码校验及部分写入进度。
10
+
11
+ ## 0.7.8 - 2026-10-11
12
+
13
+ - 将内部资源所有权对象命名为 ResourceOwner,并统一源码、示例和测试辅助代码的中文注释。
14
+ - 补齐字节前缀算法、脱敏窗口及基准、API 校验和示例辅助方法的契约注释,明确缓存、输入和副作用边界。
15
+
5
16
  ## 0.7.7 - 2026-10-11
6
17
 
7
18
  - 修复人工接管时终端模式交接中断、恢复异常覆盖业务主异常的问题;分别尝试恢复两侧输出、转义规则和终端模式。
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
@@ -142,7 +142,7 @@ module Expect
142
142
  # 仅在本轮扫描复用组合文本;回调可能更换规则或消费缓冲,下一轮必须重新构造。
143
143
  text ||= history + buffer
144
144
  utf8_regexp ||= key.fixed_encoding? && key.encoding == Encoding::UTF_8
145
- position = Pattern.new(value: key).locate(text, final:)
145
+ position = Pattern.new(value: key).locate(text, final:, captures: false)
146
146
  if position
147
147
  offset, length, = position
148
148
  raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
@@ -2,14 +2,19 @@
2
2
 
3
3
  module Expect
4
4
  # 计算字节窗口尾部与规则真前缀重合的最长长度;转义暂存和流尾脱敏使用同一边界判断。
5
- # 不持有输入或修改缓冲,调用方可为冻结规则提供前缀表缓存;短规则不建立表。
5
+ # 不持有输入或修改缓冲;仅长候选的扫描需要 KMP 回退表,冻结规则可跨调用缓存该表。
6
6
  # @api private
7
7
  module LiteralPrefix
8
+ # 返回 buffer 后缀与 pattern 真前缀重合的最大字节数;真前缀不包含完整 pattern。
9
+ # 例如 "xAB" 与 "ABC" 返回 2;空输入、长度不足 2 的规则或没有重合时返回 0。
10
+ # 两个字符串须由调用方转为二进制编码;此处不转码、不校验,也不修改它们。
11
+ # prefix_tables 为可选的可变 Hash;仅冻结 pattern 使用缓存,否则为本次调用建立临时表。
8
12
  def self.length(buffer, pattern, prefix_tables = nil)
9
13
  maximum = [pattern.bytesize - 1, buffer.bytesize].min
10
14
  return 0 unless maximum.positive?
11
15
 
12
- # 尾字节不在候选前缀内时无需扫描;最长候选本身命中时也不必构造 KMP 表。
16
+ # 重合前缀的末字节必须等于输入尾字节;排除无法结束在该字节上的更长候选。
17
+ # 最长候选直接命中时不必构造回退表;小于 64 字节的候选改用有界枚举。
13
18
  ending = pattern.rindex(buffer.byteslice(-1, 1), maximum - 1)
14
19
  return 0 unless ending
15
20
 
@@ -19,6 +24,7 @@ module Expect
19
24
 
20
25
  failure = prefix_tables && pattern.frozen? ? (prefix_tables[pattern] ||= []) : []
21
26
  extend_failure_table(pattern, failure, maximum)
27
+ # 只扫描可能重合的尾部;不匹配时沿回退表保留已有重合,避免从每个候选起点重扫。
22
28
  matched = 0
23
29
  index = buffer.bytesize - maximum
24
30
  while index < buffer.bytesize
@@ -30,6 +36,9 @@ module Expect
30
36
  matched
31
37
  end
32
38
 
39
+ # 将同一规则的可变回退表补齐到 length 项,已有项不重算,较长的缓存也不截断。
40
+ # failure[i] 是 pattern[0..i] 的最长真前缀与后缀重合长度,供失配时选择下一匹配状态。
41
+ # length 不超过规则的真前缀长度;表只能用于生成它的同一规则内容。
33
42
  def self.extend_failure_table(pattern, failure, length)
34
43
  failure << 0 if failure.empty?
35
44
  matched = failure.last
@@ -49,6 +58,8 @@ module Expect
49
58
  end
50
59
  end
51
60
 
61
+ # 从 maximum 向下枚举短候选,返回首个后缀重合长度,无命中时返回 0。
62
+ # 调用方已限制候选规模;每次比较只切取规则前缀,不创建完整输入的副本或回退表。
52
63
  def self.short_length(buffer, pattern, maximum)
53
64
  maximum.downto(1) do |length|
54
65
  return length if buffer.end_with?(pattern.byteslice(0, length))
@@ -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。
@@ -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)
@@ -100,11 +103,15 @@ 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
+ return [session, pattern, position] unless @match_by_position
109
+
110
+ earliest = [session, pattern, position] if !earliest || position.first < earliest.last.first
104
111
  end
105
112
  end
106
113
  end
107
- nil
114
+ earliest
108
115
  end
109
116
 
110
117
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -137,14 +144,15 @@ module Expect
137
144
 
138
145
  # 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
139
146
  def handle_match(session, pattern, position)
140
- previous_buffer = session.buffer
147
+ # 没有回调的终止匹配不可能继续,无须复制完整缓冲建立防空转快照。
148
+ previous_buffer = session.buffer if pattern.callback
141
149
  received_bytes = session.received_bytes
142
150
  result = session.record_match(pattern, position, consume: @consume)
143
151
  action = pattern.call(session)
144
152
  return result unless continuing?(action)
145
153
 
146
154
  # 同值赋回仍须防止空转;嵌套读取到的新字节即使内容相同,也能恢复匹配。
147
- if session.received_bytes == received_bytes && session.buffer == previous_buffer
155
+ if session.received_bytes == received_bytes && session.scan_buffer == previous_buffer
148
156
  stalled = (@stalled_matches[session] ||= { buffer: previous_buffer, received_bytes:, patterns: [] })
149
157
  stalled[:patterns] << pattern
150
158
  end
@@ -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
@@ -13,7 +13,7 @@ module Expect
13
13
  filter.append(data) + filter.finish(partial: false)
14
14
  end
15
15
 
16
- # pending 保存尚不能安全输出的原字节,hidden 的对应字节用 0/1 表示是否需要遮盖。
16
+ # 复制冻结替换文本与规则;pending 保存尚不能安全输出的原字节,hidden 用 0/1 标记遮盖。
17
17
  def initialize(patterns, replacement: "[FILTERED]")
18
18
  unless replacement.is_a?(String) && !replacement.empty?
19
19
  raise ArgumentError, "replacement must be a nonempty String"
@@ -67,6 +67,7 @@ module Expect
67
67
  private
68
68
 
69
69
  # 输出资格与秘密掩码分开保存;仅遇到被抑制的分片时才分配额外字节图。
70
+ # 三个字节窗口始终对齐;先标记完整命中,再释放最长规则长度减一之外的安全前缀。
70
71
  def append_chunk(data, visible:)
71
72
  raise ArgumentError, "data must be a String" unless data.is_a?(String)
72
73
 
@@ -147,6 +148,7 @@ module Expect
147
148
  end
148
149
 
149
150
  # 仅级别切换后的流需要按输出资格再分段,普通过滤不承担额外区间调用。
151
+ # 将指定掩码区间中可见的字节追加到 output;被抑制的明文会断开 masking,隐藏字节不会。
150
152
  def release_suppressed(output, starting, ending, hidden:)
151
153
  while starting < ending
152
154
  suppressed = @suppressed.getbyte(starting) == 1
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Expect
4
- # 独立保存句柄和 PID 所有权,让终结器无需直接捕获会话即可清理遗弃资源。
5
- # IO 是否关闭与子进程是否退出分别记录;不能仅凭句柄状态清空 PID 或伪造退出状态。
4
+ # 管理 IO 关闭责任和直属子进程归属,供显式关闭与 GC 回收共用。
5
+ # 不直接引用 Session、日志目标或用户回调;句柄关闭与进程退出分别记录。
6
6
  # @api private
7
- class SessionResources
8
- # 缓存已取得的状态;被外部回收的进程仍保留未知状态,不伪造成功。
7
+ class ResourceOwner
8
+ # 缓存已取得的 Process::Status;被外部回收的进程仍保留未知状态,不伪造成功。
9
9
  attr_accessor :status
10
10
  # own 控制句柄关闭责任,owner 控制进程回收责任;fork 后两者不能混为一谈。
11
11
  attr_reader :pid, :reader, :writer, :slave, :owner, :own
@@ -60,7 +60,7 @@ module Expect
60
60
  status
61
61
  end
62
62
 
63
- # GC 兜底关闭所属句柄并终止尚存活的子进程;不执行用户代码或软关闭等待。
63
+ # GC 兜底关闭所属句柄并终止尚存活的子进程;不调用会话日志或转义回调,也不进行软关闭等待。
64
64
  # 可直接用绑定到资源对象的 Method 注册终结器,忽略 Ruby 传来的被回收对象 ID。
65
65
  def finalize(_object_id = nil)
66
66
  return unless owner == Process.pid
@@ -77,7 +77,7 @@ module Expect
77
77
 
78
78
  private
79
79
 
80
- # GC 只做一次非阻塞回收和最多两次信号尝试;失败也把等待交给 detach,不运行用户回调。
80
+ # GC 只做一次非阻塞回收和最多两次信号尝试;失败也把等待交给 detach,不调用会话日志或转义回调。
81
81
  def finalize_child
82
82
  begin
83
83
  reap
@@ -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
@@ -5,7 +5,7 @@ require_relative "interaction"
5
5
 
6
6
  module Expect
7
7
  # 一个真实 PTY 或 IO 会话;缓冲、最近结果和所属进程都保存在本对象中。
8
- # SessionResources 保存可独立回收的资源账本;Matcher、Relay 仅在操作期间借用会话状态。
8
+ # ResourceOwner 保存可独立回收的资源账本;Matcher、Relay 仅在操作期间借用会话状态。
9
9
  # 日志与协议目标属于调用方,显式关闭和 GC 均不得接管这些借用对象。
10
10
  class Session
11
11
  include Logging
@@ -58,7 +58,7 @@ module Expect
58
58
 
59
59
  @slave.raw! if raw
60
60
  from_child = to_parent = nil
61
- Cleanup.always(-> { SessionResources.close_handles(from_child, to_parent) }) do
61
+ Cleanup.always(-> { ResourceOwner.close_handles(from_child, to_parent) }) do
62
62
  # 错误管道的写端在 exec 成功时自动关闭;父进程据此区分成功启动与 exec 前失败。
63
63
  # 管道返回到多重赋值之间也属于接管区间,确保 ensure 始终能找到两个端点。
64
64
  Thread.handle_interrupt(Object => :never) { from_child, to_parent = IO.pipe }
@@ -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。
@@ -290,7 +299,7 @@ module Expect
290
299
  if @resources
291
300
  close(graceful:)
292
301
  elsif own
293
- SessionResources.close_handles(reader, writer, slave)
302
+ ResourceOwner.close_handles(reader, writer, slave)
294
303
  end
295
304
  end
296
305
 
@@ -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 并清空,尝试回收但不终止仍活跃的子进程。
@@ -386,7 +396,7 @@ module Expect
386
396
  # 初始化前先登记所有权;参数校验失败也能释放已取得的端点。
387
397
  def initialize_io(reader, writer:, slave: nil, own: false, timeout: nil, write_timeout: nil,
388
398
  buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
389
- @resources = SessionResources.new(reader, writer:, slave:, own:)
399
+ @resources = ResourceOwner.new(reader, writer:, slave:, own:)
390
400
  raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
391
401
  raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
392
402
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.7.7"
5
+ VERSION = "0.8.0"
6
6
  end
data/lib/expect.rb CHANGED
@@ -5,12 +5,13 @@ require "io/console"
5
5
  require "stringio"
6
6
  require_relative "expect/version"
7
7
  require_relative "expect/result"
8
- require_relative "expect/session_resources"
8
+ require_relative "expect/resource_owner"
9
9
  require_relative "expect/pattern"
10
10
  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
@@ -72,7 +73,7 @@ module Expect
72
73
  if session && (block_given? || !initialized)
73
74
  session.cleanup_session(io, writer:, own:, graceful: initialized && graceful)
74
75
  elsif !session && own
75
- SessionResources.close_handles(io, writer)
76
+ ResourceOwner.close_handles(io, writer)
76
77
  end
77
78
  end
78
79
  Cleanup.always(cleanup) do
@@ -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
@@ -1,4 +1,4 @@
1
- # Public Session API; internal matching and relay protocols are documented with @api private.
1
+ # 公开 Session 接口;内部匹配与转接协议在源码中标记为 @api private。
2
2
  module Expect
3
3
  type duration = Numeric | String
4
4
  type pattern = String | Regexp | :eof | :timeout
@@ -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.7
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
@@ -76,9 +76,10 @@ files:
76
76
  - lib/expect/redactor.rb
77
77
  - lib/expect/relay.rb
78
78
  - lib/expect/relay_writer.rb
79
+ - lib/expect/resource_owner.rb
80
+ - lib/expect/responder.rb
79
81
  - lib/expect/result.rb
80
82
  - lib/expect/session.rb
81
- - lib/expect/session_resources.rb
82
83
  - lib/expect/version.rb
83
84
  - sig/expect.rbs
84
85
  homepage: https://github.com/gatework/expect-ruby