expect-pty 0.7.6 → 0.7.7

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: 1052a03b9bb9ac46051468e651c168d714f6be11aabd4bd8e2171dd390e2065a
4
- data.tar.gz: 7c178220cc65517015144b2711679a3a26739efe1156dd144a26cb64fdfd6b46
3
+ metadata.gz: 45e6676261fec4b5bcd7db41298fd513a47da86308a29f53702311a6b5219f59
4
+ data.tar.gz: 19354431d871147ebd778424288ea2d3a2f788d92e8674f6c779b8c560b23f1b
5
5
  SHA512:
6
- metadata.gz: 31b82ff772bc59ec35a4e3318be96b2b9c4b240898395c1bd8c543ed67515461d9ba282da415a1e208fbd9fb8f03390445cec132725873a039b3d75181fc552d
7
- data.tar.gz: 1a7d7adfd64345c4e0d405657edd252e841430f65c335dc638708f43cdf20c4c12b4b8da28fe70b235801f8af50905595a6f982035a5ff73e9baafd02c8453d3
6
+ metadata.gz: efdb068466acf441e74d839e9830d31f59ab18bf989726319dc6f97fb049d34a913ef53fdc399b6287bebc94d3993a5b97fb8fe41965c3c1a5e6ed3fb9aba924
7
+ data.tar.gz: c4c4d20ef93293c8958da3ffcf6f2ebc0245b37b07caea6fe5f2af3b9ef73ba29d365134f5c7be1a693a43a3c163724143a805ad4f19d27266df7ec7e9485717
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.7 - 2026-10-11
6
+
7
+ - 修复人工接管时终端模式交接中断、恢复异常覆盖业务主异常的问题;分别尝试恢复两侧输出、转义规则和终端模式。
8
+ - 修复 Matcher 与 Relay 在缓冲移交或归还期间被取消时丢失未消费输入的问题,仅在内部所有权交接期间延迟异步中断。
9
+ - 使用对象身份索引选择背压读取来源,减少多会话互为转发目标时的重复遍历,保持来源顺序和背压行为。
10
+ - SSH 示例保留 known_hosts 路径中的百分号,并在启动前拒绝 OpenSSH 会再次展开的 `${` 路径表达式。
11
+
5
12
  ## 0.7.6 - 2026-10-09
6
13
 
7
14
  - 修复会话关闭时日志异常覆盖原始错误的问题,保留进程等待、线程中断或句柄关闭的失败原因,并继续尝试完成诊断与 transcript 收尾。
data/docs/API.md CHANGED
@@ -114,6 +114,7 @@ DEBUG 关闭期间仍推进分片脱敏,但这些字节不会在重新启用
114
114
 
115
115
  `interact(input: $stdin, escape: nil, output: nil, timeout: nil, raw: true)` 临时建立双向转接,退出后恢复
116
116
  outputs、转义和本地终端模式。
117
+ 各项恢复都会尝试;恢复中的常规错误不会覆盖已有的业务异常,没有业务异常时仍报告恢复错误。
117
118
  `output: nil` 使用默认输出,`escape: nil` 不注册退出序列;显式 false 不是缺省值,非法输出或转义会在转发前报错。
118
119
  `raw: false` 将终端设置留给调用方。transcript、logger 及自定义 writer 同步执行,应及时返回。
119
120
 
@@ -64,20 +64,14 @@ module Expect
64
64
  saved_self = outputs
65
65
  saved_source = [source.outputs, source.sequences.dup]
66
66
  terminal_state = prepare_interact_terminal(source) if raw
