expect-pty 0.5.0 → 0.5.2

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: 5f3f00d375bb8a90ef67974bbd7649b031554e15f9d463cf28f1388d5d3e5dc1
4
- data.tar.gz: 9c9a556719b8c79c43553bc344fa679737abb2330f8ecdec72beabfedb3842d7
3
+ metadata.gz: a2677ffa5b928375c26caefd595b51608a9e0e84eef0c923d683eb47229749a2
4
+ data.tar.gz: fe278b8bec5f635e71b0fd7ca9ca41978dbc3ca3eb0d3f82d6f0d6be2afd0282
5
5
  SHA512:
6
- metadata.gz: 53fa1a25e62c9c16c2ecbafc3efa38ba4acd2c805afa6b1547e4b83cb95c98484bbd27f612a83397a9eb9a0715af857b7f9f4d698f52bdcaa4b7dcbd3a55a42e
7
- data.tar.gz: 226b72621b685c9fce34097838e0c10a81f616b3ba26a62a90eb6f09c9e3a6e0a3cd5cac331ba79c9a81caf134707821622a3e1ebe85842aff01da79b1d1d554
6
+ metadata.gz: 50f8eef04336c2ad673dc0837b78176858c0f903555585f2ab99210b6e950e682af2a4ea64b3ca68b398bffbda07a08a44295097fb98c6b8299bf8db32cb3479
7
+ data.tar.gz: 6ba54b2d1c701272ce73f8b197c53655719868ff63c8aeb6479dccd0da8d2eedaef7e9e16a20adfc7bff7079feecc18efc8a4526cada4d015cd2b6fe6fdedbca
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.2 - 2026-09-27
6
+
7
+ - **文档排版**:整理贡献、发布、Kibitz 示例和 SSH 集成测试说明的换行与表格格式。
8
+
9
+ ## 0.5.1 - 2026-09-27
10
+
11
+ - **终端清理**:`stty` 读取失败或中断后也会清理辅助管道和进程,保留原始异常,避免影响主会话。
12
+ - **脱敏性能**:减少连续与重叠秘密匹配时的内存分配和处理耗时,保留跨块、规则更新和流尾部的脱敏行为。
13
+
5
14
  ## 0.5.0 - 2026-09-27
6
15
 
7
16
  - **独立脱敏**:新增无需创建会话的 `Expect::Redactor`,支持完整文本和分块字节流过滤,以及自定义替换标记。
data/CONTRIBUTING.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # 参与贡献
2
2
 
3
- 欢迎中文或英文 issue 和 PR。库的公共 API 名称和错误信息使用英文;库内的责任划分、复杂生命周期和协议解释使用中文,RuboCop 等工具指令保持原格式。文档以现有文件的语言为准,示例中的可编辑命令保持直接清晰。
3
+ 欢迎中文或英文 issue 和 PR。库的公共 API 名称和错误信息使用英文;库内的责任划分、复杂生命周期和协议解释使用中文,RuboCop
4
+ 等工具指令保持原格式。文档以现有文件的语言为准,示例中的可编辑命令保持直接清晰。
4
5
 
5
6
  ## 本地检查
6
7
 
@@ -12,12 +13,17 @@ bundle exec rake # RuboCop 和默认测试
12
13
  script/ci # 另含示例、Gem 构建及隔离安装验证
