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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +1 -1
- data/README.zh-CN.md +1 -1
- data/docs/API.md +19 -6
- data/docs/MIGRATION.md +13 -1
- data/lib/expect/child_process.rb +120 -0
- data/lib/expect/interaction.rb +5 -5
- data/lib/expect/logging.rb +6 -3
- data/lib/expect/matcher.rb +6 -2
- data/lib/expect/pattern.rb +34 -1
- data/lib/expect/pty.rb +204 -2
- data/lib/expect/relay/writer.rb +90 -0
- data/lib/expect/resource_owner.rb +1 -0
- data/lib/expect/responder.rb +3 -2
- data/lib/expect/session.rb +21 -79
- data/lib/expect/session_factory.rb +35 -0
- data/lib/expect/version.rb +1 -1
- data/sig/expect.rbs +10 -7
- metadata +19 -4
- data/lib/expect/relay_writer.rb +0 -90
- data/lib/expect.rb +0 -167
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cda29c495d71214dc6221859210c40a13db4d55174c781936c2dae8eb811a20d
|
|
4
|
+
data.tar.gz: ecf866f1209506006bb7a23d1ac8a5ed18cf031ba12303e930810619cb516003
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
-
|
|
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
|
data/lib/expect/interaction.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
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
|
-
|
|
209
|
+
Relay::Writer.new(target.target, target.render(data))
|
|
208
210
|
else
|
|
209
|
-
|
|
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
|
data/lib/expect/logging.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
# 结束当前日志片段并交付过滤器尾部;重复调用不会重放已释放字节,写入异常原样传播。
|
data/lib/expect/matcher.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/expect/pattern.rb
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
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
|