expect-pty 0.5.3 → 0.7.1

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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/README.md +122 -130
  4. data/docs/API.md +113 -0
  5. data/docs/MIGRATION.md +68 -0
  6. data/lib/expect/cleanup.rb +34 -0
  7. data/lib/expect/interaction.rb +228 -210
  8. data/lib/expect/logging.rb +117 -162
  9. data/lib/expect/matcher.rb +42 -38
  10. data/lib/expect/pattern.rb +10 -3
  11. data/lib/expect/pattern_list.rb +39 -9
  12. data/lib/expect/redactor.rb +12 -5
  13. data/lib/expect/relay.rb +106 -81
  14. data/lib/expect/relay_writer.rb +7 -5
  15. data/lib/expect/result.rb +33 -9
  16. data/lib/expect/session.rb +554 -0
  17. data/lib/expect/session_resources.rb +19 -18
  18. data/lib/expect/version.rb +2 -2
  19. data/lib/expect.rb +68 -578
  20. data/sig/expect.rbs +279 -0
  21. metadata +14 -123
  22. data/.rubocop.yml +0 -66
  23. data/CONTRIBUTING.md +0 -29
  24. data/Gemfile +0 -28
  25. data/Rakefile +0 -38
  26. data/benchmark/matching.rb +0 -98
  27. data/benchmark/redactor.rb +0 -64
  28. data/benchmark/relay.rb +0 -48
  29. data/benchmark/scaling.rb +0 -70
  30. data/benchmark/send_slow.rb +0 -41
  31. data/benchmark/support.rb +0 -114
  32. data/docs/COMPATIBILITY.md +0 -98
  33. data/docs/INTERNAL_CONTRACTS.md +0 -154
  34. data/docs/PERFORMANCE.md +0 -165
  35. data/docs/RELEASING.md +0 -84
  36. data/docs/VERIFICATION.md +0 -456
  37. data/examples/dialogue.rb +0 -30
  38. data/examples/kibitz/README.md +0 -81
  39. data/examples/kibitz/kibitz.rb +0 -142
  40. data/examples/kibitz/test_kibitz.rb +0 -37
  41. data/examples/ssh_auto.rb +0 -94
  42. data/examples/ssh_interact.rb +0 -159
  43. data/examples/ssh_login.rb +0 -64
  44. data/expect-pty.gemspec +0 -33
  45. data/lib/expect/configuration.rb +0 -122
  46. data/lib/expect/terminal.rb +0 -102
  47. data/script/ci +0 -121
  48. data/script/release.rb +0 -319
  49. data/test/buffer_accounting_test.rb +0 -83
  50. data/test/cleanup_test.rb +0 -251
  51. data/test/compare_upstream.rb +0 -157
  52. data/test/configuration_test.rb +0 -133
  53. data/test/deadline_test.rb +0 -237
  54. data/test/diagnostics_test.rb +0 -353
  55. data/test/edge_case_test.rb +0 -262
  56. data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
  57. data/test/fixtures/ssh_scripts/02_output.sh +0 -5
  58. data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
  59. data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
  60. data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
  61. data/test/initialization_failure_test.rb +0 -92
  62. data/test/integration/README.md +0 -109
  63. data/test/integration/ssh_scripts.rb +0 -94
  64. data/test/interact_test.rb +0 -253
  65. data/test/interconnect_test.rb +0 -425
  66. data/test/io_test.rb +0 -321
  67. data/test/kibitz_test.rb +0 -45
  68. data/test/lifecycle_contract_test.rb +0 -71
  69. data/test/literal_scan_test.rb +0 -97
  70. data/test/matching_test.rb +0 -211
  71. data/test/multi_session_test.rb +0 -66
  72. data/test/ownership_sequence_test.rb +0 -208
  73. data/test/pattern_offset_test.rb +0 -43
  74. data/test/process_interruption_test.rb +0 -296
  75. data/test/process_test.rb +0 -291
  76. data/test/redactor_test.rb +0 -183
  77. data/test/relay_recovery_test.rb +0 -451
  78. data/test/relay_reentrancy_test.rb +0 -159
  79. data/test/release_test.rb +0 -375
  80. data/test/ruby_api_test.rb +0 -515
  81. data/test/scan_reuse_test.rb +0 -109
  82. data/test/script_logging_test.rb +0 -124
  83. data/test/support/interact_probe.rb +0 -125
  84. data/test/support/kibitz_probe.rb +0 -177
  85. data/test/support/script_probe.rb +0 -158
  86. data/test/terminal_cleanup_test.rb +0 -349
  87. data/test/test_helper.rb +0 -58
  88. data/test/timeout_test.rb +0 -289
  89. data/test/write_contract_test.rb +0 -105