13
14
  ```
14
15
 
15
- 修改转接或匹配时,先运行对应的 `test/*_test.rb`,再运行 `bundle exec rake`。默认测试不需要 SSH;真实 SSH 集成测试的环境要求见 [测试说明](test/integration/README.md)。发布操作和历史验证分别见 [发布说明](docs/RELEASING.md) 与 [验证记录](docs/VERIFICATION.md)。
16
+ 修改转接或匹配时,先运行对应的 `test/*_test.rb`,再运行 `bundle exec rake`。默认测试不需要 SSH;真实 SSH
17
+ 集成测试的环境要求见 [测试说明](test/integration/README.md)。发布操作和历史验证分别见 [发布说明](docs/RELEASING.md)
18
+ 与 [验证记录](docs/VERIFICATION.md)。
16
19
 
17
- RuboCop 对生产代码保留有限的长度、ABC、嵌套、参数数量和分支复杂度门槛。测试及基准的场景编排豁免方法长度与 ABC 计数;协议状态机的分支豁免只放在对应方法,并注明原因。阈值用于提示复核,职责拆分仍应保持状态归属和异常恢复逻辑完整。
20
+ RuboCop 对生产代码保留有限的长度、ABC、嵌套、参数数量和分支复杂度门槛。测试及基准的场景编排豁免方法长度与 ABC
21
+ 计数;协议状态机的分支豁免只放在对应方法,并注明原因。阈值用于提示复核,职责拆分仍应保持状态归属和异常恢复逻辑完整。
18
22
 
19
23
  ## 提交问题或改动
20
24
 
21
- 问题报告请附 Ruby 版本、操作系统、最小复现代码、预期与实际结果,以及必要的异常信息;日志中请删去凭据和会话敏感内容。修复行为缺陷时,先加入能在旧实现复现问题的回归测试。涉及缓冲、转义、背压或进程清理的改动,请对照 [内部状态与数据归属](docs/INTERNAL_CONTRACTS.md),说明超时、EOF 和失败后的恢复行为。
25
+ 问题报告请附 Ruby
26
+ 版本、操作系统、最小复现代码、预期与实际结果,以及必要的异常信息;日志中请删去凭据和会话敏感内容。修复行为缺陷时,先加入能在旧实现复现问题的回归测试。涉及缓冲、转义、背压或进程清理的改动,请对照 [内部状态与数据归属](docs/INTERNAL_CONTRACTS.md)
27
+ ,说明超时、EOF 和失败后的恢复行为。
22
28
 
23
29
  PR 请说明行为变化、运行过的命令及结果。不要把本机生成的 `tmp/`、日志或凭据加入提交。
data/README.md CHANGED
@@ -16,7 +16,7 @@ RubyGems/Bundler 解析。推荐入口 **`require "expect/pty"`**;本项目提
16
16
  项目和仓库名为 `expect-ruby`,Gem 名为 `expect-pty`。在应用的 Gemfile 中添加以下内容,然后运行 `bundle install`:
17
17
 
18
18
  ```ruby
19
- gem "expect-pty", "~> 0.5.0", require: "expect/pty"
19
+ gem "expect-pty", "~> 0.5.2", require: "expect/pty"
20
20
  ```
21
21
 
22
22
  也可直接执行 `gem install expect-pty`。需要跟随开发分支时,可从 GitHub 安装:
@@ -29,8 +29,8 @@ gem "expect-pty", git: "https://github.com/gatework/expect-ruby.git", branch: "m
29
29
 
30
30
  ```sh
31
31
  mkdir -p tmp
32
- gem build expect-pty.gemspec --output tmp/expect-pty-0.5.0.gem
33
- gem install ./tmp/expect-pty-0.5.0.gem
32
+ gem build expect-pty.gemspec --output tmp/expect-pty-0.5.2.gem
33
+ gem install ./tmp/expect-pty-0.5.2.gem
34
34
  ```
35
35
 
36
36
  ```ruby
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support"
4
+
5
+ # 同一驱动可加载基线或候选库;每轮创建独立过滤器,不能跨样本继承流状态。
6
+ module RedactorBenchmark
7
+ def self.measure(runner, name, input, patterns, expected, chunk_size: nil, partial: false)
8
+ chunks = if chunk_size
9
+ (0...input.bytesize).step(chunk_size).map { |offset| input.byteslice(offset, chunk_size) }
10
+ else
11
+ [input]
12
+ end
13
+ runner.measure(name, bytes: input.bytesize,
14
+ inputs: { bytes: input.bytesize, secret_lengths: patterns.map(&:bytesize),
15
+ chunk_size: chunk_size, partial: partial, repeated_finish: true },
16
+ verify: ->(output) { ExpectBenchmark.check(output == expected, "incorrect #{name} output") },
17
+ iterations: 5) do
18
+ filter = Expect::Redactor.new(patterns)
19
+ output = +"".b
20
+ chunks.each { |chunk| output << filter.append(chunk) }
21
+ output << filter.finish(partial: partial) << filter.finish(partial: partial)
22
+ end
23
+ end
24
+ end
25
+
26
+ runner = ExpectBenchmark::Runner.new("redactor")
27
+ size = runner.smoke ? 512 : 65_536
28
+ plain = "x" * size
29
+ RedactorBenchmark.measure(runner, "empty_rules", plain, [], plain)
30
+ RedactorBenchmark.measure(runner, "no_match", plain, ["secret"], plain)
31
+ RedactorBenchmark.measure(runner, "no_match_many", plain, %w[secret password token credential], plain)
32
+ RedactorBenchmark.measure(runner, "sparse", "#{plain}secret!", ["secret"], "#{plain}[FILTERED]!")
33
+ RedactorBenchmark.measure(runner, "contiguous_short", "token" * (size / 5), ["token"], "[FILTERED]")
34
+
35
+ (runner.smoke ? [1, 32, 128] : [1, 32, 1024, 4096]).each do |length|
36
+ RedactorBenchmark.measure(runner, "overlap_#{length}", "a" * size, ["a" * length], "[FILTERED]")
37
+ end
38
+ count = runner.smoke ? 8 : 256
39
+ RedactorBenchmark.measure(runner, "contained_patterns", "abcde!" * count,
40
+ %w[abc bc ab bcd], "[FILTERED]e!" * count)
41
+ RedactorBenchmark.measure(runner, "binary_overlap", "\0\xffABC\x80!".b * count,
42
+ ["\xffABC".b, "ABC\x80".b], "\0[FILTERED]!".b * count, chunk_size: 3)
43
+ RedactorBenchmark.measure(runner, "marker_in_input", "[FILTERED] secret!" * count,
44
+ %w[secret FILTERED], "[[FILTERED]] [FILTERED]!" * count)
45
+
46
+ [1, 7, 4096].each do |chunk_size|
47
+ RedactorBenchmark.measure(runner, "stream_#{chunk_size}", "prefix secret!\n" * count,
48
+ ["secret"], "prefix [FILTERED]!\n" * count, chunk_size: chunk_size)
49
+ end
50
+ [true, false].each do |partial|
51
+ RedactorBenchmark.measure(runner, "partial_#{partial}", "prefix pass", ["password"],
52
+ partial ? "prefix [FILTERED]" : "prefix pass", chunk_size: 1, partial: partial)
53
+ end
54
+
55
+ runner.measure("update_pending", bytes: 10, inputs: { update: "keep old mask and match new secret" },
56
+ verify: lambda { |output|
57
+ ExpectBenchmark.check(output == "[FILTERED]!")
58
+ }, iterations: 100) do
59
+ filter = Expect::Redactor.new(%w[abc long-pattern])
60
+ output = filter.append("abcsec")
61
+ filter.patterns = ["secret"]
62
+ output << filter.append("ret!") << filter.finish << filter.finish
63
+ end
64
+ runner.finish
@@ -61,6 +61,16 @@ exec 成功后诊断输出失败,也要立即回收未交付给调用方的子
61
61
  是否保留原始异常由当前构造、块或关闭作用域显式记录,不能直接读取调用者 rescue 中的 `$!`。`Interrupt` 和 `SystemExit`
62
62
  同样先清理再传播;`break` / `throw` 不是异常,此时清理失败仍应抛出。
63
63
 
64
+ `stty` 为辅助管道和 PID 创建独立的 SessionResources,不写入主会话的 PID/status,不关闭主会话。
65
+ 账本构造被中断时仍关闭局部变量中已经创建的管道;fork 后的副本只关闭本地管道,不向父进程的辅助 PID 发信号或 detach,等待途中也重新核对归属。
66
+ 正常路径读取输出并阻塞回收,EINTR 只重试 wait,不再次启动命令,也不增加固定等待。
67
+ 异常路径先尝试关闭两个管道,再依次等待自然退出、发送 TERM、发送 KILL;每阶段使用固定的 50ms 单调时钟预算,
68
+ 非阻塞 wait 或 signal 的 EINTR 不延长期限。ECHILD 立即释放该 PID 的归属,不向可能复用的 PID 发信号;
69
+ ESRCH 后仍尝试回收。三个阶段结束或系统调用失败后仍有 PID 时交给 `Process.detach` 异步回收,不保证返回前退出,
70
+ 也不伪造退出状态。预算约束进程轮询,不是任意同步 IO 或系统调度的硬实时保证。
71
+ 本次主异常(包括 Interrupt/SystemExit)保持对象身份;没有主异常时传播首个 IOError/SystemCallError 清理失败。
72
+ 永久失败的 close 只保证被尝试,不能报告成句柄已关闭;缺失 stty 仍为 IOError 并保留 ENOENT cause。
73
+
64
74
  ## 缓冲裁剪与字面扫描
65
75
 
66
76
  `buffer_discarded_bytes` 随会话累计,只在匹配窗口执行 `trim_buffer` 时增加。读取、`buffer=`、降低上限和下一次匹配应用上限都可能触发裁剪;匹配消费、清空、EOF 和 Relay 交接不计入,关闭后仍保留计数。它是可观察的丢弃量,不是缓冲满事件,也不提供全文归档。
@@ -80,6 +90,8 @@ exec 成功后诊断输出失败,也要立即回收未交付给调用方的子
80
90
  `redactor.rb` 是无 IO 的公共字节过滤器。会话复制并追加注册秘密,每个接收日志流以及发送、接收诊断方向各自持有过滤器。暂存最长秘密长度减一的尾部,重叠秘密合并为隐藏区间,过滤发生在 `inspect` 转义之前。缓冲快照可能只剩秘密中间字节,启用脱敏时整体隐藏,不重复展示其内容。
81
91
 
82
92
  公共构造器及 `patterns=` 会校验并复制秘密;空模式列表直接交付字节,更新规则不清除已经建立的掩码。
93
+ 同一模式的命中按字节偏移递增枚举,相交或相邻区间合并后才写入掩码;不能通过跳过整个秘密长度省略重叠命中。
94
+ 合并仍与现有掩码取并集,保留其他模式和旧规则已标记的隐藏字节,不改变跨块连续区间只输出一个替换标记的规则。
83
95
  默认替换标记为 `[FILTERED]`,上层库可显式指定自己的标记。类方法 `redact` 使用独立流并以 `finish(partial: false)` 结束完整文本;
84
96
  `append`/`finish` 的默认流策略仍隐藏末尾疑似秘密前缀。作用域所有权、终端渲染和异常字段选择由调用方负责。
85
97
 
@@ -99,11 +111,12 @@ EOF 结束接收流,日志或诊断目标替换先冲刷旧流,显式关闭
99
111
 
100
112
  | 不变量 | 测试 |
101
113
  | --- | --- |
114
+ | stty 主错误优先、辅助进程回收、EINTR 预算、ECHILD/ESRCH、detach 和主会话隔离 | terminal_cleanup_test |
102
115
  | 裁剪计数、正常消费、转接交接与日志错误 | buffer_accounting_test |
103
116
  | 外部回收、借用 IO、关闭失败重试、原生错误分类 | lifecycle_contract_test |
104
117
  | 绝对期限、接收和回调重置、过期文本、EOF、EINTR、零轮询 | deadline_test |
105
118
  | Logger/IO/回调、分片与重叠秘密、二进制、流尾部、关闭失败、覆盖与重入 | diagnostics_test |
106
- | 公共过滤器独立加载、完整文本与分块、空规则、规则更新、输入复制和安全摘要 | redactor_test |
119
+ | 公共过滤器独立加载、完整文本与分块、空规则、规则更新、输入复制、安全摘要及固定种子字节区间差分 | redactor_test |
107
120
  | 字面跨读取命中、声明优先级、缓存失效、操作序列与全量扫描对照 | literal_scan_test |
108
121
 
109
122
  ## 既有回归入口
data/docs/PERFORMANCE.md CHANGED
@@ -7,9 +7,10 @@ bundle exec ruby benchmark/matching.rb
7
7
  bundle exec ruby benchmark/relay.rb
8
8
  bundle exec ruby benchmark/send_slow.rb
9
9
  bundle exec ruby benchmark/scaling.rb
10
+ bundle exec ruby benchmark/redactor.rb
10
11
  ```
11
12
 
12
- 默认预热一次、采样五次。结果分别写入 `tmp/benchmark/matching.json`、`relay.json`、`send_slow.json`
13
+ 默认预热一次、采样五次。结果分别写入 `tmp/benchmark/matching.json`、`relay.json`、`send_slow.json`、`scaling.json`、`redactor.json`
13
14
  ,该目录不提交。每个场景先核对非空输入的预期结果,再计时;每轮计时后再次核对最后一次执行的结果。输出包括
14
15
  Ruby、平台、源码提交、工作区是否修改、库源码 SHA-256、输入规模、迭代次数、处理字节数、墙钟耗时、分配对象数和 GC 次数。
15
16
 
@@ -20,6 +21,7 @@ Ruby、平台、源码提交、工作区是否修改、库源码 SHA-256、输
20
21
  | matching | 4 KiB、64 KiB、1 MiB;1、8、32 个正则;首个命中、末个命中、全未命中;UTF-8 前缀和可选捕获;1、8、32 个会话、多组重复来源和同时就绪的真实管道 |
21
22
  | relay | 无转义、字面转义、16 个正则转义;正常目标与每次只接受 17 字节的目标共同接收相同数据 |
22
23
  | send_slow | 真实本地 socket;无回显、持续回显;零延迟和每字符 1ms 延迟 |
24
+ | redactor | 空规则、无命中/稀疏/连续/高重叠;包含关系、多秘密、二进制、原文替换标记、1/7/4096 字节分块、partial finish、重复 finish 及 pending 中更新规则 |
23
25
 
24
26
  `matching` 的扫描用内部 `find_match` 单独度量缓冲扫描,排除 PTY 启动和回调开销;就绪场景包含管道写入、选择和读取。`relay`
25
27
  的前三项单独度量转义扫描,混合目标项运行完整转接循环。短写模拟目标吞吐受限,不等同于真实慢网络。`send_slow` 包含
@@ -34,6 +36,10 @@ socket、接收线程和完整回显校验的成本;无回显时计时止于
34
36
 
35
37
  每个样本记录计时区间前后的 RSS 和打开描述符数,以及进程的描述符上限;采样自身不进入计时或分配计数。RSS 是端点快照,受 Ruby 堆和分配器保留影响,不代表峰值或存活对象大小。平台不支持某项采样时记录 `null`。描述符快照也不是长期泄漏证明,应同时检查重复样本和关闭流程。
36
38
 
39
+ `redactor` 每次操作创建独立过滤器,分块数据在计时前准备,计时包含过滤器构造、append 和两次 finish。
40
+ 主要输入规模为 64 KiB,高重叠秘密长度为 1/32/1024/4096 字节;smoke 缩小为 512 字节及 1/32/128 字节秘密。
41
+ 公共 Runner 会加载完整 Expect(含 PTY);基准本身不启动 PTY 会话,独立 `require "expect/redactor"` 的无 PTY 契约由测试验证。
42
+
37
43
  ## 同环境对照
38
44
 
39
45
  对照目录必须包含已知提交的完整源码。两个版本使用同一套基准脚本、同一 Ruby 和依赖,在机器空闲时交替运行,比较原始样本的中位数与分配量:
@@ -72,6 +78,7 @@ bundle exec ruby benchmark/matching.rb --smoke
72
78
  bundle exec ruby benchmark/relay.rb --smoke
73
79
  bundle exec ruby benchmark/send_slow.rb --smoke
74
80
  bundle exec ruby benchmark/scaling.rb --smoke
81
+ bundle exec ruby benchmark/redactor.rb --smoke
75
82
  ```
76
83
 
77
84
  `script/ci` 运行这些小规模正确性检查,不设置墙钟性能阈值。热点优化必须有实际收益证据;减少对象分配不代表所有输入都会变快,也不能替代完整测试和安装验证。
@@ -104,3 +111,55 @@ bundle exec ruby benchmark/scaling.rb --samples 3 --iterations 10
104
111
  同一工作树的三次容量样本中,1、16、64 来源各执行十轮的耗时中位数分别为 0.179、1.372、8.285 ms;1,000 来源执行三轮为 401.878 ms。1,000 来源样本的描述符数均为 2,007 → 2,007,RSS 端点从约 35.8 MB 增至 37.9 MB,不能解释为稳定内存上限。阻塞目标场景十轮共 0.186 ms,其他来源仍可推进,描述符数均为 13 → 13。
105
112
 
106
113
  原始记录为 `tmp/core-improvements/scaling-final.json` 和 `scaling-1000-final.json`,库源码摘要与上述工作树匹配记录一致。当前证据支持保留既有 select 调度,先量化真实负载,再决定是否需要替换后端。
114
+
115
+ ## Redactor 重叠区间合并验证(2026-09-27)
116
+
117
+ 基线为 `v0.5.0` / `b7cd25e736acbfcb43e412ec4d233f9b2602cb03` 的源码归档。使用同一份
118
+ `benchmark/redactor.rb`、macOS arm64、Ruby 4.0.6、Bundler 4.0.17 和相同依赖,按“基线 → 候选”交替运行两轮,
119
+ 每轮 `--samples 5 --iterations 20`。下表是最后一轮五个样本的中位数,耗时和分配均为 **20 次操作总量**。
120
+ 每项输出在预热及各样本结束后校验,性能采样期间没有并行运行项目测试。
121
+
122
+ | 工作负载 | 基线 ms | 候选 ms | 基线分配对象 | 候选分配对象 |
123
+ | --- | ---: | ---: | ---: | ---: |
124
+ | 空规则 | 0.438 | 0.416 | 541 | 541 |
125
+ | 单秘密无命中 | 0.774 | 0.973 | 561 | 561 |
126
+ | 多秘密无命中 | 2.305 | 1.447 | 641 | 641 |
127
+ | 稀疏命中 | 1.145 | 1.175 | 581 | 581 |
128
+ | 连续短秘密 | 26.159 | 9.345 | 262,661 | 541 |
129
+ | 重叠 / 1 字节秘密 | 113.787 | 42.799 | 1,311,241 | 541 |
130
+ | 重叠 / 32 字节秘密 | 189.706 | 97.756 | 1,310,661 | 581 |
131
+ | 重叠 / 1024 字节秘密 | 642.376 | 429.316 | 1,290,821 | 581 |
132
+ | 重叠 / 4096 字节秘密 | 1698.130 | 1361.663 | 1,229,381 | 581 |
133
+ | 包含关系 | 2.881 | 2.915 | 26,201 | 26,201 |
134
+ | 二进制跨块重叠 | 7.967 | 8.299 | 101,221 | 101,221 |
135
+ | 原文包含替换标记 | 2.758 | 2.837 | 21,081 | 21,081 |
136
+ | 1 字节分块 | 34.612 | 34.151 | 589,161 | 589,161 |
137
+ | 7 字节分块 | 6.746 | 6.716 | 93,361 | 93,361 |
138
+ | 4096 字节分块 | 1.434 | 1.456 | 10,781 | 10,781 |
139
+ | partial finish | 0.128 | 0.129 | 2,141 | 2,141 |
140
+ | exact finish | 0.117 | 0.113 | 2,021 | 2,021 |
141
+ | pending 中更新规则 | 0.075 | 0.076 | 961 | 961 |
142
+
143
+ 两轮中连续短秘密耗时减少 63.6%–64.3%;重叠秘密按长度分别减少 62.4%–62.8%、48.5%–48.9%、
144
+ 33.1%–33.2%、19.7%–19.8%。重叠查找仍逐字节进行,只减少重复掩码分配/覆盖,没有跳过重叠命中或改变脱敏范围。
145
+
146
+ 短样本中的无命中结果波动明显,因此又通过本地 `tmp/guide-review/redactor-common.rb` 筛选同一驱动的常见负载,
147
+ 每轮改为 `--samples 5 --iterations 500`,再交替运行两轮。该包装器只跳过上述五项密集命中,不改变任何输入、过滤或校验逻辑。
148
+ 第二轮中单秘密无命中为 23.883 → 24.009 ms,多秘密为 56.148 → 56.081 ms,1 字节分块为 873.397 → 870.462 ms。
149
+ 存在小幅代价:稀疏命中两轮慢 2.3%/4.5%,4096 字节分块慢 2.2%/2.5%,规则更新慢 1.0%/6.0%;
150
+ 后者第二轮 500 次操作为 1.562 → 1.655 ms。其余常见负载约在 -2.8% 至 +3.6% 之间,分配量不变。
151
+ 保留此优化的取舍是以少量区间记录计算换取密集/重叠场景的显著分配下降,不声称所有负载都更快,也不设置 CI 墙钟阈值。
152
+
153
+ 复现全负载:
154
+
155
+ ```sh
156
+ bundle exec ruby benchmark/redactor.rb --library /path/to/v0.5.0/lib --samples 5 --iterations 20 --output tmp/benchmark/redactor-before.json
157
+ bundle exec ruby benchmark/redactor.rb --samples 5 --iterations 20 --output tmp/benchmark/redactor-after.json
158
+ ```
159
+
160
+ 原始样本:`tmp/guide-review/redactor-before-{4,5}.json`、`redactor-after-{4,5}.json`;
161
+ 延长采样:`redactor-common-before-{1,2}.json`、`redactor-common-after-{1,2}.json`。均在忽略目录,不随 Gem 分发。
162
+ 基线库 SHA-256 为 `d2a55eb1aa1d00658195f21f32adf37cf3ab8a24bb6a5bcf9564f6e9a6047b28`,
163
+ 性能采样时的候选库(版本号仍为 `0.5.0`)为 `753d403cf4801900cb53be5c34b04da0ffa313f25b4d2fb23a6d25ffce36e75a`,
164
+ 驱动文件为 `f99f6c8f1ec21d4d97c984dbfd51fd3e17b64fa7968095323e16d68d4a0d4cc0`。
165
+ 这些是合成字节过滤负载,不代表完整设备会话吞吐;RSS 端点不用于声称峰值内存降低。
data/docs/RELEASING.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # 发布版本
2
2
 
3
- 项目名为 `expect-ruby`,RubyGems 名为 `expect-pty`。使用 `script/release.rb --rubygems-only` 可仅发布 RubyGems;不加该选项时还会创建 GitHub Release,附带同一个 Gem 和 `SHA256SUMS`。生成文件统一放在已被 Git 忽略的 `tmp/` 下。
3
+ 项目名为 `expect-ruby`,RubyGems 名为 `expect-pty`。使用 `script/release.rb --rubygems-only` 可仅发布 RubyGems;不加该选项时还会创建
4
+ GitHub Release,附带同一个 Gem 和 `SHA256SUMS`。生成文件统一放在已被 Git 忽略的 `tmp/` 下。
4
5
 
5
6
  ## 准备版本
6
7
 
@@ -12,7 +13,8 @@
12
13
 
13
14
  ## 仅发布 RubyGems
14
15
 
15
- 本地已登录 `gem` 时,脚本直接使用已有凭据。如果 RubyGems 要求一次性验证码,`gem push` 会提示输入。此模式不调用 `gh`,不要求推送到 GitHub,也不创建标签或 GitHub Release。
16
+ 本地已登录 `gem` 时,脚本直接使用已有凭据。如果 RubyGems 要求一次性验证码,`gem push` 会提示输入。此模式不调用 `gh`,不要求推送到
17
+ GitHub,也不创建标签或 GitHub Release。
16
18
 
17
19
  ```sh
18
20
  bundle install
@@ -20,9 +22,13 @@ ruby script/release.rb --rubygems-only --dry-run
20
22
  ruby script/release.rb --rubygems-only
21
23
  ```
22
24
 
23
- 默认执行 `script/ci` 的检查、完整测试、构建和隔离安装验证,构建包保存在 `tmp/ci/expect-pty-版本号.gem`。随后复制到 `tmp/release/版本号/candidate-*/` 的独占目录,核对包内文件并生成发布说明和校验文件。该副本贯穿后续发布,目录会保留供失败重试。正式发布前再次确认源码未变,上传 RubyGems 后下载远端包核对 SHA256。
25
+ 默认执行 `script/ci` 的检查、完整测试、构建和隔离安装验证,构建包保存在 `tmp/ci/expect-pty-版本号.gem`。随后复制到
26
+ `tmp/release/版本号/candidate-*/` 的独占目录,核对包内文件并生成发布说明和校验文件。该副本贯穿后续发布,目录会保留供失败重试。正式发布前再次确认源码未变,上传
27
+ RubyGems 后下载远端包核对 SHA256。
24
28
 
25
- 正式发布逐个核对包内普通文件在目标 Git 提交中的存在性、原始字节和执行权限,同时核对 gemspec 的安装元数据及主页。`git status` 为空不能替代这一检查:忽略规则可能隐藏被 glob 收入包的本地文件,`assume-unchanged` 和 `core.filemode` 也可能隐藏差异。候选包必须与已提交源码一致。
29
+ 正式发布逐个核对包内普通文件在目标 Git 提交中的存在性、原始字节和执行权限,同时核对 gemspec 的安装元数据及主页。
30
+ `git status` 为空不能替代这一检查:忽略规则可能隐藏被 glob 收入包的本地文件,`assume-unchanged` 和 `core.filemode`
31
+ 也可能隐藏差异。候选包必须与已提交源码一致。
26
32
 
27
33
  `--dry-run` 只做本地验证,可在提交前使用。它仍要求版本号和发布说明完整。
28
34
 
@@ -35,11 +41,13 @@ ruby script/release.rb --dry-run
35
41
  ruby script/release.rb
36
42
  ```
