expect-pty 0.2.0 → 0.3.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 +9 -0
- data/README.md +22 -9
- data/docs/COMPATIBILITY.md +23 -1
- data/docs/RELEASING.md +21 -8
- data/docs/VERIFICATION.md +65 -1
- data/expect-pty.gemspec +1 -1
- data/lib/expect/configuration.rb +7 -0
- data/lib/expect/interaction.rb +249 -0
- data/lib/expect/{engine.rb → matcher.rb} +40 -5
- data/lib/expect/pattern.rb +12 -20
- data/lib/expect/relay.rb +133 -0
- data/lib/expect/relay_writer.rb +66 -0
- data/lib/expect/result.rb +2 -0
- data/lib/expect/{resources.rb → session_resources.rb} +3 -3
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +105 -30
- data/script/ci +3 -4
- data/script/release.rb +22 -10
- data/test/edge_case_test.rb +101 -22
- data/test/interact_test.rb +102 -0
- data/test/interconnect_test.rb +203 -4
- data/test/io_test.rb +102 -0
- data/test/matching_test.rb +33 -0
- data/test/relay_recovery_test.rb +414 -0
- data/test/release_test.rb +67 -1
- data/test/support/script_probe.rb +1 -0
- metadata +9 -6
- data/lib/expect/interconnect.rb +0 -170
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eda03bcece07a45c1c1f4f4010ae389928db6954b44624d072ce0e4249ee5a4d
|
|
4
|
+
data.tar.gz: df16127b3002243113a3f4bb6f1945ebb5430a4a1ba614e1926f5c4be17c6a0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0aab6a9174549cac6cbdfd33eb658b4027c6509bc9a8f246d48d76329fd9c8594e38bfc0f03430f2121bbdc82b7810f89b329c5f05051ea2657c2d0498e12825
|
|
7
|
+
data.tar.gz: d819201280329ce39678db25c32d4657336b2e2e0288945285377b90d309e30aa616a60847024a1aeccf157eedbfd32165e6b01caba7e4c8c66b357c72656e3b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.0 - 2026-09-26
|
|
6
|
+
|
|
7
|
+
- **终端模式调整**:通用 `interconnect` 改由调用方设置和恢复终端模式,需要自动切换本地输入终端时使用 `interact`,直接转接终端的现有代码需要相应调整。
|
|
8
|
+
- **转接恢复**:真实 IO 的背压受转接总期限和目标写期限约束,超时、短写入或监听器失败后可继续发送未完成的后缀,`pending_output?` 可查询待发送状态。
|
|
9
|
+
- **输入与写入**:重复 `interact` 会接续同一输入 IO 的预读尾部,背压输入保留转义过滤,`WriteTimeout#bytes_written` 提供写入进度,临时 `EINTR` 不再中断原操作或重置期限。
|
|
10
|
+
- **匹配与编码**:固定 UTF-8 正则等待分片字符收齐,非法或 EOF 截断编码抛出 `EncodingError` 并保留原字节,无进展回调等待新输入,正则转义默认只保留最近 65,536 字节历史。
|
|
11
|
+
- **日志与回收**:新建日志默认权限为 `0600`,日志异常后接收数据仍可恢复,捕获会话的日志回调不再阻止子进程的 GC 回收。
|
|
12
|
+
- **重复接管与回调**:切换退出正则不再匹配已转发的旧输入,相同终端的跨次 CRLF 保持原样,转义回调可用 `expect` 消费预读尾部和新回复,嵌套输出超时保留调用方的写入进度。
|
|
13
|
+
|
|
5
14
|
## 0.2.0 - 2026-09-13
|
|
6
15
|
|
|
7
16
|
- **不兼容变更**:接口统一采用 Ruby 属性、谓词、关键字参数、原生正则和块回调,移除 `exp_*`、`get/set_accum`、位置超时和数组回调等旧入口,不提供兼容别名,迁移方式见 `docs/COMPATIBILITY.md`。
|
data/README.md
CHANGED
|
@@ -78,7 +78,7 @@ end
|
|
|
78
78
|
| `preserve_buffer` | `false` | 匹配后保留完整缓冲 |
|
|
79
79
|
| `log_stdout` | `false` | 将接收内容输出到 `$stdout` |
|
|
80
80
|
| `log_listeners` | `true` | 将接收内容转发给 `listeners` |
|
|
81
|
-
| `raw_terminal` | `true` |
|
|
81
|
+
| `raw_terminal` | `true` | `interact` 期间自动设置并恢复输入终端模式,同时保留输出换行处理 |
|
|
82
82
|
| `reset_timeout_on_read` | `false` | 每次收到数据时重置匹配期限 |
|
|
83
83
|
| `graceful_close` | `false` | `close` 先尝试软关闭,再完成强制清理 |
|
|
84
84
|
|
|
@@ -114,10 +114,12 @@ number, error, match, before, after, session, captures = result.to_a
|
|
|
114
114
|
|
|
115
115
|
成功匹配后删除匹配内容及其之前的内容,尾部留给下次匹配;超时保留缓冲,EOF 将未匹配内容放入 `before` 并清空缓冲。EOF 与子进程退出是不同事件,使用 `wait` / `process_status` 判断进程结果。底层 IO 错误保留原始异常,回调中的普通异常直接抛出。
|
|
116
116
|
|
|
117
|
-
接收缓冲、匹配和捕获值为 `ASCII-8BIT` 字节串,保留控制字符、NUL 和无效 UTF-8
|
|
117
|
+
接收缓冲、匹配和捕获值为 `ASCII-8BIT` 字节串,保留控制字符、NUL 和无效 UTF-8。固定 UTF-8 正则会等待读取末尾拆开的字符收齐后再匹配,以免尾部锚点提前命中;显示捕获内容时可 `.force_encoding("UTF-8")`。任意二进制流请用字面字符串或二进制正则 `/.../n`。固定 UTF-8 正则遇到无效数据抛出 `EncodingError`;EOF 时仍未收齐的字符也属于无效编码,匹配缓冲保留原字节供诊断或二进制匹配。缓冲上限按字节截断,应为文本设置足够的上限。
|
|
118
118
|
|
|
119
119
|
正则完全遵循 Ruby:`^` / `$` 是行锚点,`\A` / `\z` 是整个缓冲的锚点,`/m` 让点号匹配换行;不再提供全局正则模式开关。
|
|
120
120
|
|
|
121
|
+
IO 等待的 `timeout` 不会中断单次正则计算。处理用户提供的正则或不可信长输出时,应使用有限时的正则实例,例如 `Regexp.new('prompt>\\s*', timeout: 0.05)`;该限制同样适用于 `on_sequence`。正则超时原样抛出 `Regexp::TimeoutError`,匹配缓冲保留,库不会修改进程全局 `Regexp.timeout`。`timeout: 0` 仍会匹配已有缓冲,不代表禁止正则计算。长输出可用日志保存全文,按业务需要设置 `buffer_limit` 限制匹配窗口;缩小窗口会改变 `before` 和跨窗口匹配范围。
|
|
122
|
+
|
|
121
123
|
## 回调、事件与多会话
|
|
122
124
|
|
|
123
125
|
```ruby
|
|
@@ -153,7 +155,7 @@ Expect.expect("ready", from: [first, second], timeout: 5)
|
|
|
153
155
|
|
|
154
156
|
`from:` 指定一个或多个会话;实例块默认当前会话,类方法需提供来源。相邻且来源列表相同的模式组成一组,按组、会话、模式顺序匹配。类方法省略超时使用 `Expect.configuration.timeout`。
|
|
155
157
|
|
|
156
|
-
`preserve_buffer = true`
|
|
158
|
+
`preserve_buffer = true` 时,继续回调通常应自行消费匹配,例如 `connection.buffer = connection.after`。如果回调没有改变缓冲,当前模式会等待缓冲变化后才重新匹配,避免反复处理同一内容。被信号中断的匹配 select/read 会自动重试,保留原期限。
|
|
157
159
|
|
|
158
160
|
## 已有 IO、写入和终端
|
|
159
161
|
|
|
@@ -168,6 +170,8 @@ ready = Expect.readable_sessions(first, second, timeout: 5)
|
|
|
168
170
|
|
|
169
171
|
`Expect.open` 支持可 `select` 的 File、管道、Socket 和 PTY,`writer:` 可指定独立写端。默认借用 IO,关闭会话不关闭原始 IO;`own: true` 转移关闭责任,初始化失败也会释放接管的 IO。`StringIO` 可以用作日志和监听器,不能用作读取会话。
|
|
170
172
|
|
|
173
|
+
用于写入或转接的真实 IO 应在首次写入前设置 `io.sync = true`,并由调用方保证没有未刷新的 Ruby 写缓冲;`write_nonblock` 可能先阻塞刷新已有缓冲,这一步不受本库的 IO 等待期限控制。已有缓冲应在交付给本库前由调用方排空,库不会绕过缓冲或改变字节顺序。
|
|
174
|
+
|
|
171
175
|
`readable_sessions` 返回可读的会话对象数组,不消费数据、不包含已关闭会话、同一会话只返回一次。默认 `timeout: 0`;`nil` 无限等待。同一会话应由一个读取者驱动,多会话共同监听使用 `Expect.expect`。
|
|
172
176
|
|
|
173
177
|
| API | 行为 |
|
|
@@ -182,7 +186,7 @@ ready = Expect.readable_sessions(first, second, timeout: 5)
|
|
|
182
186
|
| `winsize` / `winsize=` | 读取/修改 `[rows, cols]`,由内核通知前台进程 |
|
|
183
187
|
| `slave` / `tty_name` / `to_io` / `writer` / `fileno` / `tty?` | 底层 IO 和终端信息 |
|
|
184
188
|
|
|
185
|
-
大块写入遇到背压时同时读取输出,避免双向传输互相阻塞。超过 `write_timeout` 抛出 `Expect::WriteTimeout
|
|
189
|
+
大块写入遇到背压时同时读取输出,避免双向传输互相阻塞。超过 `write_timeout` 抛出 `Expect::WriteTimeout`,`error.bytes_written` 给出本次 `write` 已被底层接受的字节数;这些字节不回滚,不要从头重发整个命令。写入、等待和背压读取中的 `EINTR` 均保留原期限重试。控制字符可直接发送,例如 `session.write("\x03")`,其信号作用取决于终端设置。`send`、`public_send`、`__send__` 保留 Ruby 反射语义。
|
|
186
190
|
|
|
187
191
|
## 日志与人工交互
|
|
188
192
|
|
|
@@ -197,7 +201,7 @@ session.listeners = [output_io, another_session]
|
|
|
197
201
|
session.log_listeners = false
|
|
198
202
|
```
|
|
199
203
|
|
|
200
|
-
日志读取用 `log_output`,设置用 `log_output=`,打开路径或注册日志块用 `log_to
|
|
204
|
+
日志读取用 `log_output`,设置用 `log_output=`,打开路径或注册日志块用 `log_to`。新建日志权限为 `0600`(仍受 umask 限制),已有文件保留原权限。不能同时提供日志目标与块。`listeners` 返回列表副本,`listeners = []` 清空;替换无效目标不会丢失原目标。
|
|
201
205
|
|
|
202
206
|
所有会话默认不输出到 stdout。日志仅记录实际读取的接收字节;写入不重复记录,终端回显可能作为接收内容返回。密码交互应关闭日志、调试,并确保被控程序不回显密码。
|
|
203
207
|
|
|
@@ -214,9 +218,17 @@ end
|
|
|
214
218
|
|
|
215
219
|
`on_sequence(sequence) { ... }` 注册字符串、原生正则或 `:eof`,通过闭包传递参数。无回调、返回 `nil` / `false` 停止,其他 Ruby 真值(包括 `0`)继续;字符串 `"EOF"` 按字面匹配。转接返回导致停止的会话,超时或所有 EOF 回调均继续时返回 `nil`。
|
|
216
220
|
|
|
217
|
-
|
|
221
|
+
`interconnect` 统一调度真实 IO 的非阻塞读写;慢目标不会阻止其他源前进,等待同时受总 `timeout` 和目标会话的 `write_timeout` 约束。总期限到达返回 `nil`,目标写期限先到则抛出 `WriteTimeout`。超时后的字面转义前缀只尝试非阻塞发送,不再等待下游。作为写入目标但未显式列出的 Expect 会话,背压期间读取的回复保留在其匹配缓冲;需要同时转发这些回复时,把它也传给 `interconnect`。
|
|
222
|
+
|
|
223
|
+
每个源独立保存待发送数据以及各目标的发送位置,`source.pending_output?` 表示仍有未交付内容。超时或异常后再次对同一源调用 `interconnect`,会接着发送未完成的后缀,已完成的目标不会重复接收;转义回调在前缀交付后执行。待发送数据与 `buffer` 中尚未处理的输入分开保存,修改 `listeners` 仅影响后续数据,旧数据仍发往原目标。恢复时不要把原始数据再次赋给 `buffer`,也不要在排空旧输出前插入新的直接写入;关闭源会话会放弃其待发送数据。转接保留的输入暂不按 `buffer_limit` 裁剪,下次匹配时重新应用该上限。
|
|
224
|
+
|
|
225
|
+
自定义写入对象必须及时返回实际接受的字节数,短写入会继续发送后缀,零、负数或非法返回值抛出 `IOError`。对象若先写入再抛错而不报告进度,库无法推断其副作用。日志、用户回调及自定义 `write` / `flush` 同步运行,应由调用方保证它们不会无限阻塞;上述 IO 期限不会强行中断这些代码。普通 `expect` 的同步日志和监听器输出也不受匹配等待期限限制。
|
|
226
|
+
|
|
227
|
+
字面转义可以跨读取完整过滤,尾部留给下次调用。正则转义使用历史记录,默认最多保留最近 65,536 字节;设置 `buffer_limit` 后改用该值。正则及其锚点作用于当前历史窗口,超过窗口的跨读取正则无法匹配,已实时转发的前缀也无法撤回;零长度正则匹配抛出 `ArgumentError`。日志始终记录原始接收字节,包括被过滤的转义,在 `expect` / `interconnect` 之间切换也不会重复记录。
|
|
228
|
+
|
|
229
|
+
`interact` 会自动设置并恢复本地输入终端模式,同时保留输出换行处理;输入会话的 `raw_terminal = false` 将设置交给调用方。通用的 `interconnect` 只负责字节转发,由调用方管理终端模式。`interact` 还会恢复临时监听组、日志开关和转义设置,包括超时和异常路径。
|
|
218
230
|
|
|
219
|
-
|
|
231
|
+
对同一连接重复传入同一个原始输入 IO 时,`interact` 会复用输入包装器,接续上次预读的尾部。包装器由该连接持有,关闭连接时释放,但不关闭借用的原始 IO;已关闭的输入或包装器不再复用。需要跨连接共享或自行管理输入生命周期时,显式传入 `Expect.open(input)` 创建的会话。
|
|
220
232
|
|
|
221
233
|
## 软关闭、硬关闭与进程状态
|
|
222
234
|
|
|
@@ -253,12 +265,13 @@ ruby examples/ssh_interact.rb --auto
|
|
|
253
265
|
|
|
254
266
|
[GitHub Actions](https://github.com/gatework/expect-ruby/actions/workflows/ci.yml) 在推送 `main`、推送 `v*` 标签、提交到 `main` 的 Pull Request 或手动触发时运行。流水线覆盖 Ubuntu 24.04 / macOS 15 与 Ruby 3.2、3.3、3.4、4.0 的 8 种组合;每个环境执行 `script/ci`,包括真实 PTY 测试和构建包的隔离安装验证。Ubuntu / Ruby 4.0 作业保留已验证的 Gem 构建产物 14 天,可从该次工作流的 Artifacts 下载。
|
|
255
267
|
|
|
256
|
-
运行前先执行 `bundle install`。Gem 库的开发锁文件 `Gemfile.lock` 保留在本地,各 Ruby 环境按 `Gemfile` 解析兼容依赖。生成文件写入已忽略的 `
|
|
268
|
+
运行前先执行 `bundle install`。Gem 库的开发锁文件 `Gemfile.lock` 保留在本地,各 Ruby 环境按 `Gemfile` 解析兼容依赖。生成文件写入已忽略的 `tmp/`,Gem 构建产物位于 `tmp/ci/`,发布候选包位于 `tmp/release/`。更新工作流中的 Action 时,应同步更新固定的提交 SHA 和版本注释。
|
|
257
269
|
|
|
258
|
-
|
|
270
|
+
仅发布 RubyGems 使用 `ruby script/release.rb --rubygems-only`,直接复用本机已有的 Gem 登录状态;添加 `--dry-run` 可先完成本地检查、测试、构建和安装验证。需要同时创建 GitHub Release 时使用 `ruby script/release.rb`,也可以在 GitHub Actions 手动运行 Release 工作流。版本准备、Actions 凭据和失败重试见 [发布说明](docs/RELEASING.md)。
|
|
259
271
|
|
|
260
272
|
SSH 示例用 `SSH_USER`、`SSH_HOST`、`SSH_KNOWN_HOSTS` 配置,密码隐藏输入或从 `EXPECT_PASSWORD` 读取;非本地主机要求受信任的 known_hosts 文件。`ssh_auto.rb` 顶部 `COMMANDS` 可直接修改,日志写入 `tmp/ssh-auto/`,权限 0600。
|
|
261
273
|
|
|
262
274
|
多脚本验证入口为 `test/integration/ssh_scripts.rb`,人工/自动接管入口为 `examples/ssh_interact.rb`,详细配置及日志检查见 [SSH 测试说明](test/integration/README.md)。
|
|
263
275
|
|
|
264
276
|
当前接口迁移表见 [接口说明](docs/COMPATIBILITY.md),本次与历史验证分列在 [验证记录](docs/VERIFICATION.md)。此次重构直接移除了旧入口,不提供兼容别名。
|
|
277
|
+
防火墙连接器已迁移到相邻的 `algosec` 项目;本库只保留 `Expect` 与 `expect-pty` 通用传输能力。
|
data/docs/COMPATIBILITY.md
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
|
-
# Ruby 接口与 Expect
|
|
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` | `continue` / `continue(reset_timeout: false)`;使用单调时钟和秒数关键字参数 |
|
|
14
|
+
| 匹配后消费输入、`-notransfer` | `before` / `match` / `after` 与会话级 `preserve_buffer`;无进展回调不会原地重复执行 |
|
|
15
|
+
| glob、exact、regexp 模式 | 字符串只作字面匹配,正则采用 Ruby `Regexp`;锚点和多行行为遵守 Ruby |
|
|
16
|
+
| `match_max`、`full_buffer` | `buffer_limit` 只保留尾部字节,默认无限;没有缓冲满事件或丢弃字节结果,不能作为完整输出存储 |
|
|
17
|
+
| 前置/后置模式、后台匹配 | 没有全局隐式规则或后台读取器;调用方显式组合模式块,每个会话由一个读取者驱动 |
|
|
18
|
+
| `interact` 的部分正则匹配 | Tcl 的 `CANMATCH` 可以暂存潜在匹配;Ruby 原生正则不提供该接口,本库使用有限历史窗口,已转发前缀不能撤回。需要完整过滤时使用字面转义 |
|
|
19
|
+
| `close` 与 `wait` | IO 结束与进程退出仍分开判断;本库的块生命周期和 `close` 会额外回收直属子进程 |
|
|
20
|
+
|
|
21
|
+
长输出任务应通过日志或监听器流式保存,并按提示长度设置 `buffer_limit`。需要无损截断通知时,后续应单独设计缓冲满事件,不能把现有尾部裁剪当成该功能。后台匹配与共享前置/后置模式也需要独立的取消、优先级和资源归属约定,尚未实现。
|
|
22
|
+
|
|
23
|
+
## Expect.pm 接口迁移
|
|
2
24
|
|
|
3
25
|
交互能力参考 [jacoby/expect.pm](https://github.com/jacoby/expect.pm),源码基准为版本 1.38、提交 `2ea0e4ce20a896c95cb4c94e781f1b1f3145150d`。此项目独立实现,使用 MIT 许可,没有复制 Perl 实现代码;原项目作者及维护者为 Austin Schutz、Roland Giersig、Dave Jacoby,采用与 Perl 相同的许可。
|
|
4
26
|
|
data/docs/RELEASING.md
CHANGED
|
@@ -1,29 +1,40 @@
|
|
|
1
1
|
# 发布版本
|
|
2
2
|
|
|
3
|
-
项目名为 `expect-ruby`,RubyGems 名为 `expect-pty
|
|
3
|
+
项目名为 `expect-ruby`,RubyGems 名为 `expect-pty`。使用 `script/release.rb --rubygems-only` 可仅发布 RubyGems;不加该选项时还会创建 GitHub Release,附带同一个 Gem 和 `SHA256SUMS`。生成文件统一放在已被 Git 忽略的 `tmp/` 下。
|
|
4
4
|
|
|
5
5
|
## 准备版本
|
|
6
6
|
|
|
7
7
|
1. 更新 `lib/expect/version.rb` 的 `Expect::VERSION`,例如 `0.2.0`。
|
|
8
8
|
2. 把 `CHANGELOG.md` 的 `Unreleased` 内容移到对应版本标题下,例如 `## 0.2.0 - 2026-09-13`;可以保留空的 `Unreleased` 标题。
|
|
9
|
-
3.
|
|
9
|
+
3. 提交源码,发布时工作区必须干净。若同时发布 GitHub Release,还需推送到 `main`,远端 `main` 必须包含该提交,已有同名标签必须指向该提交。
|
|
10
10
|
|
|
11
11
|
发布脚本只接受正式版 `X.Y.Z`;未归档的变更会阻止发布。
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## 仅发布 RubyGems
|
|
14
14
|
|
|
15
|
-
本地已登录 `gem`
|
|
15
|
+
本地已登录 `gem` 时,脚本直接使用已有凭据。如果 RubyGems 要求一次性验证码,`gem push` 会提示输入。此模式不调用 `gh`,不要求推送到 GitHub,也不创建标签或 GitHub Release。
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
18
|
bundle install
|
|
19
|
-
ruby script/release.rb --dry-run
|
|
20
|
-
ruby script/release.rb
|
|
19
|
+
ruby script/release.rb --rubygems-only --dry-run
|
|
20
|
+
ruby script/release.rb --rubygems-only
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
默认执行 `script/ci`
|
|
23
|
+
默认执行 `script/ci` 的检查、完整测试、构建和隔离安装验证,构建包保存在 `tmp/ci/expect-pty-版本号.gem`。随后复制到 `tmp/release/版本号/candidate-*/` 的独占目录,核对包内文件并生成发布说明和校验文件。该副本贯穿后续发布,目录会保留供失败重试。正式发布前再次确认源码未变,上传 RubyGems 后下载远端包核对 SHA256。
|
|
24
24
|
|
|
25
25
|
`--dry-run` 只做本地验证,可在提交前使用。它仍要求版本号和发布说明完整。
|
|
26
26
|
|
|
27
|
+
## 同时发布 GitHub Release
|
|
28
|
+
|
|
29
|
+
此模式还会复用 `gh auth login` 的登录状态:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
ruby script/release.rb --dry-run
|
|
33
|
+
ruby script/release.rb
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
正式发布先创建 GitHub Release,再上传 RubyGems,最后下载 RubyGems 上的包核对 SHA256。GitHub 上还没有标签时,会为当前提交创建 `v版本号` 标签。
|
|
37
|
+
|
|
27
38
|
## GitHub Actions 发布
|
|
28
39
|
|
|
29
40
|
GitHub Runner 不会继承本机的 Gem 登录状态。要在 Actions 发布 RubyGems,需在仓库的 Settings → Secrets and variables → Actions 中配置 `RUBYGEMS_API_KEY`,使用具有 `Push rubygem` 权限的发布 Key。
|
|
@@ -45,9 +56,11 @@ gh workflow run release.yml --ref v0.2.0 --repo gatework/expect-ruby
|
|
|
45
56
|
保留脚本输出的 `Artifact` 路径,用该候选 Gem 重试发布;更换 RubyGems 工具版本或重新构建可能得到不同字节,同一个版本不得覆盖已有内容。也可以直接指定从 CI 或 Release 下载的原包:
|
|
46
57
|
|
|
47
58
|
```sh
|
|
48
|
-
ruby script/release.rb --artifact
|
|
59
|
+
ruby script/release.rb --rubygems-only --artifact tmp/ci/expect-pty-0.3.0.gem
|
|
49
60
|
```
|
|
50
61
|
|
|
62
|
+
将示例路径替换为实际输出的 `Artifact` 路径。`--artifact` 会跳过构建和测试,但仍核对包与当前源码是否一致;需要同时恢复 GitHub Release 时去掉 `--rubygems-only`。
|
|
63
|
+
|
|
51
64
|
工作流失败时优先使用 Re-run failed jobs,继续使用本次 CI 保存的产物。需要在本地恢复时,检出发布标签对应的干净源码,下载该 Release 的 Gem,再通过 `--artifact` 指定它。
|
|
52
65
|
|
|
53
66
|
脚本会校验现有 RubyGems 版本和 GitHub Release 附件的 SHA256;一致时复用,不一致时中止。已有 GitHub Release 缺少附件时会补传,已有版本和附件不会被覆盖。
|
data/docs/VERIFICATION.md
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
# 验证记录
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.3.0 发布前复核(2026-09-26)
|
|
4
|
+
|
|
5
|
+
以 0.2.0 为基线导入源码更新,发布前增加 8 项回归,覆盖退出正则切换、同规则跨次匹配、待发送恢复、跨次 CRLF、转义回调内匹配与异常尾部恢复、嵌套写入超时进度。新用例先在修复前失败,再验证修复;仅内部转义处理器的两项测试随职责调整更新调用方式。
|
|
6
|
+
|
|
7
|
+
macOS arm64、Ruby 4.0.6、Bundler 4.0.17 下执行 `BUNDLER_VERSION=4.0.17 bash script/ci`:225 项测试、903 条断言,0 失败、错误或跳过;RuboCop 检查 42 个文件无违规,对话示例、Gem 构建、隔离安装及 PTY 对话全部通过。连续 1,000 次使用同一输入调用 `interact`,仅保留一个输入包装器,关闭会话不关闭借用 IO。
|
|
8
|
+
|
|
9
|
+
真实 IO 的已有 Ruby 写缓冲可能让 `write_nonblock` 先阻塞刷新,已在 README 明确首次写入前设置 `sync` 或交接前排空缓冲的要求;未通过绕过缓冲改变字节顺序。真实 SSH 与完整上游差分不在本次验证范围。远端平台矩阵以发布提交对应的 GitHub Actions 结果为准,以下记录属于各自历史时点。
|
|
10
|
+
|
|
11
|
+
## 转接期限、交付恢复与输入接续(2026-09-26)
|
|
12
|
+
|
|
13
|
+
继续引用审查中的 R1–R5,在原有未提交改动上修复;基线为 194 项测试 / 737 条断言全部通过。新增 `test/relay_recovery_test.rb` 的前 8 个用例先在未修复实现运行,得到 5 个失败、3 个错误,分别确认阻塞超时、部分发送重放、监听器失败重放、短写入丢后缀、原始输入 IO 接续、背压读取 EINTR、写超时进度和 EOF 截断编码问题。修复后这 8 项通过,再扩展到 23 项恢复与边界用例。
|
|
14
|
+
|
|
15
|
+
根因与修复:
|
|
16
|
+
|
|
17
|
+
- `interconnect` 的期限原先只约束读取循环,同步 `target.write` 可以无限阻塞;`Relay` 现统一 select 读写并保留总期限,超时后的前缀刷新也只尝试非阻塞写入。
|
|
18
|
+
- 原先只有单次 `write` 的局部偏移,转发失败后重新发送整个源缓冲;`RelayWriter` 现按目标保留游标,成功目标不重放,短写入继续后缀,flush 异常只重试 flush。转义回调等待前缀交付,剩余输入与待发送数据分别保留。
|
|
19
|
+
- `interact` 原先每次新建输入包装器;现由连接持有并按原始 IO 身份复用,关闭连接时不等键盘 EOF、不关闭借用 IO。关闭路径也先通过有界测试复现等待,再修复并复验。
|
|
20
|
+
- 背压读取的 `EINTR` 纳入原写期限重试;直接写超时异常提供 `bytes_written`。编码检查感知 EOF,固定 UTF-8 正则对截断字符抛出 `EncodingError`,匹配缓冲保留原字节。
|
|
21
|
+
|
|
22
|
+
同类路径复核覆盖直接写入、转接的常规/转义/EOF/超时发送、日志及普通监听器的 `emit`、匹配和转义两处正则定位。共同 `emit` 同样补齐短写入检查;匹配和转义都传入 EOF 状态。日志、回调、普通匹配的监听器以及自定义写入对象仍同步运行,文档明确这些操作不受 IO 等待期限强制中断;自定义对象必须准确返回已接受的字节数。R6 使用 Ruby 已有的正则实例 timeout,新增测试验证异常透传、缓冲保留及全局配置不变,未增加进程全局设置。
|
|
23
|
+
|
|
24
|
+
| 环境 | 完整 `script/ci` | 打包及运行 |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| macOS arm64,Ruby 4.0.7 | 217 项 / 857 断言,0 失败、错误、跳过;RuboCop 42 文件无违规 | 对话示例、Gem 构建、隔离安装及真实 PTY 对话通过 |
|
|
27
|
+
| Linux aarch64,Ruby 3.2.11 | 217 项 / 857 断言,0 失败、错误、跳过;RuboCop 42 文件无违规 | 对话示例、Gem 构建、隔离安装及真实 PTY 对话通过 |
|
|
28
|
+
|
|
29
|
+
Linux 从 gemspec 文件白名单及锁文件构造独立源码快照,只读挂载后复制到容器,使用 Bundler 4.0.20、锁定依赖及 `BUNDLE_FROZEN=true`;未读取或分发工作区设备日志。检查构建包包含 `relay.rb`、转接写入器(现名 `relay_writer.rb`)、原有重命名模块和新增回归测试,不含 `tmp/` 或 `.git/`。macOS 构建时存在本机 RDoc 多版本重复常量警告,命令成功退出,隔离安装与 PTY 验证通过。工作区原有 16 处样式问题只作相应格式修正。
|
|
30
|
+
|
|
31
|
+
Linux 首轮新增用例误把 0.1 秒转接超时当作发送完成:`pending_output? == false` 时,`buffer` 仍可能含尚未排入队列的输入。探针证实该状态存在;用例改为关闭源写端并等待转接返回该 EOF 来源,明确验证缓冲已空后再断言回复,避免依赖机器吞吐量。
|
|
32
|
+
|
|
33
|
+
`git diff --check` 通过。未运行 Ruby 3.3/3.4、远端 CI 或真实 SSH;未提交、推送或发布。新增源码仍需随现有重构文件一起纳入后续提交。性能窗口、完整输出存储等建议已补充使用说明,未改变默认缓冲上限或任意正则的匹配语义。
|
|
34
|
+
|
|
35
|
+
## Tcl 对照与全项目复核(2026-09-26)
|
|
36
|
+
|
|
37
|
+
基于 `e6fc883` 及当前全部未提交改动,复核库源码、测试、示例、打包和发布脚本。保留既有 `Matcher`、`SessionResources` 和 `interaction.rb` 职责命名,不新增兼容别名。本轮补充四项回归测试,修复三处问题:
|
|
38
|
+
|
|
39
|
+
- Ruby 编码转换器识别非法 UTF-8 前缀,避免将过长编码、代理区或越界码点误判为等待更多字节;合法分片继续等待,原字节保留。
|
|
40
|
+
- 转接读取先存入转接缓冲,再调用日志;日志异常后输入归还会话,可继续匹配。
|
|
41
|
+
- 新建日志使用 `0600` 权限;追加和覆盖已有文件均保留其权限。
|
|
42
|
+
|
|
43
|
+
三项缺陷均先运行回归测试确认旧实现失败,再修改实现并复验。合法分片用例覆盖二、三、四字节字符的每个截断位置。
|
|
44
|
+
|
|
45
|
+
| 环境 | 完整 `script/ci` | 打包验证 |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| macOS arm64,Ruby 4.0.7 | 191 项 / 728 断言,0 失败、错误、跳过;RuboCop 39 文件无违规 | 对话示例、构建、隔离安装与真实 PTY 对话通过 |
|
|
48
|
+
| Linux aarch64,Ruby 3.2.11 | 191 项 / 728 断言,0 失败、错误、跳过;RuboCop 39 文件无违规 | 对话示例、构建、隔离安装与真实 PTY 对话通过 |
|
|
49
|
+
|
|
50
|
+
macOS 验证目录从 `git archive HEAD`、全部工作区补丁及未跟踪源码重建,包含本地开发锁文件;隔离的 GEM_HOME 安装 Bundler 4.0.17,并以 `BUNDLE_FROZEN=true bash script/ci` 验证,未依赖本机损坏的 Bundler 启动器。Linux 使用 `ruby:3.2-bookworm`,源码只读挂载并复制到容器临时目录,同样安装锁定依赖;完整测试包含 `release_test.rb`。
|
|
51
|
+
|
|
52
|
+
核对 Tcl 官方 `expect.c` 的匹配、缓冲消费和继续计时流程,以及 `exp_inter.c` 的部分模式匹配和终端恢复;另在 Tcl Expect 5.45.4 与本库中分别实跑缓冲消费、保留、EOF 和 wait 场景,均通过。功能差异和未实现能力见 [接口说明](COMPATIBILITY.md#tcl-expect-语义边界),不代表完整 Tcl 差分验收。
|
|
53
|
+
|
|
54
|
+
本轮未重跑真实 SSH、Ruby 3.3/3.4 或远端 CI,也未提交、推送或发布。以下记录保留各自验证时点,不作为本轮执行证据。
|
|
55
|
+
|
|
56
|
+
## 当前工作区:交互、匹配与 IO 修复(2026-09-24)
|
|
57
|
+
|
|
58
|
+
本轮检查并修复固定 UTF-8 正则在末尾字符未收齐时提前匹配、正则转义历史窗口截断到 UTF-8 续字节,以及就绪查询、写入和转接中的 `EINTR`。新增回归覆盖分片字符、历史窗口和 select/read/write 中断。
|
|
59
|
+
|
|
60
|
+
| 环境 | 测试结果 | 其他检查 |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| macOS arm64,Ruby 4.0.7 | 187 项 / 692 断言,无失败、错误、跳过 | RuboCop 39 个文件无违规;对话示例通过 |
|
|
63
|
+
| Linux aarch64,Ruby 4.0.6 | 176 项 / 658 断言,无失败、错误、跳过 | 只读挂载源码;排除依赖本机打包环境的 `release_test.rb` |
|
|
64
|
+
|
|
65
|
+
`git diff --check` 通过。本轮未在 Ruby 3.2 重新运行完整测试,也未运行远端 CI;下节的 Ruby 3.2 结果属于 2026-09-12 的历史验证,不能代表当前工作区。
|
|
66
|
+
|
|
67
|
+
## Ruby 原生接口与关闭策略(2026-09-12)
|
|
4
68
|
|
|
5
69
|
此次重构以 144 项测试 / 526 个断言为基线。原有交互场景全部迁移到新接口,新增 18 项回归,三个环境均通过 `bundle exec rake`(RuboCop 和完整测试)及 `ruby examples/dialogue.rb`:
|
|
6
70
|
|
data/expect-pty.gemspec
CHANGED
|
@@ -13,7 +13,7 @@ Gem::Specification.new do |spec|
|
|
|
13
13
|
spec.license = "MIT"
|
|
14
14
|
spec.required_ruby_version = ">= 3.2"
|
|
15
15
|
spec.files = Dir[
|
|
16
|
-
"lib/**/*.rb", "examples/**/*.rb", "examples/**/*.md", "docs/**/*.md",
|
|
16
|
+
"lib/**/*.rb", "examples/**/*.rb", "examples/**/*.md", "docs/**/*.md", "benchmark/**/*.rb",
|
|
17
17
|
"test/**/*.rb", "test/**/*.sh", "test/**/*.md", "Gemfile", "Rakefile",
|
|
18
18
|
".rubocop.yml", "expect-pty.gemspec", "README.md", "LICENSE", "CHANGELOG.md", "script/ci", "script/release.rb"
|
|
19
19
|
]
|
data/lib/expect/configuration.rb
CHANGED
|
@@ -67,42 +67,49 @@ class Expect
|
|
|
67
67
|
def raw_pty=(value)
|
|
68
68
|
@raw_pty = !!value
|
|
69
69
|
end
|
|
70
|
+
|
|
70
71
|
alias raw_pty? raw_pty
|
|
71
72
|
|
|
72
73
|
# 控制匹配成功后是否保留完整缓冲;启用时由继续回调自行消费匹配内容。
|
|
73
74
|
def preserve_buffer=(value)
|
|
74
75
|
@preserve_buffer = !!value
|
|
75
76
|
end
|
|
77
|
+
|
|
76
78
|
alias preserve_buffer? preserve_buffer
|
|
77
79
|
|
|
78
80
|
# 控制接收字节是否同步输出到当前 $stdout;默认关闭。
|
|
79
81
|
def log_stdout=(value)
|
|
80
82
|
@log_stdout = !!value
|
|
81
83
|
end
|
|
84
|
+
|
|
82
85
|
alias log_stdout? log_stdout
|
|
83
86
|
|
|
84
87
|
# 控制接收字节是否转发给监听器,与 stdout 和日志目标分别管理。
|
|
85
88
|
def log_listeners=(value)
|
|
86
89
|
@log_listeners = !!value
|
|
87
90
|
end
|
|
91
|
+
|
|
88
92
|
alias log_listeners? log_listeners
|
|
89
93
|
|
|
90
94
|
# 控制人工转接期间是否自动设置并恢复终端模式。
|
|
91
95
|
def raw_terminal=(value)
|
|
92
96
|
@raw_terminal = !!value
|
|
93
97
|
end
|
|
98
|
+
|
|
94
99
|
alias raw_terminal? raw_terminal
|
|
95
100
|
|
|
96
101
|
# 控制收到任何新数据时是否刷新匹配期限,适用于按静默时长判断超时。
|
|
97
102
|
def reset_timeout_on_read=(value)
|
|
98
103
|
@reset_timeout_on_read = !!value
|
|
99
104
|
end
|
|
105
|
+
|
|
100
106
|
alias reset_timeout_on_read? reset_timeout_on_read
|
|
101
107
|
|
|
102
108
|
# 控制通用 close 是否先软关闭;最终资源清理仍由硬关闭兜底。
|
|
103
109
|
def graceful_close=(value)
|
|
104
110
|
@graceful_close = !!value
|
|
105
111
|
end
|
|
112
|
+
|
|
106
113
|
alias graceful_close? graceful_close
|
|
107
114
|
|
|
108
115
|
# 导出新的属性 Hash,用于构造会话副本或发布下一份默认配置。
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "relay_writer"
|
|
4
|
+
require_relative "relay"
|
|
5
|
+
|
|
6
|
+
# 为会话补充人工接管和多路 IO 转接;核心会话定义位于 lib/expect.rb。
|
|
7
|
+
class Expect
|
|
8
|
+
REGEXP_ESCAPE_HISTORY_LIMIT = 65_536
|
|
9
|
+
private_constant :REGEXP_ESCAPE_HISTORY_LIMIT
|
|
10
|
+
|
|
11
|
+
# Presents remote text on a raw local terminal without changing existing CRLF sequences.
|
|
12
|
+
class InteractOutput
|
|
13
|
+
def initialize(target)
|
|
14
|
+
@target = target
|
|
15
|
+
@previous_carriage_return = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :target
|
|
19
|
+
|
|
20
|
+
def render(data)
|
|
21
|
+
bytes = data.to_s.b
|
|
22
|
+
rendered = bytes.gsub(/(?<!\r)\n/n, "\r\n")
|
|
23
|
+
rendered = rendered.byteslice(1..) if @previous_carriage_return && bytes.start_with?("\n")
|
|
24
|
+
@previous_carriage_return = bytes.end_with?("\r")
|
|
25
|
+
rendered
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(data)
|
|
29
|
+
@target.write(render(data))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_constant :InteractOutput
|
|
34
|
+
|
|
35
|
+
# 注册字面、正则转义或 :eof 事件;回调用闭包保存上下文,nil/false 停止,其余值继续。
|
|
36
|
+
def on_sequence(sequence, &block)
|
|
37
|
+
key = case sequence
|
|
38
|
+
when :eof, Regexp then sequence
|
|
39
|
+
when String then sequence.b.freeze
|
|
40
|
+
else raise ArgumentError, "sequence must be a String, Regexp or :eof"
|
|
41
|
+
end
|
|
42
|
+
raise ArgumentError, "escape sequence must not be empty" if key == ""
|
|
43
|
+
|
|
44
|
+
@sequences[key] = block
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 临时将输入、会话和输出相连,实现人工接管;结束时恢复双方监听器、日志开关和转义设置。
|
|
49
|
+
def interact(input: $stdin, escape: nil, output: nil, timeout: nil)
|
|
50
|
+
source = interact_source(input)
|
|
51
|
+
output ||= input.equal?($stdin) ? $stdout : input
|
|
52
|
+
saved_self = [listeners, log_stdout, log_listeners]
|
|
53
|
+
saved_source = [source.listeners, source.log_stdout, source.log_listeners, source.sequences.dup]
|
|
54
|
+
terminal_state = prepare_interact_terminal(source)
|
|
55
|
+
display = interact_display(source, output, terminal_state)
|
|
56
|
+
# 临时建立“用户输入 -> 子进程 -> 显示输出”的双向连接,原监听关系在 ensure 中恢复。
|
|
57
|
+
self.listeners = [display]
|
|
58
|
+
self.log_stdout = false
|
|
59
|
+
self.log_listeners = true
|
|
60
|
+
source.listeners = [self]
|
|
61
|
+
source.log_stdout = false
|
|
62
|
+
source.log_listeners = true
|
|
63
|
+
source.on_sequence(escape) if escape
|
|
64
|
+
Expect.interconnect(self, source, timeout: timeout)
|
|
65
|
+
ensure
|
|
66
|
+
begin
|
|
67
|
+
if saved_self
|
|
68
|
+
self.listeners, self.log_stdout, self.log_listeners = saved_self
|
|
69
|
+
source.listeners, source.log_stdout, source.log_listeners, source.sequences = saved_source
|
|
70
|
+
end
|
|
71
|
+
ensure
|
|
72
|
+
restore_interact_terminal(terminal_state)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# 按各会话 listeners 建立转发图,处理转义、EOF 和总期限,返回引发停止的会话或 nil。
|
|
77
|
+
def self.interconnect(*sessions, timeout: nil)
|
|
78
|
+
raise ArgumentError, "interconnect requires Expect sessions" if sessions.empty? || sessions.any? do |session|
|
|
79
|
+
!session.is_a?(Expect)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Relay.new(sessions, timeout).run
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# 超时或异常后仍有未交付的数据;重新 interconnect 同一源会话可继续发送。
|
|
86
|
+
def pending_output? = @relay_outputs.any? { |output| !output.done? }
|
|
87
|
+
|
|
88
|
+
# 转发一个会话的待处理缓冲并剔除转义;返回 false 表示应结束整个转接。
|
|
89
|
+
# 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
|
|
90
|
+
def self.relay_buffer(session, buffers, final: false)
|
|
91
|
+
buffer = buffers.fetch(session)
|
|
92
|
+
loop do
|
|
93
|
+
sequences = session.__send__(:sequences).except(:eof)
|
|
94
|
+
history = session.__send__(:relay_history)
|
|
95
|
+
matches = sequences.filter_map do |key, handler|
|
|
96
|
+
if key.is_a?(Regexp)
|
|
97
|
+
position = Pattern.new(value: key).locate(history + buffer, final: final)
|
|
98
|
+
if position
|
|
99
|
+
offset, length, = position
|
|
100
|
+
raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
|
|
101
|
+
|
|
102
|
+
# 历史前缀已实时转发,不能撤回;负偏移表示本次只需消费转义尚未转发的部分。
|
|
103
|
+
[offset - history.bytesize, length, handler]
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
position = buffer.index(key)
|
|
107
|
+
[position, key.bytesize, handler] if position
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
if (found = matches.min_by(&:first))
|
|
111
|
+
position, length, callback = found
|
|
112
|
+
if position.positive?
|
|
113
|
+
if block_given?
|
|
114
|
+
yield buffer.byteslice(0, position)
|
|
115
|
+
buffer.replace(buffer.byteslice((position + length)..))
|
|
116
|
+
history.clear
|
|
117
|
+
# 已确定的转义不能在等待写入后重新匹配;尾部可能在等待期间继续增长。
|
|
118
|
+
session.__send__(:relay_callback=, [callback])
|
|
119
|
+
return :pending
|
|
120
|
+
end
|
|
121
|
+
session.__send__(:propagate, buffer.byteslice(0, position))
|
|
122
|
+
end
|
|
123
|
+
buffer.replace(buffer.byteslice([position + length, 0].max..))
|
|
124
|
+
# 转义消费后清除历史,防止继续回调再次匹配同一个转义。
|
|
125
|
+
history.clear
|
|
126
|
+
return false unless callback&.call
|
|
127
|
+
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# 暂存可能构成字面转义的最长后缀,保证 STOP 分两次读取时 ST 不会提前发给子进程。
|
|
132
|
+
held = 0
|
|
133
|
+
unless final
|
|
134
|
+
sequences.each_key do |key|
|
|
135
|
+
next if key.is_a?(Regexp)
|
|
136
|
+
|
|
137
|
+
[key.bytesize - 1, buffer.bytesize].min.downto(1) do |length|
|
|
138
|
+
if buffer.end_with?(key.byteslice(0, length))
|
|
139
|
+
held = [held, length].max
|
|
140
|
+
break
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
count = buffer.bytesize - held
|
|
146
|
+
count = [count, READ_SIZE].min if block_given?
|
|
147
|
+
if count.positive?
|
|
148
|
+
data = buffer.byteslice(0, count)
|
|
149
|
+
block_given? ? yield(data) : session.__send__(:propagate, data)
|
|
150
|
+
end
|
|
151
|
+
if sequences.keys.any?(Regexp)
|
|
152
|
+
history << buffer.byteslice(0, count)
|
|
153
|
+
limit = session.buffer_limit || REGEXP_ESCAPE_HISTORY_LIMIT
|
|
154
|
+
if history.bytesize > limit
|
|
155
|
+
history.replace(history.byteslice(-limit, limit))
|
|
156
|
+
if sequences.keys.any? { |key| key.is_a?(Regexp) && key.fixed_encoding? && key.encoding == Encoding::UTF_8 }
|
|
157
|
+
# 窗口可能从 UTF-8 续字节开始;丢弃不完整的首字符后再交给固定编码正则。
|
|
158
|
+
history.slice!(0) while (byte = history.getbyte(0)) && (0x80..0xBF).cover?(byte)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
buffer.slice!(0, count)
|
|
163
|
+
return block_given? && count.positive? ? :pending : true
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private_class_method :relay_buffer
|
|
168
|
+
|
|
169
|
+
def interact_source(input)
|
|
170
|
+
return input if input.is_a?(Expect)
|
|
171
|
+
|
|
172
|
+
@interact_inputs ||= {}.compare_by_identity
|
|
173
|
+
@interact_inputs.delete_if { |io, session| io.closed? || session.closed? }
|
|
174
|
+
@interact_inputs[input] ||= Expect.open(input)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def queue_output(data)
|
|
178
|
+
targets = []
|
|
179
|
+
targets << $stdout if log_stdout?
|
|
180
|
+
targets.concat(@listeners) if log_listeners?
|
|
181
|
+
@relay_outputs = targets.map do |target|
|
|
182
|
+
if target.is_a?(InteractOutput)
|
|
183
|
+
RelayWriter.new(target.target, target.render(data))
|
|
184
|
+
else
|
|
185
|
+
RelayWriter.new(target, data)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private :interact_source, :queue_output
|
|
191
|
+
|
|
192
|
+
# Only interact knows which stream is the local keyboard. Generic interconnect leaves terminals alone.
|
|
193
|
+
def prepare_interact_terminal(source)
|
|
194
|
+
return unless source.raw_terminal? && source.tty?
|
|
195
|
+
|
|
196
|
+
io = source.to_io
|
|
197
|
+
state = [io, io.console_mode]
|
|
198
|
+
io.raw!
|
|
199
|
+
state
|
|
200
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Restore a partially changed terminal on interrupts.
|
|
201
|
+
restore_interact_terminal(state)
|
|
202
|
+
raise
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def restore_interact_terminal(state)
|
|
206
|
+
return unless state
|
|
207
|
+
|
|
208
|
+
io, mode = state
|
|
209
|
+
io.console_mode = mode unless io.closed?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def interact_display(source, output, terminal_state)
|
|
213
|
+
@interact_output = nil unless terminal_state && @interact_output&.target.equal?(output)
|
|
214
|
+
return output unless terminal_state
|
|
215
|
+
|
|
216
|
+
target = output.respond_to?(:to_io) ? output.to_io : output
|
|
217
|
+
return output unless target.respond_to?(:tty?) && target.tty?
|
|
218
|
+
return output unless source.to_io.stat.rdev == target.stat.rdev
|
|
219
|
+
|
|
220
|
+
# 同一输出的 CRLF 可能跨越两次接管;只缓存最近目标,避免长期持有旧终端。
|
|
221
|
+
@interact_output ||= InteractOutput.new(output)
|
|
222
|
+
rescue IOError, SystemCallError
|
|
223
|
+
output
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private :prepare_interact_terminal, :restore_interact_terminal, :interact_display
|
|
227
|
+
|
|
228
|
+
protected
|
|
229
|
+
|
|
230
|
+
# 仅供转接内部保存和恢复注册表,避免公开可变 Hash 绕过 on_sequence 的校验。
|
|
231
|
+
attr_accessor :sequences
|
|
232
|
+
# 让同步写入的背压读取遵守当前转接的数据所有权,退出后恢复普通匹配缓冲。
|
|
233
|
+
attr_accessor :interaction_buffer
|
|
234
|
+
attr_reader :relay_outputs
|
|
235
|
+
attr_accessor :relay_callback
|
|
236
|
+
|
|
237
|
+
# 历史属于产生它的转义规则;同规则重入继续匹配,换规则不能重放已转发输入。
|
|
238
|
+
def relay_history
|
|
239
|
+
sequences = @sequences.except(:eof)
|
|
240
|
+
@relay_history = "".b unless @relay_history_sequences == sequences
|
|
241
|
+
@relay_history_sequences = sequences
|
|
242
|
+
@relay_history
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# 转接尚未处理的输入不能被匹配窗口上限裁掉;下次 expect 会重新应用该上限。
|
|
246
|
+
def restore_relay_buffer(buffer)
|
|
247
|
+
@buffer = buffer + @buffer
|
|
248
|
+
end
|
|
249
|
+
end
|