expect-pty 0.7.4 → 0.7.6

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: 2b48d4fb81d2c6f7035133387df5641fb420b5c108e7257c2348f4ad119edecf
4
- data.tar.gz: c6dcfa0a5edc30db418739decdaa262a480e905a234ddf179c67c609edac56a4
3
+ metadata.gz: 1052a03b9bb9ac46051468e651c168d714f6be11aabd4bd8e2171dd390e2065a
4
+ data.tar.gz: 7c178220cc65517015144b2711679a3a26739efe1156dd144a26cb64fdfd6b46
5
5
  SHA512:
6
- metadata.gz: 3a310e67d4303ebc2d9a2db7231722ab59b52a46bca2cf1aa32a1abd0de63a956867c17f4013df5029d21e4df509c8c6980fcc4193484f1e485b84e0933dff64
7
- data.tar.gz: 19a972f1e1c19a64ba9b18f2148d0436c2f0a4211261f4a3396447b5863d8e6ac9e8f30322cb28e92ef45a5e8801dc519593c6a82044dacb42fe33b0ed17b1e0
6
+ metadata.gz: 31b82ff772bc59ec35a4e3318be96b2b9c4b240898395c1bd8c543ed67515461d9ba282da415a1e208fbd9fb8f03390445cec132725873a039b3d75181fc552d
7
+ data.tar.gz: 1a7d7adfd64345c4e0d405657edd252e841430f65c335dc638708f43cdf20c4c12b4b8da28fe70b235801f8af50905595a6f982035a5ff73e9baafd02c8453d3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.6 - 2026-10-09
6
+
7
+ - 修复会话关闭时日志异常覆盖原始错误的问题,保留进程等待、线程中断或句柄关闭的失败原因,并继续尝试完成诊断与 transcript 收尾。
8
+ - 优化长秘密的流尾脱敏和长字面转义扫描,减少近似前缀输入下的耗时与临时对象,保持分片、重叠和部分秘密的过滤行为。
9
+
10
+ ## 0.7.5 - 2026-10-08
11
+
12
+ - 修复 PTY、启动错误管道和工厂初始化的异步中断交接窗口;线程中断或终止后立即关闭已取得的句柄,保持用户初始化协议可中断。
13
+ - 修复 `write_transcript` 在对象转换期间切换或禁用目标时的记录归属;整条补记与脱敏尾部交付到同一目标。
14
+ - 将纯文本扫描组和 EOF 处理器索引在模式声明完成时编译,减少多来源 EOF 派发的重复扫描;保留来源身份、声明顺序、显式冻结和规则复用行为。
15
+ - 使用增量 KMP 扫描长字面转义的潜在后缀,并按冻结规则复用前缀表,避免未命中时逐短前缀比较造成平方耗时和大量临时字符串。
16
+ - 更新 RubyGems 摘要和文档入口,直接描述 Ruby PTY Gem;README 改为英文默认首页,精简快速开始并提供独立简体中文版及文档导航。
17
+
5
18
  ## 0.7.4 - 2026-10-06
6
19
 
7
20
  - **连续匹配**:修复回调中嵌套读取相同内容的新消息后漏触发匹配、最终超时的问题,保留零宽匹配和同值赋回的防空转保护。
data/README.md CHANGED
@@ -2,442 +2,55 @@
2
2
 
