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