expect-pty 0.7.2 → 0.7.4
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 +17 -0
- data/README.md +14 -3
- data/docs/API.md +8 -0
- data/lib/expect/interaction.rb +15 -5
- data/lib/expect/logging.rb +8 -1
- data/lib/expect/matcher.rb +11 -12
- data/lib/expect/redactor.rb +64 -15
- data/lib/expect/relay.rb +23 -7
- data/lib/expect/relay_writer.rb +11 -0
- data/lib/expect/session.rb +55 -12
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2b48d4fb81d2c6f7035133387df5641fb420b5c108e7257c2348f4ad119edecf
|
|
4
|
+
data.tar.gz: c6dcfa0a5edc30db418739decdaa262a480e905a234ddf179c67c609edac56a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3a310e67d4303ebc2d9a2db7231722ab59b52a46bca2cf1aa32a1abd0de63a956867c17f4013df5029d21e4df509c8c6980fcc4193484f1e485b84e0933dff64
|
|
7
|
+
data.tar.gz: 19a972f1e1c19a64ba9b18f2148d0436c2f0a4211261f4a3396447b5863d8e6ac9e8f30322cb28e92ef45a5e8801dc519593c6a82044dacb42fe33b0ed17b1e0
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.4 - 2026-10-06
|
|
6
|
+
|
|
7
|
+
- **连续匹配**:修复回调中嵌套读取相同内容的新消息后漏触发匹配、最终超时的问题,保留零宽匹配和同值赋回的防空转保护。
|
|
8
|
+
- **大缓冲转接**:降低已积累大量输出时的转接耗时与 GC 压力,保持完整交付、结果快照和失败续发行为。
|
|
9
|
+
- **流式脱敏**:减少长短秘密混合、细粒度分片时的重复扫描和临时对象,保留规则更新、跨块重叠及已有掩码。
|
|
10
|
+
|
|
11
|
+
## 0.7.3 - 2026-10-06
|
|
12
|
+
|
|
13
|
+
- **转义连续性**:匹配消费、显式缓冲替换/清空及实际窗口裁剪后清除旧正则历史,避免跨过已消费输入误拼转义并吞字节;内部交接及非消费匹配保留连续历史。
|
|
14
|
+
- **慢速发送进度**:send_slow 超时累计前序字符及当前字符的已确认字节,排除对象转换、回复交付中的内层写入进度,原异常保留在 cause。
|
|
15
|
+
- **匹配性能**:单轮扫描只读借用内部缓冲,避免持续读取时反复复制增长窗口;公开 buffer 与回调快照保持独立。
|
|
16
|
+
- **共享读取**:同一 IO 的多个会话包装每轮仅读取一次,保留首来源归属,避免有限窗口在匹配前被重复读取挤掉。
|
|
17
|
+
- **诊断连续性**:DEBUG 关闭期间仍维护脱敏上下文,禁止后续补记关闭期间的原始字节;转接发送与直接写入共用过滤状态。
|
|
18
|
+
- **转接恢复**:发送诊断按实际短写推进,失败不回退游标;诊断嵌套超时正确报告外层进度,并以 cause 保留内层异常。
|
|
19
|
+
- **回调期限**:正数转接总期限覆盖扫描、转义及 EOF 继续回调之间的调度,保留未消费尾部与零超时行为。
|
|
20
|
+
- **接收重入**:接收日志与同步输出的回调递归读取同会话时抛出 ReentrancyError,避免交付乱序;已有缓冲匹配、独立会话及转义回调嵌套匹配仍可用。
|
|
21
|
+
|
|
5
22
|
## 0.7.2 - 2026-10-05
|
|
6
23
|
|
|
7
24
|
- **可信发布**:版本标签触发 GitHub Actions,通过 RubyGems OIDC 短期凭据发布,移除静态发布 Secret 依赖。
|
data/README.md
CHANGED
|
@@ -17,7 +17,7 @@ RubyGems/Bundler 解析。推荐入口 **`require "expect/pty"`**;本项目提
|
|
|
17
17
|
项目和仓库名为 `expect-ruby`,Gem 名为 `expect-pty`。在应用的 Gemfile 中添加以下内容,然后运行 `bundle install`:
|
|
18
18
|
|
|
19
19
|
```ruby
|
|
20
|
-
gem "expect-pty", "~> 0.7.
|
|
20
|
+
gem "expect-pty", "~> 0.7.4", require: "expect/pty"
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
也可直接执行 `gem install expect-pty`。需要跟随开发分支时,可从 GitHub 安装:
|
|
@@ -30,8 +30,8 @@ gem "expect-pty", git: "https://github.com/gatework/expect-ruby.git", branch: "m
|
|
|
30
30
|
|
|
31
31
|
```sh
|
|
32
32
|
mkdir -p tmp
|
|
33
|
-
gem build expect-pty.gemspec --output tmp/expect-pty-0.7.
|
|
34
|
-
gem install ./tmp/expect-pty-0.7.
|
|
33
|
+
gem build expect-pty.gemspec --output tmp/expect-pty-0.7.4.gem
|
|
34
|
+
gem install ./tmp/expect-pty-0.7.4.gem
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
```ruby
|
|
@@ -229,6 +229,8 @@ ready = Expect.readable_sessions(first, second, timeout: 5)
|
|
|
229
229
|
|
|
230
230
|
`send_slow` 在每次写入后只检查已经可读的回复,不附加固定等待;返回时不保证收齐最后一个字符引发的回复,完整对话请继续使用
|
|
231
231
|
`expect`。
|
|
232
|
+
失败时 `WriteTimeout#bytes_written` 累计这次 `send_slow` 已发送的所有字符及当前字符的部分字节;
|
|
233
|
+
转换对象或交付回复引发的内层写入不计入。新增包装异常通过 `cause` 保留原错误,可按原始字节偏移续发剩余内容。
|
|
232
234
|
|
|
233
235
|
大块写入遇到背压时同时读取输出,避免双向传输互相阻塞。背压等待超过 `write_timeout` 抛出 `Expect::WriteTimeout`,
|
|
234
236
|
`error.bytes_written` 给出本次 `write` 已被底层接受的字节数;这些字节不回滚,不要从头重发整个命令。写入、等待和背压读取中的
|
|
@@ -270,10 +272,15 @@ ActiveSupport logger,本库不依赖 ActiveSupport。生命周期和匹配使
|
|
|
270
272
|
|
|
271
273
|
所有会话默认没有 stdout 输出。需要显示时显式把 `$stdout` 放进 `outputs`。普通 `expect` 按读取顺序同步执行诊断、
|
|
272
274
|
transcript 和 outputs,目标须及时消费;匹配期限不会中断这些代码。慢目标需要独立调度时使用 `interconnect`。
|
|
275
|
+
交付接收字节期间,这些目标的回调可以匹配已有缓冲或驱动独立会话;若递归读取同一会话的新输入,会抛出 `ReentrancyError`,
|
|
276
|
+
避免后读字节抢先进入其他目标。转义回调中的嵌套匹配不受此限制。
|
|
273
277
|
记录不重复包含发送字节,但终端回显可能作为接收内容返回;敏感会话须控制回显、显示和目标文件权限。
|
|
274
278
|
|
|
275
279
|
`redact` 复制并追加非空字符串秘密,以 `[FILTERED]` 遮盖 transcript、`write_transcript` 及收发诊断,支持跨分片和重叠秘密。
|
|
276
280
|
匹配缓冲、Result 和 outputs 始终保留原始字节。它不推断编码、终端转义、哈希或其他变换后的秘密,也不能删除已交付的记录。
|
|
281
|
+
临时关闭 DEBUG 仍保留连续脱敏上下文;关闭期间的原始字节不会在重新启用或冲刷尾部时补记。
|
|
282
|
+
直接 `write` 的发送诊断描述尝试发送的内容;转接到 Session 时,发送诊断按底层实际接受的片段推进,并与直接写入共用过滤状态。
|
|
283
|
+
转接诊断失败后,恢复只补未写入的后缀;若诊断内嵌套写入超时,外层 `bytes_written` 保留转接进度,内层异常通过 `cause` 获取。
|
|
277
284
|
|
|
278
285
|
过滤器最多延迟最长秘密长度减一的尾部字节,EOF、目标替换和显式关闭时交付剩余内容;流边界的疑似秘密前缀也会遮盖。
|
|
279
286
|
发送与接收诊断独立保留过滤状态,因此实际写入分块可能变化。GC 兜底不调用用户代码;需显式关闭会话以交付过滤尾部,
|
|
@@ -313,6 +320,8 @@ end
|
|
|
313
320
|
`interconnect` 统一调度真实 IO 的非阻塞读写;慢目标不会阻止其他源前进,等待同时受总 `timeout` 和目标会话的 `write_timeout`
|
|
314
321
|
约束。总期限到达返回 `nil`,目标写期限先到则抛出 `WriteTimeout`。超时后的字面转义前缀只尝试非阻塞发送,不再等待下游。作为写入目标但未显式列出的
|
|
315
322
|
Session,背压期间读取的回复保留在其匹配缓冲;需要同时转发这些回复时,把它也传给 `interconnect`。
|
|
323
|
+
正数总期限会在扫描及继续回调返回后检查,包括延迟执行的转义回调和 EOF 回调;到期后保留尚未处理的输入。
|
|
324
|
+
`timeout: 0` 保留处理当前缓冲及首次非阻塞轮询的行为。回调明确停止时仍返回对应会话。
|
|
316
325
|
|
|
317
326
|
每个源独立保存待发送数据以及各目标的发送位置,`source.pending_output?` 表示仍有未交付内容。超时或异常后再次对同一源调用
|
|
318
327
|
`interconnect`,会接着发送未完成的后缀,已完成的目标不会重复接收;转义回调在前缀交付后执行。待发送数据与 `buffer`
|
|
@@ -326,6 +335,8 @@ IO 期限不会强行中断这些代码。普通 `expect` 的同步 transcript
|
|
|
326
335
|
字面转义可以跨读取完整过滤,尾部留给下次调用。正则转义使用历史记录,默认最多保留最近 65,536 字节;设置 `buffer_limit`
|
|
327
336
|
后改用该值。正则及其锚点作用于当前历史窗口,超过窗口的跨读取正则无法匹配,已实时转发的前缀也无法撤回;零长度正则匹配抛出
|
|
328
337
|
`ArgumentError`。日志包括被转接过滤的转义,显式启用 `redact` 时遮盖注册秘密;在 `expect` / `interconnect` 之间切换不会重复记录。
|
|
338
|
+
正则历史只连接连续输入:匹配消费了字节、显式 `buffer=` / `clear_buffer` 或窗口实际裁剪后会清除旧历史。
|
|
339
|
+
非消费匹配、未丢弃字节的空等待及内部缓冲交接仍保留历史,因此连续转接可以继续识别跨调用转义。
|
|
329
340
|
|
|
330
341
|
一次转接尚未返回时,递归 `interconnect` 的来源若与活跃来源重叠,会在移动缓冲和修改发送游标前抛出
|
|
331
342
|
`Expect::ReentrancyError`。完全独立的来源仍可嵌套转接;`on_sequence` 中的嵌套 `expect` 及返回后再次转接仍受支持。
|
data/docs/API.md
CHANGED
|
@@ -49,6 +49,7 @@ result => { number:, captures: }
|
|
|
49
49
|
`reset_timeout: false` 保持原相对期限。全部 EOF 后直接返回 EOF;已处理的来源不再参与超时。
|
|
50
50
|
|
|
51
51
|
`consume: false` 仅在本轮保留完整匹配缓冲;继续但不改变缓冲的模式暂停到输入变化,避免反复触发。
|
|
52
|
+
回调中的嵌套等待实际读入新字节时,即使缓冲内容与之前相同也会恢复匹配;仅同值赋回缓冲不算新输入。
|
|
52
53
|
`reset_timeout_on_read: true` 使每次读取重置本轮相对期限。`deadline` 是单调时钟绝对秒数,不被输入或回调延长。
|
|
53
54
|
IO 期限不打断用户代码或单次正则;不可信正则应使用 Ruby `Regexp` 自身的 timeout。
|
|
54
55
|
|
|
@@ -76,10 +77,13 @@ EncodingError。
|
|
|
76
77
|
|
|
77
78
|
操作参数 `raw`、`consume`、`reset_timeout_on_read`、`graceful` 不持久化为会话设置。
|
|
78
79
|
`buffer` 返回副本,`buffer=` 复制并应用上限,`clear_buffer` 移交并清空缓冲;`buffer_discarded_bytes` 累计窗口裁剪量,匹配消费不计入。
|
|
80
|
+
显式替换/清空、实际裁剪和消费输入会清除正则转义历史;非消费匹配、未裁剪的等待与内部缓冲交接保持连续历史。
|
|
79
81
|
|
|
80
82
|
`write(*objects)` 按 to_s 写字节并返回字节数,`<<` 返回 Session,`puts` 遵循 Ruby IO 换行与数组规则并返回 nil。
|
|
81
83
|
`send_slow(*objects, delay:)` 按字符延迟发送并收取回复。背压超时抛出 `Expect::WriteTimeout`,`bytes_written` 仅标识本次调用已确认进度,
|
|
82
84
|
嵌套写入异常放在 cause。成功短写不受 write_timeout 的总耗时限制。
|
|
85
|
+
`send_slow` 的进度累计本次调用中此前完整字符与失败字符实际接受的字节;转换对象和记录回复的内层写入进度不计入。
|
|
86
|
+
首字符 write 的异常无需修正进度时保留原对象;需要累计或排除内层进度时包装并保留 cause。
|
|
83
87
|
`to_io`、`writer`、`slave` 暴露句柄;`pid`、`command`、`tty_name`、`fileno`、`tty?`、`alive?`、`exit_code` 查询会话属性。
|
|
84
88
|
终端直接使用 `session.to_io.console_mode`、`echo=`、`winsize` 等 Ruby io/console 接口,没有 stty 或窗口尺寸包装方法。
|
|
85
89
|
|
|
@@ -95,6 +99,8 @@ Logger 自身控制级别和格式。INFO 记录生命周期/匹配,DEBUG 增
|
|
|
95
99
|
logger、transcript、outputs 一律借用,显式关闭冲刷过滤尾部,但不会关闭这些目标。
|
|
96
100
|
|
|
97
101
|
`redact(*secrets)` 追加非空字符串,仅过滤 transcript 和诊断,不更改匹配、Result 或 outputs。
|
|
102
|
+
DEBUG 关闭期间仍推进分片脱敏,但这些字节不会在重新启用后补记。转接到 Session 的发送诊断仅记录底层接受的片段,
|
|
103
|
+
与直接 write 共用过滤状态;诊断失败不回退转接进度,嵌套 WriteTimeout 的原始进度由 cause 保留。
|
|
98
104
|
`Expect::Redactor` 提供 `append`、`finish(partial: true)`、`patterns=` 和完整文本类方法 `redact`;流尾部默认隐藏疑似秘密前缀。
|
|
99
105
|
|
|
100
106
|
`outputs=` 校验并复制可写目标数组,读取返回副本;`$stdout` 与其他 writer 一样显式加入。
|
|
@@ -102,6 +108,8 @@ logger、transcript、outputs 一律借用,显式关闭冲刷过滤尾部,
|
|
|
102
108
|
`on_sequence(String/Regexp/:eof) { ... }` 注册无参数回调,未给块或返回 nil/false 停止,其余值继续。
|
|
103
109
|
`pending_output?` 表示尚未交付数据;再次转接同源会话继续发送,不重放成功前缀;更换 outputs 不改变已排队字节的目标。
|
|
104
110
|
同源递归转接抛出 ReentrancyError;转义回调可嵌套匹配,所有来源状态按对象身份隔离。
|
|
111
|
+
接收字节交付期间,logger/transcript/outputs 的回调不能递归读取同一会话的新输入,否则抛出 ReentrancyError;
|
|
112
|
+
匹配已有缓冲及读取独立会话仍可用。正数转接总期限会在扫描和继续回调返回后检查;零超时仍处理缓冲并首次轮询。
|
|
105
113
|
|
|
106
114
|
`interact(input: $stdin, escape: nil, output: nil, timeout: nil, raw: true)` 临时建立双向转接,退出后恢复
|
|
107
115
|
outputs、转义和本地终端模式。
|
data/lib/expect/interaction.rb
CHANGED
|
@@ -83,34 +83,44 @@ module Expect
|
|
|
83
83
|
# 超时或异常后仍有未交付的数据;重新 interconnect 同一源会话可继续发送。
|
|
84
84
|
def pending_output? = @pending_writes.any? { |output| !output.done? }
|
|
85
85
|
|
|
86
|
-
# 将待处理输入排入唯一的转接发送队列;返回 :ready、:queued 或 :
|
|
86
|
+
# 将待处理输入排入唯一的转接发送队列;返回 :ready、:queued、:stopped 或 :timeout。
|
|
87
87
|
# 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
|
|
88
88
|
# @api private
|
|
89
|
-
def self.queue_input(session, buffer, final: false)
|
|
89
|
+
def self.queue_input(session, buffer, final: false, deadline: nil)
|
|
90
90
|
loop do
|
|
91
91
|
sequences = session.sequences.except(:eof)
|
|
92
92
|
history = session.relay_history
|
|
93
93
|
found, regexp_scanned, utf8_regexp = scan_sequences(buffer, sequences, history, final:)
|
|
94
|
+
return :timeout if expired?(deadline)
|
|
95
|
+
|
|
94
96
|
if found
|
|
95
97
|
result = handle_escape(session, buffer, history, found)
|
|
96
98
|
return result unless result == :ready
|
|
99
|
+
return :timeout if expired?(deadline)
|
|
97
100
|
|
|
98
101
|
next
|
|
99
102
|
end
|
|
100
103
|
|
|
101
104
|
# 暂存可能构成字面转义的最长后缀,保证 STOP 分两次读取时 ST 不会提前发给子进程。
|
|
102
105
|
held = final ? 0 : hold_literal_prefix(buffer, sequences)
|
|
106
|
+
return :timeout if expired?(deadline)
|
|
107
|
+
|
|
103
108
|
count = [buffer.bytesize - held, READ_SIZE].min
|
|
104
109
|
session.queue_output(buffer.byteslice(0, count)) if count.positive?
|
|
105
110
|
if regexp_scanned
|
|
106
111
|
trim_history(history, buffer.byteslice(0, count), limit: session.buffer_limit || REGEXP_ESCAPE_HISTORY_LIMIT,
|
|
107
112
|
utf8: utf8_regexp)
|
|
108
113
|
end
|
|
109
|
-
|
|
114
|
+
# 排队前缀与尾部可共享原字节;替换窗口而非删除前缀,避免逐块复制整个余量。
|
|
115
|
+
# 保留缓冲对象身份,嵌套 Matcher 仍把尾部交回 Relay 借用的同一个对象。
|
|
116
|
+
buffer.replace(buffer.byteslice(count..)) if count.positive?
|
|
110
117
|
return count.positive? ? :queued : :ready
|
|
111
118
|
end
|
|
112
119
|
end
|
|
113
120
|
|
|
121
|
+
# 单轮扫描和同步回调恢复控制后才检查预算,不能强行中断用户代码。
|
|
122
|
+
def self.expired?(deadline) = !deadline.nil? && Expect.monotonic >= deadline
|
|
123
|
+
|
|
114
124
|
# 转义前缀必须交付完才运行回调;有前缀时保存回调,交给 Relay 交付后再执行。
|
|
115
125
|
def self.handle_escape(session, buffer, history, found)
|
|
116
126
|
position, length, callback = found
|
|
@@ -190,7 +200,7 @@ module Expect
|
|
|
190
200
|
history.slice!(0) while (byte = history.getbyte(0)) && (0x80..0xBF).cover?(byte)
|
|
191
201
|
end
|
|
192
202
|
|
|
193
|
-
private_class_method :handle_escape, :scan_sequences, :hold_literal_prefix, :trim_history
|
|
203
|
+
private_class_method :expired?, :handle_escape, :scan_sequences, :hold_literal_prefix, :trim_history
|
|
194
204
|
|
|
195
205
|
# 为当前数据块冻结目标选择并各建一个发送游标;此后修改 outputs 只影响后续数据。
|
|
196
206
|
# 调用方须先排空旧游标;显示转换也只做一次,短写重试时不能重复转换 CRLF。
|
|
@@ -226,7 +236,7 @@ module Expect
|
|
|
226
236
|
# @api private
|
|
227
237
|
attr_accessor :relay_callback
|
|
228
238
|
|
|
229
|
-
#
|
|
239
|
+
# 同规则保留跨次转接的连续历史;换规则或消费、改写输入后,不能拼接旧的前缀。
|
|
230
240
|
# @api private
|
|
231
241
|
def relay_history
|
|
232
242
|
sequences = @sequences.except(:eof)
|
data/lib/expect/logging.rb
CHANGED
|
@@ -102,13 +102,20 @@ module Expect
|
|
|
102
102
|
|
|
103
103
|
# 字节诊断在 inspect 转义之前过滤,发送与接收各自保留分片状态。
|
|
104
104
|
def trace_data(event, data)
|
|
105
|
-
return unless logger
|
|
105
|
+
return unless logger
|
|
106
|
+
|
|
107
|
+
enabled = logger.debug?
|
|
106
108
|
|
|
107
109
|
if @secrets
|
|
108
110
|
redactor = (@diagnostic_redactors[event] ||= Redactor.new(@secrets))
|
|
111
|
+
# 级别变化不切断协议流;关闭期间仍识别秘密,但这些字节以后也不可输出。
|
|
112
|
+
return redactor.suppress(data) unless enabled
|
|
113
|
+
|
|
109
114
|
data = redactor.append(data)
|
|
110
115
|
return if data.empty?
|
|
111
116
|
end
|
|
117
|
+
return unless enabled
|
|
118
|
+
|
|
112
119
|
trace("#{event} #{data.inspect}", severity: Logger::DEBUG, event:)
|
|
113
120
|
end
|
|
114
121
|
|
data/lib/expect/matcher.rb
CHANGED
|
@@ -52,7 +52,7 @@ module Expect
|
|
|
52
52
|
ensure
|
|
53
53
|
# 嵌套 expect 即使异常退出,也要把未消费尾部还给原 Relay 的同一个缓冲对象。
|
|
54
54
|
@relay_buffers.each do |session, buffer|
|
|
55
|
-
buffer.replace(session.
|
|
55
|
+
buffer.replace(session.take_buffer)
|
|
56
56
|
session.interaction_buffer = buffer
|
|
57
57
|
end
|
|
58
58
|
end
|
|
@@ -60,20 +60,18 @@ module Expect
|
|
|
60
60
|
private
|
|
61
61
|
|
|
62
62
|
# 按声明组、会话、模式的顺序寻找首个匹配,不按文本中的出现位置重新排序。
|
|
63
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity --
|
|
63
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 声明优先级与单轮缓冲须在同一次扫描保持一致。
|
|
64
64
|
def find_match
|
|
65
|
-
#
|
|
65
|
+
# 单组且来源不重复时无需缓存;借用仅活在本次无回调扫描,不跨越 IO 或用户回调。
|
|
66
66
|
groups = @groups
|
|
67
|
-
if groups.size > 1 || (groups.first && groups.first.first.size > @sessions.size)
|
|
68
|
-
snapshots = {}.compare_by_identity
|
|
69
|
-
end
|
|
67
|
+
buffers = {}.compare_by_identity if groups.size > 1 || (groups.first && groups.first.first.size > @sessions.size)
|
|
70
68
|
groups.each do |sessions, patterns|
|
|
71
69
|
sessions.each do |session|
|
|
72
70
|
next if @handled_eof.key?(session)
|
|
73
71
|
|
|
74
|
-
buffer =
|
|
72
|
+
buffer = buffers ? (buffers[session] ||= session.scan_buffer) : session.scan_buffer
|
|
75
73
|
stalled = @stalled_matches[session]
|
|
76
|
-
if stalled && stalled[:buffer] != buffer
|
|
74
|
+
if stalled && (stalled[:received_bytes] != session.received_bytes || stalled[:buffer] != buffer)
|
|
77
75
|
@stalled_matches.delete(session)
|
|
78
76
|
stalled = nil
|
|
79
77
|
end
|
|
@@ -126,13 +124,14 @@ module Expect
|
|
|
126
124
|
# 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
|
|
127
125
|
def handle_match(session, pattern, position)
|
|
128
126
|
previous_buffer = session.buffer
|
|
127
|
+
received_bytes = session.received_bytes
|
|
129
128
|
result = session.record_match(pattern, position, consume: @consume)
|
|
130
129
|
action = pattern.call(session)
|
|
131
130
|
return result unless continuing?(action)
|
|
132
131
|
|
|
133
|
-
#
|
|
134
|
-
if session.buffer == previous_buffer
|
|
135
|
-
stalled = (@stalled_matches[session] ||= { buffer: previous_buffer, patterns: [] })
|
|
132
|
+
# 同值赋回仍须防止空转;嵌套读取到的新字节即使内容相同,也能恢复匹配。
|
|
133
|
+
if session.received_bytes == received_bytes && session.buffer == previous_buffer
|
|
134
|
+
stalled = (@stalled_matches[session] ||= { buffer: previous_buffer, received_bytes:, patterns: [] })
|
|
136
135
|
stalled[:patterns] << pattern
|
|
137
136
|
end
|
|
138
137
|
@deadline = next_deadline if CONTINUE.equal?(action)
|
|
@@ -172,7 +171,7 @@ module Expect
|
|
|
172
171
|
begin
|
|
173
172
|
# 先标记已轮询;即使 select 连续被信号中断,下一轮也会检查原期限。
|
|
174
173
|
@polled = true
|
|
175
|
-
ready = IO.select(readers.map(&:to_io), nil, nil, remaining)
|
|
174
|
+
ready = IO.select(readers.map(&:to_io).uniq(&:object_id), nil, nil, remaining)
|
|
176
175
|
rescue Errno::EINTR
|
|
177
176
|
return :retry
|
|
178
177
|
rescue IOError, SystemCallError => error
|
data/lib/expect/redactor.rb
CHANGED
|
@@ -32,16 +32,20 @@ module Expect
|
|
|
32
32
|
|
|
33
33
|
@patterns = patterns.map { |pattern| pattern.b.freeze }.uniq.freeze
|
|
34
34
|
@lookbehind = [(@patterns.map(&:bytesize).max || 0) - 1, 0].max
|
|
35
|
+
@scanned_bytes = 0
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
# 追加一个原始字节块,返回已经可以确定的安全前缀;新秘密可能跨越此前保留的尾部。
|
|
38
39
|
def append(data)
|
|
39
|
-
|
|
40
|
+
append_chunk(data, visible: true)
|
|
41
|
+
end
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
# 诊断级别关闭时仍推进连续匹配,禁止本块和它延迟到以后释放的字节进入日志。
|
|
44
|
+
# 输出资格只用于内部诊断;独立过滤器的 append / finish 契约不变。
|
|
45
|
+
# @api private
|
|
46
|
+
def suppress(data)
|
|
47
|
+
append_chunk(data, visible: false)
|
|
48
|
+
nil
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
# EOF、日志目标替换及关闭是流边界;尾部疑似秘密前缀也遮盖,不能因 flush 泄露片段。
|
|
@@ -60,6 +64,18 @@ module Expect
|
|
|
60
64
|
|
|
61
65
|
private
|
|
62
66
|
|
|
67
|
+
# 输出资格与秘密掩码分开保存;仅遇到被抑制的分片时才分配额外字节图。
|
|
68
|
+
def append_chunk(data, visible:)
|
|
69
|
+
raise ArgumentError, "data must be a String" unless data.is_a?(String)
|
|
70
|
+
|
|
71
|
+
@suppressed ||= "\0" * @pending.bytesize unless visible
|
|
72
|
+
@suppressed << ((visible ? "\0" : "\1") * data.bytesize) if @suppressed
|
|
73
|
+
@pending << data.b
|
|
74
|
+
@hidden << ("\0" * data.bytesize)
|
|
75
|
+
mark_secrets
|
|
76
|
+
release([@pending.bytesize - @lookbehind, 0].max)
|
|
77
|
+
end
|
|
78
|
+
|
|
63
79
|
# 流关闭时无法再等待后续字节,默认隐藏与秘密开头一致的未完成尾部。
|
|
64
80
|
def mark_partial_secrets
|
|
65
81
|
tail = @pending.byteslice(-1, 1)
|
|
@@ -79,14 +95,18 @@ module Expect
|
|
|
79
95
|
end
|
|
80
96
|
end
|
|
81
97
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
98
|
+
# 每个模式只扫描可能跨越新增字节的起点;规则更新后从头重扫保留窗口。
|
|
99
|
+
# 重叠命中取并集,同一模式的相交/相邻区间只写一次,不清除旧掩码。
|
|
84
100
|
def mark_secrets
|
|
101
|
+
size = @pending.bytesize
|
|
102
|
+
return if @scanned_bytes == size
|
|
103
|
+
|
|
85
104
|
@patterns.each do |pattern|
|
|
86
|
-
|
|
105
|
+
length = pattern.bytesize
|
|
106
|
+
offset = @scanned_bytes >= length ? @scanned_bytes - length + 1 : 0
|
|
107
|
+
starting = @pending.index(pattern, offset)
|
|
87
108
|
next unless starting
|
|
88
109
|
|
|
89
|
-
length = pattern.bytesize
|
|
90
110
|
ending = starting + length
|
|
91
111
|
offset = starting
|
|
92
112
|
while (offset = @pending.index(pattern, offset + 1))
|
|
@@ -98,27 +118,56 @@ module Expect
|
|
|
98
118
|
end
|
|
99
119
|
@hidden[starting, ending - starting] = "\1" * (ending - starting)
|
|
100
120
|
end
|
|
121
|
+
@scanned_bytes = size
|
|
101
122
|
end
|
|
102
123
|
|
|
103
124
|
# 按连续区间输出,避免逐字节构造字符串;只保存尚可能与下一块组成秘密的后缀。
|
|
104
125
|
# masking 跨 append 保留,使被分成多个块的同一隐藏区间只输出一次替换标记。
|
|
105
126
|
def release(length)
|
|
106
127
|
output = "".b
|
|
128
|
+
return output if length.zero?
|
|
129
|
+
|
|
107
130
|
cursor = 0
|
|
108
131
|
while cursor < length
|
|
109
132
|
hidden = @hidden.getbyte(cursor) == 1
|
|
110
133
|
ending = [@hidden.index(hidden ? "\0" : "\1", cursor) || length, length].min
|
|
111
|
-
if
|
|
112
|
-
output
|
|
134
|
+
if @suppressed
|
|
135
|
+
release_suppressed(output, cursor, ending, hidden:)
|
|
113
136
|
else
|
|
114
|
-
|
|
137
|
+
if hidden
|
|
138
|
+
output << @replacement unless @masking
|
|
139
|
+
else
|
|
140
|
+
output << @pending.byteslice(cursor, ending - cursor)
|
|
141
|
+
end
|
|
142
|
+
@masking = hidden
|
|
115
143
|
end
|
|
116
|
-
@masking = hidden
|
|
117
144
|
cursor = ending
|
|
118
145
|
end
|
|
119
|
-
|
|
120
|
-
@
|
|
146
|
+
remaining = @pending.bytesize - length
|
|
147
|
+
@pending = @pending.byteslice(length, remaining)
|
|
148
|
+
@hidden = @hidden.byteslice(length, remaining)
|
|
149
|
+
@suppressed = @suppressed.byteslice(length, remaining) if @suppressed
|
|
150
|
+
@scanned_bytes = remaining
|
|
121
151
|
output
|
|
122
152
|
end
|
|
153
|
+
|
|
154
|
+
# 仅级别切换后的流需要按输出资格再分段,普通过滤不承担额外区间调用。
|
|
155
|
+
def release_suppressed(output, starting, ending, hidden:)
|
|
156
|
+
while starting < ending
|
|
157
|
+
suppressed = @suppressed.getbyte(starting) == 1
|
|
158
|
+
boundary = [@suppressed.index(suppressed ? "\0" : "\1", starting) || ending, ending].min
|
|
159
|
+
if suppressed
|
|
160
|
+
@masking = false unless hidden
|
|
161
|
+
else
|
|
162
|
+
if hidden
|
|
163
|
+
output << @replacement unless @masking
|
|
164
|
+
else
|
|
165
|
+
output << @pending.byteslice(starting, boundary - starting)
|
|
166
|
+
end
|
|
167
|
+
@masking = hidden
|
|
168
|
+
end
|
|
169
|
+
starting = boundary
|
|
170
|
+
end
|
|
171
|
+
end
|
|
123
172
|
end
|
|
124
173
|
end
|
data/lib/expect/relay.rb
CHANGED
|
@@ -15,6 +15,8 @@ module Expect
|
|
|
15
15
|
@active = @sessions.dup
|
|
16
16
|
period = Expect.duration(timeout)
|
|
17
17
|
@deadline = period && (Expect.monotonic + period)
|
|
18
|
+
# 零超时保留首轮缓冲及一次轮询;正预算在扫描和每次回调之间重新检查。
|
|
19
|
+
@dispatch_deadline = @deadline unless period&.zero?
|
|
18
20
|
@token = Object.new
|
|
19
21
|
@buffers = {}.compare_by_identity
|
|
20
22
|
@previous = {}.compare_by_identity
|
|
@@ -28,6 +30,7 @@ module Expect
|
|
|
28
30
|
polled = false
|
|
29
31
|
loop do
|
|
30
32
|
stopped, idle, queued = dispatch_sources
|
|
33
|
+
return finish_timeout(idle) if :timeout.equal?(stopped)
|
|
31
34
|
return stopped if stopped
|
|
32
35
|
|
|
33
36
|
return nil if @active.empty?
|
|
@@ -50,15 +53,13 @@ module Expect
|
|
|
50
53
|
idle = []
|
|
51
54
|
queued = false
|
|
52
55
|
@active.dup.each do |session|
|
|
53
|
-
|
|
56
|
+
return [:timeout, idle, queued] if dispatch_expired?
|
|
54
57
|
|
|
55
|
-
if
|
|
56
|
-
session.relay_callback = nil
|
|
57
|
-
return [session, idle, queued] unless callback.call
|
|
58
|
-
end
|
|
58
|
+
next if session.pending_output?
|
|
59
59
|
|
|
60
|
-
result =
|
|
60
|
+
result = dispatch_input(session)
|
|
61
61
|
return [session, idle, queued] if result == :stopped
|
|
62
|
+
return [:timeout, idle, queued] if result == :timeout
|
|
62
63
|
|
|
63
64
|
if result == :queued
|
|
64
65
|
queued = true
|
|
@@ -72,10 +73,22 @@ module Expect
|
|
|
72
73
|
return [session, idle, queued] unless callback&.call
|
|
73
74
|
|
|
74
75
|
@active.delete_if { |active| active.equal?(session) }
|
|
76
|
+
return [:timeout, idle, queued] if dispatch_expired?
|
|
75
77
|
end
|
|
76
78
|
[nil, idle, queued]
|
|
77
79
|
end
|
|
78
80
|
|
|
81
|
+
# 前缀交付完才调用延迟动作;动作耗尽预算后,尚未扫描的输入留给下次转接。
|
|
82
|
+
def dispatch_input(session)
|
|
83
|
+
if (callback = session.relay_callback)
|
|
84
|
+
session.relay_callback = nil
|
|
85
|
+
return :stopped unless callback.call
|
|
86
|
+
return :timeout if dispatch_expired?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
Interaction.queue_input(session, @buffers.fetch(session), final: session.eof?, deadline: @dispatch_deadline)
|
|
90
|
+
end
|
|
91
|
+
|
|
79
92
|
# 共同等待来源与目标,每轮读每个就绪来源一次;EINTR 返回原期限循环。
|
|
80
93
|
def select_and_read(progress)
|
|
81
94
|
pending = pending_writes
|
|
@@ -131,7 +144,7 @@ module Expect
|
|
|
131
144
|
end
|
|
132
145
|
@sessions.each do |session|
|
|
133
146
|
@previous[session] = session.interaction_buffer
|
|
134
|
-
@buffers[session] = session.
|
|
147
|
+
@buffers[session] = session.take_buffer
|
|
135
148
|
session.interaction_buffer = @buffers.fetch(session)
|
|
136
149
|
end
|
|
137
150
|
end
|
|
@@ -165,6 +178,9 @@ module Expect
|
|
|
165
178
|
# 转接总期限不因持续输入或某个目标的写入进展而重置。
|
|
166
179
|
def expired? = !@deadline.nil? && Expect.monotonic >= @deadline
|
|
167
180
|
|
|
181
|
+
# 回调结束后只暂停后续工作,不打断当前用户代码,也不消费尚未扫描的转义尾部。
|
|
182
|
+
def dispatch_expired? = !@dispatch_deadline.nil? && Expect.monotonic >= @dispatch_deadline
|
|
183
|
+
|
|
168
184
|
# 背压源暂停吸收新输入,限制排队增长;作为写入目标的会话仍需读取以解除双向等待。
|
|
169
185
|
def read_sources(pending)
|
|
170
186
|
# 输出目标可能也在等待我们读取;这些来源即使有待发送数据也必须继续排空。
|
data/lib/expect/relay_writer.rb
CHANGED
|
@@ -52,6 +52,8 @@ module Expect
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
@offset += count
|
|
55
|
+
# 先登记底层已接受的进度,再通知诊断;logger 失败后恢复只补未发送后缀。
|
|
56
|
+
trace_delivery(bytes.byteslice(0, count)) if target.is_a?(Session)
|
|
55
57
|
end
|
|
56
58
|
if @offset == @data.bytesize
|
|
57
59
|
# write_nonblock 已直接写到底层;普通 write 对象仍遵守自身 flush 协议。
|
|
@@ -73,6 +75,15 @@ module Expect
|
|
|
73
75
|
|
|
74
76
|
raise WriteTimeout.new("relay target write timed out", bytes_written: @offset)
|
|
75
77
|
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# 诊断可嵌套写入;外层只报告本游标的进度,原超时及其内层进度保留在 cause。
|
|
82
|
+
def trace_delivery(data)
|
|
83
|
+
target.__send__(:trace_data, :sending, data)
|
|
84
|
+
rescue WriteTimeout
|
|
85
|
+
raise WriteTimeout.new("relay interrupted by a diagnostic timeout", bytes_written: @offset)
|
|
86
|
+
end
|
|
76
87
|
end
|
|
77
88
|
|
|
78
89
|
private_constant :RelayWriter
|
data/lib/expect/session.rb
CHANGED
|
@@ -140,17 +140,34 @@ module Expect
|
|
|
140
140
|
# 返回缓冲副本,防止调用方原地修改绕过裁剪规则。
|
|
141
141
|
def buffer = @buffer.dup
|
|
142
142
|
|
|
143
|
+
# Matcher 仅在无回调的当前扫描借用原字节,避免副本使下一次追加复制整个窗口。
|
|
144
|
+
# 不得修改或跨读取、回调保留;公开 buffer 和继续回调的快照仍独立复制。
|
|
145
|
+
# @api private
|
|
146
|
+
def scan_buffer = @buffer
|
|
147
|
+
|
|
148
|
+
# 成功读取的累计字节数;消费、替换和移交缓冲不算新的接收进展。
|
|
149
|
+
# @api private
|
|
150
|
+
attr_reader :received_bytes
|
|
151
|
+
|
|
143
152
|
# 复制并替换原始字节缓冲,应用当前上限;调用方后续修改原字符串不会影响会话。
|
|
144
153
|
def buffer=(value)
|
|
145
154
|
raise ArgumentError, "buffer must be a String" unless value.is_a?(String)
|
|
146
155
|
|
|
147
156
|
@buffer = value.b
|
|
148
157
|
@buffer_generation += 1
|
|
158
|
+
@relay_history.clear
|
|
149
159
|
trim_buffer
|
|
150
160
|
end
|
|
151
161
|
|
|
152
|
-
#
|
|
162
|
+
# 显式清空并返回旧字节;它建立新的输入边界,不能再拼接此前的正则转义历史。
|
|
153
163
|
def clear_buffer
|
|
164
|
+
@relay_history.clear
|
|
165
|
+
take_buffer
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Matcher / Relay 的所有权交接不消费输入,也不能截断跨次转接的正则历史。
|
|
169
|
+
# @api private
|
|
170
|
+
def take_buffer
|
|
154
171
|
previous = @buffer
|
|
155
172
|
@buffer = "".b
|
|
156
173
|
@buffer_generation += 1
|
|
@@ -199,18 +216,29 @@ module Expect
|
|
|
199
216
|
nil
|
|
200
217
|
end
|
|
201
218
|
|
|
202
|
-
#
|
|
219
|
+
# 逐字符延迟发送,同时收集回复;返回总字节数,背压失败也报告本次累计确认进度。
|
|
203
220
|
def send_slow(*objects, delay:)
|
|
204
221
|
pause = Expect.duration(delay)
|
|
205
222
|
raise ArgumentError, "delay is required" unless pause
|
|
206
223
|
|
|
207
224
|
count = 0
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
225
|
+
writing = false
|
|
226
|
+
begin
|
|
227
|
+
objects.each do |object|
|
|
228
|
+
object.to_s.each_char do |character|
|
|
229
|
+
sleep(pause) if pause.positive?
|
|
230
|
+
writing = true
|
|
231
|
+
count += write(character)
|
|
232
|
+
writing = false
|
|
233
|
+
read_available if !eof? && to_io.wait_readable(0)
|
|
234
|
+
end
|
|
213
235
|
end
|
|
236
|
+
rescue WriteTimeout => error
|
|
237
|
+
# 只有当前字符的 write 进度属于本次发送;转换和回复交付中的嵌套写入不计入。
|
|
238
|
+
raise if writing && count.zero?
|
|
239
|
+
|
|
240
|
+
progress = count + (writing ? error.bytes_written : 0)
|
|
241
|
+
raise WriteTimeout.new("slow send interrupted", bytes_written: progress)
|
|
214
242
|
end
|
|
215
243
|
count
|
|
216
244
|
end
|
|
@@ -281,6 +309,8 @@ module Expect
|
|
|
281
309
|
if consume
|
|
282
310
|
@buffer = @last_result.after.dup
|
|
283
311
|
@buffer_generation += 1
|
|
312
|
+
# before 与 match 都已消费,后续转接不能跨过这段输入拼接旧的转义前缀。
|
|
313
|
+
@relay_history.clear if (offset + length).positive?
|
|
284
314
|
end
|
|
285
315
|
# 诊断回调可能嵌套等待;恢复本次结果后再交给正式模式回调,不能返回内层等待的结果。
|
|
286
316
|
result = @last_result
|
|
@@ -306,6 +336,8 @@ module Expect
|
|
|
306
336
|
# 先将读取字节交给匹配或转接缓冲,再记录日志;日志失败也能恢复输入。
|
|
307
337
|
# @api private
|
|
308
338
|
def read_available(propagate: true, buffer: @buffer, trim: true)
|
|
339
|
+
raise ReentrancyError, "cannot read while delivering received data" if @receiving
|
|
340
|
+
|
|
309
341
|
return nil if eof?
|
|
310
342
|
|
|
311
343
|
# 写入背压也会读取;转接期间统一交给转义处理器,不能直接转发或另存匹配缓冲。
|
|
@@ -331,11 +363,19 @@ module Expect
|
|
|
331
363
|
|
|
332
364
|
data = data.b
|
|
333
365
|
buffer << data
|
|
366
|
+
@received_bytes += data.bytesize
|
|
334
367
|
trim_buffer if trim
|
|
335
|
-
|
|
336
|
-
#
|
|
337
|
-
|
|
338
|
-
|
|
368
|
+
# 本块完成全部交付前不能递归读取,否则后块会抢先进入其他日志及输出目标。
|
|
369
|
+
# 回调仍可匹配已有缓冲或驱动其他会话;拒绝嵌套读取不能释放外层的保护。
|
|
370
|
+
@receiving = true
|
|
371
|
+
begin
|
|
372
|
+
trace_data(:received, data)
|
|
373
|
+
# 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
|
|
374
|
+
write_transcript(data)
|
|
375
|
+
propagate(data) if propagate
|
|
376
|
+
ensure
|
|
377
|
+
@receiving = false
|
|
378
|
+
end
|
|
339
379
|
data
|
|
340
380
|
end
|
|
341
381
|
|
|
@@ -353,6 +393,7 @@ module Expect
|
|
|
353
393
|
@tty_name = slave.path if slave
|
|
354
394
|
@buffer = "".b
|
|
355
395
|
@buffer_generation = 0
|
|
396
|
+
@received_bytes = 0
|
|
356
397
|
@buffer_discarded_bytes = 0
|
|
357
398
|
@outputs = []
|
|
358
399
|
@sequences = {}
|
|
@@ -366,7 +407,7 @@ module Expect
|
|
|
366
407
|
@diagnostic_redactors = {}
|
|
367
408
|
@logger = @transcript = nil
|
|
368
409
|
@last_result = @command = nil
|
|
369
|
-
@closed = @eof = false
|
|
410
|
+
@closed = @eof = @receiving = false
|
|
370
411
|
self.timeout = timeout
|
|
371
412
|
self.write_timeout = write_timeout
|
|
372
413
|
self.buffer_limit = buffer_limit
|
|
@@ -540,6 +581,8 @@ module Expect
|
|
|
540
581
|
@buffer_discarded_bytes += @buffer.bytesize - limit
|
|
541
582
|
@buffer = @buffer.byteslice(-limit, limit)
|
|
542
583
|
@buffer_generation += 1
|
|
584
|
+
# 丢弃的前缀使当前窗口与已转发历史之间不再连续。
|
|
585
|
+
@relay_history.clear
|
|
543
586
|
end
|
|
544
587
|
|
|
545
588
|
# 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
|
data/lib/expect/version.rb
CHANGED
data/lib/expect.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: expect-pty
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- expect-pty contributors
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: io-console
|