@@ -3,100 +3,133 @@
3
3
  require_relative "relay_writer"
4
4
  require_relative "relay"
5
5
 
6
- # 为会话补充人工接管和多路 IO 转接;核心会话定义位于 lib/expect.rb。
7
- class Expect
8
- # 正则没有“潜在部分匹配”接口,只保留有限历史;已转发的历史字节不能撤回。
9
- REGEXP_ESCAPE_HISTORY_LIMIT = 65_536
10
- private_constant :REGEXP_ESCAPE_HISTORY_LIMIT
11
-
12
- # 在 raw 本地终端显示远端文本,补齐 LF 所需的 CR,同时保留已有 CRLF。
13
- class InteractOutput
14
- # 包装器借用目标,不复制或关闭其描述符;换行状态属于这个目标的连续显示流。
15
- def initialize(target)
16
- @target = target
17
- @previous_carriage_return = false
18
- end
6
+ # 为会话补充人工接管和多路 IO 转接;核心会话定义位于 session.rb。
7
+ module Expect
8
+ # 为公开 Session 混入转义注册与人工接管接口;协作方法各自标记为内部协议。
9
+ # 宿主提供真实读写 IO、缓冲及 outputs;本模块保存转义、显示历史和待写游标。
10
+ # Relay 暂借输入缓冲并调度游标,Matcher 可在转义回调中临时接管,退出时归还未消费尾部。
11
+ module Interaction
12
+ # 正则没有“潜在部分匹配”接口,只保留有限历史;已转发的历史字节不能撤回。
13
+ REGEXP_ESCAPE_HISTORY_LIMIT = 65_536
14
+ # 没有显式回调的转义仍用同一 callable 协议,返回 false 让 Relay 停止。
15
+ STOP = -> { false }.freeze
16
+ private_constant :REGEXP_ESCAPE_HISTORY_LIMIT, :STOP
17
+
18
+ # 在 raw 本地终端显示远端文本,补齐 LF 所需的 CR,同时保留已有 CRLF。
19
+ # @api private
20
+ class InteractOutput
21
+ # 包装器借用目标,不复制或关闭其描述符;换行状态属于这个目标的连续显示流。
22
+ def initialize(target)
23
+ @target = target
24
+ @previous_carriage_return = false
25
+ end
19
26
 
20
- attr_reader :target
27
+ attr_reader :target
21
28
 
22
- # 只改变显示字节,不改写匹配输入;记住上一块的 CR,避免分块的 CRLF 被扩成 CRCRLF。
23
- def render(data)
24
- bytes = data.to_s.b
25
- rendered = bytes.gsub(/(?<!\r)\n/n, "\r\n")
26
- rendered = rendered.byteslice(1..) if @previous_carriage_return && bytes.start_with?("\n")
27
- @previous_carriage_return = bytes.end_with?("\r")
28
- rendered
29
- end
29
+ # 只改变显示字节,不改写匹配输入;记住上一块的 CR,避免分块的 CRLF 被扩成 CRCRLF。
30
+ def render(data)
31
+ bytes = data.to_s.b
32
+ rendered = bytes.gsub(/(?<!\r)\n/n, "\r\n")
33
+ rendered = rendered.byteslice(1..) if @previous_carriage_return && bytes.start_with?("\n")
34
+ @previous_carriage_return = bytes.end_with?("\r")
35
+ rendered
36
+ end
30
37
 
31
- # 提供普通可写对象接口;Relay 会先 render,再按转换后字节数维护独立发送游标。
32
- def write(data)
33
- @target.write(render(data))
38
+ # 提供普通可写对象接口;Relay 会先 render,再按转换后字节数维护独立发送游标。
39
+ def write(data)
40
+ @target.write(render(data))
41
+ end
34
42
  end