37
43
 
38
- 正式发布先创建 GitHub Release,再上传 RubyGems,最后下载 RubyGems 上的包核对 SHA256。GitHub 上还没有标签时,会为当前提交创建 `v版本号` 标签。
44
+ 正式发布先创建 GitHub Release,再上传 RubyGems,最后下载 RubyGems 上的包核对 SHA256。GitHub 上还没有标签时,会为当前提交创建
45
+ `v版本号` 标签。
39
46
 
40
47
  ## GitHub Actions 发布
41
48
 
42
- GitHub Runner 不会继承本机的 Gem 登录状态。要在 Actions 发布 RubyGems,需在仓库的 Settings → Secrets and variables → Actions 中配置 `RUBYGEMS_API_KEY`,使用具有 `Push rubygem` 权限的发布 Key。
49
+ GitHub Runner 不会继承本机的 Gem 登录状态。要在 Actions 发布 RubyGems,需在仓库的 Settings → Secrets and variables →
50
+ Actions 中配置 `RUBYGEMS_API_KEY`,使用具有 `Push rubygem` 权限的发布 Key。
43
51
 
44
52
  ```sh
45
53
  git tag -a v0.3.3 -m 'Release v0.3.3'
@@ -49,22 +57,28 @@ gh workflow run release.yml --ref v0.3.3 --repo gatework/expect-ruby
49
57
 
50
58
  也可以在 Actions → Release → Run workflow 选择对应版本标签。工作流仅支持手动触发,避免本地发布时出现第二次并发上传。
51
59
 
52
- 发布作业先验证标签与版本号一致,再复用 CI 的 Linux/macOS、Ruby 3.2/3.3/3.4/4.0 共 8 个环境。全部通过后,下载 Ubuntu / Ruby 4.0 作业验证过的 Gem,交给同一个发布脚本;发布阶段不重新构建。
60
+ 发布作业先验证标签与版本号一致,再复用 CI 的 Linux/macOS、Ruby 3.2/3.3/3.4/4.0 共 8 个环境。全部通过后,下载 Ubuntu / Ruby
61
+ 4.0 作业验证过的 Gem,交给同一个发布脚本;发布阶段不重新构建。
53
62
 
54
63
  未配置 `RUBYGEMS_API_KEY` 时,GitHub Release 仍会创建,RubyGems 步骤会明确失败;此时可以下载 Release 中的原包,在本地使用已有登录状态完成上传。
55
64
 
56
65
  ## 失败后继续
57
66
 
58
- 保留脚本输出的 `Artifact` 路径,用该候选 Gem 重试发布;更换 RubyGems 工具版本或重新构建可能得到不同字节,同一个版本不得覆盖已有内容。也可以直接指定从 CI 或 Release 下载的原包:
67
+ 保留脚本输出的 `Artifact` 路径,用该候选 Gem 重试发布;更换 RubyGems 工具版本或重新构建可能得到不同字节,同一个版本不得覆盖已有内容。也可以直接指定从
68
+ CI 或 Release 下载的原包:
59
69
 
60
70
  ```sh
