expect-pty 0.8.3 → 0.9.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: 4d2dcd5a8b2bbfcce02c7cc9873fda4c6574158ffe82a607f6b608ee65a261bc
4
- data.tar.gz: fbecdd112d168d10b05d5e14a87f733021469f5a6c42963562779ad97e5c9456
3
+ metadata.gz: b56d5e855684574710a2b3de08554ff517979a5ad8a96ff455780f55b5313b4a
4
+ data.tar.gz: 7f9b80ed4cbe5b0bc98bee0b1400941cecbfefe4229c23fedd9837ce8550d393
5
5
  SHA512:
6
- metadata.gz: 8ca9bae4a304942c41d19c274cbbeff9dacdb0c2d966d13e57a3c7dc8fbe4d81b4130c900d8e4e26f48600e7410e60d3677c206163b7bec88b7f474a49afe17f
7
- data.tar.gz: 680b2db6dfbb16f4646f8e102c4607cda2d1e3781c7ab675fcde2234a3ad60d9f8edde425afcfc6c1aaa6c7d57eca5dab03033a884ed66ca7922f4b702d9f5c8
6
+ metadata.gz: ff9e7769c5ecfd1b4d6df2e6405b37acb9c8bcf5edbc5b0fbeea570bd50c774b1020a53d4593fb344efdd2404b8de0cce03f14c503011ab8079ac1649e4234be
7
+ data.tar.gz: 40d52a9b7388f2c740842b3c6abcd4c1d04787b4cf1c1ed20d7e855113e9d333330820f1404475a91698f9870426da2d85aeece9b728ed989b4628d3ef00b965
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.1 - 2026-10-18
6
+
7
+ - 修复自定义 Numeric 转换失败时泄漏常规异常的问题;时间参数统一抛出 ArgumentError 并保留 cause,Interrupt/SystemExit 仍原样传播。
8
+
9
+ ## 0.9.0 - 2026-10-18
10
+
11
+ - **不兼容**:命令默认直接按 argv 执行,单字符串也只作为可执行文件名,需要 shell 语法时须显式传入 `shell: true`。
12
+ - **不兼容**:移除与标准库同名的加载入口,本库统一使用 `require "expect/pty"`,`require "expect"` 恢复加载标准库的 IO#expect。
13
+ - **不兼容**:时间参数只接受有限实数 Numeric 或 nil,不再解析字符串,非法值统一抛出 ArgumentError。
14
+ - 新增每次等待的 `search_window` 字节窗口选项,限制长输出中的字符串和正则扫描范围,匹配结果仍保留完整缓冲偏移。
15
+ - 启动失败保留 `SpawnError#errno` 和 SystemCallError cause,包含非法编码字节的路径也能正确报告失败。
16
+ - 缩短已退出子进程的关闭等待,并减少未启用日志时的匹配分配。
17
+ - 修复正则转接在小缓冲窗口下丢失 UTF-8 字符前缀的问题,合法分片可跨超时继续匹配,EOF 时仍拒绝未完成字符。
18
+
5
19
  ## 0.8.3 - 2026-10-17
6
20
 
7
21
  - 修复多参数写入和日志补记受 Ruby 全局分隔符 `$,` 影响而插入额外字节的问题,保持原始字节和写入计数准确。
data/README.md CHANGED
@@ -34,7 +34,7 @@ Expect.spawn("ruby", "-e", program) do |session|
34
34
  end
35
35
  ~~~
36
36
 
37
- The block closes the session and reaps its child process, including when the block raises. Use separate command arguments for untrusted input; a single command string follows Ruby shell semantics.
37
+ The block closes the session and reaps its child process, including when the block raises. Commands execute directly with separate argv entries, including a single executable string. Shell syntax requires explicit `shell: true` with one command string; never pass untrusted input into that string.
38
38
 
39
39
  ## Features
40
40
 
data/README.zh-CN.md CHANGED
@@ -34,7 +34,7 @@ Expect.spawn("ruby", "-e", program) do |session|
34
34
  end
35
35
  ~~~
36
36
 
37
- 代码块退出时会关闭会话并回收子进程,异常路径也会执行清理。不可信命令参数应分别传入;单个命令字符串遵循 Ruby shell 语义。
37
+ 代码块退出时会关闭会话并回收子进程,异常路径也会执行清理。命令默认直接执行,单字符串也仅作为可执行文件名;需要 shell 语法时显式传入 `shell: true`,且只能传一个命令字符串,不要将外部输入拼入该字符串。
38
38
 
39
39
  ## 功能
40
40
 
data/docs/API.md CHANGED
@@ -5,13 +5,16 @@
5
5
 
6
6
  ## 创建与关闭
7
7
 
8
- `Expect.spawn(*command, env: {}, chdir: nil, raw: false, graceful: false, **settings)` 创建并启动 PTY。
8
+ `Expect.spawn(*command, env: {}, chdir: nil, raw: false, shell: false, graceful: false, **settings)` 创建并启动 PTY。
9
9
  `settings` 是下文列出的六个显式会话关键字的统称,并非任意配置 Hash。需要预设终端时先 `Expect::Session.new(**settings)`,
10
- 通过 `session.slave` 使用原生终端方法,再调用 `session.spawn(*command, env: {}, chdir: nil, raw: false)`。
10
+ 通过 `session.slave` 使用原生终端方法,再调用 `session.spawn(*command, env: {}, chdir: nil, raw: false, shell: false)`。
11
11
  Session 构造器不接收命令或操作参数。同一会话只能启动一次。
12
12
 
13
- 单字符串遵循 Ruby 的自动 shell 语义;多个独立参数按 argv 执行。外部输入应作为独立参数传入。
14
- 启动失败抛出 `Expect::SpawnError`;工厂失败时回收尚未交付的会话。
13
+ 命令默认直接按可执行文件名与 argv 执行,单字符串也只作为文件名。需要 shell 语法时显式指定
14
+ `shell: true`,仅接受一个字符串并交给 `/bin/sh -c`;外部输入应作为独立 argv 传入。
15
+ 启动失败抛出 `Expect::SpawnError`;系统错误的 `errno` 和 `cause` 保存错误编号与相应 `SystemCallError`。
16
+ 通过 `error.cause.is_a?(Errno::ENOENT)` 判断原因,外层仍为 SpawnError。非系统错误保留描述,不按任意类名恢复异常。
17
+ 工厂失败时回收尚未交付的会话。
15
18
 
16
19
  `Expect.open(io, writer: io, own: false, graceful: false, **settings)` 适配真实可 select 的 IO,默认借用。
17
20
  `own: true` 接管关闭责任,包括初始化中已知设置的非法值导致的失败;未知关键字在 Ruby 方法入口拒绝,此时不接管 IO。
@@ -28,7 +31,7 @@ nil。
28
31
 
29
32
  ## 匹配与回调
30
33
 
31
- `session.expect(*patterns, timeout: session.timeout, deadline: nil, consume: true, reset_timeout_on_read: false)` 返回不可变
34
+ `session.expect(*patterns, timeout: session.timeout, deadline: nil, consume: true, reset_timeout_on_read: false, search_window: nil)` 返回不可变
32
35
  `Expect::Result`。
33
36
  `Expect.expect` 使用相同操作关键字,另加 `from:` 指定默认来源;其 timeout 默认 nil。模式为 String、Regexp、`:eof` 或
34
37
  `:timeout`,
@@ -55,6 +58,13 @@ result => { number:, captures: }
55
58
  `reset_timeout_on_read: true` 使每次读取重置本轮相对期限。`deadline` 是单调时钟绝对秒数,不被输入或回调延长。
56
59
  IO 期限不打断用户代码或单次正则;不可信正则应使用 Ruby `Regexp` 自身的 timeout。
57
60
 
61
+ `search_window` 为正 Integer 字节数或 nil,只作用于本次等待,包括 Responder 的完成模式与应答规则。
62
+ nil 保留全缓冲扫描;指定时,字符串和正则只搜索最新的尾部窗口。它不限制接收缓冲内存,也不丢弃旧字节,
63
+ 结果的 before/after 和消费范围仍相对于完整缓冲。长输出建议同时按业务需要设置 `buffer_limit` 并用 transcript 保存全文。
64
+ 窗口必须容纳完整提示与跨读取片段,窗口之外的模式可能错过;正则的锚点、前瞻和后顾只看到独立窗口。
65
+ 固定 UTF-8 正则跳过边界切开的合法首字符,等待合法未完成尾字符;窗口内非法字节及 EOF 未完成字符仍报错。
66
+ 二进制正则按原始字节匹配。
67
+
58
68
  Result 字段为 `number`、`error`、`match`、`before`、`after`、`session`、`captures`。对象、文本及捕获数组均不可变,未参与捕获为