35
- end
36
43
 
37
- private_constant :InteractOutput
44
+ private_constant :InteractOutput
38
45
 
39
- # 注册字面、正则转义或 :eof 事件;回调用闭包保存上下文,nil/false 停止,其余值继续。
40
- def on_sequence(sequence, &block)
41
- key = case sequence
42
- when :eof, Regexp then sequence
43
- when String then sequence.b.freeze
44
- else raise ArgumentError, "sequence must be a String, Regexp or :eof"
45
- end
46
- raise ArgumentError, "escape sequence must not be empty" if key == ""
46
+ # 注册字面、正则转义或 :eof 事件;回调用闭包保存上下文,nil/false 停止,其余值继续。
47
+ def on_sequence(sequence, &block)
48
+ key = case sequence
49
+ when :eof, Regexp then sequence
50
+ when String then sequence.b.freeze
51
+ else raise ArgumentError, "sequence must be a String, Regexp or :eof"
52
+ end
53
+ raise ArgumentError, "escape sequence must not be empty" if key == ""
47
54
 
48
- @sequences[key] = block
49
- self
50
- end
55
+ @sequences[key] = block || STOP
56
+ self
57
+ end
51
58
 
52
- # 临时将输入、会话和输出相连,实现人工接管;结束时恢复双方监听器、日志开关和转义设置。
53
- def interact(input: $stdin, escape: nil, output: nil, timeout: nil)
54
- source = interact_source(input)
55
- output ||= input.equal?($stdin) ? $stdout : input
56
- saved_self = [listeners, log_stdout, log_listeners]
57
- saved_source = [source.listeners, source.log_stdout, source.log_listeners, source.sequences.dup]
58
- terminal_state = prepare_interact_terminal(source)
59
- display = interact_display(source, output, terminal_state)
60
- # 临时建立“用户输入 -> 子进程 -> 显示输出”的双向连接,原监听关系在 ensure 中恢复。
61
- self.listeners = [display]
62
- self.log_stdout = false
63
- self.log_listeners = true
64
- source.listeners = [self]
65
- source.log_stdout = false
66
- source.log_listeners = true
67
- source.on_sequence(escape) if escape
68
- Expect.interconnect(self, source, timeout: timeout)
69
- ensure
70
- begin
71
- if saved_self
72
- self.listeners, self.log_stdout, self.log_listeners = saved_self
73
- source.listeners, source.log_stdout, source.log_listeners, source.sequences = saved_source
74
- end
59
+ # 临时将输入、会话和输出相连,实现人工接管;结束时恢复双方输出目标、转义和终端模式。
60
+ def interact(input: $stdin, escape: nil, output: nil, timeout: nil, raw: true)
61
+ source = interact_source(input)
62
+ output = input.equal?($stdin) ? $stdout : input if output.nil?
63
+ saved_self = outputs
64
+ saved_source = [source.outputs, source.sequences.dup]
65
+ terminal_state = prepare_interact_terminal(source) if raw
66
+ display = interact_display(source, output, terminal_state)
67
+ # 临时建立“用户输入 -> 子进程 -> 显示输出”的双向连接,原监听关系在 ensure 中恢复。
68
+ self.outputs = [display]
69
+ source.outputs = [self]
70
+ source.on_sequence(escape) unless escape.nil?
71
+ Relay.new([self, source], timeout).run
75
72
  ensure
76
- restore_interact_terminal(terminal_state)
73
+ begin
74
+ if saved_self
75
+ self.outputs = saved_self
76
+ source.outputs, source.sequences = saved_source
77
+ end
78
+ ensure
79
+ restore_interact_terminal(terminal_state)
80
+ end
77
81
  end
78
- end
79
82
 