61
71
  ruby script/release.rb --rubygems-only --artifact tmp/ci/expect-pty-0.3.3.gem
62
72
  ```
63
73
 
64
- 将示例路径替换为实际输出的 `Artifact` 路径。`--artifact` 会跳过构建和测试,但仍核对包与当前源码是否一致;需要同时恢复 GitHub Release 时去掉 `--rubygems-only`。
74
+ 将示例路径替换为实际输出的 `Artifact` 路径。`--artifact` 会跳过构建和测试,但仍核对包与当前源码是否一致;需要同时恢复
75
+ GitHub Release 时去掉 `--rubygems-only`。
65
76
 
66
- 工作流失败时优先使用 Re-run failed jobs,继续使用本次 CI 保存的产物。需要在本地恢复时,检出发布标签对应的干净源码,下载该 Release 的 Gem,再通过 `--artifact` 指定它。
77
+ 工作流失败时优先使用 Re-run failed jobs,继续使用本次 CI 保存的产物。需要在本地恢复时,检出发布标签对应的干净源码,下载该
78
+ Release 的 Gem,再通过 `--artifact` 指定它。
67
79
 
68
- 脚本会校验现有 RubyGems 版本和 GitHub Release 附件的 SHA256;一致时复用,不一致时中止。已有 GitHub Release 缺少附件时会补传,已有版本和附件不会被覆盖。
80
+ 脚本会校验现有 RubyGems 版本和 GitHub Release 附件的 SHA256;一致时复用,不一致时中止。已有 GitHub Release
81
+ 缺少附件时会补传,已有版本和附件不会被覆盖。
69
82
 
70
- 上传中断若留下 `starter` 状态的空附件,脚本会明确指出附件名称。先确认没有其他发布或上传在运行,再在 GitHub Release 中删除该失败附件,使用原包重试;脚本不会自动删除可能仍在上传的附件。
83
+ 上传中断若留下 `starter` 状态的空附件,脚本会明确指出附件名称。先确认没有其他发布或上传在运行,再在 GitHub Release
84
+ 中删除该失败附件,使用原包重试;脚本不会自动删除可能仍在上传的附件。
@@ -1,6 +1,8 @@
1
1
  # 本地 Kibitz 示例与测试
2
2
 
3
- 参考 [Expect.pm 的 kibitz](https://github.com/jacoby/expect.pm/tree/master/examples/kibitz),使用本项目的 `Expect.open` 块、`listeners=`、`on_sequence` 和 `Expect.interconnect` 连接两端输入与同一个交互进程。参考源码为上游提交 `2ea0e4ce20a896c95cb4c94e781f1b1f3145150d`。
3
+ 参考 [Expect.pm 的 kibitz](https://github.com/jacoby/expect.pm/tree/master/examples/kibitz),使用本项目的 `Expect.open`
4
+ 块、`listeners=`、`on_sequence` 和 `Expect.interconnect` 连接两端输入与同一个交互进程。参考源码为上游提交
5
+ `2ea0e4ce20a896c95cb4c94e781f1b1f3145150d`。
4
6
 
5
7
  需要 Ruby 3.2+ 和 macOS/Linux。示例和自动脚本只依赖标准库,不需要 SSH 服务或密码。
6
8
 
@@ -12,7 +14,8 @@
12
14
  ruby examples/kibitz/kibitz.rb -- /bin/sh -i
13
15
  ```
14
16
 
15
- 程序打印一条完整的 `--join /tmp/expect-kibitz-.../peer.sock` 命令。在本机、同一账户的第二个终端执行这条命令,两端即可操作同一个 shell。输入和输出在两端都可见,例如:
17
+ 程序打印一条完整的 `--join /tmp/expect-kibitz-.../peer.sock` 命令。在本机、同一账户的第二个终端执行这条命令,两端即可操作同一个
18
+ shell。输入和输出在两端都可见,例如:
16
19
 
17
20
  ```sh
18
21
  # 第一个终端设置 shell 变量
@@ -21,7 +24,8 @@ shared_value=42
21
24
  printf '%s\n' "$shared_value"
22
25
  ```
23
26
 
24
- 任一端输入 `exit` 结束共享 shell;Ctrl-C 发给共享进程的前台任务。任一端按 Ctrl-] 结束自己的转接并断开连接,另一端随 EOF 结束。终端配置在退出时恢复。可以通过 `-- program args...` 运行其他交互程序;不传程序时使用 `$SHELL`,缺省 `/bin/sh`。
27
+ 任一端输入 `exit` 结束共享 shell;Ctrl-C 发给共享进程的前台任务。任一端按 Ctrl-] 结束自己的转接并断开连接,另一端随 EOF
28
+ 结束。终端配置在退出时恢复。可以通过 `-- program args...` 运行其他交互程序;不传程序时使用 `$SHELL`,缺省 `/bin/sh`。
25
29
 
26
30
  ## 无子进程互传和可选参数
27
31
 
@@ -39,9 +43,11 @@ ruby examples/kibitz/kibitz.rb --noescape -- /bin/sh -i
39
43
  ruby examples/kibitz/kibitz.rb --timeout 30 --log /tmp/my-kibitz-session.log -- /bin/sh -i
40
44
  ```
41
45
 
42
- 共享进程模式的日志记录该进程实际返回的字节,包括终端回显;无子进程模式记录对端返回的字节。日志使用 0600 权限。进程正常结束时,发起端返回该进程的退出码;转义或对端 EOF 返回 0,超时或连接失败返回非零。加入端只能观察连接结束,不取得共享进程的退出码。
46
+ 共享进程模式的日志记录该进程实际返回的字节,包括终端回显;无子进程模式记录对端返回的字节。日志使用 0600
47
+ 权限。进程正常结束时,发起端返回该进程的退出码;转义或对端 EOF 返回 0,超时或连接失败返回非零。加入端只能观察连接结束,不取得共享进程的退出码。
43
48
 
44
- 这是同账户本地示例:连接使用 0700 临时目录内的 0600 Unix socket,接受一位加入者后移除 socket,退出时清理目录。原例的用户邀请、跨主机 rlogin、FIFO 以及转义菜单没有移植;双方终端初始尺寸由发起端决定,未实现运行期间的窗口尺寸同步。
49
+ 这是同账户本地示例:连接使用 0700 临时目录内的 0600 Unix socket,接受一位加入者后移除 socket,退出时清理目录。原例的用户邀请、跨主机
50
+ rlogin、FIFO 以及转义菜单没有移植;双方终端初始尺寸由发起端决定,未实现运行期间的窗口尺寸同步。
45
51
 
46
52
  ## 自动运行同样的两个终端流程
47
53
 
@@ -51,21 +57,23 @@ ruby examples/kibitz/test_kibitz.rb
51
57
  bundle exec rake test:kibitz
52
58
  ```
53
59
 
