expect-pty 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/docs/API.md +3 -1
- data/lib/expect/interaction.rb +15 -10
- data/lib/expect/logging.rb +2 -1
- data/lib/expect/matcher.rb +15 -10
- data/lib/expect/redactor.rb +12 -2
- data/lib/expect/relay.rb +2 -0
- data/lib/expect/session.rb +13 -3
- data/lib/expect/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cbcc16796d1be757cc45df5ed803aa8d3518601923eaa3765d512cd3000baaf7
|
|
4
|
+
data.tar.gz: 47943a69f86cfc7ba7564eea3f2ec7e0f71a30910e398535fbe734cff8cba54f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9d0688944e25cd879072059ecdf8556676bc32c0b83634dbac773f002dad67485e9dad95034a52b306dbf0e1a3e38eff1e67efb0fcd382cd45c8b0c06bf79bd
|
|
7
|
+
data.tar.gz: 62aed043158e01a8023111d441fb9c484d697763157e18db97c6c01e9bbb7c55af7fb55ea3e311a4bcae2e191209a1401a42b139523d0e7d4c0873be78c2800c
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.8.2 - 2026-10-11
|
|
6
|
+
|
|
7
|
+
- 修复对象转换、日志回调或背压读取关闭会话后仍继续写入借用 IO 的问题,空写入也会检查关闭状态。
|
|
8
|
+
- 修复转接接收回调耗尽总期限后仍读取后续来源的问题,保留零超时的首次轮询行为。
|
|
9
|
+
- 加快多来源 EOF 连续派发,并减少诊断重新启用后持续脱敏的临时分配,保留来源顺序与秘密过滤。
|
|
10
|
+
|
|
11
|
+
## 0.8.1 - 2026-10-11
|
|
12
|
+
|
|
13
|
+
- 修复自动应答已在缓冲开头确定获胜模式,却被后续无关正则的编码错误阻断的问题,保留未消费尾部。
|
|
14
|
+
- 修复 EOF 时 transcript 尾部交付失败跳过接收诊断收尾的问题,两个日志通道均尝试冲刷并保留首个常规异常。
|
|
15
|
+
- 降低大量规则继续匹配、转义扫描和单对象日志补记的开销,并让脱敏流结束后的可见日志恢复普通过滤路径。
|
|
16
|
+
|
|
5
17
|
## 0.8.0 - 2026-10-11
|
|
6
18
|
|
|
7
19
|
- 新增可复用的自动应答规则,支持重复确认、分页按键、动态回复和次数上限,按输入顺序处理提示且不延长等待期限,并提供批量脚本示例。
|
data/docs/API.md
CHANGED
|
@@ -126,6 +126,7 @@ end
|
|
|
126
126
|
`write(*objects)` 按 to_s 写字节并返回字节数,`<<` 返回 Session,`puts` 遵循 Ruby IO 换行与数组规则并返回 nil。
|
|
127
127
|
`send_slow(*objects, delay:)` 按字符延迟发送并收取回复。背压超时抛出 `Expect::WriteTimeout`,`bytes_written` 仅标识本次调用已确认进度,
|
|
128
128
|
嵌套写入异常放在 cause。成功短写不受 write_timeout 的总耗时限制。
|
|
129
|
+
对象转换、发送诊断或背压读取的回调若关闭会话,后续写入抛出 IOError;借用 IO 仍归调用方所有,空写入同样检查关闭状态。
|
|
129
130
|
`send_slow` 的进度累计本次调用中此前完整字符与失败字符实际接受的字节;转换对象和记录回复的内层写入进度不计入。
|
|
130
131
|
首字符 write 的异常无需修正进度时保留原对象;需要累计或排除内层进度时包装并保留 cause。
|
|
131
132
|
`to_io`、`writer`、`slave` 暴露句柄;`pid`、`command`、`tty_name`、`fileno`、`tty?`、`alive?`、`exit_code` 查询会话属性。
|
|
@@ -154,7 +155,8 @@ DEBUG 关闭期间仍推进分片脱敏,但这些字节不会在重新启用
|
|
|
154
155
|
`pending_output?` 表示尚未交付数据;再次转接同源会话继续发送,不重放成功前缀;更换 outputs 不改变已排队字节的目标。
|
|
155
156
|
同源递归转接抛出 ReentrancyError;转义回调可嵌套匹配,所有来源状态按对象身份隔离。
|
|
156
157
|
接收字节交付期间,logger/transcript/outputs 的回调不能递归读取同一会话的新输入,否则抛出 ReentrancyError;
|
|
157
|
-
|
|
158
|
+
匹配已有缓冲及读取独立会话仍可用。正数转接总期限会在扫描、继续回调返回后及读取每个就绪来源前检查;接收回调耗尽预算后不再读取后续来源。
|
|
159
|
+
已读分片的交付仍完整完成,零超时仍处理缓冲并首次轮询。
|
|
158
160
|
|
|
159
161
|
`interact(input: $stdin, escape: nil, output: nil, timeout: nil, raw: true)` 临时建立双向转接,退出后恢复
|
|
160
162
|
outputs、转义和本地终端模式。
|
data/lib/expect/interaction.rb
CHANGED
|
@@ -137,25 +137,30 @@ module Expect
|
|
|
137
137
|
def self.scan_sequences(buffer, sequences, history, final:)
|
|
138
138
|
text = nil
|
|
139
139
|
utf8_regexp = false
|
|
140
|
-
|
|
140
|
+
earliest = nil
|
|
141
|
+
# 只保存最早候选,同位置不替换先注册者;仍扫描全部正则以校验零宽规则并收集编码状态。
|
|
142
|
+
sequences.each do |key, handler|
|
|
141
143
|
if key.is_a?(Regexp)
|
|
142
144
|
# 仅在本轮扫描复用组合文本;回调可能更换规则或消费缓冲,下一轮必须重新构造。
|
|
143
145
|
text ||= history + buffer
|
|
144
146
|
utf8_regexp ||= key.fixed_encoding? && key.encoding == Encoding::UTF_8
|
|
145
147
|
position = Pattern.new(value: key).locate(text, final:, captures: false)
|
|
146
|
-
|
|
147
|
-
offset, length, = position
|
|
148
|
-
raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
|
|
148
|
+
next unless position
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
offset, length, = position
|
|
151
|
+
raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
|
|
152
|
+
|
|
153
|
+
# 历史前缀已实时转发,不能撤回;负偏移表示本次只需消费转义尚未转发的部分。
|
|
154
|
+
offset -= history.bytesize
|
|
153
155
|
else
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
offset = buffer.index(key)
|
|
157
|
+
next unless offset
|
|
158
|
+
|
|
159
|
+
length = key.bytesize
|
|
156
160
|
end
|
|
161
|
+
earliest = [offset, length, handler] if !earliest || offset < earliest.first
|
|
157
162
|
end
|
|
158
|
-
[
|
|
163
|
+
[earliest, !text.nil?, utf8_regexp]
|
|
159
164
|
end
|
|
160
165
|
|
|
161
166
|
# 普通转发和超时排出的字面前缀都属于正则历史,跨次转接时必须接续同一字节流。
|
data/lib/expect/logging.rb
CHANGED
|
@@ -53,7 +53,8 @@ module Expect
|
|
|
53
53
|
def write_transcript(*objects)
|
|
54
54
|
return unless transcript
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
# 单对象沿用 Session#write 的转换路径,省去数组与 join;转换后仍重新取得目标。
|
|
57
|
+
data = objects.size == 1 ? objects.first.to_s.b : objects.map { |object| object.to_s.b }.join
|
|
57
58
|
# to_s 可切换或禁用记录目标;整条记录与其过滤尾部必须属于转换完成后的同一目标。
|
|
58
59
|
target = transcript
|
|
59
60
|
return unless target
|
data/lib/expect/matcher.rb
CHANGED
|
@@ -18,7 +18,8 @@ module Expect
|
|
|
18
18
|
# 相对期限可因接收或 continue 重算,总期限始终固定;两者共用单调时钟。
|
|
19
19
|
@hard_deadline = deadline
|
|
20
20
|
@deadline = next_deadline
|
|
21
|
-
|
|
21
|
+
# 按声明顺序维护仍参与本轮等待的来源;EOF 删除后不再从完整列表重复跳过已处理项。
|
|
22
|
+
@active_sessions = @sessions.each_with_object({}.compare_by_identity) { |session, active| active[session] = true }
|
|
22
23
|
@stalled_matches = {}.compare_by_identity
|
|
23
24
|
@polled = false
|
|
24
25
|
@expired_eof_continuation = false
|
|
@@ -84,7 +85,7 @@ module Expect
|
|
|
84
85
|
earliest = nil
|
|
85
86
|
groups.each do |sessions, patterns|
|
|
86
87
|
sessions.each do |session|
|
|
87
|
-
next
|
|
88
|
+
next unless @active_sessions.key?(session)
|
|
88
89
|
|
|
89
90
|
buffer = buffers ? (buffers[session] ||= session.scan_buffer) : session.scan_buffer
|
|
90
91
|
stalled = @stalled_matches[session]
|
|
@@ -94,7 +95,7 @@ module Expect
|
|
|
94
95
|
end
|
|
95
96
|
patterns.each do |pattern|
|
|
96
97
|
return nil if hard_expired?
|
|
97
|
-
next if stalled && stalled[:patterns].
|
|
98
|
+
next if stalled && stalled[:patterns].key?(pattern)
|
|
98
99
|
|
|
99
100
|
position = if pattern.value.is_a?(String)
|
|
100
101
|
locate_literal(session, pattern, buffer)
|
|
@@ -105,7 +106,8 @@ module Expect
|
|
|
105
106
|
return nil if hard_expired?
|
|
106
107
|
next unless position
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
# 字节 0 已是最早位置,同位置仍由先声明者胜出;不再执行无法胜出的规则。
|
|
110
|
+
return [session, pattern, position] if !@match_by_position || position.first.zero?
|
|
109
111
|
|
|
110
112
|
earliest = [session, pattern, position] if !earliest || position.first < earliest.last.first
|
|
111
113
|
end
|
|
@@ -153,8 +155,11 @@ module Expect
|
|
|
153
155
|
|
|
154
156
|
# 同值赋回仍须防止空转;嵌套读取到的新字节即使内容相同,也能恢复匹配。
|
|
155
157
|
if session.received_bytes == received_bytes && session.scan_buffer == previous_buffer
|
|
156
|
-
|
|
157
|
-
stalled[
|
|
158
|
+
# 声明模式各有独立身份;恒定成本查询,避免重复继续时遍历不断增长的停滞数组。
|
|
159
|
+
stalled = (@stalled_matches[session] ||= {
|
|
160
|
+
buffer: previous_buffer, received_bytes:, patterns: {}.compare_by_identity
|
|
161
|
+
})
|
|
162
|
+
stalled[:patterns][pattern] = true
|
|
158
163
|
end
|
|
159
164
|
@deadline = next_deadline if CONTINUE.equal?(action)
|
|
160
165
|
# 总期限到达时回主循环先派发已知 EOF;仅相对期限延续原有立即超时语义。
|
|
@@ -165,19 +170,19 @@ module Expect
|
|
|
165
170
|
|
|
166
171
|
# 找出尚未派发 EOF 事件的会话,保证每个源只处理一次结束事件。
|
|
167
172
|
def unhandled_eof
|
|
168
|
-
@
|
|
173
|
+
@active_sessions.each_key.find(&:eof?)
|
|
169
174
|
end
|
|
170
175
|
|
|
171
176
|
# 将剩余字节交给 EOF 回调;需要继续时等待其他源,全部结束则立即返回。
|
|
172
177
|
def handle_eof(session)
|
|
173
178
|
result = session.record_eof
|
|
174
|
-
@
|
|
179
|
+
@active_sessions.delete(session)
|
|
175
180
|
actions = @patterns.eof_patterns_for(session).map { |pattern| pattern.call(session) }
|
|
176
181
|
return result unless actions.any? { |action| continuing?(action) }
|
|
177
182
|
|
|
178
183
|
reset_timeout = actions.any? { |action| CONTINUE.equal?(action) }
|
|
179
184
|
@deadline = next_deadline if reset_timeout
|
|
180
|
-
return result if @
|
|
185
|
+
return result if @active_sessions.empty?
|
|
181
186
|
|
|
182
187
|
# 期限已过时不再扫描文本,但先派发已知 EOF;最后一个源结束不能被误报为超时。
|
|
183
188
|
@expired_eof_continuation = !reset_timeout && expired?
|
|
@@ -230,7 +235,7 @@ module Expect
|
|
|
230
235
|
end
|
|
231
236
|
|
|
232
237
|
# 返回本次仍需监听的会话,供读取选择和超时回调使用。
|
|
233
|
-
def active_sessions = @
|
|
238
|
+
def active_sessions = @active_sessions.keys
|
|
234
239
|
|
|
235
240
|
# 只有约定的继续符号会驱动下一轮,普通回调返回值不会改变等待流程。
|
|
236
241
|
def continuing?(action) = CONTINUE.equal?(action) || CONTINUE_WITHOUT_RESET.equal?(action)
|
data/lib/expect/redactor.rb
CHANGED
|
@@ -57,6 +57,9 @@ module Expect
|
|
|
57
57
|
mark_secrets
|
|
58
58
|
mark_partial_secrets if partial
|
|
59
59
|
output = release(@pending.bytesize)
|
|
60
|
+
# 已结束的流没有尚待抑制的字节,后续可见分片恢复普通路径;再次 suppress 时重新建立字节图。
|
|
61
|
+
@suppressed = nil
|
|
62
|
+
@suppressed_until = nil
|
|
60
63
|
@masking = false
|
|
61
64
|
output
|
|
62
65
|
end
|
|
@@ -71,7 +74,10 @@ module Expect
|
|
|
71
74
|
def append_chunk(data, visible:)
|
|
72
75
|
raise ArgumentError, "data must be a String" unless data.is_a?(String)
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
unless visible || data.empty?
|
|
78
|
+
@suppressed ||= "\0" * @pending.bytesize
|
|
79
|
+
@suppressed_until = @pending.bytesize + data.bytesize
|
|
80
|
+
end
|
|
75
81
|
@suppressed << ((visible ? "\0" : "\1") * data.bytesize) if @suppressed
|
|
76
82
|
@pending << data.b
|
|
77
83
|
@hidden << ("\0" * data.bytesize)
|
|
@@ -142,7 +148,11 @@ module Expect
|
|
|
142
148
|
remaining = @pending.bytesize - length
|
|
143
149
|
@pending = @pending.byteslice(length, remaining)
|
|
144
150
|
@hidden = @hidden.byteslice(length, remaining)
|
|
145
|
-
|
|
151
|
+
if @suppressed
|
|
152
|
+
# 只追踪最后一个抑制字节的排空边界,避免每轮扫描字节图;跨块秘密掩码不受影响。
|
|
153
|
+
@suppressed_until -= length
|
|
154
|
+
@suppressed = @suppressed_until.positive? ? @suppressed.byteslice(length, remaining) : nil
|
|
155
|
+
end
|
|
146
156
|
@scanned_bytes = remaining
|
|
147
157
|
output
|
|
148
158
|
end
|
data/lib/expect/relay.rb
CHANGED
|
@@ -122,6 +122,8 @@ module Expect
|
|
|
122
122
|
end
|
|
123
123
|
readers.each do |session|
|
|
124
124
|
next unless ready ? ready.key?(session.to_io) : readable.first.equal?(session.to_io)
|
|
125
|
+
# 前一个接收回调可耗尽总预算;本块交付完成后暂停新的读取,零超时仍允许首次批量轮询。
|
|
126
|
+
break if dispatch_expired?
|
|
125
127
|
|
|
126
128
|
begin
|
|
127
129
|
if @buffers.key?(session)
|
data/lib/expect/session.rb
CHANGED
|
@@ -178,16 +178,19 @@ module Expect
|
|
|
178
178
|
|
|
179
179
|
# 按 Ruby to_s 规则原样写入所有字节,返回字节数;背压等待受 write_timeout 限制。
|
|
180
180
|
def write(*objects)
|
|
181
|
-
|
|
181
|
+
check_writable!
|
|
182
182
|
|
|
183
183
|
begin
|
|
184
184
|
# 单对象不需要中间数组与 join 副本;to_s、诊断和部分写入仍经过同一条路径。
|
|
185
185
|
data = objects.size == 1 ? objects.first.to_s.b : objects.map { |object| object.to_s.b }.join
|
|
186
|
+
check_writable!
|
|
186
187
|
trace_data(:sending, data)
|
|
187
188
|
rescue WriteTimeout
|
|
188
189
|
# 转换或诊断中的嵌套写入不属于当前命令;此时尚未向 writer 发送任何字节。
|
|
189
190
|
raise WriteTimeout.new("write interrupted before sending data", bytes_written: 0)
|
|
190
191
|
end
|
|
192
|
+
# 转换与诊断均可关闭借用会话;即使底层 IO 仍打开,也不能把关闭后的发送报告为成功。
|
|
193
|
+
check_writable!
|
|
191
194
|
deadline = write_timeout && (Expect.monotonic + write_timeout)
|
|
192
195
|
offset = 0
|
|
193
196
|
while offset < data.bytesize
|
|
@@ -467,6 +470,8 @@ module Expect
|
|
|
467
470
|
|
|
468
471
|
# EINTR 未确认交付时不移动游标;其他写入计数必须落在当前块范围内。
|
|
469
472
|
def write_chunk(data, offset, deadline)
|
|
473
|
+
# 背压读取会执行接收回调;每次重试重新检查,关闭会话不等于关闭调用方拥有的 writer。
|
|
474
|
+
check_writable!
|
|
470
475
|
chunk = data.byteslice(offset, READ_SIZE)
|
|
471
476
|
count = writer.write_nonblock(chunk, exception: false)
|
|
472
477
|
return count if count == :wait_writable
|
|
@@ -481,6 +486,11 @@ module Expect
|
|
|
481
486
|
nil
|
|
482
487
|
end
|
|
483
488
|
|
|
489
|
+
# 同一状态检查覆盖入口、用户转换/诊断之后和背压恢复;只检查,不取得借用 IO 所有权。
|
|
490
|
+
def check_writable!
|
|
491
|
+
raise IOError, "closed Expect session" if closed? || writer.closed?
|
|
492
|
+
end
|
|
493
|
+
|
|
484
494
|
# 背压等待同时排空读端,期限不因 EINTR 重算,异常始终报告外层写入进度。
|
|
485
495
|
def wait_writable(offset, deadline)
|
|
486
496
|
remaining = deadline && [deadline - Expect.monotonic, 0].max
|
|
@@ -576,8 +586,8 @@ module Expect
|
|
|
576
586
|
# 只结束读取方向并冲刷接收记录;EOF 不代表子进程已经退出或写端已经关闭。
|
|
577
587
|
def mark_eof
|
|
578
588
|
@eof = true
|
|
579
|
-
|
|
580
|
-
flush_diagnostics(:received)
|
|
589
|
+
# EOF 只登记一次;任一日志尾部失败也必须尝试另一通道,保持首个常规异常。
|
|
590
|
+
Cleanup.always(-> { flush_diagnostics(:received) }) { flush_transcript }
|
|
581
591
|
nil
|
|
582
592
|
end
|
|
583
593
|
|
data/lib/expect/version.rb
CHANGED