80
- # 按各会话 listeners 建立转发图,处理转义、EOF 和总期限,返回引发停止的会话或 nil。
81
- def self.interconnect(*sessions, timeout: nil)
82
- raise ArgumentError, "interconnect requires Expect sessions" if sessions.empty? || sessions.any? do |session|
83
- !session.is_a?(Expect)
83
+ # 超时或异常后仍有未交付的数据;重新 interconnect 同一源会话可继续发送。
84
+ def pending_output? = @pending_writes.any? { |output| !output.done? }
85
+
86
+ # 将待处理输入排入唯一的转接发送队列;返回 :ready、:queued 或 :stopped。
87
+ # 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
88
+ # @api private
89
+ def self.queue_input(session, buffer, final: false)
90
+ loop do
91
+ sequences = session.sequences.except(:eof)
92
+ history = session.relay_history
93
+ found, regexp_scanned, utf8_regexp = scan_sequences(buffer, sequences, history, final:)
94
+ if found
95
+ result = handle_escape(session, buffer, history, found)
96
+ return result unless result == :ready
97
+
98
+ next
99
+ end
100
+
101
+ # 暂存可能构成字面转义的最长后缀,保证 STOP 分两次读取时 ST 不会提前发给子进程。
102
+ held = final ? 0 : hold_literal_prefix(buffer, sequences)
103
+ count = [buffer.bytesize - held, READ_SIZE].min
104
+ session.queue_output(buffer.byteslice(0, count)) if count.positive?
105
+ if regexp_scanned
106
+ trim_history(history, buffer.byteslice(0, count), limit: session.buffer_limit || REGEXP_ESCAPE_HISTORY_LIMIT,
107
+ utf8: utf8_regexp)
108
+ end
109
+ buffer.slice!(0, count)
110
+ return count.positive? ? :queued : :ready
111
+ end
84
112
  end
85
113
 
86
- Relay.new(sessions, timeout).run
87
- end
114
+ # 转义前缀必须交付完才运行回调;有前缀时保存回调,交给 Relay 交付后再执行。
115
+ def self.handle_escape(session, buffer, history, found)
116
+ position, length, callback = found
117
+ if position.positive?
118
+ session.queue_output(buffer.byteslice(0, position))
119
+ buffer.replace(buffer.byteslice((position + length)..))
120
+ history.clear
121
+ # 已确定的转义不能在等待写入后重新匹配;尾部可能在等待期间继续增长。
122
+ session.relay_callback = callback
123
+ return :queued
124
+ end
125
+ buffer.replace(buffer.byteslice([position + length, 0].max..))
126
+ # 转义消费后清除历史,防止继续回调再次匹配同一个转义。
127
+ history.clear
128
+ callback&.call ? :ready : :stopped
129
+ end
88
130
 
89
- # 超时或异常后仍有未交付的数据;重新 interconnect 同一源会话可继续发送。
90
- def pending_output? = @relay_outputs.any? { |output| !output.done? }
91
-
92
- # 转发一个会话的待处理缓冲并剔除转义;返回 false 表示应结束整个转接。
93
- # 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
94
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 转义匹配、前缀暂存和回调消费属于同一轮扫描。
95
- def self.relay_buffer(session, buffers, final: false)
96
- buffer = buffers.fetch(session)
97
- loop do
98
- sequences = session.__send__(:sequences).except(:eof)
99
- history = session.__send__(:relay_history)
131
+ # 单轮正则共用一个组合文本;回调返回后下一轮重新取得规则和历史。
132
+ def self.scan_sequences(buffer, sequences, history, final:)
100
133
  text = nil
101
134
  utf8_regexp = false
102
135
  matches = sequences.filter_map do |key, handler|
@@ -104,7 +137,7 @@ class Expect
104
137
  # 仅在本轮扫描复用组合文本;回调可能更换规则或消费缓冲,下一轮必须重新构造。
105
138
  text ||= history + buffer
106
139
  utf8_regexp ||= key.fixed_encoding? && key.encoding == Encoding::UTF_8
107
- position = Pattern.new(value: key).locate(text, final: final)
140
+ position = Pattern.new(value: key).locate(text, final:)
108
141
  if position
109
142
  offset, length, = position
110
143
  raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
@@ -117,157 +150,142 @@ class Expect
117
150
  [position, key.bytesize, handler] if position
118
151
  end
119
152
  end