54
- 脚本真正启动两份 `kibitz.rb`,通过两个独立 PTY 模拟两位用户,并执行打印出的 Unix socket 加入流程。共享模式运行真实 `/bin/sh -i`,没有 mock 转发逻辑。
55
-
56
- | 场景 | 检查 |
57
- | --- | --- |
58
- | 共享 shell | 输入回显、双方命令、共享变量、两端输出、中文、stderr、失败后继续执行 |
59
- | Ctrl-C 与进程 EOF | 第二端中断第一端启动的前台任务,最终输出到达两端,进程退出 |
60
- | `--noproc` | 双向字节互传,发送端无本地回显 |
61
- | 自定义转义 | `STOP` 分两次发送,前缀不泄漏,转义和尾部不进入对端 |
62
- | 加入端转义 | Ctrl-] 关闭加入端,发起端响应 EOF |
63
- | `--noescape` | Ctrl-] 字节被完整转发 |
64
- | 非零退出 | 共享 shell 返回 7,发起程序也返回 7 |
65
- | 超时 | 转接到期返回失败,另一端结束 |
66
- | 每个场景 | 就绪时已关闭本地回显,退出后两端终端配置恢复,socket 清理、日志权限正确 |
67
-
68
- 每轮结果写入 `tmp/kibitz/` 的独立目录,可用 `EXPECT_LOG_DIR` 指定其他目录。`report.json` 包含各场景检查项与整体 `passed`,各场景子目录保留 `session.log`。只有全部检查通过才返回 0。默认测试集还覆盖没有第二端加入时的超时清理:
60
+ 脚本真正启动两份 `kibitz.rb`,通过两个独立 PTY 模拟两位用户,并执行打印出的 Unix socket 加入流程。共享模式运行真实
61
+ `/bin/sh -i`,没有 mock 转发逻辑。
62
+
63
+ | 场景 | 检查 |
64
+ |-------------------|-------------------------------------------------------------------------|
65
+ | 共享 shell | 输入回显、双方命令、共享变量、两端输出、中文、stderr、失败后继续执行 |
66
+ | Ctrl-C 与进程 EOF | 第二端中断第一端启动的前台任务,最终输出到达两端,进程退出 |
67
+ | `--noproc` | 双向字节互传,发送端无本地回显 |
68
+ | 自定义转义 | `STOP` 分两次发送,前缀不泄漏,转义和尾部不进入对端 |
69
+ | 加入端转义 | Ctrl-] 关闭加入端,发起端响应 EOF |
70
+ | `--noescape` | Ctrl-] 字节被完整转发 |
71
+ | 非零退出 | 共享 shell 返回 7,发起程序也返回 7 |
72
+ | 超时 | 转接到期返回失败,另一端结束 |
73
+ | 每个场景 | 就绪时已关闭本地回显,退出后两端终端配置恢复,socket 清理、日志权限正确 |
74
+
75
+ 每轮结果写入 `tmp/kibitz/` 的独立目录,可用 `EXPECT_LOG_DIR` 指定其他目录。`report.json` 包含各场景检查项与整体 `passed`
76
+ ,各场景子目录保留 `session.log`。只有全部检查通过才返回 0。默认测试集还覆盖没有第二端加入时的超时清理:
69
77
 
70
78
  ```sh
71
79
  bundle exec ruby -Itest test/kibitz_test.rb
@@ -135,6 +135,7 @@ module Kibitz
135
135
  File.unlink(socket_path) if socket_path && File.socket?(socket_path)
136
136
  Dir.rmdir(directory) if directory && Dir.exist?(directory)
137
137
  end
138
+
138
139
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
139
140
  end
140
141
 
@@ -72,13 +72,24 @@ class Expect
72
72
  end
73
73
  end
74
74
 
75
- # 每次只将命中区域标为隐藏,不清除旧掩码;偏移逐字节推进以识别相互重叠的秘密。
75
+ # 仍逐字节推进重叠命中,但同一模式的相交/相邻区间只写一次掩码。
76
+ # 只向旧掩码取并集,不能清除其他模式或旧规则已经隐藏的 pending 字节。
76
77
  def mark_secrets
77
78
  @patterns.each do |pattern|
78
- offset = -1
79
+ starting = @pending.index(pattern)
80
+ next unless starting
81
+
82
+ length = pattern.bytesize
83
+ ending = starting + length
84
+ offset = starting
79
85
  while (offset = @pending.index(pattern, offset + 1))
80
- @hidden[offset, pattern.bytesize] = "\1" * pattern.bytesize
86
+ if offset > ending
87
+ @hidden[starting, ending - starting] = "\1" * (ending - starting)
88
+ starting = offset
89
+ end
90
+ ending = offset + length
81
91
  end
92
+ @hidden[starting, ending - starting] = "\1" * (ending - starting)
82
93
  end
83
94
  end
84
95
 
@@ -5,25 +5,30 @@ require "shellwords"
5
5
  # 会话终端的模式和窗口尺寸接口;人工接管期间的临时恢复由 interaction.rb 负责。
6
6
  class Expect
7
7
  # 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
8
- # 非终端返回空字符串;命令不存在或执行失败抛出 IOError,管道在所有退出路径关闭。
8
+ # 辅助进程与管道独立记账,不能覆盖主会话 PID;失败时保留原异常并有界回收。
9
9
  def stty(*modes)
10
10
  return "" unless tty?
11
11
 
12
12
  modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
13
13
  modes = ["-g"] if modes.empty?
14
14
  reader, sink = IO.pipe
15
- child = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
15
+ resources = SessionResources.new(reader, writer: sink, own: true)
16
+ resources.pid = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
16
17
  sink.close
17
18
  output = reader.read
18
- _, status = Process.waitpid2(child)
19
+ status = wait_stty(resources.pid)
20
+ resources.pid = nil
19
21
  raise IOError, "stty failed: #{output.strip}" unless status.success?
20
22
 
21
23
  output.strip
22
24
  rescue Errno::ENOENT
25
+ failed = true
23
26
  raise IOError, "stty executable not found in PATH; install the system terminal utilities"
27
+ rescue Exception # rubocop:disable Lint/RescueException -- 中断同样先回收辅助进程,再原样传播;不读取调用者的旧 $!。
28
+ failed = true
29
+ raise
24
30
  ensure
25
- reader&.close unless reader&.closed?
26
- sink&.close unless sink&.closed?
31
+ cleanup_preserving_failure(failed) { cleanup_stty(resources, reader, sink) }
27
32
  end
28
33
 
29
34
  # 读取终端的 [行数, 列数];底层并非终端或句柄已关闭时保留原生 IO 异常。
@@ -33,4 +38,65 @@ class Expect
33
38
  def winsize=(size)
34
39
  to_io.winsize = size
35
40
  end
41
+
42
+ private
43
+
44
+ # 正常路径阻塞取得真实状态,不额外轮询或固定等待;EINTR 不重新执行 stty。
45
+ def wait_stty(pid)
46
+ Process.waitpid2(pid).last
47
+ rescue Errno::EINTR
48
+ retry
49
+ end
50
+
51
+ # 即使账本初始化被中断,局部变量中的管道仍须关闭;单端失败不跳过其他清理。
52
+ # 无主异常时由调用方传播首个常规清理错误。
53
+ def cleanup_stty(resources, reader, sink)
54
+ failure = nil
55
+ [reader, sink].compact.each do |io|
56
+ io.close unless io.closed?
57
+ rescue IOError, SystemCallError => error
58
+ failure ||= error
59
+ end
60
+ begin
61
+ reap_stty(resources) if resources
62
+ rescue IOError, SystemCallError => error
63
+ failure ||= error
64
+ end
65
+ raise failure if failure
66
+ end
67
+
68
+ # 每阶段共享固定的 50ms 单调时钟预算:自然退出、TERM、KILL;中断不续期。
69
+ # 仍无法同步回收或系统调用失败时交给 detach 的后台 wait,不伪造退出状态。
70
+ def reap_stty(resources)
71
+ return unless resources.owner == Process.pid
72
+
73
+ [nil, "TERM", "KILL"].each do |signal|
74
+ deadline = Expect.monotonic + 0.05
75
+ loop do
76
+ begin
77
+ resources.reap
78
+ return resources.status unless resources.pid && resources.owner == Process.pid
79
+
80
+ if signal
81
+ begin
82
+ Process.kill(signal, resources.pid)
83
+ rescue Errno::ESRCH
84
+ # 退出可发生在非阻塞 wait 与信号之间,仍需继续 wait。
85
+ nil
86
+ end
87
+ signal = nil
88
+ end
89
+ rescue Errno::EINTR
90
+ # 重试沿用本阶段期限,连续 EINTR 也必须交还控制权。
91
+ nil
92
+ end
93
+ remaining = deadline - Expect.monotonic
94
+ break unless remaining.positive?
95
+
96
+ sleep [remaining, 0.005].min
97
+ end
98
+ end
99
+ ensure
100
+ Process.detach(resources.pid) if resources.pid && resources.owner == Process.pid
101
+ end
36
102
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.5.0"
5
+ VERSION = "0.5.2"
6
6
  end
data/script/ci CHANGED
@@ -7,7 +7,7 @@ bundle exec rake
7
7
  bundle exec ruby examples/dialogue.rb
8
8
 
9
9
  # 只检查小规模工作负载的正确性,不为共享 CI 机器设置性能阈值。
10
- for benchmark in matching relay send_slow scaling; do
10
+ for benchmark in matching relay send_slow scaling redactor; do
11
11
  bundle exec ruby "benchmark/${benchmark}.rb" --smoke
12
12
  done
13
13
 
@@ -60,7 +60,11 @@ child = 'STDOUT.sync = true; puts "ready"; puts "reply:#{$stdin.gets&.strip}"'
60
60
  Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_stdout: false) do |session|
61
61
  session.winsize = [30, 100]
62
62
  abort "io-console terminal size failed" unless session.winsize == [30, 100]
63
+ # BSD stty 设置模式会清空待读输出,先收取提示,使子进程停在等待输入的位置。
63
64
  abort "installed gem did not receive the PTY prompt" unless session.expect("ready", timeout: 5)
65
+ mode = session.stty
66
+ abort "installed stty query failed" if mode.empty?
67
+ session.stty(mode)
64
68
 
65
69
  session.puts("expect-ruby")
66
70
  abort "installed gem PTY dialogue failed" unless session.expect("reply:expect-ruby", timeout: 5)
@@ -8,9 +8,11 @@ SSH_HOST=127.0.0.1 SSH_USER=crate ruby test/integration/ssh_scripts.rb
8
8
  SSH_HOST=127.0.0.1 SSH_USER=crate bundle exec rake test:ssh
9
9
  ```
10
10
 
11
- 脚本隐藏读取密码;也支持由运行环境提供 `EXPECT_PASSWORD`。密码不会作为 SSH 命令行参数传递,日志在认证完成后才开启。默认自动测试不连接 SSH、不读取密码。
11
+ 脚本隐藏读取密码;也支持由运行环境提供 `EXPECT_PASSWORD`。密码不会作为 SSH 命令行参数传递,日志在认证完成后才开启。默认自动测试不连接
12
+ SSH、不读取密码。
12
13
 
13
- 支持 `SSH_PORT`(默认 22)、`EXPECT_LOG_DIR`(日志根目录)、`SSH_KNOWN_HOSTS`。连接非本机主机时,必须提供已有的受信任 known_hosts,例如:
14
+ 支持 `SSH_PORT`(默认 22)、`EXPECT_LOG_DIR`(日志根目录)、`SSH_KNOWN_HOSTS`。连接非本机主机时,必须提供已有的受信任
15
+ known_hosts,例如:
14
16
 
