expect-pty 0.8.2 → 0.9.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: cbcc16796d1be757cc45df5ed803aa8d3518601923eaa3765d512cd3000baaf7
4
- data.tar.gz: 47943a69f86cfc7ba7564eea3f2ec7e0f71a30910e398535fbe734cff8cba54f
3
+ metadata.gz: cda29c495d71214dc6221859210c40a13db4d55174c781936c2dae8eb811a20d
4
+ data.tar.gz: ecf866f1209506006bb7a23d1ac8a5ed18cf031ba12303e930810619cb516003
5
5
  SHA512:
6
- metadata.gz: b9d0688944e25cd879072059ecdf8556676bc32c0b83634dbac773f002dad67485e9dad95034a52b306dbf0e1a3e38eff1e67efb0fcd382cd45c8b0c06bf79bd
7
- data.tar.gz: 62aed043158e01a8023111d441fb9c484d697763157e18db97c6c01e9bbb7c55af7fb55ea3e311a4bcae2e191209a1401a42b139523d0e7d4c0873be78c2800c
6
+ metadata.gz: c61d1880a85a03a49457f28e8408b7db47c2e50e2abfa299d54b6e63ecf75d43fa07ead9bf4a868563dbdf02127dd07d6bfc39a81b2edaa91f3a920c93200682
7
+ data.tar.gz: '04109bd835876fa8957db8791cd30883b185a01e10e3d57fa1bef0f074d05b3b81771e2715d1a8cb4e53018badbb0975d422a9aa247dd888898d6893603ee8ec'
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.0 - 2026-10-18
6
+
7
+ - **不兼容**:命令默认直接按 argv 执行,单字符串也只作为可执行文件名,需要 shell 语法时须显式传入 `shell: true`。
8
+ - **不兼容**:移除与标准库同名的加载入口,本库统一使用 `require "expect/pty"`,`require "expect"` 恢复加载标准库的 IO#expect。
9
+ - **不兼容**:时间参数只接受有限实数 Numeric 或 nil,不再解析字符串,非法值统一抛出 ArgumentError。
10
+ - 新增每次等待的 `search_window` 字节窗口选项,限制长输出中的字符串和正则扫描范围,匹配结果仍保留完整缓冲偏移。
11
+ - 启动失败保留 `SpawnError#errno` 和 SystemCallError cause,包含非法编码字节的路径也能正确报告失败。
12
+ - 缩短已退出子进程的关闭等待,并减少未启用日志时的匹配分配。
13
+ - 修复正则转接在小缓冲窗口下丢失 UTF-8 字符前缀的问题,合法分片可跨超时继续匹配,EOF 时仍拒绝未完成字符。
14
+
15
+ ## 0.8.3 - 2026-10-17
16
+
17
+ - 修复多参数写入和日志补记受 Ruby 全局分隔符 `$,` 影响而插入额外字节的问题,保持原始字节和写入计数准确。
18
+
5
19
  ## 0.8.2 - 2026-10-11
6
20
 
7
21
  - 修复对象转换、日志回调或背压读取关闭会话后仍继续写入借用 IO 的问题,空写入也会检查关闭状态。
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
@@ -54,7 +54,7 @@ module Expect
54
54
  return unless transcript
55
55
 
56
56
  # 单对象沿用 Session#write 的转换路径,省去数组与 join;转换后仍重新取得目标。
57
- data = objects.size == 1 ? objects.first.to_s.b : objects.map { |object| object.to_s.b }.join
57
+ data = objects.size == 1 ? objects.first.to_s.b : objects.map { |object| object.to_s.b }.join("") # rubocop:disable Style/RedundantArgument -- 显式空分隔符隔离全局 $,。
58
58
  # to_s 可切换或禁用记录目标;整条记录与其过滤尾部必须属于转换完成后的同一目标。
59
59
  target = transcript
60
60
  return unless target
@@ -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,206 @@
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 = Float(value)
174
+ raise ArgumentError, "seconds must be finite" unless number.finite?
175
+
176
+ number
177
+ rescue TypeError, RangeError
178
+ raise ArgumentError, "seconds must be a finite real Numeric"
179
+ end
180
+
181
+ # 按对象身份归属就绪描述符,不能用 IO 的值相等规则合并来源。
182
+ def select_ready_sessions(sessions, readable)
183
+ by_io = readable.each_with_object({}.compare_by_identity) { |io, index| index[io] = true }
184
+ sessions.select { |session| by_io.key?(session.to_io) }
185
+ end
186
+
187
+ # 声明完成后固定模式;有参数块保留调用方 self,无参数块使用模式 DSL。
188
+ def run_expect(sessions, patterns, timeout, deadline:, consume:, reset_timeout_on_read:,
189
+ match_by_position: false, search_window: nil, &block)
190
+ unless search_window.nil? || (search_window.is_a?(Integer) && search_window.positive?)
191
+ raise ArgumentError, "search_window must be a positive Integer or nil"
192
+ end
193
+
194
+ timeout = duration(timeout)
195
+ deadline = numeric_seconds(deadline) unless deadline.nil?
196
+ raise ArgumentError, "provide patterns or a pattern block, not both" if block_given? && !patterns.empty?
197
+
198
+ pattern_list = PatternList.new(sessions, patterns)
199
+ if block
200
+ block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
201
+ end
202
+ Matcher.new(pattern_list, timeout, deadline:, consume:, reset_timeout_on_read:, match_by_position:,
203
+ search_window:).run
204
+ end
205
+ end
206
+ end