120
- if (found = matches.min_by(&:first))
121
- position, length, callback = found
122
- if position.positive?
123
- if block_given?
124
- yield buffer.byteslice(0, position)
125
- buffer.replace(buffer.byteslice((position + length)..))
126
- history.clear
127
- # 已确定的转义不能在等待写入后重新匹配;尾部可能在等待期间继续增长。
128
- session.__send__(:relay_callback=, [callback])
129
- return :pending
130
- end
131
- session.__send__(:propagate, buffer.byteslice(0, position))
132
- end
133
- buffer.replace(buffer.byteslice([position + length, 0].max..))
134
- # 转义消费后清除历史,防止继续回调再次匹配同一个转义。
135
- history.clear
136
- return false unless callback&.call
153
+ [matches.min_by(&:first), !text.nil?, utf8_regexp]
154
+ end
137
155
 
138
- next
139
- end
156
+ # 普通转发和超时排出的字面前缀都属于正则历史,跨次转接时必须接续同一字节流。
157
+ # @api private
158
+ def self.remember_output(session, data)
159
+ regexps = session.sequences.keys.grep(Regexp)
160
+ return if regexps.empty?
161
+
162
+ utf8 = regexps.any? { |regexp| regexp.fixed_encoding? && regexp.encoding == Encoding::UTF_8 }
163
+ trim_history(session.relay_history, data, limit: session.buffer_limit || REGEXP_ESCAPE_HISTORY_LIMIT, utf8:)
164
+ end
140
165
 
141
- # 暂存可能构成字面转义的最长后缀,保证 STOP 分两次读取时 ST 不会提前发给子进程。
166
+ # 只暂存可能拼成完整字面转义的最长后缀。
167
+ def self.hold_literal_prefix(buffer, sequences)
142
168
  held = 0
143
- unless final
144
- sequences.each_key do |key|
145
- next if key.is_a?(Regexp)
146
-
147
- [key.bytesize - 1, buffer.bytesize].min.downto(1) do |length|
148
- if buffer.end_with?(key.byteslice(0, length))
149
- held = [held, length].max
150
- break
151
- end
152
- end
153
- end
154
- end
155
- count = buffer.bytesize - held
156
- count = [count, READ_SIZE].min if block_given?
157
- if count.positive?
158
- data = buffer.byteslice(0, count)
159
- block_given? ? yield(data) : session.__send__(:propagate, data)
160
- end
161
- if text
162
- history << buffer.byteslice(0, count)
163
- limit = session.buffer_limit || REGEXP_ESCAPE_HISTORY_LIMIT
164
- if history.bytesize > limit
165
- history.replace(history.byteslice(-limit, limit))
166
- if utf8_regexp
167
- # 窗口可能从 UTF-8 续字节开始;丢弃不完整的首字符后再交给固定编码正则。
168
- history.slice!(0) while (byte = history.getbyte(0)) && (0x80..0xBF).cover?(byte)
169
- end
169
+ sequences.each_key do |key|
170
+ next if key.is_a?(Regexp)
171
+
172
+ [key.bytesize - 1, buffer.bytesize].min.downto(1) do |prefix_length|
173
+ next unless buffer.end_with?(key.byteslice(0, prefix_length))
174
+
175
+ held = [held, prefix_length].max
176
+ break
170
177
  end
171
178
  end
172
- buffer.slice!(0, count)
173
- return block_given? && count.positive? ? :pending : true
179
+ held
174
180
  end
175
- end
176
181
 
177
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
182
+ # 已转发历史有界保留,固定 UTF-8 正则不能从续字节开始匹配。
183
+ def self.trim_history(history, data, limit:, utf8:)
184
+ history << data
185
+ return unless history.bytesize > limit
178
186
 
179
- private_class_method :relay_buffer
187
+ history.replace(history.byteslice(-limit, limit))
188
+ return unless utf8
180
189
 
181
- # 同一输入 IO 重用一个借用会话,以保留上次接管预读的尾部并避免不断积累包装器。
182
- def interact_source(input)
183
- return input if input.is_a?(Expect)
190
+ history.slice!(0) while (byte = history.getbyte(0)) && (0x80..0xBF).cover?(byte)
191
+ end
184
192
 
185
- @interact_inputs ||= {}.compare_by_identity
186
- @interact_inputs.delete_if { |io, session| io.closed? || session.closed? }
187
- @interact_inputs[input] ||= Expect.open(input)
188
- end
193
+ private_class_method :handle_escape, :scan_sequences, :hold_literal_prefix, :trim_history
189
194
 