15
17
  ```sh
16
18
  SSH_HOST=192.0.2.10 SSH_USER=operator SSH_PORT=22 \
@@ -18,23 +20,27 @@ SSH_HOST=192.0.2.10 SSH_USER=operator SSH_PORT=22 \
18
20
  EXPECT_LOG_DIR=tmp/device-check ruby test/integration/ssh_scripts.rb
19
21
  ```
20
22
 
21
- 这是 POSIX shell 脚本测试;厂商专用 CLI(如交换机的 `show` 命令、分页提示符)需要对应设备的命令和预期输出,不能直接使用这些 shell fixtures。
23
+ 这是 POSIX shell 脚本测试;厂商专用 CLI(如交换机的 `show` 命令、分页提示符)需要对应设备的命令和预期输出,不能直接使用这些
24
+ shell fixtures。
22
25
 
23
26
  ## 同一个登录会话执行的脚本
24
27
 
25
- | 脚本 | 验证内容 | 预期退出码 |
26
- | --- | --- | --- |
27
- | `01_identity.sh` | 当前用户、远端 PTY、完成标记 | 0 |
28
- | `02_output.sh` | stdout、stderr、中文和输出顺序 | 0 |
29
- | `03_delayed.sh` | 分三次延迟输出,确保日志完整 | 0 |
30
- | `04_failure.sh` | 明确输出错误并退出 | 7(预期失败) |
31
- | `05_recovery.sh` | 上个脚本失败后继续执行,计算结果为 42 | 0 |
28
+ | 脚本 | 验证内容 | 预期退出码 |
29
+ |------------------|---------------------------------------|---------------|
30
+ | `01_identity.sh` | 当前用户、远端 PTY、完成标记 | 0 |
31
+ | `02_output.sh` | stdout、stderr、中文和输出顺序 | 0 |
32
+ | `03_delayed.sh` | 分三次延迟输出,确保日志完整 | 0 |
33
+ | `04_failure.sh` | 明确输出错误并退出 | 7(预期失败) |
34
+ | `05_recovery.sh` | 上个脚本失败后继续执行,计算结果为 42 | 0 |
32
35
 
33
- fixtures 位于 `test/fixtures/ssh_scripts/`,都不修改目标文件或设备配置。文件内容通过 SSH 的交互 shell 传给独立 `/bin/sh -c` 执行,不需要上传文件。每个脚本都有随机开始/结束标记,结束标记携带真实 `$?`;脚本输出和退出码都必须符合预期才算通过。
36
+ fixtures 位于 `test/fixtures/ssh_scripts/`,都不修改目标文件或设备配置。文件内容通过 SSH 的交互 shell 传给独立
37
+ `/bin/sh -c` 执行,不需要上传文件。每个脚本都有随机开始/结束标记,结束标记携带真实 `$?`;脚本输出和退出码都必须符合预期才算通过。
34
38
 
35
- 测试关闭终端回显和 shell 行编辑,使用单引号传输完整脚本,保留中文 UTF-8 和引号,避免 C locale 的交互式 readline 把高位字节当快捷键执行。末尾仍等待 shell 提示符,确认可以继续执行下一项。
39
+ 测试关闭终端回显和 shell 行编辑,使用单引号传输完整脚本,保留中文 UTF-8 和引号,避免 C locale 的交互式 readline
40
+ 把高位字节当快捷键执行。末尾仍等待 shell 提示符,确认可以继续执行下一项。
36
41
 
37
- 另外执行两条日志控制命令:关闭日志后输出 `UNLOGGED_OUTPUT`,再以默认追加模式开启日志并输出 `APPEND_OK`。最后发送延迟输出及退出命令,只调用 `soft_close`,确认 `SESSION_FINAL_TAIL` 确实在关闭过程中写入日志。
42
+ 另外执行两条日志控制命令:关闭日志后输出 `UNLOGGED_OUTPUT`,再以默认追加模式开启日志并输出 `APPEND_OK`。最后发送延迟输出及退出命令,只调用
43
+ `soft_close`,确认 `SESSION_FINAL_TAIL` 确实在关闭过程中写入日志。
38
44
 
39
45
  ## 检查和输出文件
40
46
 
@@ -43,11 +49,14 @@ fixtures 位于 `test/fixtures/ssh_scripts/`,都不修改目标文件或设备
43
49
  - `session.log`:收到的原始数据,以及手工添加的 `[SEND] 脚本名 sha256=...` 和 `[EXIT] 脚本名 status=...`。
44
50
  - `report.json`:主机、用户、远端 TTY、每项脚本输出和退出码、校验结果、SSH 退出码、日志字节数及 SHA-256。
45
51
 
46
- 只有认证后的输出进入日志。Expect 自动记录接收字节,发送的脚本名称和摘要由 `write_log` 明确写入,便于审核执行了哪个 fixture;fixture 文件中的原文可以用报告内的 SHA-256 核对。
52
+ 只有认证后的输出进入日志。Expect 自动记录接收字节,发送的脚本名称和摘要由 `write_log` 明确写入,便于审核执行了哪个
53
+ fixture;fixture 文件中的原文可以用报告内的 SHA-256 核对。
47
54
 
48
- 共 11 项日志/流程检查:完整输出、执行顺序、即时 flush、stdout/stderr、UTF-8、非零退出后恢复、覆盖模式、暂停记录、追加模式、关闭前尾部输出、密码不出现在日志中。另校验日志条目没有重复,日志句柄在停止记录和关闭会话后正确关闭。
55
+ 共 11 项日志/流程检查:完整输出、执行顺序、即时
56
+ flush、stdout/stderr、UTF-8、非零退出后恢复、覆盖模式、暂停记录、追加模式、关闭前尾部输出、密码不出现在日志中。另校验日志条目没有重复,日志句柄在停止记录和关闭会话后正确关闭。
49
57
 
50
- 成功返回进程退出码 0。认证失败、超时、脚本输出错误、意外退出码或日志校验失败均返回非零,并在有报告目录时留下 `passed: false` 的报告。不用仅看到终端输出就判断成功;请查看最终 PASS 和 `report.json` 的 `passed`。
58
+ 成功返回进程退出码 0。认证失败、超时、脚本输出错误、意外退出码或日志校验失败均返回非零,并在有报告目录时留下 `passed: false`
59
+ 的报告。不用仅看到终端输出就判断成功;请查看最终 PASS 和 `report.json` 的 `passed`。
51
60
 
52
61
  ## 无需 SSH 的回归测试
53
62
 
@@ -56,7 +65,8 @@ bundle exec ruby -Itest test/script_logging_test.rb
56
65
  bundle exec rake test
57
66
  ```
58
67
 
59
- `test/script_logging_test.rb` 使用真实本地 PTY 执行相同 fixtures,另外验证回调日志、借用 File 日志、错误退出/错误输出的反例、引号和 shell 元字符、日志错误上抛,以及超时后继续收集日志。它和 SSH 脚本共享测试 helper,不依赖远端账户。
68
+ `test/script_logging_test.rb` 使用真实本地 PTY 执行相同 fixtures,另外验证回调日志、借用 File 日志、错误退出/错误输出的反例、引号和
69
+ shell 元字符、日志错误上抛,以及超时后继续收集日志。它和 SSH 脚本共享测试 helper,不依赖远端账户。
60
70
 
61
71
  ## SSH interact 人工和自动验证
62
72
 
@@ -67,13 +77,18 @@ ruby examples/ssh_interact.rb --auto
67
77
  bundle exec rake test:ssh_interact
68
78
  ```
69
79
 
70
- 使用上述同一套 SSH 环境变量和隐藏密码输入。人工模式显示 `EXPECT_SCRIPT_PROMPT>`;输入命令有回显,Ctrl-C 传给远端前台任务。在命令结束、回到 shell 提示符后按 Ctrl-],会关闭远端回显,执行 `MANUAL_AUTOMATION_RESUMED` 检查,再正常退出 SSH。也支持直接输入 `exit`:成功退出记为 `completion: remote_exit`,此路径不执行返回 expect 的检查。非零 SSH 退出仍判为失败。
80
+ 使用上述同一套 SSH 环境变量和隐藏密码输入。人工模式显示 `EXPECT_SCRIPT_PROMPT>`;输入命令有回显,Ctrl-C 传给远端前台任务。在命令结束、回到
81
+ shell 提示符后按 Ctrl-],会关闭远端回显,执行 `MANUAL_AUTOMATION_RESUMED` 检查,再正常退出 SSH。也支持直接输入 `exit`:成功退出记为
82
+ `completion: remote_exit`,此路径不执行返回 expect 的检查。非零 SSH 退出仍判为失败。
71
83
 
72
- `--auto` 创建真实本地 PTY,验证两次 `interact` 接管、当前用户和远端 TTY、stdout/stderr/中文、Ctrl-C、Ctrl-]、转义后的本地尾部不进入 SSH、恢复 `expect` 及终端与监听设置。4 项命令输出和 13 项交互/日志检查全部通过后才返回 0。
84
+ `--auto` 创建真实本地 PTY,验证两次 `interact` 接管、当前用户和远端 TTY、stdout/stderr/中文、Ctrl-C、Ctrl-]、转义后的本地尾部不进入
85
+ SSH、恢复 `expect` 及终端与监听设置。4 项命令输出和 13 项交互/日志检查全部通过后才返回 0。
73
86
 
74
- 日志在认证之后开启;每次在 `tmp/ssh-interact/` 新建 0700 目录,保存 0600 的 `session.log` 和 `report.json`。报告包含检查项、命令输出、SSH 退出码及日志摘要。自动模式也校验日志无重复、退出尾部不丢失、密码不出现在日志中。
87
+ 日志在认证之后开启;每次在 `tmp/ssh-interact/` 新建 0700 目录,保存 0600 的 `session.log` 和 `report.json`
88
+ 。报告包含检查项、命令输出、SSH 退出码及日志摘要。自动模式也校验日志无重复、退出尾部不丢失、密码不出现在日志中。
75
89
 