67
- display = interact_display(source, output, terminal_state)
68
- # 临时建立“用户输入 -> 子进程 -> 显示输出”的双向连接,原监听关系在 ensure 中恢复。
69
- self.outputs = [display]
70
- source.outputs = [self]
71
- source.on_sequence(escape) unless escape.nil?
72
- Relay.new([self, source], timeout).run
73
- ensure
74
- begin
75
- if saved_self
76
- self.outputs = saved_self
77
- source.outputs, source.sequences = saved_source
78
- end
79
- ensure
80
- restore_interact_terminal(terminal_state)
67
+ # 先持有恢复快照,再在同一清理作用域内切换终端;返回值交接中断不能留下 raw 模式。
68
+ Cleanup.always(-> { restore_interact(source, saved_self, saved_source, terminal_state) }) do
69
+ terminal_state&.first&.raw!
70
+ display = interact_display(source, output, terminal_state)
71
+ self.outputs = [display]
72
+ source.outputs = [self]
73
+ source.on_sequence(escape) unless escape.nil?
74
+ Relay.new([self, source], timeout).run
81
75
  end
82
76
  end
83
77
 
@@ -268,16 +262,20 @@ module Expect
268
262
  @interact_inputs[input] ||= Expect.open(input)
269
263
  end
270
264
 
271
- # 只有 interact 知道哪个流是本地键盘;通用 interconnect 不修改终端模式。
265
+ # 只取得快照;实际 raw! 由持有快照的 interact 在清理作用域内执行。
272
266
  def prepare_interact_terminal(source)
273
267
  return unless source.tty?
274
268
 
275
- state = nil
276
- Cleanup.on_failure(-> { restore_interact_terminal(state) }) do
277
- io = source.to_io
278
- state = [io, io.console_mode]
279
- io.raw!
280
- state
269
+ io = source.to_io
270
+ [io, io.console_mode]
271
+ end
272
+
273
+ # 独立尝试每项恢复;常规恢复错误不能覆盖业务主异常,新的中断仍按 Cleanup 契约传播。
274
+ def restore_interact(source, saved_self, saved_source, terminal_state)
275
+ Cleanup.always(-> { restore_interact_terminal(terminal_state) }) do
276
+ Cleanup.always(-> { source.sequences = saved_source.last }) do
277
+ Cleanup.always(-> { source.outputs = saved_source.first }) { self.outputs = saved_self }
278
+ end
281
279
  end
282
280
  end
283
281
 
@@ -25,17 +25,7 @@ module Expect
25
25
  # 运行匹配状态机;内部 :retry 表示继续循环,最终返回一个 Result。
26
26
  def run
27
27
  @relay_buffers = {}.compare_by_identity
28
- @sessions.each do |session|
29
- buffer = session.interaction_buffer
30
- if buffer
31
- # 转义回调中的显式匹配临时接管读取,先消费转接已经预读的尾部。
32
- @relay_buffers[session] = buffer
33
- session.interaction_buffer = nil
34
- session.restore_relay_buffer(buffer)
35
- buffer.clear
36
- end
37
- session.reset_result
38
- end
28
+ prepare_sessions
39
29
  loop do
40
30
  # 先消费已缓冲的匹配,再处理 EOF,最后读取;避免进程退出时丢失最后一个匹配。
41
31
  result = if !@expired_eof_continuation && !hard_expired? && (matched = find_match)
@@ -50,15 +40,39 @@ module Expect
50
40
  return result unless result == :retry
51
41
  end
52
42
  ensure
53
- # 嵌套 expect 即使异常退出,也要把未消费尾部还给原 Relay 的同一个缓冲对象。
54
- @relay_buffers.each do |session, buffer|
55
- buffer.replace(session.take_buffer)
56
- session.interaction_buffer = buffer
57
- end
43
+ restore_relay_buffers
58
44
  end
59
45
 
60
46
  private
61
47
 
48
+ # 转义回调中的匹配临时接管预读尾部;缓冲登记和移交是一个不可拆开的状态变更。
49
+ # 仅内部所有权操作延迟异步取消,扫描、IO、日志及用户回调仍可立即中断。
50
+ def prepare_sessions
51
+ @sessions.each do |session|
52
+ if (buffer = session.interaction_buffer)
53
+ Thread.handle_interrupt(Object => :never) do
54
+ @relay_buffers[session] = buffer
55
+ session.interaction_buffer = nil
56
+ session.restore_relay_buffer(buffer)
57
+ buffer.clear
58
+ end
59
+ end
60
+ session.reset_result
61
+ end
62
+ end
63
+
64
+ # 先归还所有借用来源再交付新的取消;返回尾部与恢复原缓冲对象不能被中断拆开。
65
+ def restore_relay_buffers
66
+ return if @relay_buffers.empty?
67
+
68
+ Thread.handle_interrupt(Object => :never) do
69
+ @relay_buffers.each do |session, buffer|
70
+ buffer.replace(session.take_buffer)
71
+ session.interaction_buffer = buffer
72
+ end
73
+ end
74
+ end
75
+
62
76
  # 按声明组、会话、模式的顺序寻找首个匹配,不按文本中的出现位置重新排序。