59
69
  nil。
60
70
  错误为 nil、`:timeout`、`:eof` 或原始 IO 异常;来源会话与异常不复制、不冻结。使用 `matched?`、`timeout?`、`eof?` 查询,
@@ -89,7 +99,7 @@ end
89
99
  静态字符串复制并冻结,块收到带 `before`、`match`、`captures` 的 Session;块返回值忽略,应答后继续等待。
90
100
  - `reply` 原样发送,行确认自行包含 `\n` 或 `\r`,分页可发送空格;空回复合法但不产生字节。
91
101
  不内置通用“全部确认”规则,调用方应按目标设备及业务选择精确提示和回复。
92
- - `expect(session, *patterns, timeout: session.timeout, deadline: nil)` 仅处理一个 Session;也可使用与 `expect` 相同的
102
+ - `expect(session, *patterns, timeout: session.timeout, deadline: nil, search_window: nil)` 仅处理一个 Session;也可使用与 `expect` 相同的
93
103
  PatternList 块声明完成模式、EOF 和 timeout 回调。位置参数模式与块不能同时提供。
94
104
  - 自动应答与完成模式按最早的字节位置处理;同位置时完成模式先于应答,多个应答按声明顺序。
95
105
  不按正则长度推测未完成提示,不在未知提示上猜测回复。仅将成功匹配的文本视为完成,业务错误输出需由调用方注册为终止模式并检查。
@@ -122,6 +132,7 @@ end
122
132
  操作参数 `raw`、`consume`、`reset_timeout_on_read`、`graceful` 不持久化为会话设置。
123
133
  `buffer` 返回副本,`buffer=` 复制并应用上限,`clear_buffer` 移交并清空缓冲;`buffer_discarded_bytes` 累计窗口裁剪量,匹配消费不计入。
124
134
  显式替换/清空、实际裁剪和消费输入会清除正则转义历史;非消费匹配、未裁剪的等待与内部缓冲交接保持连续历史。
135
+ 正则转接历史默认保留至多 65,536 字节,设置 `buffer_limit` 后采用该上限。存在固定 UTF-8 正则时,历史预算至少为 3 字节,以保留四字节字符尚未接收完整的前缀;这不扩大普通匹配缓冲的上限,也不延迟协议转发。
125
136
 
126
137
  `write(*objects)` 按 to_s 写字节并返回字节数,`<<` 返回 Session,`puts` 遵循 Ruby IO 换行与数组规则并返回 nil。
127
138
  `send_slow(*objects, delay:)` 按字符延迟发送并收取回复。背压超时抛出 `Expect::WriteTimeout`,`bytes_written` 仅标识本次调用已确认进度,
@@ -165,5 +176,7 @@ outputs、转义和本地终端模式。
165
176
  `raw: false` 将终端设置留给调用方。transcript、logger 及自定义 writer 同步执行,应及时返回。
166
177
 
167
178
  `Expect.monotonic` 读取单调时钟,`Expect.duration` 校验秒数,`Expect.readable_sessions(*sources, timeout: 0)` 返回就绪来源。
179
+ 时间参数只接受有限实数 Numeric 或 nil,拒绝字符串、Complex、布尔值及非有限数,非法值统一抛 ArgumentError。
180
+ 相对期限必须非负,deadline 允许负数表示已到期。
168
181
  Matcher、Relay、资源账本和标为 `@api private` 的 Session 协作方法不是用户契约。RBS 覆盖公开声明;`rbs validate` 不验证 Ruby
169
182
  方法体。
data/docs/MIGRATION.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # 迁移说明
2
2
 
3
+ ## 0.8.x → 0.9.0:启动与时间参数
4
+
5
+ - 始终使用 `require "expect/pty"`,Gemfile 指定 `require: "expect/pty"`。本库删除 `lib/expect.rb` 后,
6
+ `require "expect"` 恢复加载标准库的 IO#expect;两者可按任意顺序加载。
7
+ - `Expect.spawn("echo A; echo B")` 改为 `Expect.spawn("echo A; echo B", shell: true)`;
8
+ 常规命令优先拆成 `Expect.spawn("echo", argument)`。单字符串默认只查找同名可执行文件,含空格的路径无需 shell 转义。
9
+ - timeout、write_timeout、delay、deadline 等秒数不再接受字符串;在应用输入边界明确解析后传入 Numeric。
10
+ nil 仍表示无期限,相对秒数须非负,deadline 允许已过期的负数,非法值统一为 ArgumentError。
11
+ - 启动错误仍 rescue `Expect::SpawnError`,通过 `errno` 或 `cause` 判断系统原因。
12
+ 错误消息中不可编码字节替换为 Unicode 替代字符,原始 errno 保留。
13
+ - `search_window:` 是可选的每次等待参数,默认 nil 保持全缓冲扫描;与 buffer_limit 的区别见 API。
14
+
3
15
  ## 0.6.x → 0.7.0
4
16
 
5
17
  0.7.0 主动移除门面和旧配置接口,不提供兼容别名。最低版本仍为 Ruby 3.4,`require "expect/pty"` 和不可变 Result 保持不变。
@@ -65,4 +77,4 @@ end
65
77
  - 模式进入匹配器后冻结,运行中不能追加或替换本轮规则。
66
78
  - Gem 仅携带源码、RBS 与使用文档;测试、示例、基准和发布脚本从仓库取得。
67
79
  - 0.6.0 曾保留外层 Expect 门面和布尔配置,这两部分已由 0.7.0 的 Session 与操作关键字替代。
