expect-pty 0.5.0 → 0.5.1
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 +5 -0
- data/README.md +3 -3
- data/benchmark/redactor.rb +64 -0
- data/docs/INTERNAL_CONTRACTS.md +14 -1
- data/docs/PERFORMANCE.md +60 -1
- data/lib/expect/redactor.rb +14 -3
- data/lib/expect/terminal.rb +71 -5
- data/lib/expect/version.rb +1 -1
- data/script/ci +5 -1
- data/test/redactor_test.rb +77 -0
- data/test/terminal_cleanup_test.rb +349 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e860210612e392f800cf5839bb130625bafb17e47ffc86e49787446537c63a81
|
|
4
|
+
data.tar.gz: cbabed54f7dcd21c2bd5dc7ee2b63627a3ed424f94101df786812ba5e652ab00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb11a81d8cce676da6cc3fc0aaf66044eff142e7e4fb01d8537b4b3d3e77bb691974f28f7a5eebbda260b77def9efaf3b8c5b26956e41ff576cf4aae1b73c24c
|
|
7
|
+
data.tar.gz: f362101cc8db4d810c9a4ad8c48cf1b4000cd626d9063085421901092edd76d8347b56c2ad44681e4906b6ad77d5f5809fccc5a24bb111cd254fa7e0523266fc
|
data/CHANGELOG.md
CHANGED
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.
|
|
19
|
+
gem "expect-pty", "~> 0.5.1", 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.
|
|
33
|
-
gem install ./tmp/expect-pty-0.5.
|
|
32
|
+
gem build expect-pty.gemspec --output tmp/expect-pty-0.5.1.gem
|
|
33
|
+
gem install ./tmp/expect-pty-0.5.1.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
|
data/docs/INTERNAL_CONTRACTS.md
CHANGED
|
@@ -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
|
-
|
|
|
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/lib/expect/redactor.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/expect/terminal.rb
CHANGED
|
@@ -5,25 +5,30 @@ require "shellwords"
|
|
|
5
5
|
# 会话终端的模式和窗口尺寸接口;人工接管期间的临时恢复由 interaction.rb 负责。
|
|
6
6
|
class Expect
|
|
7
7
|
# 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/expect/version.rb
CHANGED
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)
|
data/test/redactor_test.rb
CHANGED
|
@@ -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.
|
|
4
|
+
version: 0.5.1
|
|
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
|