190
- # 为当前数据块冻结目标选择并各建一个发送游标;此后修改 listeners 只影响后续数据。
191
- # 调用方须先排空旧游标;显示转换也只做一次,短写重试时不能重复转换 CRLF。
192
- def queue_output(data)
193
- targets = []
194
- targets << $stdout if log_stdout?
195
- targets.concat(@listeners) if log_listeners?
196
- @relay_outputs = targets.map do |target|
197
- if target.is_a?(InteractOutput)
198
- RelayWriter.new(target.target, target.render(data))
199
- else
200
- RelayWriter.new(target, data)
195
+ # 为当前数据块冻结目标选择并各建一个发送游标;此后修改 outputs 只影响后续数据。
196
+ # 调用方须先排空旧游标;显示转换也只做一次,短写重试时不能重复转换 CRLF。
197
+ # @api private
198
+ def queue_output(data)
199
+ @pending_writes = @outputs.map do |target|
200
+ if target.is_a?(InteractOutput)
201
+ RelayWriter.new(target.target, target.render(data))
202
+ else
203
+ RelayWriter.new(target, data)
204
+ end
201
205
  end
202
206
  end
203
- end
204
207
 
205
- private :interact_source, :queue_output
208
+ public
209
+
210
+ # 仅供转接内部保存和恢复注册表,避免公开可变 Hash 绕过 on_sequence 的校验。
211
+ # @api private
212
+ attr_accessor :sequences
213
+ # 让同步写入的背压读取遵守当前转接的数据所有权,退出后恢复普通匹配缓冲。
214
+ # @api private
215
+ attr_accessor :interaction_buffer
216
+ # 活跃 Relay 的所有权 token;只限制同源递归转接,不限制转义回调中的 Matcher。
217
+ # @api private
218
+ attr_accessor :relay_owner
219
+ # 只有裁剪、替换和消费才改变代次;同一代次只会追加,供字面扫描复用已排除的前缀。
220
+ # @api private
221
+ attr_reader :buffer_generation
222
+ # 待交付游标随源会话保存,Relay 的超时或异常退出不会丢失各目标已经写出的进度。
223
+ # @api private
224
+ attr_reader :pending_writes
225
+ # 等待前缀交付的动作;缺省停止动作也是真正的 callable,不使用数组包装状态。
226
+ # @api private
227
+ attr_accessor :relay_callback
228
+
229
+ # 历史属于产生它的转义规则;同规则重入继续匹配,换规则不能重放已转发输入。
230
+ # @api private
231
+ def relay_history
232
+ sequences = @sequences.except(:eof)
233
+ @relay_history = "".b unless @relay_history_sequences == sequences
234
+ @relay_history_sequences = sequences
235
+ @relay_history
236
+ end
206
237
 
207
- # 只有 interact 知道哪个流是本地键盘;通用 interconnect 不修改终端模式。
208
- def prepare_interact_terminal(source)
209
- return unless source.raw_terminal? && source.tty?
238
+ # 转接尚未处理的输入不能被匹配窗口上限裁掉;下次 expect 会重新应用该上限。
239
+ # @api private
240
+ def restore_relay_buffer(buffer)
241
+ @buffer = buffer + @buffer
242
+ @buffer_generation += 1
243
+ end
210
244
 
211
- io = source.to_io
212
- state = [io, io.console_mode]
213
- io.raw!
214
- state
215
- rescue Exception # rubocop:disable Lint/RescueException -- 中断时也恢复可能已部分修改的终端。
216
- restore_interact_terminal(state)
217
- raise
218
- end
245
+ private
219
246
 
220
- # 还原 prepare 保存的完整终端模式;没有切换过或句柄已关闭时无需恢复。
221
- def restore_interact_terminal(state)
222
- return unless state
247
+ # 同一输入 IO 重用一个借用会话,以保留上次接管预读的尾部并避免不断积累包装器。
248
+ def interact_source(input)
249
+ return input if input.is_a?(Session)
223
250
 
