expect-pty 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +14 -3
- data/docs/API.md +7 -0
- data/lib/expect/interaction.rb +12 -4
- data/lib/expect/logging.rb +8 -1
- data/lib/expect/matcher.rb +6 -8
- data/lib/expect/redactor.rb +48 -9
- data/lib/expect/relay.rb +23 -7
- data/lib/expect/relay_writer.rb +11 -0
- data/lib/expect/session.rb +49 -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: c57608249970451325b18ded1deb6ecea96a110e3640e198aa947a8dd9a15b98
|
|
4
|
+
data.tar.gz: b14a1b19bff3673fa64d89ee6fc052cdb11d8fd7f64be00a74e4d47688b72c28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d242048c45c5143d7a651bb64ef82c352aee96474c764ce3c168038274dfac2b6aec602548f5f9de497a23c8f90c4e9c46efab58c3e6e13add9cd4351b61f99d
|
|
7
|
+
data.tar.gz: e2a86987567a5a0e254d76adf89d744af34f521c60172a4e8a32c373f6a31f020f777a1e983bcb078bd92048533371b0a94bc98564568aa092b3bde4425fadea
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.3 - 2026-10-06
|
|
6
|
+
|
|
7
|
+
- **转义连续性**:匹配消费、显式缓冲替换/清空及实际窗口裁剪后清除旧正则历史,避免跨过已消费输入误拼转义并吞字节;内部交接及非消费匹配保留连续历史。
|
|
8
|
+
- **慢速发送进度**:send_slow 超时累计前序字符及当前字符的已确认字节,排除对象转换、回复交付中的内层写入进度,原异常保留在 cause。
|
|
9
|
+
- **匹配性能**:单轮扫描只读借用内部缓冲,避免持续读取时反复复制增长窗口;公开 buffer 与回调快照保持独立。
|
|
10
|
+
- **共享读取**:同一 IO 的多个会话包装每轮仅读取一次,保留首来源归属,避免有限窗口在匹配前被重复读取挤掉。
|
|
11
|
+
- **诊断连续性**:DEBUG 关闭期间仍维护脱敏上下文,禁止后续补记关闭期间的原始字节;转接发送与直接写入共用过滤状态。
|
|
12
|
+
- **转接恢复**:发送诊断按实际短写推进,失败不回退游标;诊断嵌套超时正确报告外层进度,并以 cause 保留内层异常。
|
|
13
|
+
- **回调期限**:正数转接总期限覆盖扫描、转义及 EOF 继续回调之间的调度,保留未消费尾部与零超时行为。
|
|
14
|
+
- **接收重入**:接收日志与同步输出的回调递归读取同会话时抛出 ReentrancyError,避免交付乱序;已有缓冲匹配、独立会话及转义回调嵌套匹配仍可用。
|
|
15
|
+
|
|
5
16
|
## 0.7.2 - 2026-10-05
|
|
6
17
|
|
|
7
18
|
- **可信发布**:版本标签触发 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.3", 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.3.gem
|
|
34
|
+
gem install ./tmp/expect-pty-0.7.3.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
|
@@ -76,10 +76,13 @@ EncodingError。
|
|
|
76
76
|
|
|
77
77
|
操作参数 `raw`、`consume`、`reset_timeout_on_read`、`graceful` 不持久化为会话设置。
|
|
78
78
|
`buffer` 返回副本,`buffer=` 复制并应用上限,`clear_buffer` 移交并清空缓冲;`buffer_discarded_bytes` 累计窗口裁剪量,匹配消费不计入。
|
|
79
|
+
显式替换/清空、实际裁剪和消费输入会清除正则转义历史;非消费匹配、未裁剪的等待与内部缓冲交接保持连续历史。
|
|
79
80
|
|
|
80
81
|
`write(*objects)` 按 to_s 写字节并返回字节数,`<<` 返回 Session,`puts` 遵循 Ruby IO 换行与数组规则并返回 nil。
|
|
81
82
|
`send_slow(*objects, delay:)` 按字符延迟发送并收取回复。背压超时抛出 `Expect::WriteTimeout`,`bytes_written` 仅标识本次调用已确认进度,
|
|
82
83
|
嵌套写入异常放在 cause。成功短写不受 write_timeout 的总耗时限制。
|
|
84
|
+
`send_slow` 的进度累计本次调用中此前完整字符与失败字符实际接受的字节;转换对象和记录回复的内层写入进度不计入。
|
|
85
|
+
首字符 write 的异常无需修正进度时保留原对象;需要累计或排除内层进度时包装并保留 cause。
|
|
83
86
|
`to_io`、`writer`、`slave` 暴露句柄;`pid`、`command`、`tty_name`、`fileno`、`tty?`、`alive?`、`exit_code` 查询会话属性。
|
|
84
87
|
终端直接使用 `session.to_io.console_mode`、`echo=`、`winsize` 等 Ruby io/console 接口,没有 stty 或窗口尺寸包装方法。
|
|
85
88
|
|
|
@@ -95,6 +98,8 @@ Logger 自身控制级别和格式。INFO 记录生命周期/匹配,DEBUG 增
|
|
|
95
98
|
logger、transcript、outputs 一律借用,显式关闭冲刷过滤尾部,但不会关闭这些目标。
|
|
96
99
|
|
|
97
100
|
`redact(*secrets)` 追加非空字符串,仅过滤 transcript 和诊断,不更改匹配、Result 或 outputs。
|
|
101
|
+
DEBUG 关闭期间仍推进分片脱敏,但这些字节不会在重新启用后补记。转接到 Session 的发送诊断仅记录底层接受的片段,
|
|
102
|
+
与直接 write 共用过滤状态;诊断失败不回退转接进度,嵌套 WriteTimeout 的原始进度由 cause 保留。
|
|
98
103
|
`Expect::Redactor` 提供 `append`、`finish(partial: true)`、`patterns=` 和完整文本类方法 `redact`;流尾部默认隐藏疑似秘密前缀。
|
|
99
104
|
|
|
100
105
|
`outputs=` 校验并复制可写目标数组,读取返回副本;`$stdout` 与其他 writer 一样显式加入。
|
|
@@ -102,6 +107,8 @@ logger、transcript、outputs 一律借用,显式关闭冲刷过滤尾部,
|
|
|
102
107
|
`on_sequence(String/Regexp/:eof) { ... }` 注册无参数回调,未给块或返回 nil/false 停止,其余值继续。
|
|
103
108
|
`pending_output?` 表示尚未交付数据;再次转接同源会话继续发送,不重放成功前缀;更换 outputs 不改变已排队字节的目标。
|
|
104
109
|
同源递归转接抛出 ReentrancyError;转义回调可嵌套匹配,所有来源状态按对象身份隔离。
|
|
110
|
+
接收字节交付期间,logger/transcript/outputs 的回调不能递归读取同一会话的新输入,否则抛出 ReentrancyError;
|
|
111
|
+
匹配已有缓冲及读取独立会话仍可用。正数转接总期限会在扫描和继续回调返回后检查;零超时仍处理缓冲并首次轮询。
|
|
105
112
|
|
|
106
113
|
`interact(input: $stdin, escape: nil, output: nil, timeout: nil, raw: true)` 临时建立双向转接,退出后恢复
|
|
107
114
|
outputs、转义和本地终端模式。
|
data/lib/expect/interaction.rb
CHANGED
|
@@ -83,23 +83,28 @@ 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
|
|
@@ -111,6 +116,9 @@ module Expect
|
|
|
111
116
|
end
|
|
112
117
|
end
|
|
113
118
|
|
|
119
|
+
# 单轮扫描和同步回调恢复控制后才检查预算,不能强行中断用户代码。
|
|
120
|
+
def self.expired?(deadline) = !deadline.nil? && Expect.monotonic >= deadline
|
|
121
|
+
|
|
114
122
|
# 转义前缀必须交付完才运行回调;有前缀时保存回调,交给 Relay 交付后再执行。
|
|
115
123
|
def self.handle_escape(session, buffer, history, found)
|
|
116
124
|
position, length, callback = found
|
|
@@ -190,7 +198,7 @@ module Expect
|
|
|
190
198
|
history.slice!(0) while (byte = history.getbyte(0)) && (0x80..0xBF).cover?(byte)
|
|
191
199
|
end
|
|
192
200
|
|
|
193
|
-
private_class_method :handle_escape, :scan_sequences, :hold_literal_prefix, :trim_history
|
|
201
|
+
private_class_method :expired?, :handle_escape, :scan_sequences, :hold_literal_prefix, :trim_history
|
|
194
202
|
|
|
195
203
|
# 为当前数据块冻结目标选择并各建一个发送游标;此后修改 outputs 只影响后续数据。
|
|
196
204
|
# 调用方须先排空旧游标;显示转换也只做一次,短写重试时不能重复转换 CRLF。
|
|
@@ -226,7 +234,7 @@ module Expect
|
|
|
226
234
|
# @api private
|
|
227
235
|
attr_accessor :relay_callback
|
|
228
236
|
|
|
229
|
-
#
|
|
237
|
+
# 同规则保留跨次转接的连续历史;换规则或消费、改写输入后,不能拼接旧的前缀。
|
|
230
238
|
# @api private
|
|
231
239
|
def relay_history
|
|
232
240
|
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,18 +60,16 @@ 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
74
|
if stalled && stalled[:buffer] != buffer
|
|
77
75
|
@stalled_matches.delete(session)
|
|
@@ -172,7 +170,7 @@ module Expect
|
|
|
172
170
|
begin
|
|
173
171
|
# 先标记已轮询;即使 select 连续被信号中断,下一轮也会检查原期限。
|
|
174
172
|
@polled = true
|
|
175
|
-
ready = IO.select(readers.map(&:to_io), nil, nil, remaining)
|
|
173
|
+
ready = IO.select(readers.map(&:to_io).uniq(&:object_id), nil, nil, remaining)
|
|
176
174
|
rescue Errno::EINTR
|
|
177
175
|
return :retry
|
|
178
176
|
rescue IOError, SystemCallError => error
|
data/lib/expect/redactor.rb
CHANGED
|
@@ -36,12 +36,15 @@ module Expect
|
|
|
36
36
|
|
|
37
37
|
# 追加一个原始字节块,返回已经可以确定的安全前缀;新秘密可能跨越此前保留的尾部。
|
|
38
38
|
def append(data)
|
|
39
|
-
|
|
39
|
+
append_chunk(data, visible: true)
|
|
40
|
+
end
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
# 诊断级别关闭时仍推进连续匹配,禁止本块和它延迟到以后释放的字节进入日志。
|
|
43
|
+
# 输出资格只用于内部诊断;独立过滤器的 append / finish 契约不变。
|
|
44
|
+
# @api private
|
|
45
|
+
def suppress(data)
|
|
46
|
+
append_chunk(data, visible: false)
|
|
47
|
+
nil
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
# EOF、日志目标替换及关闭是流边界;尾部疑似秘密前缀也遮盖,不能因 flush 泄露片段。
|
|
@@ -60,6 +63,18 @@ module Expect
|
|
|
60
63
|
|
|
61
64
|
private
|
|
62
65
|
|
|
66
|
+
# 输出资格与秘密掩码分开保存;仅遇到被抑制的分片时才分配额外字节图。
|
|
67
|
+
def append_chunk(data, visible:)
|
|
68
|
+
raise ArgumentError, "data must be a String" unless data.is_a?(String)
|
|
69
|
+
|
|
70
|
+
@suppressed ||= "\0" * @pending.bytesize unless visible
|
|
71
|
+
@suppressed << ((visible ? "\0" : "\1") * data.bytesize) if @suppressed
|
|
72
|
+
@pending << data.b
|
|
73
|
+
@hidden << ("\0" * data.bytesize)
|
|
74
|
+
mark_secrets
|
|
75
|
+
release([@pending.bytesize - @lookbehind, 0].max)
|
|
76
|
+
end
|
|
77
|
+
|
|
63
78
|
# 流关闭时无法再等待后续字节,默认隐藏与秘密开头一致的未完成尾部。
|
|
64
79
|
def mark_partial_secrets
|
|
65
80
|
tail = @pending.byteslice(-1, 1)
|
|
@@ -108,17 +123,41 @@ module Expect
|
|
|
108
123
|
while cursor < length
|
|
109
124
|
hidden = @hidden.getbyte(cursor) == 1
|
|
110
125
|
ending = [@hidden.index(hidden ? "\0" : "\1", cursor) || length, length].min
|
|
111
|
-
if
|
|
112
|
-
output
|
|
126
|
+
if @suppressed
|
|
127
|
+
release_suppressed(output, cursor, ending, hidden:)
|
|
113
128
|
else
|
|
114
|
-
|
|
129
|
+
if hidden
|
|
130
|
+
output << @replacement unless @masking
|
|
131
|
+
else
|
|
132
|
+
output << @pending.byteslice(cursor, ending - cursor)
|
|
133
|
+
end
|
|
134
|
+
@masking = hidden
|
|
115
135
|
end
|
|
116
|
-
@masking = hidden
|
|
117
136
|
cursor = ending
|
|
118
137
|
end
|
|
119
138
|
@pending = @pending.byteslice(length..)
|
|
120
139
|
@hidden = @hidden.byteslice(length..)
|
|
140
|
+
@suppressed = @suppressed.byteslice(length..) if @suppressed
|
|
121
141
|
output
|
|
122
142
|
end
|
|
143
|
+
|
|
144
|
+
# 仅级别切换后的流需要按输出资格再分段,普通过滤不承担额外区间调用。
|
|
145
|
+
def release_suppressed(output, starting, ending, hidden:)
|
|
146
|
+
while starting < ending
|
|
147
|
+
suppressed = @suppressed.getbyte(starting) == 1
|
|
148
|
+
boundary = [@suppressed.index(suppressed ? "\0" : "\1", starting) || ending, ending].min
|
|
149
|
+
if suppressed
|
|
150
|
+
@masking = false unless hidden
|
|
151
|
+
else
|
|
152
|
+
if hidden
|
|
153
|
+
output << @replacement unless @masking
|
|
154
|
+
else
|
|
155
|
+
output << @pending.byteslice(starting, boundary - starting)
|
|
156
|
+
end
|
|
157
|
+
@masking = hidden
|
|
158
|
+
end
|
|
159
|
+
starting = boundary
|
|
160
|
+
end
|
|
161
|
+
end
|
|
123
162
|
end
|
|
124
163
|
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,30 @@ 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
|
+
|
|
143
148
|
# 复制并替换原始字节缓冲,应用当前上限;调用方后续修改原字符串不会影响会话。
|
|
144
149
|
def buffer=(value)
|
|
145
150
|
raise ArgumentError, "buffer must be a String" unless value.is_a?(String)
|
|
146
151
|
|
|
147
152
|
@buffer = value.b
|
|
148
153
|
@buffer_generation += 1
|
|
154
|
+
@relay_history.clear
|
|
149
155
|
trim_buffer
|
|
150
156
|
end
|
|
151
157
|
|
|
152
|
-
#
|
|
158
|
+
# 显式清空并返回旧字节;它建立新的输入边界,不能再拼接此前的正则转义历史。
|
|
153
159
|
def clear_buffer
|
|
160
|
+
@relay_history.clear
|
|
161
|
+
take_buffer
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Matcher / Relay 的所有权交接不消费输入,也不能截断跨次转接的正则历史。
|
|
165
|
+
# @api private
|
|
166
|
+
def take_buffer
|
|
154
167
|
previous = @buffer
|
|
155
168
|
@buffer = "".b
|
|
156
169
|
@buffer_generation += 1
|
|
@@ -199,18 +212,29 @@ module Expect
|
|
|
199
212
|
nil
|
|
200
213
|
end
|
|
201
214
|
|
|
202
|
-
#
|
|
215
|
+
# 逐字符延迟发送,同时收集回复;返回总字节数,背压失败也报告本次累计确认进度。
|
|
203
216
|
def send_slow(*objects, delay:)
|
|
204
217
|
pause = Expect.duration(delay)
|
|
205
218
|
raise ArgumentError, "delay is required" unless pause
|
|
206
219
|
|
|
207
220
|
count = 0
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
221
|
+
writing = false
|
|
222
|
+
begin
|
|
223
|
+
objects.each do |object|
|
|
224
|
+
object.to_s.each_char do |character|
|
|
225
|
+
sleep(pause) if pause.positive?
|
|
226
|
+
writing = true
|
|
227
|
+
count += write(character)
|
|
228
|
+
writing = false
|
|
229
|
+
read_available if !eof? && to_io.wait_readable(0)
|
|
230
|
+
end
|
|
213
231
|
end
|
|
232
|
+
rescue WriteTimeout => error
|
|
233
|
+
# 只有当前字符的 write 进度属于本次发送;转换和回复交付中的嵌套写入不计入。
|
|
234
|
+
raise if writing && count.zero?
|
|
235
|
+
|
|
236
|
+
progress = count + (writing ? error.bytes_written : 0)
|
|
237
|
+
raise WriteTimeout.new("slow send interrupted", bytes_written: progress)
|
|
214
238
|
end
|
|
215
239
|
count
|
|
216
240
|
end
|
|
@@ -281,6 +305,8 @@ module Expect
|
|
|
281
305
|
if consume
|
|
282
306
|
@buffer = @last_result.after.dup
|
|
283
307
|
@buffer_generation += 1
|
|
308
|
+
# before 与 match 都已消费,后续转接不能跨过这段输入拼接旧的转义前缀。
|
|
309
|
+
@relay_history.clear if (offset + length).positive?
|
|
284
310
|
end
|
|
285
311
|
# 诊断回调可能嵌套等待;恢复本次结果后再交给正式模式回调,不能返回内层等待的结果。
|
|
286
312
|
result = @last_result
|
|
@@ -306,6 +332,8 @@ module Expect
|
|
|
306
332
|
# 先将读取字节交给匹配或转接缓冲,再记录日志;日志失败也能恢复输入。
|
|
307
333
|
# @api private
|
|
308
334
|
def read_available(propagate: true, buffer: @buffer, trim: true)
|
|
335
|
+
raise ReentrancyError, "cannot read while delivering received data" if @receiving
|
|
336
|
+
|
|
309
337
|
return nil if eof?
|
|
310
338
|
|
|
311
339
|
# 写入背压也会读取;转接期间统一交给转义处理器,不能直接转发或另存匹配缓冲。
|
|
@@ -332,10 +360,17 @@ module Expect
|
|
|
332
360
|
data = data.b
|
|
333
361
|
buffer << data
|
|
334
362
|
trim_buffer if trim
|
|
335
|
-
|
|
336
|
-
#
|
|
337
|
-
|
|
338
|
-
|
|
363
|
+
# 本块完成全部交付前不能递归读取,否则后块会抢先进入其他日志及输出目标。
|
|
364
|
+
# 回调仍可匹配已有缓冲或驱动其他会话;拒绝嵌套读取不能释放外层的保护。
|
|
365
|
+
@receiving = true
|
|
366
|
+
begin
|
|
367
|
+
trace_data(:received, data)
|
|
368
|
+
# 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
|
|
369
|
+
write_transcript(data)
|
|
370
|
+
propagate(data) if propagate
|
|
371
|
+
ensure
|
|
372
|
+
@receiving = false
|
|
373
|
+
end
|
|
339
374
|
data
|
|
340
375
|
end
|
|
341
376
|
|
|
@@ -366,7 +401,7 @@ module Expect
|
|
|
366
401
|
@diagnostic_redactors = {}
|
|
367
402
|
@logger = @transcript = nil
|
|
368
403
|
@last_result = @command = nil
|
|
369
|
-
@closed = @eof = false
|
|
404
|
+
@closed = @eof = @receiving = false
|
|
370
405
|
self.timeout = timeout
|
|
371
406
|
self.write_timeout = write_timeout
|
|
372
407
|
self.buffer_limit = buffer_limit
|
|
@@ -540,6 +575,8 @@ module Expect
|
|
|
540
575
|
@buffer_discarded_bytes += @buffer.bytesize - limit
|
|
541
576
|
@buffer = @buffer.byteslice(-limit, limit)
|
|
542
577
|
@buffer_generation += 1
|
|
578
|
+
# 丢弃的前缀使当前窗口与已转发历史之间不再连续。
|
|
579
|
+
@relay_history.clear
|
|
543
580
|
end
|
|
544
581
|
|
|
545
582
|
# 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
|
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.3
|
|
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-06 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: io-console
|