68
- - 单字符串命令保留 Ruby 自动 shell 语义;多参数命令按 argv 传入。
80
+ - 0.7.0 当时保留单字符串自动 shell 语义;0.9.0 的变更见上文。
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expect
4
+ # 启动与监管直属子进程;资源身份只保存在传入的 ResourceOwner,不持有 Session。
5
+ # @api private
6
+ class ChildProcess
7
+ def initialize(resources)
8
+ @resources = resources
9
+ end
10
+
11
+ def pid = @resources.pid
12
+
13
+ # 状态未知或 EINTR 时不伪造退出事实;等待循环继续使用原期限。
14
+ def status
15
+ @resources.reap
16
+ rescue Errno::EINTR
17
+ @resources.status
18
+ end
19
+
20
+ # 管道、fork 与 PID 登记维持原所有权窗口;可由 Session 提供子进程启动钩子。
21
+ def spawn(command, env:, chdir:, shell:)
22
+ from_child = to_parent = nil
23
+ Cleanup.always(-> { ResourceOwner.close_handles(from_child, to_parent) }) do
24
+ Thread.handle_interrupt(Object => :never) { from_child, to_parent = IO.pipe }
25
+ to_parent.close_on_exec = true
26
+ child = Thread.handle_interrupt(Object => :never) do
27
+ @resources.pid = fork do
28
+ Thread.handle_interrupt(Object => :immediate) do
29
+ if block_given?
30
+ yield from_child, to_parent
31
+ else
32
+ exec_child(command, env:, chdir:, from_child:, to_parent:, shell:)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ to_parent.close
38
+ @resources.slave.close
39
+ failure = from_child.read
40
+ raise_spawn_error(failure) unless failure.empty?
41
+ child
42
+ end
43
+ end
44
+
45
+ # 每阶段只计算一次期限;回收或信号被中断后仍沿用剩余预算,零预算也先做一次尝试。
46
+ def wait(period, signal: nil)
47
+ deadline = period && (Expect.monotonic + period)
48
+ interval = 0.001
49
+ loop do
50
+ state = status
51
+ return state if state || !pid || @resources.owner != Process.pid
52
+
53
+ begin
54
+ self.signal(signal) if signal
55
+ signal = nil
56
+ rescue Errno::EINTR
57
+ # 下轮先回收再重试信号,避免在无限等待或持续中断时忙等。
58
+ nil
59
+ end
60
+ # ESRCH 后可能已完成回收;即使预算耗尽,也要返回刚获得的状态。
61
+ return status unless pid
62
+
63
+ remaining = deadline && (deadline - Expect.monotonic)
64
+ return nil if remaining && remaining <= 0
65
+
66
+ pause = remaining ? [interval, remaining].min : interval
67
+ block_given? ? yield(pause) : sleep(pause)
68
+ interval = [interval * 2, 0.01].min
69
+ end
70
+ end
71
+
72
+ # 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
73
+ def signal(signal)
74
+ return unless pid && @resources.owner == Process.pid
75
+
76
+ Process.kill(signal, pid)
77
+ rescue Errno::ESRCH
78
+ @resources.reap
79
+ end
80
+
81
+ # 子进程独占控制终端;成功 exec 关闭错误管道,失败时回传后立即退出。
82
+ def exec_child(command, env:, chdir:, from_child:, to_parent:, shell:)
83
+ from_child.close
84
+ Process.setsid
85
+ # 创建独立进程会话后重新打开 slave,使它成为子进程的控制终端。
86
+ File.open(@resources.slave.path, File::RDWR) do |terminal|
87
+ # 重定向操作系统的标准描述符;即使宿主替换过 Ruby 标准流,也能正确连接子进程。
88
+ # rubocop:disable Style/GlobalStdStream
89
+ STDIN.reopen(terminal)
90
+ STDOUT.reopen(terminal)
91
+ STDERR.reopen(terminal)
92
+ # rubocop:enable Style/GlobalStdStream
93
+ end
94
+ @resources.close_handles
95
+ Dir.chdir(chdir) if chdir
96
+ executable, *arguments = shell ? ["/bin/sh", "-c", command.first] : command
97
+ exec(env, [executable, executable], *arguments, close_others: true)
98
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 子进程回传启动异常后立即退出。
99
+ begin
100
+ message = error.message.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
101
+ to_parent.write(JSON.generate(class: error.class.name, message:,
102
+ errno: error.is_a?(SystemCallError) ? error.errno : nil))
103
+ ensure
104
+ exit! 127
105
+ end
106
+ end
107
+
108
+ # 错误管道只传递标量;系统错误从 errno 恢复,不动态加载异常类。
109
+ def raise_spawn_error(payload)
110
+ failure = JSON.parse(payload)
111
+ errno = failure.fetch("errno")
112
+ message = "#{failure.fetch("class")}: #{failure.fetch("message")}"
113
+ cause = errno ? SystemCallError.new(failure.fetch("message"), errno) : ChildFailure.new(message)
114
+ raise SpawnError.new(message, errno:), cause:
115
+ rescue JSON::ParserError, KeyError => error
116
+ raise SpawnError.new("invalid child startup failure"), cause: error
117
+ end
118
+ end
119
+ private_constant :ChildProcess
120
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "relay_writer"
3
+ require_relative "relay/writer"
4
4
  require_relative "relay"
5
5
  require_relative "literal_prefix"
6
6
 
@@ -188,6 +188,8 @@ module Expect
188
188
  # 已转发历史有界保留,固定 UTF-8 正则不能从续字节开始匹配。
189
189
  def self.trim_history(history, data, limit:, utf8:)
190
190
  history << data
191
+ # 四字节 UTF-8 字符在下一片到来前最多需要保留三个字节;小窗口不能截掉这段编码状态。
192
+ limit = [limit, 3].max if utf8
191
193
  return unless history.bytesize > limit
192
194
 
193
195
  history.replace(history.byteslice(-limit, limit))
@@ -204,15 +206,13 @@ module Expect
204
206
  def queue_output(data)
205
207
  @pending_writes = @outputs.map do |target|
206
208
  if target.is_a?(InteractOutput)
207
- RelayWriter.new(target.target, target.render(data))
209
+ Relay::Writer.new(target.target, target.render(data))
208
210
  else
209
- RelayWriter.new(target, data)
211
+ Relay::Writer.new(target, data)
210
212
  end
211
213
  end
212
214
  end
213
215
 
214
- public
215
-
216
216
  # 仅供转接内部保存和恢复注册表,避免公开可变 Hash 绕过 on_sequence 的校验。
217
217
  # @api private
218
218
  attr_reader :sequences
@@ -130,8 +130,11 @@ module Expect
130
130
  end
131
131
 
132
132
  # 向标准 Logger 提交不可变事件,不包含会话对象;格式化由 logger 完成。
133
- def trace(message, severity: Logger::INFO, event: :matched)
134
- logger&.add(severity, { event:, pid:, fd: fileno, message: message.freeze }.freeze, "Expect")
133
+ def trace(message = nil, severity: Logger::INFO, event: :matched)
134
+ return unless logger
135
+
136
+ message = yield if block_given?
137
+ logger.add(severity, { event:, pid:, fd: fileno, message: message.freeze }.freeze, "Expect")
135
138
  end
136
139
 
137
140
  # 结束当前日志片段并交付过滤器尾部;重复调用不会重放已释放字节,写入异常原样传播。
@@ -7,12 +7,13 @@ module Expect
7
7
  class Matcher
8
8
  # 固定本次参与的会话及初始期限;已处理 EOF 的会话仅从本次等待中移除。
9
9
  def initialize(patterns, timeout, deadline: nil, consume: true, reset_timeout_on_read: false,
10
- match_by_position: false)
10
+ match_by_position: false, search_window: nil)
11
11
  @patterns = patterns.finalize!
12
12
  @sessions = patterns.sessions
13
13
  @groups = patterns.text_groups
14
14
  @consume = consume
15
15
  @match_by_position = match_by_position
16
+ @search_window = search_window
16
17
  @reset_timeout_on_read = reset_timeout_on_read
17
18
  @timeout = Expect.duration(timeout)
18
19
  # 相对期限可因接收或 continue 重算,总期限始终固定;两者共用单调时钟。
@@ -97,7 +98,9 @@ module Expect
97
98
  return nil if hard_expired?
98
99
  next if stalled && stalled[:patterns].key?(pattern)
99
100
 
100
- position = if pattern.value.is_a?(String)
101
+ position = if @search_window
102
+ pattern.locate(buffer, final: session.eof?, search_window: @search_window)
103
+ elsif pattern.value.is_a?(String)
101
104
  locate_literal(session, pattern, buffer)
102
105
  else
103
106
  pattern.locate(buffer, final: session.eof?)
@@ -277,4 +280,5 @@ module Expect
277
280
  :retry
278
281
  end
279
282
  end
283
+ private_constant :Matcher
280
284
  end
@@ -21,7 +21,9 @@ module Expect
21
21
  # buffer 应为二进制字符串,offset 仅用于字面扫描;正则始终看到完整窗口以保留锚点语义。
22
22
  # captures 为 false 时只求字节范围,转义扫描不分配随后丢弃的捕获字符串。
23
23
  # final 表示不会再有新输入,此时不完整的编码尾部也必须报错,不能永远当作等待分片。
24
- def locate(buffer, final: false, offset: 0, captures: true)
24
+ def locate(buffer, final: false, offset: 0, captures: true, search_window: nil)
25
+ return locate_window(buffer, search_window, final:, captures:) if search_window && buffer.bytesize > search_window
26
+
25
27
  case value
26
28
  when String
27
29
  offset = buffer.index(value, offset)
@@ -50,6 +52,37 @@ module Expect
50
52
 
51
53
  private
52
54
 