3
3
  [![CI](https://github.com/gatework/expect-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/gatework/expect-ruby/actions/workflows/ci.yml)
4
4
 
5
- 用 Ruby 自动操作交互式程序:启动拥有控制终端的子进程,等待文本或正则,发送输入,处理超时、EOF 和回调,也能接管已有
6
- IO、同时监听多个会话和转接人工交互。交互能力参考 [Expect.pm](https://github.com/jacoby/expect.pm),接口采用 Ruby
7
- 的属性、关键字参数和代码块。
5
+ [English](README.md) · [简体中文](README.zh-CN.md)
8
6
 
9
- 要求 **Ruby 3.4+、POSIX 系统(Linux/macOS)**。运行时仅使用 Ruby 标准库,其中可独立安装的 gem 已在 gemspec 中声明,由
10
- RubyGems/Bundler 解析。推荐入口 **`require "expect/pty"`**;本项目提供独立的 `Expect` 模块和 `Expect::Session` 会话,不修改标准库的
11
- `IO#expect`。
7
+ A Ruby library for automating interactive programs through POSIX pseudo-terminals. Start a child process, wait for text or regular expressions, send input, and manage timeouts, callbacks, and cleanup with Ruby objects and blocks.
12
8
 
13
- 源码中的解释性注释主要使用中文;欢迎用中文或英文提交 issue 和 PR,参与方式见 [贡献指南](CONTRIBUTING.md)。
9
+ RubyGems package: **expect-pty**. Requires Ruby 3.4+ on Linux or macOS.
14
10
 
15
- ## 安装和运行
11
+ ## Install
16
12
 
17
- 项目和仓库名为 `expect-ruby`,Gem 名为 `expect-pty`。在应用的 Gemfile 中添加以下内容,然后运行 `bundle install`:
13
+ Add the gem to your Gemfile:
18
14
 
19
- ```ruby
20
- gem "expect-pty", "~> 0.7.4", require: "expect/pty"
21
- ```
15
+ ~~~ruby
16
+ gem "expect-pty", require: "expect/pty"
17
+ ~~~
22
18
 
23
- 也可直接执行 `gem install expect-pty`。需要跟随开发分支时,可从 GitHub 安装:
19
+ Then run `bundle install`, or install it directly with `gem install expect-pty`.
24
20
 
25
- ```ruby
26
- gem "expect-pty", git: "https://github.com/gatework/expect-ruby.git", branch: "main", require: "expect/pty"
27
- ```
21
+ ## Quick start
28
22
 
29
- 本地开发可改用 `gem "expect-pty", path: "/path/to/expect-ruby", require: "expect/pty"`,也可在源码目录构建安装:
30
-
31
- ```sh
32
- mkdir -p tmp
33
- gem build expect-pty.gemspec --output tmp/expect-pty-0.7.4.gem
34
- gem install ./tmp/expect-pty-0.7.4.gem
35
- ```
36
-
37
- ```ruby
23
+ ~~~ruby
38
24
  require "expect/pty"
39
25
 
40
- Expect.spawn("/bin/sh", "-i") do |shell|
41
- shell.puts("printf 'hello ruby\\n'")
42
- if shell.expect(/^hello ruby\r?$/, timeout: 3).matched?
43
- puts shell.match
44
- else
45
- warn shell.error
46
- end
47
- shell.puts("exit")
48
- shell.soft_close(timeout: 2)
49
- end
50
- ```
51
-
52
- 块返回其执行结果,退出时关闭会话并回收子进程,异常和 `break` 也执行清理。构造或启动失败时同样释放已创建的资源;清理中的
53
- `StandardError` 不替换本次作用域已有的主异常,没有主异常时仍传播;清理中新发生的 `Interrupt` / `SystemExit` 不吞掉。
54
- 无块形式需用 `ensure` 显式调用 `close`。`Expect::Session.new` 可以先创建 PTY,通过 `slave.echo=` /
55
- `slave.winsize=` 设置终端,然后调用 `session.spawn`。工厂返回、回调参数和 `Result#session` 都是同一个真实 Session。
56
-
57
- 多个命令参数原样传给 Ruby `exec`;单个命令字符串使用 Ruby 的 shell 语义。不可信参数应使用独立参数形式。支持
58
- `env: { "NAME" => "value" }` 和 `chdir: "/path"`。同一会话只能启动一次,启动失败抛出 `Expect::SpawnError`。
59
-
60
- ## 会话设置与操作参数
26
+ program = "STDOUT.sync = true; print 'ready>'; STDIN.gets; puts 'done'"
27
+ Expect.spawn("ruby", "-e", program) do |session|
28
+ prompt = session.expect("ready>", timeout: 5)
29
+ raise "child did not become ready" unless prompt.matched?
61
30
 
62
- ```ruby
63
- logger = Logger.new($stderr, level: Logger::INFO)
64
- Expect.spawn("/bin/sh", "-i", timeout: 3, buffer_limit: 65_536,
65
- logger: logger, outputs: [$stdout]) do |session|
66
- session.timeout = 5
67
- session.puts("exit")
31
+ session.puts("continue")
32
+ result = session.expect("done", timeout: 5)
33
+ puts result.match if result.matched?
68
34
  end
69
- ```
70
-
71
- 所有设置显式传给会话,不提供全局默认配置或配置基类。需要应用默认值时,由调用方保存 Hash,再用关键字展开传入。
72
- 各会话独立保存以下属性,修改不会影响其他会话;借用的 logger、transcript 和输出对象可以由调用方共享。
73
-
74
- | 会话属性 | 默认值 | 行为 |
75
- |-----------------|--------|-------------------------------------------------------|
76
- | `timeout` | `nil` | `session.expect` 的默认相对期限;`nil` 无限、`0` 轮询 |
77
- | `write_timeout` | `nil` | 写入遇到背压或 EINTR 时的等待期限 |
78
- | `buffer_limit` | `nil` | 匹配缓冲保留的尾部字节数;正整数或 `nil` |
79
- | `logger` | `nil` | 借用支持 `add` / `debug?` 的诊断 logger |
80
- | `transcript` | `nil` | 借用支持 `write` 的接收字节记录目标 |
81
- | `outputs` | `[]` | 原始接收字节的转发目标数组,可包含 `$stdout` |
82
-
83
- 这些属性都有同名 reader/writer;非法赋值不改变原设置。超时必须有限且非负。输出数组在设置时复制,读取时也返回副本。
84
-
85
- 只属于一次操作的策略放在该操作的关键字中:`spawn(raw: false)` 控制子终端,`expect(consume: true,
86
- reset_timeout_on_read: false)` 控制匹配消费和输入重置,`interact(raw: true)` 控制本地终端模式,
87
- `close(graceful: false)` 控制清理顺序。工厂的 `graceful:` 决定块退出时的关闭策略。`Session.new` 只接收会话属性,
88
- 命令、`env:`、`chdir:` 和 `raw:` 由之后的 `session.spawn` 接收;均无旧名称别名。
89
-
90
- ## 等待和匹配
91
-
92
- ```ruby
93
- session.expect("literal text", /value=(\d+)/, timeout: 5)
94
- session.timeout = 5
95
- session.expect("ready") # 使用会话超时
96
- session.expect("ready", timeout: nil) # 无限等待
97
- session.expect("ready", timeout: 0) # 匹配现有缓冲,并最多轮询读取一次
98
- session.expect(timeout: 1) # 仅收集输出,直到超时或 EOF
99
- ```
100
-
101
- 字符串始终按字面匹配,包括 `"-i"`、`"-re"`、`"timeout"` 和 `"eof"`;正则直接使用 Ruby `Regexp`
102
- 。按声明顺序选择第一个能匹配的模式,不按它们在文本中的位置排序。返回不可变 `Expect::Result`,`number` 为模式的 **1
103
- 起始序号**;超时、EOF 或 IO 错误时 `number` 为 `nil`。
104
- 判断成功使用 `matched?`,不能直接判断 Result 对象的真值。超时使用单调时钟。
105
-
106
- ```ruby
107
- result = session.expect(/value=(\d+)/, timeout: 3)
108
- result.matched?
109
- result.timeout?
110
- result.eof?
111
- result.number
112
- result.captures
113
- result.session
114
- result.error # nil、:timeout、:eof 或原始 IOError / SystemCallError 对象
115
-
116
- result => { number:, match:, captures: }
117
- ```
118
-
119
- `Result` 使用原生 Ruby `Data`,支持 `to_h`、位置和键模式解构。结果、文本和捕获数组均为不可变快照;
120
- 来源会话与原始异常保持原对象。不提供字段写入、`to_a` 或隐式 `to_ary`。会话提供 `last_result`,以及 `match`、
121
- `before`、`after`、`match_number`、`captures`、`error` 快捷读取方法。
122
-
123
- 成功匹配后删除匹配内容及其之前的内容,尾部留给下次匹配;超时保留缓冲,EOF 将未匹配内容放入 `before` 并清空缓冲。EOF
124
- 与子进程退出是不同事件,使用 `wait` / `process_status` 判断进程结果。底层 IO 错误保留原始异常,回调中的普通异常直接抛出。
125
-
126
- 接收缓冲、匹配和捕获值为 `ASCII-8BIT` 字节串,保留控制字符、NUL 和无效 UTF-8。固定 UTF-8
127
- 正则会等待读取末尾拆开的字符收齐后再匹配,以免尾部锚点提前命中;显示捕获内容时可 `.dup.force_encoding("UTF-8")`
128
- 。任意二进制流请用字面字符串或二进制正则 `/.../n`。固定 UTF-8 正则遇到无效数据抛出 `EncodingError`;EOF
129
- 时仍未收齐的字符也属于无效编码,匹配缓冲保留原字节供诊断或二进制匹配。缓冲上限按字节截断,应为文本设置足够的上限。
130
-
131
- 正则完全遵循 Ruby:`^` / `$` 是行锚点,`\A` / `\z` 是整个缓冲的锚点,`/m` 让点号匹配换行;不再提供全局正则模式开关。
132
-
133
- IO 等待的 `timeout` 不会中断单次正则计算。处理用户提供的正则或不可信长输出时,应使用有限时的正则实例,例如
134
- `Regexp.new('prompt>\\s*', timeout: 0.05)`;该限制同样适用于 `on_sequence`。正则超时原样抛出 `Regexp::TimeoutError`
135
- ,匹配缓冲保留,库不会修改进程全局 `Regexp.timeout`。`timeout: 0` 仍会匹配已有缓冲,不代表禁止正则计算。长输出可用日志保存全文,按业务需要设置
136
- `buffer_limit` 限制匹配窗口;缩小窗口会改变 `before` 和跨窗口匹配范围。
137
-
138
- `session.buffer_discarded_bytes` 是只读的会话累计计数,只统计 `buffer_limit` 裁剪掉的字节。
139
- 读取新数据、赋值 `buffer` 或调低上限触发裁剪时递增;成功匹配、`clear_buffer`、EOF 消费和转接交接均不计入。
140
- 关闭会话不会清零。日志在实际读取时保存完整接收内容,不受匹配窗口裁剪影响;它可按显式注册的秘密脱敏。
141
-
142
- ## 回调、事件与多会话
143
-
144
- ```ruby
145
- session.expect(timeout: 10) do
146
- on(/username:\s*/i) do |connection|
147
- connection.puts("demo")
148
- Expect.continue
149
- end
150
- on(/password:\s*/i) do |connection|
151
- connection.puts(password)
152
- Expect.continue(reset_timeout: false)
153
- end
154
- on("ready>")
155
- eof { |connection| warn "EOF: #{connection.before}" }
156
- timeout { |sessions| warn "timeout: #{sessions.length} session(s)" }
157
- end
158
- ```
159
-
160
- 回调通过闭包访问局部变量。无参数声明块在模式构建器中执行;希望保留调用方 `self` 时使用 `do |patterns|`,调用
161
- `patterns.on(...)`。所有模式注册完成后才读取 IO;注册异常或 `break` 不消费输入。块和位置模式不能混用。注册完成后规则冻结,回调不能再向本次等待追加模式。
162
-
163
- `Expect.continue` 继续等待并重新计时;`Expect.continue(reset_timeout: false)`
164
- 保留原期限。回调返回后若保留的期限已过,不再扫描新的文本匹配,未消费的输入留给下一次等待。无回调或返回其他值时结束本次匹配。超时回调只有返回重置计时的
165
- `Expect.continue` 才再次等待。EOF 回调继续时移除该源并等待其余会话;已知的 EOF 仍依次派发,全部 EOF 时直接返回,期限已过时仅对剩余活跃源触发超时。
166
-
167
- `expect` 另接受 `deadline:`,值为 `Expect.monotonic` 时钟上的绝对秒数,`nil` 表示不设总期限。总期限与普通
168
- `timeout` 取较早者,接收重置、文本/EOF 继续及超时回调均不能延长它。同一个 deadline 可用于连续多次等待:
169
-
170
- ```ruby
171
- deadline = Expect.monotonic + 60
172
- session.expect("ready", timeout: 5, deadline: deadline, reset_timeout_on_read: true)
173
- session.expect("done", timeout: 5, deadline: deadline, reset_timeout_on_read: true)
174
- ```
175
-
176
- 上例每次等待允许最多 5 秒无新输入,两次等待共用 60 秒总预算。到达总期限后返回超时,不消费已缓冲的文本或读取新数据;已经确认的
177
- EOF 仍可派发,全部来源结束时返回 EOF。`timeout: 0`
178
- 在总期限尚未到达时仍保留一次非阻塞轮询。总期限使用协作检查,不中断正在执行的回调、同步日志或单次正则;正则完成后若总期限已到,不消费其匹配结果。它不自动传给回调中的写入或其他操作。
179
-
180
- `eof` / `timeout` 声明会占用模式序号,但事件返回的 `number` 为 `nil`。一个等待只能注册一个超时回调;它接收
181
- **所有仍在监听的会话**。不需要回调时,可将 `:eof` / `:timeout` 作为位置事件参数。
182
-
183
- ```ruby
184
- Expect.expect(timeout: 5) do
185
- on(/ready/, from: [first, second]) { |connection| puts connection.inspect }
186
- on("done", from: third)
187
- end
188
-
189
- Expect.expect("ready", from: [first, second], timeout: 5)
190
- ```
191
-
192
- `from:` 指定一个或多个会话;实例块默认当前会话,模块方法需提供来源。相邻且来源列表按对象身份及顺序相同的模式组成一组,按组、会话、模式顺序匹配。模块方法省略超时为无限等待。
193
-
194
- `expect(consume: false)` 时,继续回调通常应自行消费匹配,例如 `connection.buffer = connection.after`
195
- 。如果回调没有改变缓冲,当前模式会等待缓冲变化后才重新匹配,避免反复处理同一内容。被信号中断的匹配 select/read 会自动重试,保留原期限。
196
-
197
- ## 已有 IO、写入和终端
198
-
199
- ```ruby
200
- Expect.open(socket) do |connection|
201
- connection.expect("prompt>", timeout: 5)
202
- connection.puts("command")
203
- end
204
-
205
- ready = Expect.readable_sessions(first, second, timeout: 5)
206
- ```
207
-
208
- `Expect.open` 支持可 `select` 的 File、管道、Socket 和 PTY,`writer:` 可指定独立写端。默认借用 IO,关闭会话不关闭原始 IO;
209
- `own: true` 转移关闭责任,初始化中的属性校验失败也会释放接管的 IO。未知关键字在 Ruby 调用入口拒绝,此时不接管 IO。`StringIO`
210
- 可以用作 transcript 和 outputs,不能用作读取会话。
211
-
212
- 用于写入或转接的真实 IO 应在首次写入前设置 `io.sync = true`,并由调用方保证没有未刷新的 Ruby 写缓冲;`write_nonblock`
213
- 可能先阻塞刷新已有缓冲,这一步不受本库的 IO 等待期限控制。已有缓冲应在交付给本库前由调用方排空,库不会绕过缓冲或改变字节顺序。
214
-
215
- `readable_sessions` 返回可读的会话对象数组,不消费数据、不包含已关闭会话、同一会话只返回一次。默认 `timeout: 0`;`nil`
216
- 无限等待。同一会话应由一个读取者驱动,多会话共同监听使用 `Expect.expect`。
217
-
218
- | API | 行为 |
219
- |---------------------------------------------------------------|---------------------------------------------|
220
- | `write(*objects)` | 通过 `to_s` 转换并写入所有字节,返回字节数 |
221
- | `puts(*objects)` | 原生 IO 风格的换行、数组递归和 `nil` 返回值 |
222
- | `session << object` | 写入并返回会话,可链式追加 |
223
- | `send_slow(*objects, delay:)` | 每个字符之前等待指定秒数,同时收集返回数据 |
224
- | `buffer` / `buffer=` | 获取副本 / 复制字节并应用上限 |
225
- | `clear_buffer` | 清空缓冲并返回旧内容 |
226
- | `to_io.console_mode` / `to_io.console_mode=` | 原生终端模式快照与恢复 |
227
- | `to_io.winsize` / `to_io.winsize=` | 原生 `[rows, cols]` 窗口尺寸接口 |
228
- | `slave` / `tty_name` / `to_io` / `writer` / `fileno` / `tty?` | 底层 IO 和终端信息 |
229
-
230
- `send_slow` 在每次写入后只检查已经可读的回复,不附加固定等待;返回时不保证收齐最后一个字符引发的回复,完整对话请继续使用
231
- `expect`。
232
- 失败时 `WriteTimeout#bytes_written` 累计这次 `send_slow` 已发送的所有字符及当前字符的部分字节;
233
- 转换对象或交付回复引发的内层写入不计入。新增包装异常通过 `cause` 保留原错误,可按原始字节偏移续发剩余内容。
234
-
235
- 大块写入遇到背压时同时读取输出,避免双向传输互相阻塞。背压等待超过 `write_timeout` 抛出 `Expect::WriteTimeout`,
236
- `error.bytes_written` 给出本次 `write` 已被底层接受的字节数;这些字节不回滚,不要从头重发整个命令。写入、等待和背压读取中的
237
- `EINTR` 均保留原期限重试。控制字符可直接发送,例如 `session.write("\x03")`,其信号作用取决于终端设置。`send`、`public_send`、
238
- `__send__` 保留 Ruby 反射语义。
239
-
240
- `write_timeout` 是背压相关期限:持续成功的正数短写不会因为总耗时超过它而失败,也不会强行打断同步用户代码。
241
- 它与匹配的 `timeout/deadline`、整次 `interconnect` 的总 `timeout` 分别计算。非空写入要求底层返回实际接受的正整数字节数,
242
- 且不能超过本次片段长度;非法计数立即抛出 `IOError`,空写入仍返回 0。
243
-
244
- 终端操作直接使用 Ruby `io/console`:例如 `session.to_io.echo = false`、`session.to_io.winsize = [24, 80]`。
245
- 需要作用域恢复时使用原生 `raw` / `cooked` 块;本库不启动 `stty` 子进程。
246
-
247
- ## 诊断、接收记录与人工交互
248
-
249
- ```ruby
250
- require "logger"
251
-
252
- logger = Logger.new($stderr, level: Logger::DEBUG)
253
- File.open("session.log", File::WRONLY | File::CREAT | File::APPEND, 0o600) do |transcript|
254
- Expect.spawn("/bin/sh", "-i", logger: logger, transcript: transcript) do |session|
255
- session.redact(password, token) # 在首次通信前注册需要保护的原始字节
256
- session.outputs = [$stdout, another_session]
257
- session.write_transcript("annotation\n")
258
- session.puts("exit")
259
- end
260
- end
261
- ```
262
-
263
- 三类目标职责独立:`logger` 接收结构化诊断;`transcript` 仅记录实际读取的接收字节;`outputs` 原样转发协议字节。
264
- 所有目标均由调用方创建和关闭。`transcript = nil` 停止记录,替换前先交付旧流过滤尾部;`write_transcript` 补写记录,返回
265
- `nil`,
266
- 不发送到子进程。writer 必须返回实际接受的字节数,支持短写;路径与 callable 不会自动包装成 writer。
267
-
268
- `logger` 采用 Ruby Logger 的 `add` / `debug?` 协议,默认 `nil` 禁用。Logger 自己决定级别、格式及输出位置;可直接注入兼容该协议的
269
- ActiveSupport logger,本库不依赖 ActiveSupport。生命周期和匹配使用 INFO,收发字节使用 DEBUG,不生成缓冲快照诊断。
270
- `add` 的 message 是冻结的 Hash,包含 `event`、`pid`、`fd`、`message`,其中 message 字符串也被冻结;progname 为 `"Expect"`。
271
- 自定义格式使用 Logger 的 formatter;事件不含会话对象。logger 的返回值不控制匹配。
272
-
273
- 所有会话默认没有 stdout 输出。需要显示时显式把 `$stdout` 放进 `outputs`。普通 `expect` 按读取顺序同步执行诊断、
274
- transcript 和 outputs,目标须及时消费;匹配期限不会中断这些代码。慢目标需要独立调度时使用 `interconnect`。
275
- 交付接收字节期间,这些目标的回调可以匹配已有缓冲或驱动独立会话;若递归读取同一会话的新输入,会抛出 `ReentrancyError`,
276
- 避免后读字节抢先进入其他目标。转义回调中的嵌套匹配不受此限制。
277
- 记录不重复包含发送字节,但终端回显可能作为接收内容返回;敏感会话须控制回显、显示和目标文件权限。
278
-
279
- `redact` 复制并追加非空字符串秘密,以 `[FILTERED]` 遮盖 transcript、`write_transcript` 及收发诊断,支持跨分片和重叠秘密。
280
- 匹配缓冲、Result 和 outputs 始终保留原始字节。它不推断编码、终端转义、哈希或其他变换后的秘密,也不能删除已交付的记录。
281
- 临时关闭 DEBUG 仍保留连续脱敏上下文;关闭期间的原始字节不会在重新启用或冲刷尾部时补记。
282
- 直接 `write` 的发送诊断描述尝试发送的内容;转接到 Session 时,发送诊断按底层实际接受的片段推进,并与直接写入共用过滤状态。
283
- 转接诊断失败后,恢复只补未写入的后缀;若诊断内嵌套写入超时,外层 `bytes_written` 保留转接进度,内层异常通过 `cause` 获取。
284
-
285
- 过滤器最多延迟最长秘密长度减一的尾部字节,EOF、目标替换和显式关闭时交付剩余内容;流边界的疑似秘密前缀也会遮盖。
286
- 发送与接收诊断独立保留过滤状态,因此实际写入分块可能变化。GC 兜底不调用用户代码;需显式关闭会话以交付过滤尾部,
287
- 然后由调用方关闭 transcript 和 logger。
288
-
289
- 应用需要过滤自己的日志或错误文本时,可以直接使用独立的字节过滤器,无需打开 PTY 或创建会话:
290
-
291
- ```ruby
292
- require "expect/redactor"
293
-
294
- safe_message = Expect::Redactor.redact(message, [password], replacement: "[REDACTED]")
295
- filter = Expect::Redactor.new([password], replacement: "[REDACTED]")
296
- output.write(filter.append(chunk)) # 每个输出流使用独立实例
297
- output.write(filter.finish)
298
- ```
299
-
300
- `patterns=` 用由非空字符串组成的数组替换后续规则,空数组表示不注册秘密;输入数组、字符串和替换标记都会复制,
301
- 无效更新不改变已有规则或暂存数据。已输出的内容不能撤回,更新后也会保留先前标记为隐藏的尾部区间。
302
- `redact` 处理完整文本,只匹配完整秘密;`finish` 默认还隐藏未完成的秘密前缀。已确定输入完整的调用方可用
303
- `finish(partial: false)`。连续或重叠的隐藏区间合并成一个替换标记。过滤不自动识别终端控制符或编码,过滤器本身不持有 IO,
304
- 也不负责会话作用域或异常对象的安全字段选择。该公共接口从 0.5.0 开始提供。
305
-
306
- ```ruby
307
- session.interact(input: $stdin, escape: "\x1d", output: $stdout) # Ctrl-]
308
-
309
- Expect.open($stdin) do |input|
310
- input.outputs = [session]
311
- session.outputs = [$stdout]
312
- input.on_sequence("\x1d") { false }
313
- Expect.interconnect(input, session, timeout: 60)
314
- end
315
- ```
316
-
317
- `on_sequence(sequence) { ... }` 注册字符串、原生正则或 `:eof`,通过闭包传递参数。无回调、返回 `nil` / `false` 停止,其他 Ruby
318
- 真值(包括 `0`)继续;字符串 `"EOF"` 按字面匹配。转接返回导致停止的会话,超时或所有 EOF 回调均继续时返回 `nil`。
319
-
320
- `interconnect` 统一调度真实 IO 的非阻塞读写;慢目标不会阻止其他源前进,等待同时受总 `timeout` 和目标会话的 `write_timeout`
321
- 约束。总期限到达返回 `nil`,目标写期限先到则抛出 `WriteTimeout`。超时后的字面转义前缀只尝试非阻塞发送,不再等待下游。作为写入目标但未显式列出的
322
- Session,背压期间读取的回复保留在其匹配缓冲;需要同时转发这些回复时,把它也传给 `interconnect`。
323
- 正数总期限会在扫描及继续回调返回后检查,包括延迟执行的转义回调和 EOF 回调;到期后保留尚未处理的输入。
324
- `timeout: 0` 保留处理当前缓冲及首次非阻塞轮询的行为。回调明确停止时仍返回对应会话。
325
-
326
- 每个源独立保存待发送数据以及各目标的发送位置,`source.pending_output?` 表示仍有未交付内容。超时或异常后再次对同一源调用
327
- `interconnect`,会接着发送未完成的后缀,已完成的目标不会重复接收;转义回调在前缀交付后执行。待发送数据与 `buffer`
328
- 中尚未处理的输入分开保存,修改 `outputs` 仅影响后续数据,旧数据仍发往原目标。恢复时不要把原始数据再次赋给 `buffer`
329
- ,也不要在排空旧输出前插入新的直接写入;关闭源会话会放弃其待发送数据。转接保留的输入暂不按 `buffer_limit` 裁剪,下次匹配时重新应用该上限。
330
-
331
- 自定义写入对象必须及时返回实际接受的字节数,短写入会继续发送后缀,零、负数或非法返回值抛出 `IOError`
332
- 。对象若先写入再抛错而不报告进度,库无法推断其副作用。日志、用户回调及自定义 `write` / `flush` 同步运行,应由调用方保证它们不会无限阻塞;上述
333
- IO 期限不会强行中断这些代码。普通 `expect` 的同步 transcript 和 outputs 也不受匹配等待期限限制。
334
-
335
- 字面转义可以跨读取完整过滤,尾部留给下次调用。正则转义使用历史记录,默认最多保留最近 65,536 字节;设置 `buffer_limit`
336
- 后改用该值。正则及其锚点作用于当前历史窗口,超过窗口的跨读取正则无法匹配,已实时转发的前缀也无法撤回;零长度正则匹配抛出
337
- `ArgumentError`。日志包括被转接过滤的转义,显式启用 `redact` 时遮盖注册秘密;在 `expect` / `interconnect` 之间切换不会重复记录。
338
- 正则历史只连接连续输入:匹配消费了字节、显式 `buffer=` / `clear_buffer` 或窗口实际裁剪后会清除旧历史。
339
- 非消费匹配、未丢弃字节的空等待及内部缓冲交接仍保留历史,因此连续转接可以继续识别跨调用转义。
340
-
341
- 一次转接尚未返回时,递归 `interconnect` 的来源若与活跃来源重叠,会在移动缓冲和修改发送游标前抛出
342
- `Expect::ReentrancyError`。完全独立的来源仍可嵌套转接;`on_sequence` 中的嵌套 `expect` 及返回后再次转接仍受支持。
343
- 自定义 `write` 若已产生副作用却抛错、未返回计数,库无法推断已接受的字节数,此时不能保证恢复交付恰好一次。
344
- 这一保护不代表所有会话 API 都可以跨线程并发调用。
345
-
346
- `interact` 默认 `raw: true`,自动设置并恢复本地输入终端模式,同时保留输出换行处理;`raw: false` 将设置交给调用方。
347
- 通用的 `interconnect` 只负责字节转发,由调用方管理终端模式。`interact` 会恢复临时 outputs 和转义设置,包括超时和异常路径。
348
-
349
- 对同一连接重复传入同一个原始输入 IO 时,`interact` 会复用输入包装器,接续上次预读的尾部。包装器由该连接持有,关闭连接时释放,但不关闭借用的原始
350
- IO;已关闭的输入或包装器不再复用。需要跨连接共享或自行管理输入生命周期时,显式传入 `Expect.open(input)` 创建的会话。
351
-
352
- ## 软关闭、硬关闭与进程状态
353
-
354
- ```ruby
355
- status = session.soft_close(timeout: 3, term_timeout: 1)
356
- status ||= session.hard_close(timeout: 0.2)
357
-
358
- session.close(graceful: true) # 先软关闭,必要时继续硬关闭
359
- ```
360
-
361
- - `soft_close`:等待自然 EOF 并收集尾部输出,然后关闭所属 IO、等待进程退出;超时后最多发送 TERM, **不发送 KILL**。`timeout:`
362
- 是自然退出阶段的期限(默认 15 秒),`term_timeout:` 是发 TERM 后的等待时间(默认 1 秒)。未退出返回 `nil`,保留 PID,可继续
363
- `wait` 或 `hard_close`。
364
- - `hard_close`:立即关闭所属 IO,不收集尾部输出;等待 `timeout:`,必要时发送 TERM 再等待同样时长,仍未退出则 KILL 并最多等待
365
- 1 秒。默认 `timeout: 0.2`,必须有限。
366
- - 两者返回已回收的 `Process::Status`,没有子进程或尚未回收时返回 `nil`;重复调用保留已获得的状态。借用 IO 不关闭。
367
- - `close(graceful: false)`:可选先软关闭,`ensure` 中硬关闭,返回 `nil`。块生命周期使用它完成清理;软关闭发生日志异常时也会回收子进程。
368
- - `wait(timeout: nil)`:等待并回收,返回 `Process::Status`;超时返回 `nil`。`process_status` 非阻塞查询,`exit_code`
369
- 读取普通退出码,信号退出看 `process_status.termsig`。
370
- - `closed?` 表示会话 IO 已关闭;`alive?` / `pid` 表示子进程状态。软关闭后可能同时 `closed? == true`、`alive? == true`。成功回收后
371
- PID 为 `nil`。
372
-
373
- 关闭只负责会话直接启动的子进程;垃圾回收提供非阻塞的强制清理兜底,不执行软关闭等待。优先使用块或 `ensure` 管理资源。
374
-
375
- ## 安全注意事项
376
-
377
- - 将不可信命令和参数分别传给 `spawn`,例如 `Expect.spawn("ssh", host)`;单个命令字符串会使用 Ruby 的 shell 语义。
378
- - 接收记录和诊断可能包含密码回显、令牌和其他敏感字节。调用方负责文件权限与留存,按需关闭 transcript、outputs 和 logger。
379
- - `spawn` 在子进程中使用 `fork` 后的 Ruby 操作与 `exec`。高度多线程的宿主进程,尤其使用第三方 C 扩展时,可能受到 fork
380
- 时其他线程持锁的影响;尽量在启动其他线程前创建会话,并在自己的运行环境中验证。
381
- - 不可信正则可能耗费较长时间;使用带 `timeout:` 的 `Regexp` 实例,并为匹配缓冲设置合适的 `buffer_limit`。普通 `expect` 的
382
- IO 期限不打断单次正则或同步回调。
383
-
384
- ## 示例和验证
385
-
386
- ```sh
387
- bundle install
388
- bundle exec rake # RuboCop + 完整测试
389
- script/ci # 与 CI 相同:检查、测试、构建和隔离安装验证
390
- ruby examples/dialogue.rb
391
- ruby examples/kibitz/test_kibitz.rb
392
- ruby examples/ssh_auto.rb # 登录后执行命令,再交给人工输入
393
- ruby examples/ssh_auto.rb --no-interact
394
- ruby examples/ssh_interact.rb --auto
395
- ```
396
-
397
- 普通测试使用真实 PTY、管道和 socket,无需 SSH 服务或账户。Kibitz
398
- 的双终端示例与验证见 [examples/kibitz/](examples/kibitz/README.md)。
399
-
400
- [GitHub Actions](https://github.com/gatework/expect-ruby/actions/workflows/ci.yml) 在推送 `main`、推送 `v*` 标签、提交到
401
- `main` 的 Pull Request 或手动触发时运行。流水线覆盖 Ubuntu 24.04 / macOS 15 与 Ruby 3.4、4.0 的 4 种组合;每个环境执行
402
- `script/ci`,包括真实 PTY 测试和构建包的隔离安装验证。Ubuntu / Ruby 4.0 作业保留已验证的 Gem 构建产物 14 天,可从该次工作流的
403
- Artifacts 下载。
404
-
405
- 运行前先执行 `bundle install`。Gem 库的开发锁文件 `Gemfile.lock` 保留在本地,各 Ruby 环境按 `Gemfile` 解析兼容依赖。生成文件写入已忽略的
406
- `tmp/`,Gem 构建产物位于 `tmp/ci/`,发布候选包位于 `tmp/release/`。安装验证会清除外部 Bundler 环境,分别运行普通 RubyGems
407
- 加载和只声明 `expect-pty` 的 Bundler 应用,检查运行时依赖、终端模式、窗口大小及真实 PTY 对话。更新工作流中的 Action
408
- 时,应同步更新固定的提交 SHA 和版本注释。
409
-
410
- 运行时依赖只在 `expect-pty.gemspec` 声明,开发依赖放在 Gemfile 的 `development` / `test` 组;安装或使用本库不会引入
411
- Minitest、Rake、RuboCop 及发布工具的依赖。
412
-
413
- | 运行时模块 | Gem | 用途 |
414
- |--------------------|---------------|--------------------------|
415
- | `io/console` | `io-console` | 终端模式和窗口大小 |
416
- | `IO#wait_readable` | Ruby 3.2 内置 | IO 可读等待,无独立 gem |
417
- | `logger` | `logger` | 标准诊断协议与级别 |
418
- | `stringio` | `stringio` | Ruby `puts` 语义 |
419
- | `pty` | Ruby 自带扩展 | POSIX 伪终端,无独立 gem |
35
+ ~~~
420
36
 
421
- 仅发布 RubyGems 使用 `ruby script/release.rb --rubygems-only`,直接复用本机已有的 Gem 登录状态;添加 `--dry-run`
422
- 可先完成本地检查、测试、构建和安装验证。需要同时创建 GitHub Release 时使用 `ruby script/release.rb`,也可以在 GitHub Actions
423
- 推送版本标签触发 OIDC 可信发布,或手动恢复同一标签。版本准备、可信发布配置和失败重试见 [发布说明](docs/RELEASING.md)。
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.
424
38
 
425
- SSH 示例用 `SSH_USER`、`SSH_HOST`、`SSH_KNOWN_HOSTS` 配置,密码隐藏输入或从 `EXPECT_PASSWORD` 读取;非本地主机要求受信任的
426
- known_hosts 文件。`ssh_auto.rb` 顶部 `COMMANDS` 可直接修改,日志写入 `tmp/ssh-auto/`,权限 0600。
39
+ ## Features
427
40
 
428
- 多脚本验证入口为 `test/integration/ssh_scripts.rb`,人工/自动接管入口为 `examples/ssh_interact.rb`
429
- ,详细配置及日志检查见 [SSH 测试说明](test/integration/README.md)。
41
+ - Exact byte-string and Ruby Regexp matching, immutable results, callbacks, timeouts, and EOF handling.
42
+ - Explicit PTY sessions or borrowed IO sessions, with scoped cleanup and process status.
43
+ - Multi-session waits, IO forwarding, and interactive terminal handoff.
44
+ - Optional Logger diagnostics, receive transcripts, and streaming redaction.
430
45
 
431
- 当前行为边界见 [接口说明](docs/COMPATIBILITY.md),本次与历史验证分列在 [验证记录](docs/VERIFICATION.md)
432
- 。此次重构直接移除了旧入口,不提供兼容别名。
433
- 防火墙连接器已迁移到相邻的 `algosec` 项目;本库只保留 `Expect` 与 `expect-pty` 通用传输能力。
46
+ ## Documentation
434
47
 
435
- ## 类型、文档与 0.7.0 接口变更
48
+ - [API reference](docs/API.md)
49
+ - [Compatibility and behavior boundaries](docs/COMPATIBILITY.md)
50
+ - [Migration guide](docs/MIGRATION.md)
51
+ - [Performance notes](https://github.com/gatework/expect-ruby/blob/main/docs/PERFORMANCE.md)
52
+ - [Examples](https://github.com/gatework/expect-ruby/tree/main/examples)
436
53
 
437
- 0.7.0 将 `Expect` 改为模块,公开真实 `Session`,移除全局配置、门面、终端命令包装和旧日志接口。
438
- 最低仍为 Ruby 3.4,保留不可变 `Result`。完整接口见 [API 文档](docs/API.md),从 0.6.x 或更早版本升级前请阅读
439
- [迁移说明](docs/MIGRATION.md)。类型签名随 Gem 发布在 `sig/expect.rbs`。
54
+ In a repository checkout, run the test suite and package checks with `script/ci`. The suite uses local PTYs, pipes, and sockets; SSH credentials are not required.
440
55
 
441
- 发布包仅包含运行源码、类型签名和使用文档。测试、基准、示例及维护脚本请从仓库取得;
442
- 运行 `bundle exec rake api` 验证文档覆盖与 RBS 声明,运行 `bundle exec yard doc --output-dir tmp/yard`
443
- 生成 HTML 文档。签名验证不代表全库已经通过静态类型检查。
56
+ Licensed under the MIT License. Contributions are welcome in English or Chinese; see [CONTRIBUTING.md](https://github.com/gatework/expect-ruby/blob/main/CONTRIBUTING.md).
data/README.zh-CN.md ADDED
@@ -0,0 +1,56 @@
1
+ # expect-ruby
2
+
3
+ [![CI](https://github.com/gatework/expect-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/gatework/expect-ruby/actions/workflows/ci.yml)
4
+
5
+ [English](README.md) · [简体中文](README.zh-CN.md)
6
+
7
+ 一个通过 POSIX 伪终端自动操作交互式程序的 Ruby 库。启动子进程、等待文本或正则匹配、发送输入,并用 Ruby 对象和代码块管理超时、回调与资源清理。
8
+
9
+ RubyGems 包名:**expect-pty**。要求 Ruby 3.4+,支持 Linux 和 macOS。
10
+
11
+ ## 安装
12
+
13
+ 在 Gemfile 中添加:
14
+
15
+ ~~~ruby
16
+ gem "expect-pty", require: "expect/pty"
17
+ ~~~
18
+
19
+ 然后运行 `bundle install`,或直接运行 `gem install expect-pty`。
20
+
21
+ ## 快速开始
22
+
23
+ ~~~ruby
24
+ require "expect/pty"
25
+
26
+ program = "STDOUT.sync = true; print 'ready>'; STDIN.gets; puts 'done'"
27
+ Expect.spawn("ruby", "-e", program) do |session|
28
+ prompt = session.expect("ready>", timeout: 5)
29
+ raise "子进程未就绪" unless prompt.matched?
30
+
31
+ session.puts("continue")
32
+ result = session.expect("done", timeout: 5)
33
+ puts result.match if result.matched?
34
+ end
35
+ ~~~
36
+
37
+ 代码块退出时会关闭会话并回收子进程,异常路径也会执行清理。不可信命令参数应分别传入;单个命令字符串遵循 Ruby shell 语义。
38
+
39
+ ## 功能
40
+
41
+ - 按字节精确匹配字符串,或使用 Ruby Regexp;支持不可变结果、回调、超时和 EOF。
42
+ - 显式管理 PTY 会话或借用 IO 会话,支持作用域清理和进程状态查询。
43
+ - 多会话等待、IO 转发和人工终端接管。
44
+ - 可选的 Logger 诊断、接收记录和流式脱敏。
45
+
46
+ ## 文档
47
+
48
+ - [API 参考](docs/API.md)
49
+ - [兼容性和行为边界](docs/COMPATIBILITY.md)
50
+ - [迁移指南](docs/MIGRATION.md)
51
+ - [性能说明](https://github.com/gatework/expect-ruby/blob/main/docs/PERFORMANCE.md)
52
+ - [示例](https://github.com/gatework/expect-ruby/tree/main/examples)
53
+
54
+ 在源码仓库中运行 `script/ci` 执行测试和打包检查。测试使用本地 PTY、管道和 socket,不需要 SSH 凭据。
55
+
56
+ 本项目使用 MIT 许可证。欢迎提交中文或英文贡献,详见[贡献指南](https://github.com/gatework/expect-ruby/blob/main/CONTRIBUTING.md)。
data/docs/API.md CHANGED
@@ -96,6 +96,7 @@ Logger 自身控制级别和格式。INFO 记录生命周期/匹配,DEBUG 增
96
96
 
97
97
  `transcript=` 接收 nil 或 writer;路径打开、权限和关闭由调用方通过 File.open 管理,不接收路径或 callable。
98
98
  `write_transcript(*objects)` 补写接收记录并返回 nil;不发送到子进程。writer 必须返回实际接受的正整数字节数,支持短写。
99
+ 调用开始时没有 transcript 则不转换参数;对象的 `to_s` 若切换目标,整条补记及脱敏尾部使用转换完成后的目标,禁用目标则不写入。
99
100
  logger、transcript、outputs 一律借用,显式关闭冲刷过滤尾部,但不会关闭这些目标。
100
101
 
101
102
  `redact(*secrets)` 追加非空字符串,仅过滤 transcript 和诊断,不更改匹配、Result 或 outputs。
@@ -0,0 +1,65 @@
1
+ # Ruby 接口与 Expect 语义边界
2
+
3
+ ## Tcl Expect 语义边界
4
+
5
+ 参考 Tcl Expect 的[官方手册](https://core.tcl-lang.org/expect/doc/trunk/expect.man)、
6
+ [`expect.c`](https://core.tcl-lang.org/expect/raw/expect.c?ci=trunk) 的 `eval_cases` / `expMatchProcess` / 计时循环,
7
+ 以及 [`exp_inter.c`](https://core.tcl-lang.org/expect/raw/exp_inter.c?ci=trunk) 的 `intMatch` 与终端恢复流程。
8
+ 本库借鉴交互模型,保留以下明确差异,不承诺 Tcl 脚本或完整功能兼容。
9
+
10
+ | Tcl Expect 能力 | 本库的 Ruby 表达与边界 |
11
+ |---------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------|
12
+ | `spawn`、`send`、`expect`、`interact` | PTY 会话、`write` / `puts`、原生模式块和人工接管;`send` 保留 Ruby 反射语义 |
13
+ | `exp_continue` / `-continue_timer` | `Expect.continue` / `Expect.continue(reset_timeout: false)`;使用单调时钟和秒数关键字参数 |
14
+ | 匹配后消费输入、`-notransfer` | `before` / `match` / `after` 与每轮等待的 `consume: false`;无进展回调不会原地重复执行 |
15
+ | glob、exact、regexp 模式 | 字符串只作字面匹配,正则采用 Ruby `Regexp`;锚点和多行行为遵守 Ruby |
16
+ | `match_max`、`full_buffer` | `buffer_limit` 只保留尾部字节,默认无限;`buffer_discarded_bytes` 统计累计裁剪量,无缓冲满事件 |
17
+ | 前置/后置模式、后台匹配 | 没有全局隐式规则或后台读取器;调用方显式组合模式块,每个会话由一个读取者驱动 |
18
+ | `interact` 的部分正则匹配 | Tcl 的 `CANMATCH` 可以暂存潜在匹配;Ruby 原生正则不提供该接口,本库使用有限历史窗口,已转发前缀不能撤回。需要完整过滤时使用字面转义 |
19
+ | `close` 与 `wait` | IO 结束与进程退出仍分开判断;本库的块生命周期和 `close` 会额外回收直属子进程 |
20
+
21
+ 长输出任务应通过 transcript 或 outputs 流式保存,并按提示长度设置 `buffer_limit`。只读 `buffer_discarded_bytes`
22
+ 提供会话累计裁剪字节数,正常匹配消费和清空不计入;它不是无损缓冲满事件。后台匹配与共享前置/后置模式也需要独立的取消、优先级和资源归属约定,尚未实现。
23
+
24
+ `expect` 的 `deadline:` 是额外的绝对单调时钟总期限,限制相对 `timeout` 的所有重置,不改变已知 EOF 派发和未指定总期限时的零超时轮询。会话
25
+ `logger` 与接收记录 `transcript` 独立;显式 `redact` 仅过滤日志和诊断,协议转发与匹配仍使用原始字节。
26
+
27
+ ## 历史实现参考:Perl Expect.pm
28
+
29
+ 交互能力参考 [jacoby/expect.pm](https://github.com/jacoby/expect.pm),源码基准为版本 1.38、提交
30
+ `2ea0e4ce20a896c95cb4c94e781f1b1f3145150d`。此项目独立实现,使用 MIT 许可,没有复制 Perl 实现代码;原项目作者及维护者为
31
+ Austin Schutz、Roland Giersig、Dave Jacoby,采用与 Perl 相同的许可。此处仅记录早期行为验证参考,不表示本 Gem 实现其 API、接受 Perl 语法或与其保持兼容。
32
+
33
+ ## 保留的交互能力
34
+
35
+ 仍支持真实控制终端、精确/正则匹配、模式优先级、捕获组、二进制与分片
36
+ UTF-8、EOF/超时回调、绝对期限与接收重置、多会话、缓冲上限、慢速写入和背压、借用 writer 的接收记录、标准 Logger 诊断、outputs
37
+ 转发图、人工接管、跨读取转义及终端恢复。
38
+
39
+ `expect` / `last_result` 返回不可变七字段 `Data`,匹配序号通过 `number` 读取。使用 `to_h` 或模式解构;
40
+ 字段及文本快照不可修改,不提供 `to_a`。日志按实际读取时记录,转接与匹配切换不重复写日志;显式启用 `redact` 时过滤已注册秘密。
41
+
42
+ ## 软关闭与硬关闭
43
+
44
+ 参考原版的策略边界:`soft_close` 先收集剩余输出,关闭所属句柄,等待退出并最多发送 TERM; **不发送 KILL**。未退出返回 `nil`,保留
45
+ PID。`hard_close` 不等待输出,必要时 TERM、KILL 并回收子进程。关闭会话 IO 不代表子进程已经退出。
46
+
47
+ Ruby 使用关键字指定各阶段期限,关闭方法返回 `Process::Status` 或 `nil`。`close(graceful: true)` 或工厂块的
48
+ `graceful: true`
49
+ 先尝试软关闭,再在 `ensure` 中硬关闭;这对应原版销毁时可选软关闭、随后硬关闭的清理策略。`close` 返回 `nil`;GC
50
+ 兜底直接强制清理,不阻塞等待。
51
+
52
+ ## 有意采用的 Ruby 语义
53
+
54
+ - 默认 `outputs: []`,需要显示时显式加入 `$stdout`。会话设置由关键字传入并按实例隔离,没有全局默认配置。
55
+ - `Expect` 为模块,公开 `Expect::Session`;回调和结果引用真实 Session,不经过门面转换。
56
+ - `raw`、`consume`、`reset_timeout_on_read`、`graceful` 都是所属操作的显式关键字;不保存同名布尔配置。
57
+ - logger/transcript/outputs 均借用。文件打开、权限和关闭由调用方负责;兼容标准 Logger 协议的 ActiveSupport logger 可直接注入。
58
+ - 仅 `nil` 和 `false` 为假,`0` 为真。转接回调也采用此规则。
59
+ - 字符串始终是字面文本;只有 `:eof` / `:timeout` 是事件,`"EOF"` 是普通转义文本。
60
+ - 超时回调接收所有仍在监听的会话;重复注册超时回调抛出 `ArgumentError`。
61
+ - `write` 返回字节数,`puts` 返回 `nil`,`<<` 返回会话。
62
+ - 启动失败抛出 `Expect::SpawnError` 并回收;EOF 不主动终止仍存活的进程。借用 IO 不关闭,接管 IO
63
+ 在方法体进入后的初始化失败或会话关闭时释放;未知关键字在调用入口拒绝,不接管 IO。
64
+ - Perl 特有的正则、IO::Pty 继承 API、全局信号 handler 和 Solaris 字节删除补丁不移植。Ruby 使用 `to_io`、`slave`、`io/console`
65
+ 和原生正则。
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "relay_writer"
4
4
  require_relative "relay"
5
+ require_relative "literal_prefix"
5
6
 
6
7
  # 为会话补充人工接管和多路 IO 转接;核心会话定义位于 session.rb。
7
8
  module Expect
@@ -102,7 +103,7 @@ module Expect
102
103
  end
103
104
 
104
105
  # 暂存可能构成字面转义的最长后缀,保证 STOP 分两次读取时 ST 不会提前发给子进程。
105
- held = final ? 0 : hold_literal_prefix(buffer, sequences)
106
+ held = final ? 0 : hold_literal_prefix(buffer, sequences, session.literal_prefix_tables)
106
107
  return :timeout if expired?(deadline)
107
108
 
108
109
  count = [buffer.bytesize - held, READ_SIZE].min
@@ -174,17 +175,13 @@ module Expect
174
175
  end
175
176
 
176
177
  # 只暂存可能拼成完整字面转义的最长后缀。
177
- def self.hold_literal_prefix(buffer, sequences)
178
+ def self.hold_literal_prefix(buffer, sequences, prefix_tables = {})
178
179
  held = 0
179
180
  sequences.each_key do |key|
180
181
  next if key.is_a?(Regexp)
181
182
 
182
- [key.bytesize - 1, buffer.bytesize].min.downto(1) do |prefix_length|
183
- next unless buffer.end_with?(key.byteslice(0, prefix_length))
184
-
185
- held = [held, prefix_length].max
186
- break
187
- end
183
+ prefix_length = LiteralPrefix.length(buffer, key, prefix_tables)
184
+ held = prefix_length if prefix_length > held
188
185
  end
189
186
  held
190
187
  end
@@ -219,7 +216,16 @@ module Expect
219
216
 
220
217
  # 仅供转接内部保存和恢复注册表,避免公开可变 Hash 绕过 on_sequence 的校验。
221
218
  # @api private
222
- attr_accessor :sequences
219
+ attr_reader :sequences
220
+
221
+ # @api private
222
+ def sequences=(sequences)
223
+ @sequences = sequences
224
+ @literal_prefix_tables&.delete_if { |sequence, _table| !sequences.key?(sequence) }
225
+ end
226
+ # 冻结字面规则对应的 KMP 前缀表,跨转接复用,避免每个读块重新编译长规则。
227
+ # @api private
228
+ attr_reader :literal_prefix_tables
223
229
  # 让同步写入的背压读取遵守当前转接的数据所有权,退出后恢复普通匹配缓冲。
224
230
  # @api private
225
231
  attr_accessor :interaction_buffer
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expect
4
+ # 计算字节窗口尾部与规则真前缀重合的最长长度;转义暂存和流尾脱敏使用同一边界判断。
5
+ # 不持有输入或修改缓冲,调用方可为冻结规则提供前缀表缓存;短规则不建立表。
6
+ # @api private
7
+ module LiteralPrefix
8
+ def self.length(buffer, pattern, prefix_tables = nil)
9
+ maximum = [pattern.bytesize - 1, buffer.bytesize].min
10
+ return 0 unless maximum.positive?
11
+
12
+ # 尾字节不在候选前缀内时无需扫描;最长候选本身命中时也不必构造 KMP 表。
13
+ ending = pattern.rindex(buffer.byteslice(-1, 1), maximum - 1)
14
+ return 0 unless ending
15
+
16
+ maximum = ending + 1
17
+ return maximum if buffer.end_with?(pattern.byteslice(0, maximum))
18
+ return short_length(buffer, pattern, maximum - 1) if maximum < 64
19
+
20
+ failure = prefix_tables && pattern.frozen? ? (prefix_tables[pattern] ||= []) : []
21
+ extend_failure_table(pattern, failure, maximum)
22
+ matched = 0
23
+ index = buffer.bytesize - maximum
24
+ while index < buffer.bytesize
25
+ byte = buffer.getbyte(index)
26
+ matched = failure[matched - 1] while matched.positive? && byte != pattern.getbyte(matched)
27
+ matched += 1 if byte == pattern.getbyte(matched)
28
+ index += 1
29
+ end
30
+ matched
31
+ end
32
+
33
+ def self.extend_failure_table(pattern, failure, length)
34
+ failure << 0 if failure.empty?
35
+ matched = failure.last
36
+ index = failure.length
37
+ while index < length
38
+ byte = pattern.getbyte(index)
39
+ if byte == pattern.getbyte(matched)
40
+ matched += 1
41
+ failure << matched
42
+ index += 1
43
+ elsif matched.positive?
44
+ matched = failure[matched - 1]
45
+ else
46
+ failure << 0
47
+ index += 1
48
+ end
49
+ end
50
+ end
51
+
52
+ def self.short_length(buffer, pattern, maximum)
53
+ maximum.downto(1) do |length|
54
+ return length if buffer.end_with?(pattern.byteslice(0, length))
55
+ end
56
+ 0
57
+ end
58
+
59
+ private_class_method :extend_failure_table, :short_length
60
+ end
61
+
62
+ private_constant :LiteralPrefix
63
+ end
@@ -51,10 +51,13 @@ module Expect
51
51
 
52
52
  # 向接收记录补写内容,不发送给子进程或 outputs;脱敏尾部可能延迟交付,返回 nil。
53
53
  def write_transcript(*objects)
54
+ return unless transcript
55
+
56
+ data = objects.map { |object| object.to_s.b }.join
57
+ # to_s 可切换或禁用记录目标;整条记录与其过滤尾部必须属于转换完成后的同一目标。
54
58
  target = transcript
55
59
  return unless target
56
60
 
57
- data = objects.map { |object| object.to_s.b }.join
58
61
  if @secrets
59
62
  @transcript_redactor ||= Redactor.new(@secrets)
60
63
  data = @transcript_redactor.append(data)
@@ -84,6 +87,12 @@ module Expect
84
87
 
85
88
  private
86
89
 
90
+ # 关闭时两种记录都要尝试完成;后续 writer 的常规异常不能覆盖先发生的诊断异常。
91
+ # 不关闭借用目标,新的 Interrupt/SystemExit 仍由 Cleanup 原样传播。
92
+ def finish_logging
93
+ Cleanup.always(-> { self.transcript = nil }) { flush_diagnostics }
94
+ end
95
+
87
96
  # 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
88
97
  # 这里遵循同步写入协议,不受 Matcher 的 IO 等待期限中断;慢目标转接应使用 Relay。
89
98
  # 短写只推进已确认的字节数;目标抛错时不猜测它是否已经产生副作用。
@@ -9,7 +9,7 @@ module Expect
9
9
  def initialize(patterns, timeout, deadline: nil, consume: true, reset_timeout_on_read: false)
10
10
  @patterns = patterns.finalize!
11
11
  @sessions = patterns.sessions
12
- @groups = patterns.groups
12
+ @groups = patterns.text_groups
13
13
  @consume = consume
14
14
  @reset_timeout_on_read = reset_timeout_on_read
15
15
  @timeout = Expect.duration(timeout)
@@ -62,9 +62,9 @@ module Expect
62
62
  # 按声明组、会话、模式的顺序寻找首个匹配,不按文本中的出现位置重新排序。
63
63
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 声明优先级与单轮缓冲须在同一次扫描保持一致。
64
64
  def find_match
65
- # 单组且来源不重复时无需缓存;借用仅活在本次无回调扫描,不跨越 IO 或用户回调。
65
+ # 编译后的组内来源已按身份去重;借用仅活在本次无回调扫描,不跨越 IO 或用户回调。
66
66
  groups = @groups
67
- buffers = {}.compare_by_identity if groups.size > 1 || (groups.first && groups.first.first.size > @sessions.size)
67
+ buffers = {}.compare_by_identity if groups.size > 1
68
68
  groups.each do |sessions, patterns|
69
69
  sessions.each do |session|
70
70
  next if @handled_eof.key?(session)
@@ -6,7 +6,7 @@ module Expect
6
6
  class PatternList
7
7
  # 供匹配器读取的已编译分组和超时规则。
8
8
  # @api private
9
- attr_reader :groups, :timeout_pattern
9
+ attr_reader :groups, :text_groups, :timeout_pattern
10
10
 
11
11
  # 建立默认来源,并将位置参数中的文本、正则、:eof、:timeout 转成统一模式。
12
12
  def initialize(sessions = [], patterns = [])
@@ -51,6 +51,8 @@ module Expect
51
51
  # 这里只去重会话对象;不同会话包装同一 IO 时的读取归属由 Matcher 决定。
52
52
  # @api private
53
53
  def sessions
54
+ return @sessions if frozen?
55
+
54
56
  groups.flat_map(&:first).each_with_object({}.compare_by_identity) do |session, unique|
55
57
  unique[session] = true
56
58
  end.keys
@@ -59,6 +61,8 @@ module Expect
59
61
  # 收集指定会话的所有 EOF 处理器,保留原注册顺序。
60
62
  # @api private
61
63
  def eof_patterns_for(session)
64
+ return (@eof_groups[session] || []).flatten(1) if frozen?
65
+
62
66
  groups.flat_map do |sessions, patterns|
63
67
  sessions.any? { |candidate| candidate.equal?(session) } ? patterns.select(&:eof?) : []
64
68
  end
@@ -76,17 +80,44 @@ module Expect
76
80
  # @api private
77
81
  def finalize!
78
82
  validate!
83
+ freeze
84
+ end
85
+
86
+ # 显式 Ruby freeze 与引擎完成注册遵循相同编译路径,不能把浅冻结误判为编译完成。
87
+ # @api private
88
+ def freeze
89
+ return self if frozen?
90
+
91
+ @sessions = sessions.freeze
92
+ compile_groups
79
93
  groups.each do |sessions, patterns|
80
94
  sessions.freeze
81
95
  patterns.freeze
82
96
  end
83
97
  groups.each(&:freeze).freeze
84
98
  @default_sessions.freeze
85
- freeze
99
+ super
86
100
  end
87
101
 
88
102
  private
89
103
 
104
+ # 声明只编译一次;运行状态仍属于 Matcher,冻结后的列表可以供不同等待复用。
105
+ # EOF 索引只保存各组模式数组的引用,避免把共享模式按来源数重复复制。
106
+ def compile_groups
107
+ @text_groups = []
108
+ @eof_groups = {}.compare_by_identity
109
+ groups.each do |sources, patterns|
110
+ eof, text = patterns.partition(&:eof?).each(&:freeze)
111
+ sources = sources.uniq(&:object_id).freeze
112
+ @text_groups << [sources, text].freeze unless text.empty?
113
+ next if eof.empty?
114
+
115
+ sources.each { |source| (@eof_groups[source] ||= []) << eof }
116
+ end
117
+ @text_groups.freeze
118
+ @eof_groups.each_value(&:freeze).freeze
119
+ end
120
+
90
121
  # 校验并复制来源列表,将模式加入相邻的相同来源组或新建组。
91
122
  def add(value, from, callback)
92
123
  sessions = Array(from).dup
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "literal_prefix"
4
+
3
5
  module Expect
4
6
  # 单个日志字节流的过滤器。保留最长秘密长度减一的尾部,跨 write/read 分片仍可识别。
5
7
  # 掩码与原字节一起留存;重叠命中的区间取并集,已经输出的掩码不重复生成。
@@ -78,20 +80,13 @@ module Expect
78
80
 
79
81
  # 流关闭时无法再等待后续字节,默认隐藏与秘密开头一致的未完成尾部。
80
82
  def mark_partial_secrets
81
- tail = @pending.byteslice(-1, 1)
82
- return unless tail
83
+ return if @pending.empty?
83
84
 
84
85
  @patterns.each do |pattern|
85
- length = [pattern.bytesize - 1, @pending.bytesize].min
86
- # 前缀必须以当前尾字节结束;只收紧最高候选长度,密集候选仍沿用简单倒序扫描。
87
- next unless length.positive? && (offset = pattern.rindex(tail, length - 1))
88
-
89
- (offset + 1).downto(1) do |candidate|
90
- next unless @pending.end_with?(pattern.byteslice(0, candidate))
86
+ length = LiteralPrefix.length(@pending, pattern)
87
+ next unless length.positive?
91
88
 
92
- @hidden[-candidate, candidate] = "\1" * candidate
93
- break
94
- end
89
+ @hidden[-length, length] = "\1" * length
95
90
  end
96
91
  end
97
92
 
@@ -20,7 +20,8 @@ module Expect
20
20
  def initialize(timeout: nil, write_timeout: nil, buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
21
21
  master = slave = nil
22
22
  Cleanup.on_failure(-> { cleanup_session(master, writer: master, slave:, own: true) }) do
23
- master, slave = PTY.open
23
+ # 取得原生句柄与登记局部所有权不可被异步中断拆开;后续参数协议仍立即响应中断。
24
+ Thread.handle_interrupt(Object => :never) { master, slave = PTY.open }
24
25
  initialize_io(master, writer: master, slave:, own: true, timeout:, write_timeout:, buffer_limit:,
25
26
  logger:, transcript:, outputs:)
26
27
  end
@@ -59,7 +60,8 @@ module Expect
59
60
  from_child = to_parent = nil
60
61
  Cleanup.always(-> { SessionResources.close_handles(from_child, to_parent) }) do
61
62
  # 错误管道的写端在 exec 成功时自动关闭;父进程据此区分成功启动与 exec 前失败。
62
- from_child, to_parent = IO.pipe
63
+ # 管道返回到多重赋值之间也属于接管区间,确保 ensure 始终能找到两个端点。
64
+ Thread.handle_interrupt(Object => :never) { from_child, to_parent = IO.pipe }
63
65
  to_parent.close_on_exec = true
64
66
  @command = command.map { |part| part.dup.freeze }.freeze
65
67
  # 原生 fork 返回后先登记 PID,再交付线程中断或终止,避免清理时遗漏新子进程。
@@ -397,6 +399,7 @@ module Expect
397
399
  @buffer_discarded_bytes = 0
398
400
  @outputs = []
399
401
  @sequences = {}
402
+ @literal_prefix_tables = {}
400
403
  @pending_writes = []
401
404
  @interact_inputs = {}.compare_by_identity
402
405
  @interact_output = nil
@@ -500,30 +503,26 @@ module Expect
500
503
  failure ||= error
501
504
  nil
502
505
  end
503
- # IO 关闭与进程退出独立记录:软关闭可能已经 closed?,但仍保留活跃 PID。
504
- cleanup.call { @resources.close_handles }
505
- @closed = true
506
- @interact_inputs&.delete_if do |_io, input|
507
- cleanup.call do
508
- input.close(graceful: false)
509
- true
506
+ # 日志收尾是本作用域的清理:不能覆盖进程等待/中断或已记录的句柄错误。
507
+ Cleanup.always(-> { finish_logging }) do
508
+ # IO 关闭与进程退出独立记录:软关闭可能已经 closed?,但仍保留活跃 PID。
509
+ cleanup.call { @resources.close_handles }
510
+ @closed = true
511
+ @interact_inputs&.delete_if do |_io, input|
512
+ cleanup.call do
513
+ input.close(graceful: false)
514
+ true
515
+ end
510
516
  end
517
+ @interact_output = nil
518
+ @pending_writes&.clear
519
+ @relay_history&.clear
520
+ @relay_callback = nil
521
+ status = close_child(timeout:, term_timeout:, force:)
522
+ raise failure if failure
523
+
524
+ status
511
525
  end
512
- @interact_output = nil
513
- @pending_writes&.clear
514
- @relay_history&.clear
515
- @relay_callback = nil
516
- status = close_child(timeout:, term_timeout:, force:)
517
- completed = true
518
- status
519
- ensure
520
- begin
521
- cleanup.call { flush_diagnostics }
522
- ensure
523
- cleanup.call { self.transcript = nil }
524
- end
525
- # 用本次流程的完成状态判断异常传播,不能误把调用者 rescue 中的异常当成当前错误。
526
- raise failure if failure && completed
527
526
  end
528
527
 
529
528
  # 句柄清理失败不改变进程策略;未回收 PID 保留给重复关闭或终结器继续处理。
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.7.4"
5
+ VERSION = "0.7.6"
6
6
  end
data/lib/expect.rb CHANGED
@@ -47,9 +47,15 @@ module Expect
47
47
  timeout: nil, write_timeout: nil, buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
48
48
  session = nil
49
49
  spawned = false
50
- cleanup = -> { session&.close(graceful: spawned && graceful) if block_given? || !spawned }
50
+ cleanup = lambda do
51
+ if session && (block_given? || !spawned)
52
+ session.cleanup_session(nil, writer: nil, own: false, graceful: spawned && graceful)
53
+ end
54
+ end
51
55
  Cleanup.always(cleanup) do
52
- session = Session.new(timeout:, write_timeout:, buffer_limit:, logger:, transcript:, outputs:)
56
+ # 先持有对象再初始化,构造完成到工厂接管之间不能留下无人负责关闭的窗口。
57
+ session = Session.allocate
58
+ session.__send__(:initialize, timeout:, write_timeout:, buffer_limit:, logger:, transcript:, outputs:)
53
59
  session.spawn(*command, env:, chdir:, raw:)
54
60
  spawned = true
55
61
  block_given? ? yield(session) : session
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.7.4
4
+ version: 0.7.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-07 00:00:00.000000000 Z
10
+ date: 2026-10-09 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: io-console
@@ -51,8 +51,8 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '3.0'
54
- description: Automate interactive programs with exact and regexp matching, Ruby blocks,
55
- multi-session waits, logging and terminal interaction.
54
+ description: Automate interactive programs with PTY sessions, pattern matching, callbacks,
55
+ timeouts, logging and multi-session IO.
56
56
  executables: []
57
57
  extensions: []
58
58
  extra_rdoc_files: []
@@ -60,11 +60,14 @@ files:
60
60
  - CHANGELOG.md
61
61
  - LICENSE
62
62
  - README.md
63
+ - README.zh-CN.md
63
64
  - docs/API.md
65
+ - docs/COMPATIBILITY.md
64
66
  - docs/MIGRATION.md
65
67
  - lib/expect.rb
66
68
  - lib/expect/cleanup.rb
67
69
  - lib/expect/interaction.rb
70
+ - lib/expect/literal_prefix.rb
68
71
  - lib/expect/logging.rb
69
72
  - lib/expect/matcher.rb
70
73
  - lib/expect/pattern.rb
@@ -84,6 +87,7 @@ licenses:
84
87
  metadata:
85
88
  rubygems_mfa_required: 'true'
86
89
  source_code_uri: https://github.com/gatework/expect-ruby
90
+ documentation_uri: https://github.com/gatework/expect-ruby/blob/main/docs/API.md
87
91
  changelog_uri: https://github.com/gatework/expect-ruby/blob/main/CHANGELOG.md
88
92
  bug_tracker_uri: https://github.com/gatework/expect-ruby/issues
89
93
  rdoc_options: []
@@ -102,5 +106,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
102
106
  requirements: []
103
107
  rubygems_version: 4.0.16
104
108
  specification_version: 4
105
- summary: Ruby PTY automation with the Expect.pm interaction model
109
+ summary: Ruby PTY automation for interactive programs
106
110
  test_files: []