expect-pty 0.5.2 → 0.5.3
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 +10 -1
- data/docs/INTERNAL_CONTRACTS.md +21 -0
- data/docs/VERIFICATION.md +98 -0
- data/lib/expect/interaction.rb +2 -0
- data/lib/expect/relay.rb +37 -7
- data/lib/expect/session_resources.rb +39 -14
- data/lib/expect/terminal.rb +1 -1
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +70 -33
- data/script/release.rb +10 -2
- data/test/initialization_failure_test.rb +92 -0
- data/test/ownership_sequence_test.rb +208 -0
- data/test/process_interruption_test.rb +296 -0
- data/test/relay_reentrancy_test.rb +159 -0
- data/test/release_test.rb +47 -0
- data/test/write_contract_test.rb +105 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85c1290ea232f7bf915d4c0d95a50f1c8fd2e4ac89ce423ee46cdc66f3632ff5
|
|
4
|
+
data.tar.gz: f2db309b7f7543a82736dd39b216de1b003d75c92c044582f95999b010077677
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f26e3067e300d27354ffb3251474c7644d87df6b8efa72615ff4e6695437de65a395fc7654fbb06e21dfb52ca744131110e2b14590250b930d7492ef71b29ca3
|
|
7
|
+
data.tar.gz: 1b86664a4d28868d44a4bd5c513a1c26e07483b0aae23562cd41c62bfc9bfc0a9ecb8cadc9fb44011445174b34524ff3905a34670bd24f7a9675b5d5e9dbb8fc
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.3 - 2026-09-27
|
|
6
|
+
|
|
7
|
+
- **初始化清理**:资源账本建立前的中断也会尝试关闭全部所属 IO,保留借用 IO 与原始异常身份。
|
|
8
|
+
- **进程回收**:等待及关闭在 EINTR 后沿用原预算,终结器继续有限清理;保持软关闭、创建者归属及未知状态约定。
|
|
9
|
+
- **转接边界**:新增 `Expect::ReentrancyError`,阻止活跃 Relay 的同源递归,避免重复交付;保留独立来源嵌套、嵌套匹配和顺序恢复。
|
|
10
|
+
- **写入协议**:拒绝零、负数、非整数和超出本次片段的写入计数,明确 `write_timeout` 为背压期限。
|
|
11
|
+
- **发布文本**:CHANGELOG 与 release notes 显式使用 UTF-8,本地与工作流共用读取入口,非法 UTF-8 明确报错。
|
|
12
|
+
- **内部命名与验证**:简化清理及 EOF 方法名称,补充构造、回收、递归转接、locale 与连续状态回归。
|
|
13
|
+
|
|
5
14
|
## 0.5.2 - 2026-09-27
|
|
6
15
|
|
|
7
16
|
- **文档排版**:整理贡献、发布、Kibitz 示例和 SSH 集成测试说明的换行与表格格式。
|
data/README.md
CHANGED
|
@@ -241,11 +241,15 @@ ready = Expect.readable_sessions(first, second, timeout: 5)
|
|
|
241
241
|
`send_slow` 在每次写入后只检查已经可读的回复,不附加固定等待;返回时不保证收齐最后一个字符引发的回复,完整对话请继续使用
|
|
242
242
|
`expect`。
|
|
243
243
|
|
|
244
|
-
|
|
244
|
+
大块写入遇到背压时同时读取输出,避免双向传输互相阻塞。背压等待超过 `write_timeout` 抛出 `Expect::WriteTimeout`,
|
|
245
245
|
`error.bytes_written` 给出本次 `write` 已被底层接受的字节数;这些字节不回滚,不要从头重发整个命令。写入、等待和背压读取中的
|
|
246
246
|
`EINTR` 均保留原期限重试。控制字符可直接发送,例如 `session.write("\x03")`,其信号作用取决于终端设置。`send`、`public_send`、
|
|
247
247
|
`__send__` 保留 Ruby 反射语义。
|
|
248
248
|
|
|
249
|
+
`write_timeout` 是背压相关期限:持续成功的正数短写不会因为总耗时超过它而失败,也不会强行打断同步用户代码。
|
|
250
|
+
它与匹配的 `timeout/deadline`、整次 `interconnect` 的总 `timeout` 分别计算。非空写入要求底层返回实际接受的正整数字节数,
|
|
251
|
+
且不能超过本次片段长度;非法计数立即抛出 `IOError`,空写入仍返回 0。
|
|
252
|
+
|
|
249
253
|
`stty` 需要系统命令位于 `PATH`;缺失时抛出带安装提示的 `IOError`,原始 `Errno::ENOENT` 保留在 `cause`。窗口尺寸和人工接管的终端恢复使用
|
|
250
254
|
Ruby `io/console`。
|
|
251
255
|
|
|
@@ -338,6 +342,11 @@ IO 期限不会强行中断这些代码。普通 `expect` 的同步日志和监
|
|
|
338
342
|
后改用该值。正则及其锚点作用于当前历史窗口,超过窗口的跨读取正则无法匹配,已实时转发的前缀也无法撤回;零长度正则匹配抛出
|
|
339
343
|
`ArgumentError`。日志包括被转接过滤的转义,显式启用 `redact` 时遮盖注册秘密;在 `expect` / `interconnect` 之间切换不会重复记录。
|
|
340
344
|
|
|
345
|
+
一次转接尚未返回时,递归 `interconnect` 的来源若与活跃来源重叠,会在移动缓冲和修改发送游标前抛出
|
|
346
|
+
`Expect::ReentrancyError`。完全独立的来源仍可嵌套转接;`on_sequence` 中的嵌套 `expect/expect_result` 及返回后再次转接仍受支持。
|
|
347
|
+
自定义 `write` 若已产生副作用却抛错、未返回计数,库无法推断已接受的字节数,此时不能保证恢复交付恰好一次。
|
|
348
|
+
这一保护不代表所有会话 API 都可以跨线程并发调用。
|
|
349
|
+
|
|
341
350
|
`interact` 会自动设置并恢复本地输入终端模式,同时保留输出换行处理;输入会话的 `raw_terminal = false` 将设置交给调用方。通用的
|
|
342
351
|
`interconnect` 只负责字节转发,由调用方管理终端模式。`interact` 还会恢复临时监听组、日志开关和转义设置,包括超时和异常路径。
|
|
343
352
|
|
data/docs/INTERNAL_CONTRACTS.md
CHANGED
|
@@ -47,6 +47,11 @@ EOF,不再制造一次超时事件。EOF 或超时回调重置期限后恢复
|
|
|
47
47
|
|
|
48
48
|
转义扫描只在一轮内复用 `history + buffer`,只含字面规则时不构造它。回调继续后重新读取规则、历史和缓冲;不能跨回调保存文本快照。字面转义的潜在前缀暂存,完整前缀交付后才执行转义回调。
|
|
49
49
|
|
|
50
|
+
Relay 在移动缓冲、恢复写入期限之前检查全部来源的 `relay_owner`,有重叠时抛出 `ReentrancyError`。
|
|
51
|
+
重复来源先去重,标记检查和登记只使用短临界区;不跨 IO 或用户回调持锁。退出及准备失败时只归还已转移的缓冲,
|
|
52
|
+
只释放当前调用的 token,不能清除外层标记。不同来源的嵌套转接、转义回调中的 Matcher 和转接返回后的顺序恢复保持可用。
|
|
53
|
+
自定义目标先产生副作用再抛错而不返回计数时,库不能猜测进度;该协议边界不承诺恰好一次,也不承诺通用线程安全。
|
|
54
|
+
|
|
50
55
|
## 关闭与所有权
|
|
51
56
|
|
|
52
57
|
SessionResources 保存创建者 PID、所属句柄、直属子进程和所属日志。借用 IO 不关闭,fork 后的非创建者不发送信号或回收父进程的孩子。soft_close
|
|
@@ -55,9 +60,20 @@ SessionResources 保存创建者 PID、所属句柄、直属子进程和所属
|
|
|
55
60
|
工厂和构造器在校验之前登记资源归属,失败时沿用同一清理流程;启动成功必须显式记录,不能用 PID 非空推断整个工厂调用已经成功。即使
|
|
56
61
|
exec 成功后诊断输出失败,也要立即回收未交付给调用方的子进程。
|
|
57
62
|
|
|
63
|
+
资源账本构造本身也可能被中断。账本尚未发布时,`cleanup_session` 仅按局部 `own` 关闭真实 IO,按对象身份去重,
|
|
64
|
+
一端失败仍尝试其他端;借用 IO 保持打开。账本存在后仍调用统一的 `close`,不维护第二份生命周期状态。
|
|
65
|
+
`cleanup(failed:)` 的关键字表示本次作用域是否已有异常;只有已有主异常时才抑制常规清理错误,保留同一异常对象。
|
|
66
|
+
内部动作使用 `close_resources`、`close_child`、`mark_eof` 等直接名称,不保留旧私有方法别名。
|
|
67
|
+
|
|
58
68
|
显式关闭遇到 IOError/SystemCallError 时,继续尝试其他句柄、交互包装器、日志和子进程清理,最后传播首个清理错误;已经在传播的其他异常保留。失败资源继续持有,后续关闭可重试。GC
|
|
59
69
|
终结器独立尝试句柄、日志、非阻塞回收,常规清理错误不向外传播。终结器不能强引用会话本身。
|
|
60
70
|
|
|
71
|
+
`SessionResources#reap` 只做一次非阻塞系统调用,EINTR 交给所属流程决定:主会话 `process_status` 返回当前未知/缓存状态,
|
|
72
|
+
`wait_for_child` 沿用阶段开始时的绝对期限,并在重试间休眠。自然等待、TERM 等待分别使用调用方预算,硬关闭的 KILL 阶段保留 1 秒预算;
|
|
73
|
+
零预算也先尝试一次,信号 EINTR 不重新计时。soft_close 不发 KILL,ECHILD 清空 PID,ESRCH 后仍尝试回收,预算耗尽也返回已取得的状态。
|
|
74
|
+
GC 只做一次非阻塞回收、最多两次 KILL 尝试及一次 detach;回收/信号 EINTR 不跳过后续阶段,不执行用户日志回调。
|
|
75
|
+
detach 成功才移交 PID,失败则保留未知 PID/status。每个操作仍受创建者归属约束;持续系统调用失败只保证有限尝试,不保证返回前退出。
|
|
76
|
+
|
|
61
77
|
是否保留原始异常由当前构造、块或关闭作用域显式记录,不能直接读取调用者 rescue 中的 `$!`。`Interrupt` 和 `SystemExit`
|
|
62
78
|
同样先清理再传播;`break` / `throw` 不是异常,此时清理失败仍应抛出。
|
|
63
79
|
|
|
@@ -83,6 +99,11 @@ ESRCH 后仍尝试回收。三个阶段结束或系统调用失败后仍有 PID
|
|
|
83
99
|
|
|
84
100
|
未设置总期限时,`timeout: 0` 保持现有缓冲匹配和首次非阻塞轮询语义。期限检查是协作式的,不强行打断单次正则、日志、同步监听器或用户回调;正则执行限时由调用方的 `Regexp` 实例控制。
|
|
85
101
|
|
|
102
|
+
直接写入及 Relay 目标的 `write_timeout` 用于背压等待和中断重试,不是持续成功短写的总耗时限制。
|
|
103
|
+
匹配 `deadline`、写入期限和 Relay 总 `timeout` 分别管理,不自动把匹配期限传给回调中的写入。
|
|
104
|
+
直接写入必须先验证计数为正整数且不超过本次 chunk,再推进 offset;`:wait_writable` 仍进入背压路径,空输入返回 0。
|
|
105
|
+
`WriteTimeout#bytes_written` 只计已确认字节;读取输出引发嵌套写入超时时,对外保留当前写入进度,原异常留在 cause。
|
|
106
|
+
|
|
86
107
|
## 诊断与脱敏归属
|
|
87
108
|
|
|
88
109
|
`logging.rb` 分开处理接收日志、诊断和协议转发。`diagnostic_output` 借用 Logger、可写对象或回调,不进入资源账本;默认沿用 stderr。回调接收冻结的事件 Hash 和 message 字符串,不含会话对象。诊断失败与其他同步 IO 错误同样保留已读取的原始输入。
|
data/docs/VERIFICATION.md
CHANGED
|
@@ -1,5 +1,103 @@
|
|
|
1
1
|
# 验证记录
|
|
2
2
|
|
|
3
|
+
## v0.5.2 改进计划 T00–T07(2026-09-27,本地实施阶段)
|
|
4
|
+
|
|
5
|
+
以下是版本归档前的本地验证记录,对应改动随后归入 0.5.3。发布状态以 GitHub Release、对应 CI 和 RubyGems 为准。
|
|
6
|
+
|
|
7
|
+
基线为 `ac583532b5f4c946856e79ecd7350eb298019081`,开始时主工作区干净且 HEAD 等于本地 v0.5.2 标签。
|
|
8
|
+
本轮保留同步 IO、匹配优先级、默认 nil 期限、二进制缓冲及现有运行时依赖。版本仍为 0.5.2,变更记入 Unreleased;
|
|
9
|
+
没有提交、推送、创建标签、调用发布接口或登录真实 SSH。
|
|
10
|
+
|
|
11
|
+
T00 在 macOS 26.6.2 arm64、Ruby 4.0.6、Bundler 4.0.17、Minitest 5.27.0 上执行原始 `bundle exec rake`:
|
|
12
|
+
61 个文件 lint 通过,346 项 / 6,604 断言,退出码 0,无失败、错误或跳过。
|
|
13
|
+
系统默认 Ruby 为 2.6.10,因此下列本机命令统一使用 Homebrew Ruby,不改全局配置:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
export PATH="/opt/homebrew/opt/ruby/bin:$PATH"
|
|
17
|
+
export BUNDLER_VERSION=4.0.17
|
|
18
|
+
bundle exec rake
|
|
19
|
+
bundle exec rake test TESTOPTS='--seed=20260927'
|
|
20
|
+
bundle exec rake test TESTOPTS='--seed=1'
|
|
21
|
+
bundle exec rake test TESTOPTS='--seed=42'
|
|
22
|
+
script/ci
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
计划中的 `TESTOPTS='--seed 20260927'` 在锁定的 Rake 13.4.2 上实际失败:加载器把独立的数字参数当作文件。
|
|
26
|
+
同一 `rake test` 入口改用 `--seed=...` 后,输出确认采用指定 seed;未修改加载器、依赖或测试门槛。
|
|
27
|
+
|
|
28
|
+
### 按任务核对红灯与绿灯
|
|
29
|
+
|
|
30
|
+
从基线建立独立 detached worktree `tmp/improvement-052/baseline`,只复制对应的新回归测试,逐文件重放旧行为。
|
|
31
|
+
下表红灯为该基线的真实失败,绿灯为修复后同一测试文件的独立运行;全部使用 seed 20260927。
|
|
32
|
+
|
|
33
|
+
| 任务 | 修改与回归文件 | 旧版失败证据 | 修复后结果(测试 / 断言) |
|
|
34
|
+
| --- | --- | --- | --- |
|
|
35
|
+
| T01 / F01 | `lib/expect.rb`、`session_resources.rb`、`test/initialization_failure_test.rb` | 6 项失败:PTY 未关闭、open 用 NoMethodError 覆盖原中断 | 6 / 39,通过 |
|
|
36
|
+
| T02 / F02 | 上述生命周期文件、`test/process_interruption_test.rb` | 3 项失败、4 项错误:EINTR 提前终止 wait/close,finalizer 未 detach,归属变化后仍 detach | 12 / 56,通过 |
|
|
37
|
+
| T03 / F03 | `lib/expect.rb`、`interaction.rb`、`relay.rb`、`test/relay_reentrancy_test.rb` | 5 项失败:`abc` 变为 `abcabc`、重叠来源未拒绝、准备异常丢失缓冲 | 8 / 47,通过 |
|
|
38
|
+
| T04 / F04 | `script/release.rb`、发布 workflow、`test/release_test.rb` | 2 项失败:中文说明在 US-ASCII 下无法解析,非法 UTF-8 缺少明确诊断 | 21 / 124,通过(含原有发布拒绝条件) |
|
|
39
|
+
| T05 / C01–C02 | `lib/expect.rb`、`test/write_contract_test.rb` | 2 项失败:零计数依赖 watchdog 才退出,超出当前 chunk 的计数未拒绝 | 6 / 39,通过 |
|
|
40
|
+
| T06 | `test/ownership_sequence_test.rb` | 组合回归,非新增独立缺陷 | 6 / 161,通过 |
|
|
41
|
+
| T07 | README、内部契约、CHANGELOG、本记录 | 保留所有原测试及原有 CI 门禁 | 见下方完整验证 |
|
|
42
|
+
|
|
43
|
+
F01 使用真实 IO/PTY 并注入构造与 close 故障;F02 包含真实孩子的 waitpid 中断、真实 fork 非创建者检查,
|
|
44
|
+
连续中断和 GC 路径另外使用受控时钟与全部系统调用替身。假 PID 不进入真实信号或回收调用。
|
|
45
|
+
F03 使用公开 API、真实管道和自定义 writer 自然复现;F04 使用真实 CLI 子进程和中文文件。
|
|
46
|
+
C02 是非法 IO 适配返回值注入,不能推断正常 Ruby IO 会返回这些值。C01 的持续成功短写特征在旧版及新版均通过。
|
|
47
|
+
|
|
48
|
+
T06 的字节与多目标序列使用固定 seeds 20260927、1、42,核对各目标的完整内容、已确认计数、未处理缓冲、
|
|
49
|
+
转义消费、日志唯一性及原目标续发。真实 pipe EOF 与仍活跃孩子组合验证 IO/PID 相互独立,soft_close 只发 TERM,
|
|
50
|
+
hard_close 后读取真实状态并确认 ECHILD。测试时钟与系统调用替身在 ensure 中恢复,watchdog 仅用于测试防挂起。
|
|
51
|
+
|
|
52
|
+
### 完整验证与发布预演
|
|
53
|
+
|
|
54
|
+
新增 40 项测试,最终本机 `bundle exec rake`:66 个文件 lint 通过,**386 项 / 6,965 断言**,退出码 0。
|
|
55
|
+
三个固定 seed 的默认套件也各为 386 / 6,965,退出码均为 0,无失败、错误或跳过。
|
|
56
|
+
原有 cleanup/process/terminal_cleanup、relay/interact/diagnostics、io/timeout/deadline 定向组合也全部通过。
|
|
57
|
+
|
|
58
|
+
| 环境 | 完整 `script/ci` |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| macOS 26.6.2 arm64,Ruby 4.0.6 | 退出 0;386 / 6,965;66 文件 lint 通过 |
|
|
61
|
+
| Linux aarch64,Ruby 3.2.11,`ruby:3.2` | 退出 0;386 / 6,965;66 文件 lint 通过 |
|
|
62
|
+
| Linux aarch64,Ruby 3.3.12,`ruby:3.3` | 退出 0;386 / 6,965;66 文件 lint 通过 |
|
|
63
|
+
| Linux aarch64,Ruby 3.4.10,`ruby:3.4-slim` | 退出 0;386 / 6,965;66 文件 lint 通过 |
|
|
64
|
+
| Linux aarch64,Ruby 4.0.6,`ruby:4.0` | 退出 0;386 / 6,965;66 文件 lint 通过 |
|
|
65
|
+
|
|
66
|
+
Linux 验证以只读源目录挂载到临时容器,复制到独立 `/work` 后,使用同一 Gemfile.lock、Bundler 4.0.17 与
|
|
67
|
+
`BUNDLE_FROZEN=true` 执行原始 `script/ci`。3.4 slim 的 git 和构建工具仅安装在该临时容器。
|
|
68
|
+
完整脚本包含 dialogue 示例、matching/relay/send_slow/scaling/redactor 五组 smoke、Gem 构建、
|
|
69
|
+
普通 RubyGems 与最小 Bundler 消费方的隔离 PTY/核心契约/运行时依赖检查;所有这些步骤保留并执行。
|
|
70
|
+
|
|
71
|
+
在添加本轮 Unreleased 条目前,用本地完整源码构建的同一个 Gem 实际执行:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
LC_ALL=C LANG=C ruby -EUS-ASCII script/release.rb --dry-run --artifact tmp/improvement-052/expect-pty-0.5.2.gem
|
|
75
|
+
LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 ruby -EUTF-8 script/release.rb --dry-run --artifact tmp/improvement-052/expect-pty-0.5.2.gem
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
两次退出 0、验证相同 Gem 字节,生成的中文 release-notes 逐字节相同。该候选只是中途本地验证物,不是可发布版本。
|
|
79
|
+
新增 CLI 测试另覆盖独立 fixture 项目的非法 UTF-8 拒绝,以及 C/UTF-8 两种外部编码;不会访问发布服务。
|
|
80
|
+
当前 Unreleased 保留本轮变更,正式发布前仍需维护者自行归档并选择版本,原有拒绝条件不绕过。
|
|
81
|
+
最后对当前工作区再次执行 C locale dry-run,按预期退出 1 并提示 `Move Unreleased changes into the versioned changelog before releasing`。
|
|
82
|
+
|
|
83
|
+
### 同类检查、兼容性与剩余范围
|
|
84
|
+
|
|
85
|
+
- 构造所有权:核对 new/open/spawn/stty 四条入口。new/open 补账本之前的兜底;spawn 继续委托 new,stty 保留已有独立兜底。
|
|
86
|
+
- 进程回收:核对主会话、finalizer、stty 正常等待与异常回收。主会话/GC 修复 EINTR;stty 继续使用自身阶段策略,不合并账本。
|
|
87
|
+
- 写入进度:核对直接 write、RelayWriter、logging.emit 三处。后两者已有计数验证,仅补直接 write。
|
|
88
|
+
- 发布文本:本地入口与 workflow 共用 UTF-8 读取,notes 显式 UTF-8 写出;ASCII 校验和与 Git/Gem 二进制比较保持原状。
|
|
89
|
+
- 内部命名:统一为 `cleanup(failed:)`、`close_resources`、`close_child`、`mark_eof`;库、测试和文档无旧私有入口调用或别名。
|
|
90
|
+
- 兼容性:同源活跃 Relay 现在明确报 `ReentrancyError`,异常适配计数报 IOError;普通顺序恢复、嵌套 Matcher、匹配顺序和期限默认值不变。
|
|
91
|
+
|
|
92
|
+
G1–G6 由上述红绿回归与实际 locale dry-run 覆盖;G7–G8 由默认套件及原始 CI 脚本覆盖;G9–G10 由契约回归、
|
|
93
|
+
聚焦 diff 和本记录覆盖。日志位于被忽略的 `tmp/improvement-052/`,含基线、逐项红绿、固定 seed、容器和 dry-run 输出。
|
|
94
|
+
测试迭代中修正了测试 helper 与 Minitest 同名方法/嵌套 stub 的冲突,以及 CLI fixture 必须随脚本根目录复制的问题;
|
|
95
|
+
这些测试支架错误不计入产品红灯证据,表中证据来自修正后的独立基线重放。
|
|
96
|
+
|
|
97
|
+
未执行 macOS Ruby 3.2/3.3/3.4、远端 GitHub Actions 的 ubuntu-24.04/macos-15 矩阵、真实 SSH 或发布。
|
|
98
|
+
本地 Linux arm64 容器不能替代该远端矩阵。T08 的长期压力、高水位及性能对照按计划留作后续测量;
|
|
99
|
+
本轮 smoke 不证明吞吐提升、峰值内存下降或任意系统故障下保证回收成功。
|
|
100
|
+
|
|
3
101
|
## 0.5.0 公共字节过滤器发布前复核(2026-09-27)
|
|
4
102
|
|
|
5
103
|
基线为已发布的 `5a219f7`(0.4.0)。本轮公开 `Expect::Redactor`,新增 11 项回归,覆盖独立加载不引入 PTY、完整文本与流尾部策略、跨分片与重叠秘密、自定义替换标记、空规则、输入复制、无效更新原子性及安全摘要;现有会话日志与诊断继续复用该过滤器。
|
data/lib/expect/interaction.rb
CHANGED
|
@@ -248,6 +248,8 @@ class Expect
|
|
|
248
248
|
attr_accessor :sequences
|
|
249
249
|
# 让同步写入的背压读取遵守当前转接的数据所有权,退出后恢复普通匹配缓冲。
|
|
250
250
|
attr_accessor :interaction_buffer
|
|
251
|
+
# 活跃 Relay 的所有权 token;只限制同源递归转接,不限制转义回调中的 Matcher。
|
|
252
|
+
attr_accessor :relay_owner
|
|
251
253
|
# 只有裁剪、替换和消费才改变代次;同一代次只会追加,供字面扫描复用已排除的前缀。
|
|
252
254
|
attr_reader :buffer_generation
|
|
253
255
|
# 待交付游标随源会话保存,Relay 的超时或异常退出不会丢失各目标已经写出的进度。
|
data/lib/expect/relay.rb
CHANGED
|
@@ -4,21 +4,25 @@ class Expect
|
|
|
4
4
|
# 一次转接的共同读写循环;发送游标留在源会话中,调用结束后仍可继续。
|
|
5
5
|
# 本对象只暂借未处理输入;读缓冲、发送进度与转义回调分开保存,避免重入时重复交付。
|
|
6
6
|
class Relay
|
|
7
|
-
|
|
7
|
+
OWNERSHIP_MUTEX = Mutex.new
|
|
8
|
+
private_constant :OWNERSHIP_MUTEX
|
|
9
|
+
|
|
10
|
+
# 这里只校验参数;run 取得所有来源后才转移缓冲,不关闭或接管任何外部 IO。
|
|
8
11
|
def initialize(sessions, timeout)
|
|
9
12
|
@sessions = sessions.uniq
|
|
10
13
|
@active = @sessions.dup
|
|
11
14
|
period = Expect.duration(timeout)
|
|
12
15
|
@deadline = period && (Expect.monotonic + period)
|
|
13
|
-
@
|
|
14
|
-
@
|
|
16
|
+
@token = Object.new
|
|
17
|
+
@buffers = {}
|
|
18
|
+
@previous = {}
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
# 每轮先处理转义和已排队输出,再共同选择读写;停止返回来源会话,总期限到达返回 nil。
|
|
18
22
|
# 真实 IO 的写入由非阻塞游标推进,用户日志和回调仍同步执行,须由调用方保证及时返回。
|
|
19
23
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 共享读写循环统一维护来源和目标期限。
|
|
20
24
|
def run
|
|
21
|
-
|
|
25
|
+
prepare_sources
|
|
22
26
|
outputs.each(&:restart_timeout)
|
|
23
27
|
polled = false
|
|
24
28
|
loop do
|
|
@@ -89,15 +93,41 @@ class Expect
|
|
|
89
93
|
end
|
|
90
94
|
end
|
|
91
95
|
ensure
|
|
92
|
-
|
|
93
|
-
@previous.each { |session, buffer| session.__send__(:interaction_buffer=, buffer) }
|
|
94
|
-
@buffers.each { |session, buffer| session.__send__(:restore_relay_buffer, buffer) }
|
|
96
|
+
restore_sources
|
|
95
97
|
end
|
|
96
98
|
|
|
97
99
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
98
100
|
|
|
99
101
|
private
|
|
100
102
|
|
|
103
|
+
# 锁仅保护标记检查/登记,不跨 IO 或用户回调;任一来源被占用时整组拒绝,不动缓冲和期限。
|
|
104
|
+
def prepare_sources
|
|
105
|
+
OWNERSHIP_MUTEX.synchronize do
|
|
106
|
+
if @sessions.any? { |session| session.__send__(:relay_owner) }
|
|
107
|
+
raise ReentrancyError, "source session already has an active relay"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
@sessions.each { |session| session.__send__(:relay_owner=, @token) }
|
|
111
|
+
end
|
|
112
|
+
@sessions.each do |session|
|
|
113
|
+
@previous[session] = session.__send__(:interaction_buffer)
|
|
114
|
+
@buffers[session] = session.clear_buffer
|
|
115
|
+
session.__send__(:interaction_buffer=, @buffers.fetch(session))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# 未处理输入还给会话,已排队字节仍归游标;构造中断也只恢复已转移的缓冲及自己的标记。
|
|
120
|
+
def restore_sources
|
|
121
|
+
@previous.each { |session, buffer| session.__send__(:interaction_buffer=, buffer) }
|
|
122
|
+
@buffers.each { |session, buffer| session.__send__(:restore_relay_buffer, buffer) }
|
|
123
|
+
ensure
|
|
124
|
+
OWNERSHIP_MUTEX.synchronize do
|
|
125
|
+
@sessions.each do |session|
|
|
126
|
+
session.__send__(:relay_owner=, nil) if session.__send__(:relay_owner).equal?(@token)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
101
131
|
# 只收集尚未完成的目标,供共同 select 以及最早写入期限计算使用。
|
|
102
132
|
def outputs = @sessions.flat_map { |session| session.__send__(:relay_outputs) }.reject(&:done?)
|
|
103
133
|
|
|
@@ -20,11 +20,14 @@ class Expect
|
|
|
20
20
|
# 只关闭由本库拥有的 IO;借用的 reader、writer 由调用方管理。
|
|
21
21
|
# 常规关闭错误延后到所有句柄尝试完再抛出,失败句柄仍留在账本内供下一次关闭重试。
|
|
22
22
|
def close_handles
|
|
23
|
-
|
|
23
|
+
self.class.close_handles(reader, writer, slave) if own
|
|
24
|
+
end
|
|
24
25
|
|
|
26
|
+
# 账本尚未建立时也能释放局部 IO;不依赖完整会话,也不调用无效参数上的用户方法。
|
|
27
|
+
def self.close_handles(*handles)
|
|
25
28
|
# 初始化校验失败时可能含无效参数,只关闭真实 IO;PTY 读写端也需要去重。
|
|
26
29
|
failure = nil
|
|
27
|
-
|
|
30
|
+
handles.grep(IO).uniq(&:object_id).each do |io|
|
|
28
31
|
io.close unless io.closed?
|
|
29
32
|
rescue IOError, SystemCallError => error
|
|
30
33
|
failure ||= error
|
|
@@ -59,18 +62,7 @@ class Expect
|
|
|
59
62
|
owned_log.close if owned_log && !owned_log.closed?
|
|
60
63
|
ensure
|
|
61
64
|
# 每个阶段独立收尾;句柄或日志关闭失败不能跳过进程回收。
|
|
62
|
-
|
|
63
|
-
if pid
|
|
64
|
-
begin
|
|
65
|
-
Process.kill("KILL", pid)
|
|
66
|
-
rescue Errno::ESRCH
|
|
67
|
-
# 子进程可能刚好退出,仍需尝试 wait,不能留下僵尸进程。
|
|
68
|
-
nil
|
|
69
|
-
end
|
|
70
|
-
# 将最终 wait 交给后台回收线程,避免在 GC 终结器中阻塞等待。
|
|
71
|
-
Process.detach(pid)
|
|
72
|
-
@pid = nil
|
|
73
|
-
end
|
|
65
|
+
finalize_child
|
|
74
66
|
end
|
|
75
67
|
end
|
|
76
68
|
rescue IOError, SystemCallError
|
|
@@ -81,5 +73,38 @@ class Expect
|
|
|
81
73
|
def self.finalizer(resources)
|
|
82
74
|
proc { resources.finalize }
|
|
83
75
|
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# GC 只做一次非阻塞回收和最多两次信号尝试;失败也把等待交给 detach,不运行用户回调。
|
|
80
|
+
def finalize_child
|
|
81
|
+
begin
|
|
82
|
+
reap
|
|
83
|
+
rescue Errno::EINTR
|
|
84
|
+
# GC 不等待下轮轮询,仍对本进程拥有的子进程执行后续有限清理。
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
return unless pid && owner == Process.pid
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
2.times do
|
|
91
|
+
break unless pid && owner == Process.pid
|
|
92
|
+
|
|
93
|
+
Process.kill("KILL", pid)
|
|
94
|
+
break
|
|
95
|
+
rescue Errno::EINTR
|
|
96
|
+
next
|
|
97
|
+
rescue Errno::ESRCH
|
|
98
|
+
break
|
|
99
|
+
end
|
|
100
|
+
ensure
|
|
101
|
+
if pid && owner == Process.pid
|
|
102
|
+
Process.detach(pid)
|
|
103
|
+
@pid = nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
rescue Errno::ECHILD
|
|
107
|
+
@pid = nil
|
|
108
|
+
end
|
|
84
109
|
end
|
|
85
110
|
end
|
data/lib/expect/terminal.rb
CHANGED
data/lib/expect/version.rb
CHANGED
data/lib/expect.rb
CHANGED
|
@@ -24,6 +24,7 @@ class Expect
|
|
|
24
24
|
private_constant :CONFIGURATION_MUTEX
|
|
25
25
|
|
|
26
26
|
class SpawnError < StandardError; end
|
|
27
|
+
class ReentrancyError < StandardError; end
|
|
27
28
|
|
|
28
29
|
# 已被底层接受的字节不可撤回;调用方可据此只处理尚未写出的后缀。
|
|
29
30
|
class WriteTimeout < IOError
|
|
@@ -70,7 +71,7 @@ class Expect
|
|
|
70
71
|
raise
|
|
71
72
|
ensure
|
|
72
73
|
if session && (block_given? || !spawned)
|
|
73
|
-
session.__send__(:
|
|
74
|
+
session.__send__(:cleanup, failed: failed) do
|
|
74
75
|
session.close(graceful: spawned && session.graceful_close?)
|
|
75
76
|
end
|
|
76
77
|
end
|
|
@@ -89,8 +90,9 @@ class Expect
|
|
|
89
90
|
raise
|
|
90
91
|
ensure
|
|
91
92
|
if session && (block_given? || !initialized)
|
|
92
|
-
session.__send__(:
|
|
93
|
-
session.
|
|
93
|
+
session.__send__(:cleanup, failed: failed) do
|
|
94
|
+
session.__send__(:cleanup_session, io, writer: writer, own: own,
|
|
95
|
+
graceful: initialized && session.graceful_close?)
|
|
94
96
|
end
|
|
95
97
|
end
|
|
96
98
|
end
|
|
@@ -189,7 +191,7 @@ class Expect
|
|
|
189
191
|
failed = true
|
|
190
192
|
raise
|
|
191
193
|
ensure
|
|
192
|
-
|
|
194
|
+
cleanup(failed:) { cleanup_session(master, writer: master, slave: slave, own: true) } unless initialized
|
|
193
195
|
end
|
|
194
196
|
|
|
195
197
|
# 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
|
|
@@ -273,7 +275,12 @@ class Expect
|
|
|
273
275
|
def pid = @resources.pid
|
|
274
276
|
|
|
275
277
|
# 非阻塞回收并缓存子进程状态;未退出或仅适配 IO 时返回 nil。
|
|
276
|
-
def process_status
|
|
278
|
+
def process_status
|
|
279
|
+
@resources.reap
|
|
280
|
+
rescue Errno::EINTR
|
|
281
|
+
# 单次轮询被中断时状态仍未知;wait/close 会在原期限内继续,不在这里无限重试。
|
|
282
|
+
@resources.status
|
|
283
|
+
end
|
|
277
284
|
|
|
278
285
|
def exit_code = process_status&.exitstatus
|
|
279
286
|
|
|
@@ -332,7 +339,8 @@ class Expect
|
|
|
332
339
|
offset = 0
|
|
333
340
|
while offset < data.bytesize
|
|
334
341
|
begin
|
|
335
|
-
|
|
342
|
+
chunk = data.byteslice(offset, READ_SIZE)
|
|
343
|
+
count = writer.write_nonblock(chunk, exception: false)
|
|
336
344
|
rescue Errno::EINTR
|
|
337
345
|
raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline
|
|
338
346
|
|
|
@@ -360,6 +368,10 @@ class Expect
|
|
|
360
368
|
next
|
|
361
369
|
end
|
|
362
370
|
else
|
|
371
|
+
unless count.is_a?(Integer) && count.positive? && count <= chunk.bytesize
|
|
372
|
+
raise IOError, "write must return the number of accepted bytes"
|
|
373
|
+
end
|
|
374
|
+
|
|
363
375
|
offset += count
|
|
364
376
|
end
|
|
365
377
|
end
|
|
@@ -400,15 +412,7 @@ class Expect
|
|
|
400
412
|
|
|
401
413
|
# 轮询回收状态直到进程退出或期限到达;返回 Process::Status 或 nil,超时不丢弃 PID。
|
|
402
414
|
def wait(timeout: nil)
|
|
403
|
-
|
|
404
|
-
deadline = period && (Expect.monotonic + period)
|
|
405
|
-
loop do
|
|
406
|
-
status = process_status
|
|
407
|
-
return status if status || !pid
|
|
408
|
-
return nil if deadline && Expect.monotonic >= deadline
|
|
409
|
-
|
|
410
|
-
sleep(deadline ? [0.01, deadline - Expect.monotonic].min.clamp(0, 0.01) : 0.01)
|
|
411
|
-
end
|
|
415
|
+
wait_for_child(Expect.duration(timeout))
|
|
412
416
|
end
|
|
413
417
|
|
|
414
418
|
# 先在自然退出期限内收集尾部输出,再关闭句柄并最多发送 TERM;不会发送 KILL。
|
|
@@ -425,8 +429,8 @@ class Expect
|
|
|
425
429
|
|
|
426
430
|
read_available
|
|
427
431
|
end
|
|
428
|
-
|
|
429
|
-
|
|
432
|
+
close_resources(timeout: deadline ? [deadline - Expect.monotonic, 0].max : nil,
|
|
433
|
+
term_timeout: term_timeout, force: false)
|
|
430
434
|
end
|
|
431
435
|
|
|
432
436
|
# 立即关闭句柄,再分阶段等待、TERM、KILL;不收集剩余输出,返回已回收状态或 nil。
|
|
@@ -434,7 +438,7 @@ class Expect
|
|
|
434
438
|
period = Expect.duration(timeout)
|
|
435
439
|
raise ArgumentError, "hard_close timeout must be finite" unless period
|
|
436
440
|
|
|
437
|
-
|
|
441
|
+
close_resources(timeout: period, term_timeout: period, force: true)
|
|
438
442
|
end
|
|
439
443
|
|
|
440
444
|
# 通用生命周期清理:可先软关闭,ensure 中硬关闭兜底;正常完成返回 nil。
|
|
@@ -445,20 +449,29 @@ class Expect
|
|
|
445
449
|
failed = true
|
|
446
450
|
raise
|
|
447
451
|
ensure
|
|
448
|
-
|
|
452
|
+
cleanup(failed:) { hard_close }
|
|
449
453
|
end
|
|
450
454
|
|
|
451
455
|
private
|
|
452
456
|
|
|
457
|
+
# 账本发布前只按局部所有权清理;发布后沿用完整关闭流程,避免两套生命周期状态。
|
|
458
|
+
def cleanup_session(reader, writer:, own:, slave: nil, graceful: false)
|
|
459
|
+
if @resources
|
|
460
|
+
close(graceful: graceful)
|
|
461
|
+
elsif own
|
|
462
|
+
SessionResources.close_handles(reader, writer, slave)
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
453
466
|
# 仅在本次生命周期已有异常时抑制常规清理错误;调用者 rescue 中的旧异常不算本次失败。
|
|
454
|
-
def
|
|
467
|
+
def cleanup(failed:)
|
|
455
468
|
yield
|
|
456
469
|
rescue IOError, SystemCallError
|
|
457
470
|
raise unless failed
|
|
458
471
|
end
|
|
459
472
|
|
|
460
473
|
# 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
|
|
461
|
-
def
|
|
474
|
+
def close_resources(timeout:, term_timeout:, force:)
|
|
462
475
|
failure = nil
|
|
463
476
|
# 预期的清理错误延后传播,保证其余所属资源和直属子进程仍能完成清理。
|
|
464
477
|
cleanup = lambda do |&step|
|
|
@@ -480,7 +493,7 @@ class Expect
|
|
|
480
493
|
@relay_outputs&.clear
|
|
481
494
|
@relay_history&.clear
|
|
482
495
|
@relay_callback = nil
|
|
483
|
-
status =
|
|
496
|
+
status = close_child(timeout: timeout, term_timeout: term_timeout, force: force)
|
|
484
497
|
completed = true
|
|
485
498
|
status
|
|
486
499
|
ensure
|
|
@@ -494,17 +507,41 @@ class Expect
|
|
|
494
507
|
end
|
|
495
508
|
|
|
496
509
|
# 句柄清理失败不改变进程策略;未回收 PID 保留给重复关闭或终结器继续处理。
|
|
497
|
-
def
|
|
510
|
+
def close_child(timeout:, term_timeout:, force:)
|
|
498
511
|
return process_status unless @resources.owner == Process.pid && pid
|
|
499
|
-
return process_status if wait(timeout: timeout)
|
|
500
512
|
|
|
501
|
-
|
|
502
|
-
return
|
|
513
|
+
status = wait(timeout: timeout)
|
|
514
|
+
return status if status || !pid
|
|
503
515
|
|
|
516
|
+
status = wait_for_child(term_timeout, signal: "TERM")
|
|
517
|
+
return status if status || !pid
|
|
504
518
|
return unless force
|
|
505
519
|
|
|
506
|
-
|
|
507
|
-
|
|
520
|
+
wait_for_child(1, signal: "KILL")
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# 每阶段只计算一次期限;回收或信号被中断后仍沿用剩余预算,零预算也先做一次尝试。
|
|
524
|
+
def wait_for_child(period, signal: nil)
|
|
525
|
+
deadline = period && (Expect.monotonic + period)
|
|
526
|
+
loop do
|
|
527
|
+
status = process_status
|
|
528
|
+
return status if status || !pid || @resources.owner != Process.pid
|
|
529
|
+
|
|
530
|
+
begin
|
|
531
|
+
signal_child(signal) if signal
|
|
532
|
+
signal = nil
|
|
533
|
+
rescue Errno::EINTR
|
|
534
|
+
# 下轮先回收再重试信号,避免在无限等待或持续中断时忙等。
|
|
535
|
+
nil
|
|
536
|
+
end
|
|
537
|
+
# ESRCH 后可能已完成回收;即使预算耗尽,也要返回刚获得的状态。
|
|
538
|
+
return process_status unless pid
|
|
539
|
+
|
|
540
|
+
remaining = deadline && (deadline - Expect.monotonic)
|
|
541
|
+
return nil if remaining && remaining <= 0
|
|
542
|
+
|
|
543
|
+
sleep(remaining ? [0.01, remaining].min : 0.01)
|
|
544
|
+
end
|
|
508
545
|
end
|
|
509
546
|
|
|
510
547
|
# 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
|
|
@@ -580,13 +617,13 @@ class Expect
|
|
|
580
617
|
# 某些系统用 PTY 的 EIO 表示对端关闭;普通 IO 的同类错误仍按异常处理。
|
|
581
618
|
raise unless @pty
|
|
582
619
|
|
|
583
|
-
return
|
|
620
|
+
return mark_eof
|
|
584
621
|
rescue EOFError
|
|
585
|
-
return
|
|
622
|
+
return mark_eof
|
|
586
623
|
end
|
|
587
624
|
return nil if data == :wait_readable
|
|
588
625
|
|
|
589
|
-
return
|
|
626
|
+
return mark_eof if data.nil?
|
|
590
627
|
|
|
591
628
|
data = data.b
|
|
592
629
|
buffer << data
|
|
@@ -599,7 +636,7 @@ class Expect
|
|
|
599
636
|
data
|
|
600
637
|
end
|
|
601
638
|
|
|
602
|
-
def
|
|
639
|
+
def mark_eof
|
|
603
640
|
@eof = true
|
|
604
641
|
flush_log
|
|
605
642
|
flush_diagnostics(:received)
|
|
@@ -619,7 +656,7 @@ class Expect
|
|
|
619
656
|
|
|
620
657
|
# 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
|
|
621
658
|
def signal_child(signal)
|
|
622
|
-
return unless
|
|
659
|
+
return unless pid && @resources.owner == Process.pid
|
|
623
660
|
|
|
624
661
|
Process.kill(signal, pid)
|
|
625
662
|
rescue Errno::ESRCH
|
data/script/release.rb
CHANGED
|
@@ -26,7 +26,7 @@ class Release
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def run
|
|
29
|
-
notes = self.class.
|
|
29
|
+
notes = self.class.read_notes("CHANGELOG.md", @version)
|
|
30
30
|
unless @dry_run || capture("git", "status", "--porcelain").empty?
|
|
31
31
|
raise "Commit all source changes before publishing"
|
|
32
32
|
end
|
|
@@ -46,7 +46,7 @@ class Release
|
|
|
46
46
|
@checksum_file = File.join(directory, "SHA256SUMS")
|
|
47
47
|
@notes_file = File.join(directory, "release-notes.md")
|
|
48
48
|
File.write(@checksum_file, "#{@sha256} #{File.basename(@artifact)}\n")
|
|
49
|
-
File.write(@notes_file, "#{notes}\n")
|
|
49
|
+
File.write(@notes_file, "#{notes}\n", encoding: "UTF-8")
|
|
50
50
|
puts "Verified #{@tag}: #{@sha256}\nArtifact: #{@artifact}"
|
|
51
51
|
return puts "Dry run complete: #{@artifact}" if @dry_run
|
|
52
52
|
|
|
@@ -61,6 +61,14 @@ class Release
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# 有未归档的变更时拒绝发布,避免把新接口放进旧版本或遗漏发布说明。
|
|
64
|
+
def self.read_notes(path, version)
|
|
65
|
+
changelog = File.read(path, encoding: "UTF-8")
|
|
66
|
+
raise "Invalid UTF-8 in #{path}" unless changelog.valid_encoding?
|
|
67
|
+
|
|
68
|
+
release_notes(changelog, version)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 纯章节解析与文件编码边界分离,本地入口和工作流共用 read_notes。
|
|
64
72
|
def self.release_notes(changelog, version)
|
|
65
73
|
raise "Use a stable X.Y.Z version" unless /\A\d+\.\d+\.\d+\z/.match?(version)
|
|
66
74
|
|