224
- io, mode = state
225
- io.console_mode = mode unless io.closed?
226
- end
251
+ @interact_inputs.delete_if { |io, session| io.closed? || session.closed? }
252
+ @interact_inputs[input] ||= Expect.open(input)
253
+ end
254
+
255
+ # 只有 interact 知道哪个流是本地键盘;通用 interconnect 不修改终端模式。
256
+ def prepare_interact_terminal(source)
257
+ return unless source.tty?
227
258
 
228
- # 仅为已切为 raw 的同一个本地终端补齐换行,文件、管道和其他终端保留原字节。
229
- def interact_display(source, output, terminal_state)
230
- @interact_output = nil unless terminal_state && @interact_output&.target.equal?(output)
231
- return output unless terminal_state
259
+ state = nil
260
+ Cleanup.on_failure(-> { restore_interact_terminal(state) }) do
261
+ io = source.to_io
262
+ state = [io, io.console_mode]
263
+ io.raw!
264
+ state
265
+ end
266
+ end
232
267
 
233
- target = output.respond_to?(:to_io) ? output.to_io : output
234
- return output unless target.respond_to?(:tty?) && target.tty?
235
- return output unless source.to_io.stat.rdev == target.stat.rdev
268
+ # 还原 prepare 保存的完整终端模式;没有切换过或句柄已关闭时无需恢复。
269
+ def restore_interact_terminal(state)
270
+ return unless state
236
271
 
237
- # 同一输出的 CRLF 可能跨越两次接管;只缓存最近目标,避免长期持有旧终端。
238
- @interact_output ||= InteractOutput.new(output)
239
- rescue IOError, SystemCallError
240
- output
241
- end
272
+ io, mode = state
273
+ io.console_mode = mode unless io.closed?
274
+ end
242
275
 
243
- private :prepare_interact_terminal, :restore_interact_terminal, :interact_display
244
-
245
- protected
246
-
247
- # 仅供转接内部保存和恢复注册表,避免公开可变 Hash 绕过 on_sequence 的校验。
248
- attr_accessor :sequences
249
- # 让同步写入的背压读取遵守当前转接的数据所有权,退出后恢复普通匹配缓冲。
250
- attr_accessor :interaction_buffer
251
- # 活跃 Relay 的所有权 token;只限制同源递归转接,不限制转义回调中的 Matcher。
252
- attr_accessor :relay_owner
253
- # 只有裁剪、替换和消费才改变代次;同一代次只会追加,供字面扫描复用已排除的前缀。
254
- attr_reader :buffer_generation
255
- # 待交付游标随源会话保存,Relay 的超时或异常退出不会丢失各目标已经写出的进度。
256
- attr_reader :relay_outputs
257
- # 数组包装区分“没有待执行回调”与“已识别无处理器的停止转义”,前缀交付后只派发一次。
258
- attr_accessor :relay_callback
259
-
260
- # 历史属于产生它的转义规则;同规则重入继续匹配,换规则不能重放已转发输入。
261
- def relay_history
262
- sequences = @sequences.except(:eof)
263
- @relay_history = "".b unless @relay_history_sequences == sequences
264
- @relay_history_sequences = sequences
265
- @relay_history
266
- end
276
+ # 仅为已切为 raw 的同一个本地终端补齐换行,文件、管道和其他终端保留原字节。
277
+ def interact_display(source, output, terminal_state)
278
+ @interact_output = nil unless terminal_state && @interact_output&.target.equal?(output)
279
+ return output unless terminal_state
280
+
281
+ target = output.respond_to?(:to_io) ? output.to_io : output
282
+ return output unless target.respond_to?(:tty?) && target.tty?
283
+ return output unless source.to_io.stat.rdev == target.stat.rdev
267
284
 
268
- # 转接尚未处理的输入不能被匹配窗口上限裁掉;下次 expect 会重新应用该上限。
269
- def restore_relay_buffer(buffer)
270
- @buffer = buffer + @buffer
271
- @buffer_generation += 1
285
+ # 同一输出的 CRLF 可能跨越两次接管;只缓存最近目标,避免长期持有旧终端。
286
+ @interact_output ||= InteractOutput.new(output)
287
+ rescue IOError, SystemCallError
288
+ output
289
+ end
272
290
  end
273
291
  end