76
- 无需 SSH 的回归位于 `test/interact_test.rb`,覆盖可见输入、Ctrl-C、两次接管、返回自动命令、超时、两端 EOF、回调异常和输出 IO 故障时的终端恢复。`test/script_logging_test.rb` 另验证 CRLF 输出边界,包括空输出、末尾有/无换行和多个换行。
90
+ 无需 SSH 的回归位于 `test/interact_test.rb`,覆盖可见输入、Ctrl-C、两次接管、返回自动命令、超时、两端 EOF、回调异常和输出 IO
91
+ 故障时的终端恢复。`test/script_logging_test.rb` 另验证 CRLF 输出边界,包括空输出、末尾有/无换行和多个换行。
77
92
 
78
93
  ## `ssh_auto.rb` 环境检查
79
94
 
@@ -85,6 +100,10 @@ ruby examples/ssh_auto.rb --no-interact
85
100
  bundle exec rake test:ssh_auto
86
101
  ```
87
102
 
88
- 该示例自动登录 `SSH_HOST`(默认 `127.0.0.1`),用本库的 `write` / `expect` 顺序执行文件顶部的 `COMMANDS`,显示真实输出并检查退出码。默认命令为 `id`、`uname -srm`、`sw_vers`、`df -h /` 和 `uptime`,不修改系统设置;可直接修改数组来下发其他 macOS/POSIX shell 命令。它不依赖 `test/support`,`ScriptProbe.check` 只供测试脚本使用。
103
+ 该示例自动登录 `SSH_HOST`(默认 `127.0.0.1`),用本库的 `write` / `expect` 顺序执行文件顶部的 `COMMANDS`,显示真实输出并检查退出码。默认命令为
104
+ `id`、`uname -srm`、`sw_vers`、`df -h /` 和 `uptime`,不修改系统设置;可直接修改数组来下发其他 macOS/POSIX shell 命令。它不依赖
105
+ `test/support`,`ScriptProbe.check` 只供测试脚本使用。
89
106
 
90
- 执行成功后默认进入 `interact`,输入有回显,Ctrl-C 发给远端前台任务;`exit` 或 Ctrl-] 结束连接。使用 `--no-interact` 则执行完退出。`EXPECT_LOG_DIR` 可指定日志目录,默认 `tmp/ssh-auto/`;每次创建独立 0600 日志,记录命令和远端输出,认证前关闭日志。此使用示例不生成 JSON 测试报告,详细日志断言仍由 `ssh_scripts.rb` 和 `ssh_interact.rb --auto` 验证。
107
+ 执行成功后默认进入 `interact`,输入有回显,Ctrl-C 发给远端前台任务;`exit` 或 Ctrl-] 结束连接。使用 `--no-interact` 则执行完退出。
108
+ `EXPECT_LOG_DIR` 可指定日志目录,默认 `tmp/ssh-auto/`;每次创建独立 0600 日志,记录命令和远端输出,认证前关闭日志。此使用示例不生成
109
+ JSON 测试报告,详细日志断言仍由 `ssh_scripts.rb` 和 `ssh_interact.rb --auto` 验证。
@@ -103,4 +103,81 @@ class RedactorTest < Minitest::Test
103
103
  filter.append("raw-pending")
104
104
  assert_equal "#<Expect::Redactor>", filter.inspect
105
105
  end
106
+
107
+ def test_seeded_binary_chunks_match_a_naive_union_of_full_and_partial_spans
108
+ random = Random.new(5000)
109
+ 250.times do |index|
110
+ alphabet = index.even? ? [97, 98] : [0, 97, 98, 99, 128, 255]
111
+ input = Array.new(random.rand(0..120)) { alphabet.sample(random: random) }.pack("C*")
112
+ patterns = Array.new(random.rand(0..6)) do
113
+ Array.new(random.rand(1..8)) { alphabet.sample(random: random) }.pack("C*")
114
+ end
115
+ chunks = []
116
+ offset = 0
117
+ while offset < input.bytesize
118
+ chunks << input.byteslice(offset, random.rand(1..15))
119
+ offset += chunks.last.bytesize
120
+ end
121
+ [false, true].each do |partial|
122
+ expected = reference_redact(input, patterns, partial: partial)
123
+ [[input], input.bytes.map(&:chr), chunks].each_with_index do |partition, kind|
124
+ filter = Expect::Redactor.new(patterns)
125
+ actual = partition.map { |chunk| filter.append(chunk) }.join.b + filter.finish(partial: partial)
126
+ assert_equal expected, actual, "seed=5000 case=#{index} partition=#{kind} partial=#{partial}"
127
+ assert_empty filter.finish(partial: partial)
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ def test_long_overlaps_merge_across_chunks_without_repeating_markers
134
+ filter = Expect::Redactor.new(["a" * 1024, "a" * 32, "ab"])
135
+ output = +""
136
+ 256.times { output << filter.append("a" * 17) }
137
+ output << filter.append("b!") << filter.finish
138
+ assert_equal "[FILTERED]!", output
139
+ end
140
+
141
+ def test_rule_removal_keeps_hidden_pending_bytes_and_partial_finish_is_separate
142
+ filter = Expect::Redactor.new(%w[aaaa long-pattern])
143
+ assert_empty filter.append("aaaaa")
144
+ filter.patterns = ["abc"]
145
+ assert_equal "[FILTERED]", filter.append("ab")
146
+ assert_empty filter.finish # 新的疑似前缀紧邻已隐藏区域,不能重复输出替换标记。
147
+
148
+ filter = Expect::Redactor.new(%w[aaaa long-pattern])
149
+ assert_empty filter.append("aaaaa")
150
+ filter.patterns = []
151
+ assert_equal "[FILTERED]!", filter.append("!") + filter.finish
152
+ end
153
+
154
+ private
155
+
156
+ # 独立参考模型:逐偏移比较完整秘密,再合并布尔掩码;不复用生产代码的扫描/输出实现。
157
+ def reference_redact(input, patterns, partial:)
158
+ hidden = Array.new(input.bytesize, false)
159
+ patterns.each do |pattern|
160
+ input.bytesize.times do |offset|
161
+ next unless input.byteslice(offset, pattern.bytesize) == pattern
162
+
163
+ pattern.bytesize.times { |length| hidden[offset + length] = true }
164
+ end
165
+ next unless partial
166
+
167
+ (1...pattern.bytesize).each do |length|
168
+ next unless input.end_with?(pattern.byteslice(0, length))
169
+
170
+ ((input.bytesize - length)...input.bytesize).each { |offset| hidden[offset] = true }
171
+ end
172
+ end
173
+ output = +"".b
174
+ input.bytes.each_with_index do |byte, offset|
175
+ if hidden[offset]
176
+ output << "[FILTERED]" if offset.zero? || !hidden[offset - 1]
177
+ else
178
+ output << byte
179
+ end
180
+ end
181
+ output
182
+ end
106
183
  end
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class TerminalCleanupTest < ExpectTest
6
+ def test_read_failures_preserve_identity_reap_helper_and_leave_main_session_usable
7
+ [IOError.new("read failed"), Interrupt.new("interrupted"), SystemExit.new(17)].each do |failure|
8
+ session = terminal_session
9
+ main_pid = session.pid
10
+ with_stty_pipe do |reader, sink, spawned|
11
+ reader.stub(:read, -> { raise failure }) do
12
+ assert_same failure, assert_raises(failure.class) { bounded { session.stty } }
13
+ end
14
+ assert reader.closed?
15
+ assert sink.closed?
16
+ assert_reaped spawned.fetch(:pid)
17
+ end
18
+ assert_main_session_usable session, main_pid
19
+ end
20
+ end
21
+
22
+ def test_read_error_wins_over_close_error_and_helper_is_still_reaped
23
+ session = terminal_session
24
+ failure = IOError.new("read failed")
25
+ attempts = 0
26
+ with_stty_pipe do |reader, sink, spawned|
27
+ reader.stub(:read, -> { raise failure }) do
28
+ reader.stub(:close, lambda {
29
+ attempts += 1
30
+ raise IOError, "close failed"
31
+ }) do
32
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
33
+ refute reader.closed?, "permanently failing close must not be reported as closed"
34
+ assert sink.closed?
35
+ assert_reaped spawned.fetch(:pid)
36
+ end
37
+ end
38
+ end
39
+ assert_equal 1, attempts
40
+ end
41
+
42
+ def test_cleanup_error_after_success_is_visible_even_inside_caller_rescue
43
+ session = terminal_session
44
+ failure = IOError.new("close failed")
45
+ begin
46
+ raise ArgumentError, "already handled"
47
+ rescue ArgumentError
48
+ with_stty_pipe do |reader, sink, spawned|
49
+ reader.stub(:close, -> { raise failure }) do
50
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
51
+ assert sink.closed?
52
+ assert_reaped spawned.fetch(:pid)
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ def test_spawn_failure_attempts_both_closes_and_keeps_missing_command_cause
59
+ session = terminal_session
60
+ reader, sink = IO.pipe
61
+ @ios.push(reader, sink)
62
+ failure = Errno::ENOENT.new("missing stty")
63
+ IO.stub(:pipe, [reader, sink]) do
64
+ Process.stub(:spawn, ->(*) { raise failure }) do
65
+ Process.stub(:kill, ->(*) { flunk "no helper was spawned" }) do
66
+ reader.stub(:close, -> { raise IOError, "reader close failed" }) do
67
+ error = assert_raises(IOError) { session.stty }
68
+ assert_match "stty executable not found", error.message
69
+ assert_same failure, error.cause
70
+ assert sink.closed?
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ def test_externally_reaped_helper_is_not_signalled_and_does_not_change_main_status
78
+ session = terminal_session
79
+ main_pid = session.pid
80
+ failure = IOError.new("read failed after external wait")
81
+ with_stty_pipe do |reader, _sink, spawned|
82
+ reader.stub(:read, lambda {
83
+ bounded { Process.waitpid2(spawned.fetch(:pid)) }
84
+ raise failure
85
+ }) do
86
+ Process.stub(:kill, ->(*) { flunk "must not signal an externally reaped PID" }) do
87
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
88
+ end
89
+ end
90
+ assert_reaped spawned.fetch(:pid)
91
+ end
92
+ assert_main_session_usable session, main_pid
93
+ end
94
+
95
+ def test_uncooperative_helper_is_killed_and_reaped_after_read_failure
96
+ session = terminal_session
97
+ failure = IOError.new("read failed")
98
+ command = [RbConfig.ruby, "--disable-gems", "-e",
99
+ 'Signal.trap("TERM", "IGNORE"); STDOUT.sync = true; puts "ready"; sleep 60']
100
+ signals = []
101
+ kill = Process.method(:kill)
102
+ with_stty_pipe(command: command) do |reader, _sink, spawned|
103
+ read = reader.method(:gets)
104
+ reader.stub(:read, lambda {
105
+ assert_equal("ready\n", bounded { read.call })
106
+ raise failure
107
+ }) do
108
+ Process.stub(:kill, lambda { |signal, pid|
109
+ signals << [signal, pid]
110
+ kill.call(signal, pid)
111
+ }) do
112
+ assert_same failure, assert_raises(IOError) { bounded(2) { session.stty } }
113
+ end
114
+ end
115
+ assert_equal [["TERM", spawned.fetch(:pid)], ["KILL", spawned.fetch(:pid)]], signals
116
+ assert_reaped spawned.fetch(:pid)
117
+ end
118
+ end
119
+
120
+ def test_normal_wait_retries_eintr_without_signalling_or_sleeping
121
+ session = terminal_session
122
+ wait = Process.method(:waitpid2)
123
+ attempts = 0
124
+ with_stty_pipe do |_reader, _sink, spawned|
125
+ Process.stub(:waitpid2, lambda { |pid, *flags|
126
+ assert_equal spawned.fetch(:pid), pid
127
+ attempts += 1
128
+ raise Errno::EINTR if attempts == 1
129
+
130
+ wait.call(pid, *flags)
131
+ }) do
132
+ session.stub(:sleep, ->(*) { flunk "successful stty must not add a fixed delay" }) do
133
+ Process.stub(:kill, ->(*) { flunk "successful helper must not be signalled" }) do
134
+ refute_empty session.stty
135
+ end
136
+ end
137
+ end
138
+ assert_equal 2, attempts
139
+ assert_reaped spawned.fetch(:pid)
140
+ end
141
+ end
142
+
143
+ def test_continuous_cleanup_eintr_has_a_fixed_budget_and_detaches_real_helper
144
+ session = terminal_session
145
+ failure = IOError.new("read failed")
146
+ clock = 0.0
147
+ waiter = nil
148
+ detach = Process.method(:detach)
149
+ attempts = 0
150
+ with_stty_pipe do |reader, _sink, spawned|
151
+ reader.stub(:read, -> { raise failure }) do
152
+ Expect.stub(:monotonic, -> { clock }) do
153
+ session.stub(:sleep, ->(duration) { clock += duration }) do
154
+ Process.stub(:waitpid2, lambda { |pid, flags|
155
+ assert_equal spawned.fetch(:pid), pid
156
+ assert_equal Process::WNOHANG, flags
157
+ attempts += 1
158
+ raise Errno::EINTR
159
+ }) do
160
+ Process.stub(:kill, ->(*) { flunk "ownership has not been checked successfully" }) do
161
+ Process.stub(:detach, lambda { |pid|
162
+ assert_equal spawned.fetch(:pid), pid
163
+ waiter = detach.call(pid)
164
+ }) do
165
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ assert_operator attempts, :>, 3
173
+ assert_in_delta 0.15, clock, 0.000001
174
+ refute_nil waiter
175
+ assert_instance_of(Process::Status, bounded { waiter.value })
176
+ assert_reaped spawned.fetch(:pid)
177
+ end
178
+ end
179
+
180
+ def test_esrch_between_poll_and_signal_still_reaps_without_fabricating_status
181
+ session = terminal_session
182
+ main_pid = session.pid
183
+ failure = IOError.new("read failed")
184
+ clock = 0.0
185
+ signalled = false
186
+ wait = Process.method(:waitpid2)
187
+ with_stty_pipe do |reader, _sink, spawned|
188
+ reader.stub(:read, -> { raise failure }) do
189
+ Expect.stub(:monotonic, -> { clock }) do
190
+ session.stub(:sleep, ->(duration) { clock += duration }) do
191
+ Process.stub(:waitpid2, lambda { |pid, flags|
192
+ assert_equal Process::WNOHANG, flags
193
+ signalled ? wait.call(pid, flags) : nil
194
+ }) do
195
+ Process.stub(:kill, lambda { |signal, pid|
196
+ assert_equal "TERM", signal
197
+ assert_equal spawned.fetch(:pid), pid
198
+ bounded { wait.call(pid) }
199
+ signalled = true
200
+ raise Errno::ESRCH
201
+ }) do
202
+ Process.stub(:detach, ->(*) { flunk "ECHILD already released ownership" }) do
203
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
210
+ assert signalled
211
+ assert_reaped spawned.fetch(:pid)
212
+ end
213
+ assert_main_session_usable session, main_pid
214
+ end
215
+
216
+ def test_sink_close_failure_still_attempts_reader_close_and_reaps_helper
217
+ session = terminal_session
218
+ failure = IOError.new("sink close failed")
219
+ attempts = 0
220
+ with_stty_pipe do |reader, sink, spawned|
221
+ sink.stub(:close, lambda {
222
+ attempts += 1
223
+ raise failure
224
+ }) do
225
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
226
+ assert reader.closed?
227
+ refute sink.closed?
228
+ assert_reaped spawned.fetch(:pid)
229
+ end
230
+ end
231
+ assert_equal 2, attempts
232
+ end
233
+
234
+ def test_forked_copy_closes_local_pipes_without_reaping_or_signalling_parent_helper
235
+ session = terminal_session
236
+ current_pid = Process.pid
237
+ failure = IOError.new("unwind in forked copy")
238
+ with_stty_pipe do |reader, sink, _spawned|
239
+ Process.stub(:pid, -> { current_pid }) do
240
+ reader.stub(:read, lambda {
241
+ current_pid = -1 # 在资源登记后模拟 fork 子进程的身份,父进程仍拥有辅助 PID。
242
+ raise failure
243
+ }) do
244
+ Process.stub(:waitpid2, ->(*) { flunk "non-owner must not wait for the parent's helper" }) do
245
+ Process.stub(:kill, ->(*) { flunk "non-owner must not signal the parent's helper" }) do
246
+ Process.stub(:detach, ->(*) { flunk "non-owner must not detach the parent's helper" }) do
247
+ assert_same failure, assert_raises(IOError) { session.stty }
248
+ assert reader.closed?
249
+ assert sink.closed?
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
257
+
258
+ def test_ledger_initialization_failure_still_closes_both_pipes
259
+ session = terminal_session
260
+ [IOError.new("ledger failed"), Interrupt.new("interrupted registration")].each do |failure|
261
+ with_stty_pipe do |reader, sink, spawned|
262
+ Expect::SessionResources.stub(:new, ->(*) { raise failure }) do
263
+ assert_same failure, assert_raises(failure.class) { session.stty }
264
+ assert reader.closed?
265
+ assert sink.closed?
266
+ assert_empty spawned
267
+ end
268
+ end
269
+ end
270
+ end
271
+
272
+ def test_fork_during_cleanup_wait_stops_process_cleanup
273
+ session = terminal_session
274
+ current_pid = Process.pid
275
+ failure = IOError.new("read failed")
276
+ with_stty_pipe do |reader, sink, _spawned|
277
+ Process.stub(:pid, -> { current_pid }) do
278
+ reader.stub(:read, -> { raise failure }) do
279
+ session.stub(:sleep, ->(*) { current_pid = -1 }) do
280
+ Process.stub(:waitpid2, ->(*) {}) do
281
+ Process.stub(:kill, ->(*) { flunk "fork during wait must release inherited process ownership" }) do
282
+ Process.stub(:detach, ->(*) { flunk "non-owner must not detach" }) do
283
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
284
+ assert reader.closed?
285
+ assert sink.closed?
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end
294
+
295
+ private
296
+
297
+ def terminal_session
298
+ session = child(<<~'RUBY', raw_pty: true)
299
+ puts "ready"
300
+ while (line = STDIN.gets)
301
+ puts "reply:#{line.strip}"
302
+ end
303
+ RUBY
304
+ assert_equal 1, session.expect("ready", timeout: 2)
305
+ session
306
+ end
307
+
308
+ def assert_main_session_usable(session, main_pid)
309
+ assert_equal main_pid, session.pid
310
+ assert_nil session.process_status
311
+ refute session.closed?
312
+ session.puts "still usable"
313
+ assert_equal 1, session.expect("reply:still usable", timeout: 2)
314
+ end
315
+
316
+ def assert_reaped(pid)
317
+ assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
318
+ end
319
+
320
+ # 只替换本次 stty 的管道;即使断言失败,恢复全局 stub 后仍由探针回收自己的真实子进程。
321
+ def with_stty_pipe(command: nil)
322
+ reader, sink = IO.pipe
323
+ @ios.push(reader, sink)
324
+ spawned = {}
325
+ spawn = Process.method(:spawn)
326
+ IO.stub(:pipe, [reader, sink]) do
327
+ Process.stub(:spawn, lambda { |*arguments, **options|
328
+ spawned[:pid] = spawn.call(*(command || arguments), **options)
329
+ }) do
330
+ yield reader, sink, spawned
331
+ end
332
+ end
333
+ ensure
334
+ reap_probe_child(spawned[:pid]) if spawned && spawned[:pid]
335
+ end
336
+
337
+ def reap_probe_child(pid)
338
+ return if Process.waitpid(pid, Process::WNOHANG)
339
+
340
+ begin
341
+ Process.kill("KILL", pid)
342
+ rescue Errno::ESRCH
343
+ nil
344
+ end
345
+ bounded { Process.waitpid(pid) }
346
+ rescue Errno::ECHILD
347
+ nil
348
+ end
349
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: expect-pty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
@@ -111,6 +111,7 @@ files:
111
111
  - README.md
112
112
  - Rakefile
113
113
  - benchmark/matching.rb
114
+ - benchmark/redactor.rb
114
115
  - benchmark/relay.rb
115
116
  - benchmark/scaling.rb
116
117
  - benchmark/send_slow.rb
@@ -178,6 +179,7 @@ files:
178
179
  - test/support/interact_probe.rb
179
180
  - test/support/kibitz_probe.rb
180
181
  - test/support/script_probe.rb
182
+ - test/terminal_cleanup_test.rb
181
183
  - test/test_helper.rb
182
184
  - test/timeout_test.rb
183
185
  homepage: https://github.com/gatework/expect-ruby