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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18507dadf6e9120856677f8958b1d01f8de6ead6702a0aabe3afdc21bd8600f2
4
- data.tar.gz: 43be430fc418a34753c60eeb886984a30896d01bb714d40f9b3f9f12c671b126
3
+ metadata.gz: c57608249970451325b18ded1deb6ecea96a110e3640e198aa947a8dd9a15b98
4
+ data.tar.gz: b14a1b19bff3673fa64d89ee6fc052cdb11d8fd7f64be00a74e4d47688b72c28
5
5
  SHA512:
6
- metadata.gz: bdd8a301e1473998ca4fef72d4801ea26df9285791459574e1016f279bad2648661d85680bda7a42486849d99432efa291da27a28fe3af17f8d0f6c36d4ab167
7
- data.tar.gz: d05ac12ae3fb4cf94fb1c15f89d0df95904e278cc812bb8f9fdcc8160ab729eeb2a16c8abc3831262b7b3187f68b63385693076e206f47928b5cbb5bf273663e
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.2", require: "expect/pty"
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.2.gem
34
- gem install ./tmp/expect-pty-0.7.2.gem
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、转义和本地终端模式。
@@ -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 或 :stopped。
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)
@@ -102,13 +102,20 @@ module Expect
102
102
 
103
103
  # 字节诊断在 inspect 转义之前过滤,发送与接收各自保留分片状态。
104
104
  def trace_data(event, data)
105
- return unless logger&.debug?
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
 
@@ -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.clear_buffer)
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 = snapshots ? (snapshots[session] ||= session.buffer) : session.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
@@ -36,12 +36,15 @@ module Expect
36
36
 
37
37
  # 追加一个原始字节块,返回已经可以确定的安全前缀;新秘密可能跨越此前保留的尾部。
38
38
  def append(data)
39
- raise ArgumentError, "data must be a String" unless data.is_a?(String)
39
+ append_chunk(data, visible: true)
40
+ end
40
41
 
41
- @pending << data.b
42
- @hidden << ("\0" * data.bytesize)
43
- mark_secrets
44
- release([@pending.bytesize - @lookbehind, 0].max)
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 hidden
112
- output << @replacement unless @masking
126
+ if @suppressed
127
+ release_suppressed(output, cursor, ending, hidden:)
113
128
  else
114
- output << @pending.byteslice(cursor, ending - cursor)
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
- next if session.pending_output?
56
+ return [:timeout, idle, queued] if dispatch_expired?
54
57
 
55
- if (callback = session.relay_callback)
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 = Interaction.queue_input(session, @buffers.fetch(session), final: session.eof?)
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.clear_buffer
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
  # 输出目标可能也在等待我们读取;这些来源即使有待发送数据也必须继续排空。
@@ -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
@@ -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
- objects.each do |object|
209
- object.to_s.each_char do |character|
210
- sleep(pause) if pause.positive?
211
- count += write(character)
212
- read_available if !eof? && to_io.wait_readable(0)
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
- trace_data(:received, data)
336
- # 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
337
- write_transcript(data)
338
- propagate(data) if propagate
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
  # 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.7.2"
5
+ VERSION = "0.7.3"
6
6
  end
data/lib/expect.rb CHANGED
@@ -25,7 +25,7 @@ module Expect
25
25
  # PTY 创建后命令启动失败。
26
26
  class SpawnError < StandardError; end
27
27
 
28
- # 同一个来源不能同时交给两个 Relay。
28
+ # 拒绝同源递归转接及接收交付回调中的同源递归读取,防止数据归属和顺序被破坏。
29
29
  class ReentrancyError < StandardError; end
30
30
 
31
31
  # 本次写入已经确认交付的进度;其他嵌套写入的异常保留在 cause。
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.2
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-05 00:00:00.000000000 Z
10
+ date: 2026-10-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: io-console