55
+ # 显式尾部窗口单独匹配,返回偏移仍属于原始缓冲;字节协议不做转码。
56
+ def locate_window(buffer, size, final:, captures:)
57
+ start = buffer.bytesize - size
58
+ start = utf8_window_start(buffer, start, final:) if value.is_a?(Regexp) && value.fixed_encoding? &&
59
+ value.encoding == Encoding::UTF_8
60
+ return nil unless start
61
+
62
+ # byteslice 会与完整缓冲共享存储,使下次追加复制整段;unpack 只复制本轮尾窗。
63
+ position = locate(buffer.unpack1("a*", offset: start), final:, captures:)
64
+ position[0] += start if position
65
+ position
66
+ end
67
+
68
+ # 只跳过被窗口边界切开的合法 UTF-8 字符,不隐藏窗口内的非法字节。
69
+ def utf8_window_start(buffer, start, final:)
70
+ lead = start
71
+ lead -= 1 while lead.positive? && start - lead < 3 && (buffer.getbyte(lead) & 0xc0) == 0x80
72
+ first = buffer.getbyte(lead)
73
+ width = if first.between?(0xc2, 0xdf) then 2
74
+ elsif first.between?(0xe0, 0xef) then 3
75
+ elsif first.between?(0xf0, 0xf4) then 4
76
+ else 1
77
+ end
78
+ character = buffer.byteslice(lead, width).dup.force_encoding(Encoding::UTF_8)
79
+ return start unless lead < start && lead + width > start
80
+ return lead + width if character.valid_encoding?
81
+
82
+ validate_incomplete_suffix!(character, final:)
83
+ nil
84
+ end
85
+
53
86
  # 仅容忍末尾尚未收全的 UTF-8 字符,其他非法编码直接报错,不静默替换接收字节。
54
87
  # 转码仅用于区分“不完整尾部”和“非法字节”,结果不回写缓冲,也不做编码归一化。
55
88
  def validate_incomplete_suffix!(text, final:)