63
77
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 声明优先级与单轮缓冲须在同一次扫描保持一致。
64
78
  def find_match
data/lib/expect/relay.rb CHANGED
@@ -142,21 +142,27 @@ module Expect
142
142
 
143
143
  @sessions.each { |session| session.relay_owner = @token }
144
144
  end
145
- @sessions.each do |session|
146
- @previous[session] = session.interaction_buffer
147
- @buffers[session] = session.take_buffer
148
- session.interaction_buffer = @buffers.fetch(session)
145
+ # take_buffer 已移出会话后,必须先登记到本轮账本才能交付异步取消;此处不执行 IO 或用户回调。
146
+ Thread.handle_interrupt(Object => :never) do
147
+ @sessions.each do |session|
148
+ @previous[session] = session.interaction_buffer
149
+ @buffers[session] = session.take_buffer
150
+ session.interaction_buffer = @buffers.fetch(session)
151
+ end
149
152
  end
150
153
  end
151
154
 
152
155
  # 未处理输入还给会话,已排队字节仍归游标;构造中断也只恢复已转移的缓冲及自己的标记。
153
156
  def restore_sources
154
- @previous.each { |session, buffer| session.interaction_buffer = buffer }
155
- @buffers.each { |session, buffer| session.restore_relay_buffer(buffer) }
156
- ensure
157
- OWNERSHIP_MUTEX.synchronize do
158
- @sessions.each do |session|
159
- session.relay_owner = nil if session.relay_owner.equal?(@token)
157
+ # 归还多来源缓冲和释放 token 属于同一交接,不能只恢复前几个来源就响应取消。
158
+ Thread.handle_interrupt(Object => :never) do
159
+ @previous.each { |session, buffer| session.interaction_buffer = buffer }
160
+ @buffers.each { |session, buffer| session.restore_relay_buffer(buffer) }
161
+ ensure
162
+ OWNERSHIP_MUTEX.synchronize do
163
+ @sessions.each do |session|
164
+ session.relay_owner = nil if session.relay_owner.equal?(@token)
165
+ end
160
166
  end
161
167
  end
162
168
  end
@@ -184,13 +190,13 @@ module Expect
184
190
  # 背压源暂停吸收新输入,限制排队增长;作为写入目标的会话仍需读取以解除双向等待。
185
191
  def read_sources(pending)
186
192
  # 输出目标可能也在等待我们读取;这些来源即使有待发送数据也必须继续排空。
187
- targets = pending.filter_map do |output|
188
- output.target if output.target.is_a?(Session)
193
+ targets = pending.each_with_object({}.compare_by_identity) do |output, index|
194
+ index[output.target] = true if output.target.is_a?(Session)
189
195
  end
190
196
  sources = @active.reject do |session|
191
- session.pending_output? && targets.none? { |target| target.equal?(session) }
197
+ session.pending_output? && !targets.key?(session)
192
198
  end
193
- (sources + targets).uniq { |session| session.to_io.object_id }.reject(&:eof?)
199
+ (sources + targets.keys).uniq { |session| session.to_io.object_id }.reject(&:eof?)
194
200
  end
195
201
 
196
202
  # 总期限结束时将可交付尾部转为发送游标,至多尝试一轮交付;余量由源会话保存。
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.7.6"
5
+ VERSION = "0.7.7"
6
6
  end
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.6
4
+ version: 0.7.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-09 00:00:00.000000000 Z
10
+ date: 2026-10-11 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: io-console