data/lib/expect/pty.rb CHANGED
@@ -1,4 +1,209 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # 提供明确的 Gem 加载入口,避免与 Ruby 标准库 expect.rb(IO#expect 扩展)同名冲突。
4
- require_relative "../expect"
3
+ require "pty"
4
+ require "json"
5
+ require "io/console"
6
+ require "stringio"
7
+ require_relative "version"
8
+ require_relative "result"
9
+ require_relative "resource_owner"
10
+ require_relative "pattern"
11
+ require_relative "pattern_list"
12
+ require_relative "matcher"
13
+ require_relative "cleanup"
14
+ require_relative "child_process"
15
+ require_relative "session"
16
+ require_relative "session_factory"
17
+ require_relative "responder"
18
+
19
+ # PTY 会话的创建、匹配与转接入口;可变状态只属于返回的 Session。
20
+ module Expect
21
+ # 重置相对期限后继续等待。
22
+ CONTINUE = :continue
23
+ # 保留原相对期限继续等待。
24
+ CONTINUE_WITHOUT_RESET = :continue_without_reset
25
+ # 单轮非阻塞读写的最大字节数。
26
+ # @api private
27
+ READ_SIZE = 16_384
28
+ private_constant :READ_SIZE
29
+
30
+ # PTY 创建后命令启动失败。
31
+ class SpawnError < StandardError
32
+ # 子进程报告的系统错误编号;非系统启动错误为 nil。
33
+ attr_reader :errno
34
+
35
+ # 保持启动异常类型,并携带可用于诊断的系统错误编号。
36
+ def initialize(message = "process spawn failed", errno: nil)
37
+ @errno = errno
38
+ super(message)
39
+ end
40
+ end
41
+
42
+ # 未知子进程异常仅保留描述,不根据外部类名恢复对象。
43
+ # @api private
44
+ class ChildFailure < StandardError; end
45
+ private_constant :ChildFailure
46
+
47
+ # 拒绝同源递归转接及接收交付回调中的同源递归读取,防止数据归属和顺序被破坏。
48
+ class ReentrancyError < StandardError; end
49
+
50
+ # 本次写入已经确认交付的进度;其他嵌套写入的异常保留在 cause。
51
+ class WriteTimeout < IOError
52
+ # 本次 write 已被底层接受的字节数。
53
+ attr_reader :bytes_written
54
+
55
+ # 构造包含已交付字节数的背压超时。
56
+ def initialize(message = "write timed out", bytes_written: 0)
57
+ @bytes_written = bytes_written
58
+ super(message)
59
+ end
60
+ end
61
+
62
+ class << self
63
+ # 创建并启动 Session;有块时返回块结果,并按 graceful 策略关闭所属资源。
64
+ # raw 只控制本次子进程 PTY;日志与输出对象始终借用。
65
+ def spawn(*command, env: {}, chdir: nil, raw: false, shell: false, graceful: false,
66
+ timeout: nil, write_timeout: nil, buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
67
+ session = nil
68
+ spawned = false
69
+ cleanup = lambda do
70
+ if session && (block_given? || !spawned)
71
+ session.cleanup_session(nil, writer: nil, own: false, graceful: spawned && graceful)
72
+ end
73
+ end
74
+ Cleanup.always(cleanup) do
75
+ # 工厂在构造期间处理失败;成功对象的返回与登记不可被中断拆开。
76
+ Thread.handle_interrupt(Object => :never) do
77
+ session = SessionFactory.pty(timeout:, write_timeout:, buffer_limit:, logger:, transcript:, outputs:)
78
+ end
79
+ session.spawn(*command, env:, chdir:, raw:, shell:)
80
+ spawned = true
81
+ block_given? ? yield(session) : session
82
+ end
83
+ end
84
+
85
+ # 适配真实 IO;own 只决定读写端点的关闭责任,日志与输出目标仍由调用者管理。
86
+ # 有块时返回块结果;初始化失败也清理已取得的所属端点。
87
+ def open(io, writer: io, own: false, graceful: false,
88
+ timeout: nil, write_timeout: nil, buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
89
+ session = nil
90
+ initialized = false
91
+ constructing = false
92
+ cleanup = lambda do
93
+ if session && (block_given? || !initialized)
94
+ session.cleanup_session(io, writer:, own:, graceful: initialized && graceful)
95
+ elsif !session && own && !constructing
96
+ ResourceOwner.close_handles(io, writer)
97
+ end
98
+ end
99
+ Cleanup.always(cleanup) do
100
+ Thread.handle_interrupt(Object => :never) do
101
+ constructing = true
102
+ session = SessionFactory.io(io, writer:, own:, timeout:, write_timeout:, buffer_limit:,
103
+ logger:, transcript:, outputs:)
104
+ end
105
+ initialized = true
106
+ block_given? ? yield(session) : session
107
+ end
108
+ end
109
+
110
+ # 按 outputs 建立转发图,返回引发停止的 Session;总期限到达返回 nil。
111
+ def interconnect(*sessions, timeout: nil)
112
+ raise ArgumentError, "interconnect requires Session objects" unless sessions.any? && sessions.all?(Session)
113
+
114
+ Relay.new(sessions, timeout).run
115
+ end
116
+
117
+ # 共同等待指定来源,返回不可变 Result。consume 控制本轮是否消费匹配文本。
118
+ # reset_timeout_on_read 仅重置相对期限,deadline 始终是不可延长的总期限。
119
+ def expect(*patterns, from: [], timeout: nil, deadline: nil, consume: true, reset_timeout_on_read: false,
120
+ search_window: nil, &)
121
+ run_expect(from, patterns, timeout, deadline:, consume:, reset_timeout_on_read:, search_window:, &)
122
+ end
123
+
124
+ # 返回回调继续控制符,reset_timeout 为 false 时保留原相对期限。
125
+ def continue(reset_timeout: true) = reset_timeout ? CONTINUE : CONTINUE_WITHOUT_RESET
126
+
127
+ # 当前单调时钟秒数;供跨多次等待共享 deadline。
128
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
129
+
130
+ # 转换有限非负秒数,nil 表示无相对期限。
131
+ def duration(value)
132
+ return nil if value.nil?
133
+
134
+ number = numeric_seconds(value)
135
+ raise ArgumentError, "duration must be finite and nonnegative" unless number >= 0
136
+
137
+ number
138
+ end
139
+
140
+ # 返回可读 Session,不消费输入;去重、排除已关闭来源,并保留声明顺序。
141
+ def readable_sessions(*sessions, timeout: 0)
142
+ timeout = duration(timeout)
143
+ raise ArgumentError, "readable_sessions requires Session objects" unless sessions.all?(Session)
144
+
145
+ active = sessions.uniq(&:object_id).reject(&:closed?)
146
+ return [] if active.empty?
147
+
148
+ deadline = timeout && (monotonic + timeout)
149
+ polled = false
150
+ ready = nil
151
+ loop do
152
+ remaining = deadline && [deadline - monotonic, 0].max
153
+ return [] if polled && remaining&.zero?
154
+
155
+ begin
156
+ ready = IO.select(active.map(&:to_io), nil, nil, remaining)
157
+ break
158
+ rescue Errno::EINTR
159
+ polled = true
160
+ end
161
+ end
162
+ return [] unless ready
163
+
164
+ select_ready_sessions(active, ready.first)
165
+ end
166
+
167
+ private
168
+
169
+ # 数值协议统一拒绝非实数和非有限值;不宽松解析字符串或布尔值。
170
+ def numeric_seconds(value)
171
+ raise ArgumentError, "seconds must be a real Numeric" unless value.is_a?(Numeric) && !value.is_a?(Complex)
172
+
173
+ number = begin
174
+ Float(value)
175
+ rescue StandardError
176
+ # Numeric 子类也可自定义转换;只归一化转换失败,保留致命中断和原始 cause。
177
+ raise ArgumentError, "seconds must be a finite real Numeric"
178
+ end
179
+ raise ArgumentError, "seconds must be finite" unless number.finite?
180
+
181
+ number
182
+ end
183
+
184
+ # 按对象身份归属就绪描述符,不能用 IO 的值相等规则合并来源。
185
+ def select_ready_sessions(sessions, readable)
186
+ by_io = readable.each_with_object({}.compare_by_identity) { |io, index| index[io] = true }
187
+ sessions.select { |session| by_io.key?(session.to_io) }
188
+ end
189
+
190
+ # 声明完成后固定模式;有参数块保留调用方 self,无参数块使用模式 DSL。
191
+ def run_expect(sessions, patterns, timeout, deadline:, consume:, reset_timeout_on_read:,
192
+ match_by_position: false, search_window: nil, &block)
193
+ unless search_window.nil? || (search_window.is_a?(Integer) && search_window.positive?)
194
+ raise ArgumentError, "search_window must be a positive Integer or nil"
195
+ end
196
+
197
+ timeout = duration(timeout)
198
+ deadline = numeric_seconds(deadline) unless deadline.nil?
199
+ raise ArgumentError, "provide patterns or a pattern block, not both" if block_given? && !patterns.empty?
200
+
201
+ pattern_list = PatternList.new(sessions, patterns)
202
+ if block
203
+ block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
204
+ end
205
+ Matcher.new(pattern_list, timeout, deadline:, consume:, reset_timeout_on_read:, match_by_position:,
206
+ search_window:).run
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expect
4
+ class Relay
5
+ # 向单个转接目标写入数据,维护发送进度与期限;恢复时不重放已成功写出的前缀。
6
+ # 只借用目标,不 dup 描述符,也不负责读取;所有就绪等待统一交给 Relay 调度。
7
+ # @api private
8
+ class Writer
9
+ # target 始终借用;deadline 是本轮背压预算,不表示整块数据已交付。
10
+ attr_reader :target, :deadline
11
+
12
+ # data 在本游标存活期间由上层保持不变,offset 始终以实际交付的字节数计量。
13
+ def initialize(target, data)
14
+ @target = target
15
+ @data = data
16
+ @offset = 0
17
+ @flushed = false
18
+ restart_timeout
19
+ end
20
+
21
+ # 初次排队或重新进入 interconnect 时开始新的写入预算;普通短写不会刷新期限。
22
+ # 只有 Expect 目标提供 write_timeout,原生 IO 和自定义目标只受转接总期限约束。
23
+ def restart_timeout
24
+ period = target.write_timeout if target.is_a?(Session)
25
+ @deadline = period && (Expect.monotonic + period)
26
+ end
27
+
28
+ # 真实 IO 可参与共同 select;自定义可写对象返回 nil,沿用其同步 write/flush 协议。
29
+ def io
30
+ return target.writer if target.is_a?(Session)
31
+
32
+ target if target.is_a?(IO)
33
+ end
34
+
35
+ # 数据交付完但 flush 失败仍未完成;重试只补 flush,不重新发送已接受的数据。
36
+ def done? = @offset == @data.bytesize && @flushed
37
+
38
+ # 每轮每目标至多一次写入;真实 IO 永不在此等待,交给 Relay 的共同 select。
39
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 游标、短写、flush 和期限按单个目标推进。
40
+ def advance(check_timeout: true)
41
+ return false if done?
42
+ raise IOError, "closed Expect session" if target.is_a?(Session) && target.closed?
43
+
44
+ if @offset < @data.bytesize
45
+ bytes = @data.byteslice(@offset, READ_SIZE)
46
+ count = io ? io.write_nonblock(bytes, exception: false) : target.write(bytes)
47
+ if io && count == :wait_writable
48
+ check_timeout! if check_timeout
49
+ return false
50
+ end
51
+ unless count.is_a?(Integer) && count.positive? && count <= bytes.bytesize
52
+ raise IOError, "write must return the number of accepted bytes"
53
+ end
54
+
55
+ @offset += count
56
+ # 先登记底层已接受的进度,再通知诊断;logger 失败后恢复只补未发送后缀。
57
+ trace_delivery(bytes.byteslice(0, count)) if target.is_a?(Session)
58
+ end
59
+ if @offset == @data.bytesize
60
+ # write_nonblock 已直接写到底层;普通 write 对象仍遵守自身 flush 协议。
61
+ target.flush if !io && target.respond_to?(:flush)
62
+ @flushed = true
63
+ end
64
+ true
65
+ rescue Errno::EINTR
66
+ # 未获得写入计数就不移动游标;先检查原期限,再让共同循环轮询输入并重试。
67
+ check_timeout! if check_timeout
68
+ true
69
+ end
70
+
71
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
72
+
73
+ # 保留本数据块已交付的进度,便于调用方区分未写入和部分写入;不清空恢复用的游标。
74
+ def check_timeout!
75
+ return unless deadline && Expect.monotonic >= deadline
76
+
77
+ raise WriteTimeout.new("relay target write timed out", bytes_written: @offset)
78
+ end
79
+
80
+ private
81
+
82
+ # 诊断可嵌套写入;外层只报告本游标的进度,原超时及其内层进度保留在 cause。
83
+ def trace_delivery(data)
84
+ target.__send__(:trace_data, :sending, data)
85
+ rescue WriteTimeout
86
+ raise WriteTimeout.new("relay interrupted by a diagnostic timeout", bytes_written: @offset)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -108,4 +108,5 @@ module Expect
108
108
  @pid = nil
109
109
  end
110
110
  end
111
+ private_constant :ResourceOwner
111
112
  end
@@ -31,7 +31,8 @@ module Expect
31
31
  # 等待完成模式或 EOF,同时自动处理提示;timeout 沿用会话默认值,应答不重置期限。
32
32
  # 完成块使用 PatternList DSL;Result 只描述最后一次匹配,全文请用会话 transcript 保存。
33
33
  # 每次等待快照规则,回调修改规则仅影响后续等待;不接管或关闭会话。
34
- def expect(session, *patterns, timeout: (session.timeout if session.is_a?(Session)), deadline: nil, &block)
34
+ def expect(session, *patterns, timeout: (session.timeout if session.is_a?(Session)), deadline: nil,
35
+ search_window: nil, &block)
35
36
  raise ArgumentError, "responder requires a Session" unless session.is_a?(Session)
36
37
 
37
38
  raise ArgumentError, "provide patterns or a pattern block, not both" if block && !patterns.empty?
@@ -39,7 +40,7 @@ module Expect
39
40
  rules = @rules.dup.freeze
40
41
  Expect.__send__(
41
42
  :run_expect, [session], [], timeout, deadline:, consume: true,
42
- reset_timeout_on_read: false, match_by_position: true
43
+ reset_timeout_on_read: false, match_by_position: true, search_window:
43
44
  ) do |list|
44
45
  patterns.each do |pattern|
45
46
  case pattern
@@ -48,34 +48,25 @@ module Expect
48
48
  end
49
49
 
50
50
  # 等待自身输入,返回 Result;匹配消费和接收重置策略只对本轮有效。
51
- def expect(*patterns, timeout: self.timeout, deadline: nil, consume: true, reset_timeout_on_read: false, &)
52
- Expect.__send__(:run_expect, [self], patterns, timeout, deadline:, consume:, reset_timeout_on_read:, &)
51
+ def expect(*patterns, timeout: self.timeout, deadline: nil, consume: true, reset_timeout_on_read: false,
52
+ search_window: nil, &)
53
+ Expect.__send__(:run_expect, [self], patterns, timeout,
54
+ deadline:, consume:, reset_timeout_on_read:, search_window:, &)
53
55
  end
54
56
 
55
57
  # 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
56
- def spawn(*command, env: {}, chdir: nil, raw: false)
58
+ def spawn(*command, env: {}, chdir: nil, raw: false, shell: false)
57
59
  validate_spawn!(command)
60
+ raise ArgumentError, "shell must be true or false" unless [true, false].include?(shell)
61
+ raise ArgumentError, "shell requires one command string" if shell && command.size != 1
58
62
 
59
63
  @slave.raw! if raw
60
- from_child = to_parent = nil
61
- Cleanup.always(-> { ResourceOwner.close_handles(from_child, to_parent) }) do
62
- # 错误管道的写端在 exec 成功时自动关闭;父进程据此区分成功启动与 exec 前失败。
63
- # 管道返回到多重赋值之间也属于接管区间,确保 ensure 始终能找到两个端点。
64
- Thread.handle_interrupt(Object => :never) { from_child, to_parent = IO.pipe }
65
- to_parent.close_on_exec = true
66
- @command = command.map { |part| part.dup.freeze }.freeze
67
- # 原生 fork 返回后先登记 PID,再交付线程中断或终止,避免清理时遗漏新子进程。
68
- child = Thread.handle_interrupt(Object => :never) do
69
- @resources.pid = fork do
70
- # 子进程不继承父侧登记临界区,执行前仍可立即响应异步异常。
71
- Thread.handle_interrupt(Object => :immediate) { exec_child(command, env:, chdir:, from_child:, to_parent:) }
72
- end
64
+ @command = command.map { |part| part.dup.freeze }.freeze
65
+ Cleanup.on_failure(-> { hard_close }) do
66
+ child = @child_process.spawn(command, env:, chdir:, shell:) do |from_child, to_parent|
67
+ exec_child(command, env:, chdir:, from_child:, to_parent:, shell:)
73
68
  end
74
- to_parent.close
75
- @slave.close
76
- failure = from_child.read
77
- Cleanup.always(-> { hard_close }) { raise SpawnError, failure } unless failure.empty?
78
- trace("spawned pid=#{child}", event: :spawned)
69
+ trace(event: :spawned) { "spawned pid=#{child}" }
79
70
  self
80
71
  end
81
72
  end
@@ -100,10 +91,7 @@ module Expect
100
91
 
101
92
  # 非阻塞回收并缓存子进程状态;未退出或仅适配 IO 时返回 nil。
102
93
  def process_status
103
- @resources.reap
104
- rescue Errno::EINTR
105
- # 单次轮询被中断时状态仍未知;wait/close 会在原期限内继续,不在这里无限重试。
106
- @resources.status
94
+ @child_process.status
107
95
  end
108
96
 
109
97
  # 已知进程退出码;尚未回收或信号退出时为 nil。
@@ -328,7 +316,7 @@ module Expect
328
316
  end
329
317
  # 诊断回调可能嵌套等待;恢复本次结果后再交给正式模式回调,不能返回内层等待的结果。
330
318
  result = @last_result
331
- trace("matched pattern #{pattern.number}")
319
+ trace { "matched pattern #{pattern.number}" }
332
320
  @last_result = result
333
321
  end
334
322
 
@@ -400,6 +388,7 @@ module Expect
400
388
  def initialize_io(reader, writer:, slave: nil, own: false, timeout: nil, write_timeout: nil,
401
389
  buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
402
390
  @resources = ResourceOwner.new(reader, writer:, slave:, own:)
391
+ @child_process = ChildProcess.new(@resources)
403
392
  raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
404
393
  raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
405
394
 
@@ -444,28 +433,9 @@ module Expect
444
433
  raise ArgumentError, "command is empty" if command.first.empty?
445
434
  end
446
435
 
447
- # 子进程独占控制终端;成功 exec 关闭错误管道,失败时回传后立即退出。
448
- def exec_child(command, env:, chdir:, from_child:, to_parent:)
449
- from_child.close
450
- Process.setsid
451
- # 创建独立进程会话后重新打开 slave,使它成为子进程的控制终端。
452
- File.open(@tty_name, File::RDWR) do |terminal|
453
- # 重定向操作系统的标准描述符;即使宿主替换过 Ruby 标准流,也能正确连接子进程。
454
- # rubocop:disable Style/GlobalStdStream
455
- STDIN.reopen(terminal)
456
- STDOUT.reopen(terminal)
457
- STDERR.reopen(terminal)
458
- # rubocop:enable Style/GlobalStdStream
459
- end
460
- @resources.close_handles
461
- Dir.chdir(chdir) if chdir
462
- exec(env, *command, close_others: true)
463
- rescue Exception => error # rubocop:disable Lint/RescueException -- 子进程回传启动异常后立即退出。
464
- begin
465
- to_parent.write("#{error.class}: #{error.message}")
466
- ensure
467
- exit! 127
468
- end
436
+ # 保留 Session 子类的启动钩子;进程对象管理控制终端和错误协议。
437
+ def exec_child(command, **)
438
+ @child_process.exec_child(command, **)
469
439
  end
470
440
 
471
441
  # EINTR 未确认交付时不移动游标;其他写入计数必须落在当前块范围内。
@@ -559,28 +529,9 @@ module Expect
559
529
  wait_for_child(1, signal: "KILL")
560
530
  end
561
531
 
562
- # 每阶段只计算一次期限;回收或信号被中断后仍沿用剩余预算,零预算也先做一次尝试。
532
+ # Session 的 sleep 钩子保留给受控时钟;期限与信号状态属于进程对象。
563
533
  def wait_for_child(period, signal: nil)
564
- deadline = period && (Expect.monotonic + period)
565
- loop do
566
- status = process_status
567
- return status if status || !pid || @resources.owner != Process.pid
568
-
569
- begin
570
- signal_child(signal) if signal
571
- signal = nil
572
- rescue Errno::EINTR
573
- # 下轮先回收再重试信号,避免在无限等待或持续中断时忙等。
574
- nil
575
- end
576
- # ESRCH 后可能已完成回收;即使预算耗尽,也要返回刚获得的状态。
577
- return process_status unless pid
578
-
579
- remaining = deadline && (deadline - Expect.monotonic)
580
- return nil if remaining && remaining <= 0
581
-
582
- sleep(remaining ? [0.01, remaining].min : 0.01)
583
- end
534
+ @child_process.wait(period, signal:) { |pause| sleep(pause) }
584
535
  end
585
536
 
586
537
  # 只结束读取方向并冲刷接收记录;EOF 不代表子进程已经退出或写端已经关闭。
@@ -603,14 +554,5 @@ module Expect
603
554
  # 丢弃的前缀使当前窗口与已转发历史之间不再连续。
604
555
  @relay_history.clear
605
556
  end
606
-
607
- # 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
608
- def signal_child(signal)
609
- return unless pid && @resources.owner == Process.pid
610
-
611
- Process.kill(signal, pid)
612
- rescue Errno::ESRCH
613
- @resources.reap
614
- end
615
557
  end
616
558
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expect
4
+ # 统一管理部分构造对象;调用方只在受保护的移交区间接收已初始化会话。
5
+ # @api private
6
+ module SessionFactory
7
+ def self.pty(**)
8
+ session = nil
9
+ Cleanup.on_failure(-> { session&.cleanup_session(nil, writer: nil, own: false) }) do
10
+ session = Session.allocate
11
+ Thread.handle_interrupt(Object => :immediate) { session.__send__(:initialize, **) }
12
+ session
13
+ end
14
+ end
15
+
16
+ def self.io(reader, writer:, own:, **)
17
+ session = nil
18
+ cleanup = lambda do
19
+ if session
20
+ session.cleanup_session(reader, writer:, own:)
21
+ elsif own
22
+ ResourceOwner.close_handles(reader, writer)
23
+ end
24
+ end
25
+ Cleanup.on_failure(cleanup) do
26
+ session = Session.allocate
27
+ Thread.handle_interrupt(Object => :immediate) do
28
+ session.__send__(:initialize_io, reader, writer:, own:, **)
29
+ end
30
+ session
31
+ end
32
+ end
33
+ end
34
+ private_constant :SessionFactory
35
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.8.3"
5
+ VERSION = "0.9.1"
6
6
  end
data/sig/expect.rbs CHANGED
@@ -1,6 +1,6 @@
1
1
  # 公开 Session 接口;内部匹配与转接协议在源码中标记为 @api private。
2
2
  module Expect
3
- type duration = Numeric | String
3
+ type duration = Numeric
4
4
  type pattern = String | Regexp | :eof | :timeout
5
5
  type result_error = :eof | :timeout | Exception | nil
6
6
  type result_fields = { number: Integer?, error: result_error, match: String?, before: String?, after: String?, session: Session?, captures: Array[String?] }
@@ -21,6 +21,9 @@ module Expect
21
21
  CONTINUE_WITHOUT_RESET: :continue_without_reset
22
22
 
23
23
  class SpawnError < StandardError
24
+ attr_reader errno: Integer?
25
+
26
+ def initialize: (?String message, ?errno: Integer?) -> void
24
27
  end
25
28
 
26
29
  class ReentrancyError < StandardError
@@ -33,12 +36,12 @@ module Expect
33
36
  end
34
37
 
35
38
  def self.spawn: (
36
- *String command, ?env: Hash[String, String?], ?chdir: String?, ?raw: bool, ?graceful: bool,
39
+ *String command, ?env: Hash[String, String?], ?chdir: String?, ?raw: bool, ?shell: bool, ?graceful: bool,
37
40
  ?timeout: duration?, ?write_timeout: duration?, ?buffer_limit: Integer?,
38
41
  ?logger: _Logger?, ?transcript: _Writer?, ?outputs: Array[_Writer]
39
42
  ) -> Session
40
43
  | [A] (
41
- *String command, ?env: Hash[String, String?], ?chdir: String?, ?raw: bool, ?graceful: bool,
44
+ *String command, ?env: Hash[String, String?], ?chdir: String?, ?raw: bool, ?shell: bool, ?graceful: bool,
42
45
  ?timeout: duration?, ?write_timeout: duration?, ?buffer_limit: Integer?,
43
46
  ?logger: _Logger?, ?transcript: _Writer?, ?outputs: Array[_Writer]
44
47
  ) { (Session) -> A } -> A
@@ -66,7 +69,7 @@ module Expect
66
69
 
67
70
  def self.expect: (
68
71
  *pattern patterns, ?from: (Session | Array[Session]), ?timeout: duration?, ?deadline: duration?,
69
- ?consume: bool, ?reset_timeout_on_read: bool
72
+ ?consume: bool, ?reset_timeout_on_read: bool, ?search_window: Integer?
70
73
  ) ?{ (PatternList) -> untyped } -> Result
71
74
 
72
75
  class Session
@@ -75,10 +78,10 @@ module Expect
75
78
  ?logger: _Logger?, ?transcript: _Writer?, ?outputs: Array[_Writer]
76
79
  ) -> void
77
80
 
78
- def spawn: (*String command, ?env: Hash[String, String?], ?chdir: String?, ?raw: bool) -> Session
81
+ def spawn: (*String command, ?env: Hash[String, String?], ?chdir: String?, ?raw: bool, ?shell: bool) -> Session
79
82
 
80
83
  def expect: (
81
- *pattern patterns, ?timeout: duration?, ?deadline: duration?, ?consume: bool, ?reset_timeout_on_read: bool
84
+ *pattern patterns, ?timeout: duration?, ?deadline: duration?, ?consume: bool, ?reset_timeout_on_read: bool, ?search_window: Integer?
82
85
  ) ?{ (PatternList) -> untyped } -> Result
83
86
 
84
87
  def after: () -> String?
@@ -272,7 +275,7 @@ module Expect
272
275
  def on: ((String | Regexp) pattern, ?reply: String?, ?limit: Integer?)
273
276
  ?{ (Session) -> untyped } -> Responder
274
277
 
275
- def expect: (Session session, *pattern patterns, ?timeout: duration?, ?deadline: duration?)
278
+ def expect: (Session session, *pattern patterns, ?timeout: duration?, ?deadline: duration?, ?search_window: Integer?)
276
279
  ?{ (PatternList) -> untyped } -> Result
277
280
 
278
281
  def inspect: () -> String
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.8.3
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-17 00:00:00.000000000 Z
10
+ date: 2026-10-18 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: io-console
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: json
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: logger
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -64,7 +78,7 @@ files:
64
78
  - docs/API.md
65
79
  - docs/COMPATIBILITY.md
66
80
  - docs/MIGRATION.md
67
- - lib/expect.rb
81
+ - lib/expect/child_process.rb
68
82
  - lib/expect/cleanup.rb
69
83
  - lib/expect/interaction.rb
70
84
  - lib/expect/literal_prefix.rb
@@ -75,11 +89,12 @@ files:
75
89
  - lib/expect/pty.rb
76
90
  - lib/expect/redactor.rb
77
91
  - lib/expect/relay.rb
78
- - lib/expect/relay_writer.rb
92
+ - lib/expect/relay/writer.rb
79
93
  - lib/expect/resource_owner.rb
80
94
  - lib/expect/responder.rb
81
95
  - lib/expect/result.rb
82
96
  - lib/expect/session.rb
97
+ - lib/expect/session_factory.rb
83
98
  - lib/expect/version.rb
84
99
  - sig/expect.rbs
85
100
  homepage: https://github.com/gatework/expect-ruby
@@ -1,90 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Expect
4
- # 向单个转接目标写入数据,维护发送进度与期限;恢复时不重放已成功写出的前缀。
5
- # 只借用目标,不 dup 描述符,也不负责读取;所有就绪等待统一交给 Relay 调度。
6
- # @api private
7
- class RelayWriter
8
- # target 始终借用;deadline 是本轮背压预算,不表示整块数据已交付。
9
- attr_reader :target, :deadline
10
-
11
- # data 在本游标存活期间由上层保持不变,offset 始终以实际交付的字节数计量。
12
- def initialize(target, data)
13
- @target = target
14
- @data = data
15
- @offset = 0
16
- @flushed = false
17
- restart_timeout
18
- end
19
-
20
- # 初次排队或重新进入 interconnect 时开始新的写入预算;普通短写不会刷新期限。
21
- # 只有 Expect 目标提供 write_timeout,原生 IO 和自定义目标只受转接总期限约束。
22
- def restart_timeout
23
- period = target.write_timeout if target.is_a?(Session)
24
- @deadline = period && (Expect.monotonic + period)
25
- end
26
-
27
- # 真实 IO 可参与共同 select;自定义可写对象返回 nil,沿用其同步 write/flush 协议。
28
- def io
29
- return target.writer if target.is_a?(Session)
30
-
31
- target if target.is_a?(IO)
32
- end
33
-
34
- # 数据交付完但 flush 失败仍未完成;重试只补 flush,不重新发送已接受的数据。
35
- def done? = @offset == @data.bytesize && @flushed
36
-
37
- # 每轮每目标至多一次写入;真实 IO 永不在此等待,交给 Relay 的共同 select。
38
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 游标、短写、flush 和期限按单个目标推进。
39
- def advance(check_timeout: true)
40
- return false if done?
41
- raise IOError, "closed Expect session" if target.is_a?(Session) && target.closed?
42
-
43
- if @offset < @data.bytesize
44
- bytes = @data.byteslice(@offset, READ_SIZE)
45
- count = io ? io.write_nonblock(bytes, exception: false) : target.write(bytes)
46
- if io && count == :wait_writable
47
- check_timeout! if check_timeout
48
- return false
49
- end
50
- unless count.is_a?(Integer) && count.positive? && count <= bytes.bytesize
51
- raise IOError, "write must return the number of accepted bytes"
52
- end
53
-
54
- @offset += count
55
- # 先登记底层已接受的进度,再通知诊断;logger 失败后恢复只补未发送后缀。
56
- trace_delivery(bytes.byteslice(0, count)) if target.is_a?(Session)
57
- end
58
- if @offset == @data.bytesize
59
- # write_nonblock 已直接写到底层;普通 write 对象仍遵守自身 flush 协议。
60
- target.flush if !io && target.respond_to?(:flush)
61
- @flushed = true
62
- end
63
- true
64
- rescue Errno::EINTR
65
- # 未获得写入计数就不移动游标;先检查原期限,再让共同循环轮询输入并重试。
66
- check_timeout! if check_timeout
67
- true
68
- end
69
-
70
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
71
-
72
- # 保留本数据块已交付的进度,便于调用方区分未写入和部分写入;不清空恢复用的游标。
73
- def check_timeout!
74
- return unless deadline && Expect.monotonic >= deadline
75
-
76
- raise WriteTimeout.new("relay target write timed out", bytes_written: @offset)
77
- end
78
-
79
- private
80
-
81
- # 诊断可嵌套写入;外层只报告本游标的进度,原超时及其内层进度保留在 cause。
82
- def trace_delivery(data)
83
- target.__send__(:trace_data, :sending, data)
84
- rescue WriteTimeout
85
- raise WriteTimeout.new("relay interrupted by a diagnostic timeout", bytes_written: @offset)
86
- end
87
- end
88
-
89
- private_constant :RelayWriter
90
- end
data/lib/expect.rb DELETED
@@ -1,167 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pty"
4
- require "io/console"
5
- require "stringio"
6
- require_relative "expect/version"
7
- require_relative "expect/result"
8
- require_relative "expect/resource_owner"
9
- require_relative "expect/pattern"
10
- require_relative "expect/pattern_list"
11
- require_relative "expect/matcher"
12
- require_relative "expect/cleanup"
13
- require_relative "expect/session"
14
- require_relative "expect/responder"
15
-
16
- # PTY 会话的创建、匹配与转接入口;可变状态只属于返回的 Session。
17
- module Expect
18
- # 重置相对期限后继续等待。
19
- CONTINUE = :continue
20
- # 保留原相对期限继续等待。
21
- CONTINUE_WITHOUT_RESET = :continue_without_reset
22
- # 单轮非阻塞读写的最大字节数。
23
- # @api private
24
- READ_SIZE = 16_384
25
-
26
- # PTY 创建后命令启动失败。
27
- class SpawnError < StandardError; end
28
-
29
- # 拒绝同源递归转接及接收交付回调中的同源递归读取,防止数据归属和顺序被破坏。
30
- class ReentrancyError < StandardError; end
31
-
32
- # 本次写入已经确认交付的进度;其他嵌套写入的异常保留在 cause。
33
- class WriteTimeout < IOError
34
- # 本次 write 已被底层接受的字节数。
35
- attr_reader :bytes_written
36
-
37
- # 构造包含已交付字节数的背压超时。
38
- def initialize(message = "write timed out", bytes_written: 0)
39
- @bytes_written = bytes_written
40
- super(message)
41
- end
42
- end
43
-
44
- class << self
45
- # 创建并启动 Session;有块时返回块结果,并按 graceful 策略关闭所属资源。
46
- # raw 只控制本次子进程 PTY;日志与输出对象始终借用。
47
- def spawn(*command, env: {}, chdir: nil, raw: false, graceful: false,
48
- timeout: nil, write_timeout: nil, buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
49
- session = nil
50
- spawned = false
51
- cleanup = lambda do
52
- if session && (block_given? || !spawned)
53
- session.cleanup_session(nil, writer: nil, own: false, graceful: spawned && graceful)
54
- end
55
- end
56
- Cleanup.always(cleanup) do
57
- # 先持有对象再初始化,构造完成到工厂接管之间不能留下无人负责关闭的窗口。
58
- session = Session.allocate
59
- session.__send__(:initialize, timeout:, write_timeout:, buffer_limit:, logger:, transcript:, outputs:)
60
- session.spawn(*command, env:, chdir:, raw:)
61
- spawned = true
62
- block_given? ? yield(session) : session
63
- end
64
- end
65
-
66
- # 适配真实 IO;own 只决定读写端点的关闭责任,日志与输出目标仍由调用者管理。
67
- # 有块时返回块结果;初始化失败也清理已取得的所属端点。
68
- def open(io, writer: io, own: false, graceful: false,
69
- timeout: nil, write_timeout: nil, buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
70
- session = nil
71
- initialized = false
72
- cleanup = lambda do
73
- if session && (block_given? || !initialized)
74
- session.cleanup_session(io, writer:, own:, graceful: initialized && graceful)
75
- elsif !session && own
76
- ResourceOwner.close_handles(io, writer)
77
- end
78
- end
79
- Cleanup.always(cleanup) do
80
- session = Session.allocate
81
- session.__send__(:initialize_io, io, writer:, own:, timeout:, write_timeout:, buffer_limit:,
82
- logger:, transcript:, outputs:)
83
- initialized = true
84
- block_given? ? yield(session) : session
85
- end
86
- end
87
-
88
- # 按 outputs 建立转发图,返回引发停止的 Session;总期限到达返回 nil。
89
- def interconnect(*sessions, timeout: nil)
90
- raise ArgumentError, "interconnect requires Session objects" unless sessions.any? && sessions.all?(Session)
91
-
92
- Relay.new(sessions, timeout).run
93
- end
94
-
95
- # 共同等待指定来源,返回不可变 Result。consume 控制本轮是否消费匹配文本。
96
- # reset_timeout_on_read 仅重置相对期限,deadline 始终是不可延长的总期限。
97
- def expect(*patterns, from: [], timeout: nil, deadline: nil, consume: true, reset_timeout_on_read: false, &)
98
- run_expect(from, patterns, timeout, deadline:, consume:, reset_timeout_on_read:, &)
99
- end
100
-
101
- # 返回回调继续控制符,reset_timeout 为 false 时保留原相对期限。
102
- def continue(reset_timeout: true) = reset_timeout ? CONTINUE : CONTINUE_WITHOUT_RESET
103
-
104
- # 当前单调时钟秒数;供跨多次等待共享 deadline。
105
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
-
107
- # 转换有限非负秒数,nil 表示无相对期限。
108
- def duration(value)
109
- return nil if value.nil?
110
-
111
- number = Float(value)
112
- raise ArgumentError, "duration must be finite and nonnegative" unless number.finite? && number >= 0
113
-
114
- number
115
- end
116
-
117
- # 返回可读 Session,不消费输入;去重、排除已关闭来源,并保留声明顺序。
118
- def readable_sessions(*sessions, timeout: 0)
119
- timeout = duration(timeout)
120
- raise ArgumentError, "readable_sessions requires Session objects" unless sessions.all?(Session)
121
-
122
- active = sessions.uniq(&:object_id).reject(&:closed?)
123
- return [] if active.empty?
124
-
125
- deadline = timeout && (monotonic + timeout)
126
- polled = false
127
- ready = nil
128
- loop do
129
- remaining = deadline && [deadline - monotonic, 0].max
130
- return [] if polled && remaining&.zero?
131
-
132
- begin
133
- ready = IO.select(active.map(&:to_io), nil, nil, remaining)
134
- break
135
- rescue Errno::EINTR
136
- polled = true
137
- end
138
- end
139
- return [] unless ready
140
-
141
- select_ready_sessions(active, ready.first)
142
- end
143
-
144
- private
145
-
146
- # 按对象身份归属就绪描述符,不能用 IO 的值相等规则合并来源。
147
- def select_ready_sessions(sessions, readable)
148
- by_io = readable.each_with_object({}.compare_by_identity) { |io, index| index[io] = true }
149
- sessions.select { |session| by_io.key?(session.to_io) }
150
- end
151
-
152
- # 声明完成后固定模式;有参数块保留调用方 self,无参数块使用模式 DSL。
153
- def run_expect(sessions, patterns, timeout, deadline:, consume:, reset_timeout_on_read:,
154
- match_by_position: false, &block)
155
- timeout = duration(timeout)
156
- deadline = Float(deadline) unless deadline.nil?
157
- raise ArgumentError, "deadline must be finite" if deadline && !deadline.finite?
158
- raise ArgumentError, "provide patterns or a pattern block, not both" if block_given? && !patterns.empty?
159
-
160
- pattern_list = PatternList.new(sessions, patterns)
161
- if block
162
- block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
163
- end
164
- Matcher.new(pattern_list, timeout, deadline:, consume:, reset_timeout_on_read:, match_by_position:).run
165
- end
166
- end
167
- end