expect-pty 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/Gemfile +21 -5
- data/README.md +40 -15
- data/docs/COMPATIBILITY.md +23 -1
- data/docs/RELEASING.md +26 -13
- data/docs/VERIFICATION.md +65 -1
- data/expect-pty.gemspec +8 -1
- data/lib/expect/configuration.rb +7 -0
- data/lib/expect/interaction.rb +249 -0
- data/lib/expect/{engine.rb → matcher.rb} +40 -5
- data/lib/expect/pattern.rb +12 -20
- data/lib/expect/relay.rb +133 -0
- data/lib/expect/relay_writer.rb +66 -0
- data/lib/expect/result.rb +2 -0
- data/lib/expect/{resources.rb → session_resources.rb} +3 -3
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +105 -30
- data/script/ci +44 -7
- data/script/release.rb +22 -10
- data/test/edge_case_test.rb +101 -22
- data/test/interact_test.rb +102 -0
- data/test/interconnect_test.rb +203 -4
- data/test/io_test.rb +102 -0
- data/test/matching_test.rb +33 -0
- data/test/process_test.rb +3 -2
- data/test/relay_recovery_test.rb +414 -0
- data/test/release_test.rb +98 -1
- data/test/support/script_probe.rb +1 -0
- metadata +98 -7
- data/lib/expect/interconnect.rb +0 -170
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "relay_writer"
|
|
4
|
+
require_relative "relay"
|
|
5
|
+
|
|
6
|
+
# 为会话补充人工接管和多路 IO 转接;核心会话定义位于 lib/expect.rb。
|
|
7
|
+
class Expect
|
|
8
|
+
REGEXP_ESCAPE_HISTORY_LIMIT = 65_536
|
|
9
|
+
private_constant :REGEXP_ESCAPE_HISTORY_LIMIT
|
|
10
|
+
|
|
11
|
+
# Presents remote text on a raw local terminal without changing existing CRLF sequences.
|
|
12
|
+
class InteractOutput
|
|
13
|
+
def initialize(target)
|
|
14
|
+
@target = target
|
|
15
|
+
@previous_carriage_return = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :target
|
|
19
|
+
|
|
20
|
+
def render(data)
|
|
21
|
+
bytes = data.to_s.b
|
|
22
|
+
rendered = bytes.gsub(/(?<!\r)\n/n, "\r\n")
|
|
23
|
+
rendered = rendered.byteslice(1..) if @previous_carriage_return && bytes.start_with?("\n")
|
|
24
|
+
@previous_carriage_return = bytes.end_with?("\r")
|
|
25
|
+
rendered
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(data)
|
|
29
|
+
@target.write(render(data))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_constant :InteractOutput
|
|
34
|
+
|
|
35
|
+
# 注册字面、正则转义或 :eof 事件;回调用闭包保存上下文,nil/false 停止,其余值继续。
|
|
36
|
+
def on_sequence(sequence, &block)
|
|
37
|
+
key = case sequence
|
|
38
|
+
when :eof, Regexp then sequence
|
|
39
|
+
when String then sequence.b.freeze
|
|
40
|
+
else raise ArgumentError, "sequence must be a String, Regexp or :eof"
|
|
41
|
+
end
|
|
42
|
+
raise ArgumentError, "escape sequence must not be empty" if key == ""
|
|
43
|
+
|
|
44
|
+
@sequences[key] = block
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 临时将输入、会话和输出相连,实现人工接管;结束时恢复双方监听器、日志开关和转义设置。
|
|
49
|
+
def interact(input: $stdin, escape: nil, output: nil, timeout: nil)
|
|
50
|
+
source = interact_source(input)
|
|
51
|
+
output ||= input.equal?($stdin) ? $stdout : input
|
|
52
|
+
saved_self = [listeners, log_stdout, log_listeners]
|
|
53
|
+
saved_source = [source.listeners, source.log_stdout, source.log_listeners, source.sequences.dup]
|
|
54
|
+
terminal_state = prepare_interact_terminal(source)
|
|
55
|
+
display = interact_display(source, output, terminal_state)
|
|
56
|
+
# 临时建立“用户输入 -> 子进程 -> 显示输出”的双向连接,原监听关系在 ensure 中恢复。
|
|
57
|
+
self.listeners = [display]
|
|
58
|
+
self.log_stdout = false
|
|
59
|
+
self.log_listeners = true
|
|
60
|
+
source.listeners = [self]
|
|
61
|
+
source.log_stdout = false
|
|
62
|
+
source.log_listeners = true
|
|
63
|
+
source.on_sequence(escape) if escape
|
|
64
|
+
Expect.interconnect(self, source, timeout: timeout)
|
|
65
|
+
ensure
|
|
66
|
+
begin
|
|
67
|
+
if saved_self
|
|
68
|
+
self.listeners, self.log_stdout, self.log_listeners = saved_self
|
|
69
|
+
source.listeners, source.log_stdout, source.log_listeners, source.sequences = saved_source
|
|
70
|
+
end
|
|
71
|
+
ensure
|
|
72
|
+
restore_interact_terminal(terminal_state)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# 按各会话 listeners 建立转发图,处理转义、EOF 和总期限,返回引发停止的会话或 nil。
|
|
77
|
+
def self.interconnect(*sessions, timeout: nil)
|
|
78
|
+
raise ArgumentError, "interconnect requires Expect sessions" if sessions.empty? || sessions.any? do |session|
|
|
79
|
+
!session.is_a?(Expect)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Relay.new(sessions, timeout).run
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# 超时或异常后仍有未交付的数据;重新 interconnect 同一源会话可继续发送。
|
|
86
|
+
def pending_output? = @relay_outputs.any? { |output| !output.done? }
|
|
87
|
+
|
|
88
|
+
# 转发一个会话的待处理缓冲并剔除转义;返回 false 表示应结束整个转接。
|
|
89
|
+
# 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
|
|
90
|
+
def self.relay_buffer(session, buffers, final: false)
|
|
91
|
+
buffer = buffers.fetch(session)
|
|
92
|
+
loop do
|
|
93
|
+
sequences = session.__send__(:sequences).except(:eof)
|
|
94
|
+
history = session.__send__(:relay_history)
|
|
95
|
+
matches = sequences.filter_map do |key, handler|
|
|
96
|
+
if key.is_a?(Regexp)
|
|
97
|
+
position = Pattern.new(value: key).locate(history + buffer, final: final)
|
|
98
|
+
if position
|
|
99
|
+
offset, length, = position
|
|
100
|
+
raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
|
|
101
|
+
|
|
102
|
+
# 历史前缀已实时转发,不能撤回;负偏移表示本次只需消费转义尚未转发的部分。
|
|
103
|
+
[offset - history.bytesize, length, handler]
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
position = buffer.index(key)
|
|
107
|
+
[position, key.bytesize, handler] if position
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
if (found = matches.min_by(&:first))
|
|
111
|
+
position, length, callback = found
|
|
112
|
+
if position.positive?
|
|
113
|
+
if block_given?
|
|
114
|
+
yield buffer.byteslice(0, position)
|
|
115
|
+
buffer.replace(buffer.byteslice((position + length)..))
|
|
116
|
+
history.clear
|
|
117
|
+
# 已确定的转义不能在等待写入后重新匹配;尾部可能在等待期间继续增长。
|
|
118
|
+
session.__send__(:relay_callback=, [callback])
|
|
119
|
+
return :pending
|
|
120
|
+
end
|
|
121
|
+
session.__send__(:propagate, buffer.byteslice(0, position))
|
|
122
|
+
end
|
|
123
|
+
buffer.replace(buffer.byteslice([position + length, 0].max..))
|
|
124
|
+
# 转义消费后清除历史,防止继续回调再次匹配同一个转义。
|
|
125
|
+
history.clear
|
|
126
|
+
return false unless callback&.call
|
|
127
|
+
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# 暂存可能构成字面转义的最长后缀,保证 STOP 分两次读取时 ST 不会提前发给子进程。
|
|
132
|
+
held = 0
|
|
133
|
+
unless final
|
|
134
|
+
sequences.each_key do |key|
|
|
135
|
+
next if key.is_a?(Regexp)
|
|
136
|
+
|
|
137
|
+
[key.bytesize - 1, buffer.bytesize].min.downto(1) do |length|
|
|
138
|
+
if buffer.end_with?(key.byteslice(0, length))
|
|
139
|
+
held = [held, length].max
|
|
140
|
+
break
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
count = buffer.bytesize - held
|
|
146
|
+
count = [count, READ_SIZE].min if block_given?
|
|
147
|
+
if count.positive?
|
|
148
|
+
data = buffer.byteslice(0, count)
|
|
149
|
+
block_given? ? yield(data) : session.__send__(:propagate, data)
|
|
150
|
+
end
|
|
151
|
+
if sequences.keys.any?(Regexp)
|
|
152
|
+
history << buffer.byteslice(0, count)
|
|
153
|
+
limit = session.buffer_limit || REGEXP_ESCAPE_HISTORY_LIMIT
|
|
154
|
+
if history.bytesize > limit
|
|
155
|
+
history.replace(history.byteslice(-limit, limit))
|
|
156
|
+
if sequences.keys.any? { |key| key.is_a?(Regexp) && key.fixed_encoding? && key.encoding == Encoding::UTF_8 }
|
|
157
|
+
# 窗口可能从 UTF-8 续字节开始;丢弃不完整的首字符后再交给固定编码正则。
|
|
158
|
+
history.slice!(0) while (byte = history.getbyte(0)) && (0x80..0xBF).cover?(byte)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
buffer.slice!(0, count)
|
|
163
|
+
return block_given? && count.positive? ? :pending : true
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private_class_method :relay_buffer
|
|
168
|
+
|
|
169
|
+
def interact_source(input)
|
|
170
|
+
return input if input.is_a?(Expect)
|
|
171
|
+
|
|
172
|
+
@interact_inputs ||= {}.compare_by_identity
|
|
173
|
+
@interact_inputs.delete_if { |io, session| io.closed? || session.closed? }
|
|
174
|
+
@interact_inputs[input] ||= Expect.open(input)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def queue_output(data)
|
|
178
|
+
targets = []
|
|
179
|
+
targets << $stdout if log_stdout?
|
|
180
|
+
targets.concat(@listeners) if log_listeners?
|
|
181
|
+
@relay_outputs = targets.map do |target|
|
|
182
|
+
if target.is_a?(InteractOutput)
|
|
183
|
+
RelayWriter.new(target.target, target.render(data))
|
|
184
|
+
else
|
|
185
|
+
RelayWriter.new(target, data)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private :interact_source, :queue_output
|
|
191
|
+
|
|
192
|
+
# Only interact knows which stream is the local keyboard. Generic interconnect leaves terminals alone.
|
|
193
|
+
def prepare_interact_terminal(source)
|
|
194
|
+
return unless source.raw_terminal? && source.tty?
|
|
195
|
+
|
|
196
|
+
io = source.to_io
|
|
197
|
+
state = [io, io.console_mode]
|
|
198
|
+
io.raw!
|
|
199
|
+
state
|
|
200
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Restore a partially changed terminal on interrupts.
|
|
201
|
+
restore_interact_terminal(state)
|
|
202
|
+
raise
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def restore_interact_terminal(state)
|
|
206
|
+
return unless state
|
|
207
|
+
|
|
208
|
+
io, mode = state
|
|
209
|
+
io.console_mode = mode unless io.closed?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def interact_display(source, output, terminal_state)
|
|
213
|
+
@interact_output = nil unless terminal_state && @interact_output&.target.equal?(output)
|
|
214
|
+
return output unless terminal_state
|
|
215
|
+
|
|
216
|
+
target = output.respond_to?(:to_io) ? output.to_io : output
|
|
217
|
+
return output unless target.respond_to?(:tty?) && target.tty?
|
|
218
|
+
return output unless source.to_io.stat.rdev == target.stat.rdev
|
|
219
|
+
|
|
220
|
+
# 同一输出的 CRLF 可能跨越两次接管;只缓存最近目标,避免长期持有旧终端。
|
|
221
|
+
@interact_output ||= InteractOutput.new(output)
|
|
222
|
+
rescue IOError, SystemCallError
|
|
223
|
+
output
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private :prepare_interact_terminal, :restore_interact_terminal, :interact_display
|
|
227
|
+
|
|
228
|
+
protected
|
|
229
|
+
|
|
230
|
+
# 仅供转接内部保存和恢复注册表,避免公开可变 Hash 绕过 on_sequence 的校验。
|
|
231
|
+
attr_accessor :sequences
|
|
232
|
+
# 让同步写入的背压读取遵守当前转接的数据所有权,退出后恢复普通匹配缓冲。
|
|
233
|
+
attr_accessor :interaction_buffer
|
|
234
|
+
attr_reader :relay_outputs
|
|
235
|
+
attr_accessor :relay_callback
|
|
236
|
+
|
|
237
|
+
# 历史属于产生它的转义规则;同规则重入继续匹配,换规则不能重放已转发输入。
|
|
238
|
+
def relay_history
|
|
239
|
+
sequences = @sequences.except(:eof)
|
|
240
|
+
@relay_history = "".b unless @relay_history_sequences == sequences
|
|
241
|
+
@relay_history_sequences = sequences
|
|
242
|
+
@relay_history
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# 转接尚未处理的输入不能被匹配窗口上限裁掉;下次 expect 会重新应用该上限。
|
|
246
|
+
def restore_relay_buffer(buffer)
|
|
247
|
+
@buffer = buffer + @buffer
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 驱动一次单会话或多会话匹配,管理模式优先级、EOF 和共享期限,不接管 IO 所有权。
|
|
5
|
-
class
|
|
5
|
+
class Matcher
|
|
6
6
|
# 固定本次参与的会话及初始期限;已处理 EOF 的会话仅从本次等待中移除。
|
|
7
7
|
def initialize(patterns, timeout)
|
|
8
8
|
@patterns = patterns
|
|
@@ -10,12 +10,24 @@ class Expect
|
|
|
10
10
|
@timeout = Expect.duration(timeout)
|
|
11
11
|
@deadline = next_deadline
|
|
12
12
|
@handled_eof = []
|
|
13
|
+
@stalled_matches = {}
|
|
13
14
|
@polled = false
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
# 运行匹配状态机;内部 :retry 表示继续循环,最终返回一个 Result。
|
|
17
18
|
def run
|
|
18
|
-
@
|
|
19
|
+
@relay_buffers = {}
|
|
20
|
+
@sessions.each do |session|
|
|
21
|
+
buffer = session.__send__(:interaction_buffer)
|
|
22
|
+
if buffer
|
|
23
|
+
# 转义回调中的显式匹配临时接管读取,先消费转接已经预读的尾部。
|
|
24
|
+
@relay_buffers[session] = buffer
|
|
25
|
+
session.__send__(:interaction_buffer=, nil)
|
|
26
|
+
session.__send__(:restore_relay_buffer, buffer)
|
|
27
|
+
buffer.clear
|
|
28
|
+
end
|
|
29
|
+
session.__send__(:reset_result)
|
|
30
|
+
end
|
|
19
31
|
loop do
|
|
20
32
|
# 先消费已缓冲的匹配,再处理 EOF,最后读取;避免进程退出时丢失最后一个匹配。
|
|
21
33
|
result = if (matched = find_match)
|
|
@@ -27,6 +39,11 @@ class Expect
|
|
|
27
39
|
end
|
|
28
40
|
return result unless result == :retry
|
|
29
41
|
end
|
|
42
|
+
ensure
|
|
43
|
+
@relay_buffers.each do |session, buffer|
|
|
44
|
+
buffer.replace(session.clear_buffer)
|
|
45
|
+
session.__send__(:interaction_buffer=, buffer)
|
|
46
|
+
end
|
|
30
47
|
end
|
|
31
48
|
|
|
32
49
|
private
|
|
@@ -38,8 +55,15 @@ class Expect
|
|
|
38
55
|
next if @handled_eof.include?(session)
|
|
39
56
|
|
|
40
57
|
buffer = session.buffer
|
|
58
|
+
stalled = @stalled_matches[session]
|
|
59
|
+
if stalled && stalled[:buffer] != buffer
|
|
60
|
+
@stalled_matches.delete(session)
|
|
61
|
+
stalled = nil
|
|
62
|
+
end
|
|
41
63
|
patterns.each do |pattern|
|
|
42
|
-
|
|
64
|
+
next if stalled && stalled[:patterns].include?(pattern)
|
|
65
|
+
|
|
66
|
+
position = pattern.locate(buffer, final: session.eof?)
|
|
43
67
|
return [session, pattern, position] if position
|
|
44
68
|
end
|
|
45
69
|
end
|
|
@@ -49,12 +73,17 @@ class Expect
|
|
|
49
73
|
|
|
50
74
|
# 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
|
|
51
75
|
def handle_match(session, pattern, position)
|
|
76
|
+
previous_buffer = session.buffer
|
|
52
77
|
result = session.__send__(:record_match, pattern, position)
|
|
53
78
|
action = pattern.call(session)
|
|
54
79
|
return result unless continuing?(action)
|
|
55
80
|
|
|
81
|
+
# 回调未改变缓冲时暂停当前模式,等待缓冲变化后再匹配,避免原地空转。
|
|
82
|
+
if session.buffer == previous_buffer
|
|
83
|
+
stalled = (@stalled_matches[session] ||= { buffer: previous_buffer, patterns: [] })
|
|
84
|
+
stalled[:patterns] << pattern
|
|
85
|
+
end
|
|
56
86
|
@deadline = next_deadline if action == CONTINUE
|
|
57
|
-
# 零宽匹配可能不消费字节,必须在继续回调后检查期限,避免只匹配缓冲而永久空转。
|
|
58
87
|
return handle_timeout if action == CONTINUE_WITHOUT_RESET && expired?
|
|
59
88
|
|
|
60
89
|
:retry
|
|
@@ -100,12 +129,14 @@ class Expect
|
|
|
100
129
|
readable.each do |io|
|
|
101
130
|
session = sessions.find { |candidate| candidate.to_io.equal?(io) }
|
|
102
131
|
begin
|
|
103
|
-
|
|
132
|
+
# 转接回调消费匹配内容,余下字节交回 Relay,不能在这里提前转发两次。
|
|
133
|
+
data = session.__send__(:read_available, propagate: !@relay_buffers.key?(session))
|
|
104
134
|
rescue Errno::EINTR
|
|
105
135
|
next
|
|
106
136
|
rescue IOError, SystemCallError => error
|
|
107
137
|
return session.__send__(:record_error, error)
|
|
108
138
|
end
|
|
139
|
+
@stalled_matches.delete(session) if data
|
|
109
140
|
@deadline = next_deadline if data && session.reset_timeout_on_read?
|
|
110
141
|
end
|
|
111
142
|
:retry
|
|
@@ -113,12 +144,16 @@ class Expect
|
|
|
113
144
|
|
|
114
145
|
# 返回本次仍需监听的会话,供读取选择和超时回调使用。
|
|
115
146
|
def active_sessions = @sessions.reject { |session| @handled_eof.include?(session) }
|
|
147
|
+
|
|
116
148
|
# 只有约定的继续符号会驱动下一轮,普通回调返回值不会改变等待流程。
|
|
117
149
|
def continuing?(action) = [CONTINUE, CONTINUE_WITHOUT_RESET].include?(action)
|
|
150
|
+
|
|
118
151
|
# 使用单调时钟计算期限;nil 一直表示无限等待,不受系统时间调整影响。
|
|
119
152
|
def next_deadline = @timeout && (Expect.monotonic + @timeout)
|
|
153
|
+
|
|
120
154
|
# 计算传给 select 的非负等待秒数,避免计时跨过边界时产生负数。
|
|
121
155
|
def remaining = @deadline && [@deadline - Expect.monotonic, 0].max
|
|
156
|
+
|
|
122
157
|
# 判断有限期限是否已到达;无限等待不会触发超时。
|
|
123
158
|
def expired? = @deadline && Expect.monotonic >= @deadline
|
|
124
159
|
|
data/lib/expect/pattern.rb
CHANGED
|
@@ -12,7 +12,7 @@ class Expect
|
|
|
12
12
|
def eof? = value == :eof
|
|
13
13
|
|
|
14
14
|
# 在缓冲中定位字符串或正则,返回 [字节偏移, 字节长度, 捕获组];事件或未匹配返回 nil。
|
|
15
|
-
def locate(buffer)
|
|
15
|
+
def locate(buffer, final: false)
|
|
16
16
|
case value
|
|
17
17
|
when String
|
|
18
18
|
offset = buffer.index(value)
|
|
@@ -21,8 +21,9 @@ class Expect
|
|
|
21
21
|
text = buffer.dup
|
|
22
22
|
text.force_encoding(value.encoding) if value.fixed_encoding?
|
|
23
23
|
unless text.valid_encoding?
|
|
24
|
-
#
|
|
25
|
-
text
|
|
24
|
+
# 不完整的尾字符可能改变锚点或前瞻结果,必须等字符收齐后再匹配。
|
|
25
|
+
validate_incomplete_suffix!(text, final: final)
|
|
26
|
+
return nil
|
|
26
27
|
end
|
|
27
28
|
found = value.match(text)
|
|
28
29
|
return unless found
|
|
@@ -37,24 +38,15 @@ class Expect
|
|
|
37
38
|
private
|
|
38
39
|
|
|
39
40
|
# 仅容忍末尾尚未收全的 UTF-8 字符,其他非法编码直接报错,不静默替换接收字节。
|
|
40
|
-
def
|
|
41
|
-
if text.encoding == Encoding::UTF_8
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
expected = case lead
|
|
48
|
-
when 0xC2..0xDF then 2
|
|
49
|
-
when 0xE0..0xEF then 3
|
|
50
|
-
when 0xF0..0xF4 then 4
|
|
51
|
-
end
|
|
52
|
-
if expected && length < expected && suffix.bytes.drop(1).all? do |byte|
|
|
53
|
-
(0x80..0xBF).cover?(byte)
|
|
54
|
-
end && prefix.valid_encoding?
|
|
55
|
-
return prefix
|
|
56
|
-
end
|
|
41
|
+
def validate_incomplete_suffix!(text, final:)
|
|
42
|
+
if !final && text.encoding == Encoding::UTF_8
|
|
43
|
+
incomplete = begin
|
|
44
|
+
text.encode(Encoding::UTF_16LE)
|
|
45
|
+
false
|
|
46
|
+
rescue Encoding::InvalidByteSequenceError => error
|
|
47
|
+
error.incomplete_input?
|
|
57
48
|
end
|
|
49
|
+
return if incomplete
|
|
58
50
|
end
|
|
59
51
|
raise EncodingError, "received invalid #{text.encoding} data; use a binary regexp (/.../n) for binary streams"
|
|
60
52
|
end
|
data/lib/expect/relay.rb
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Expect
|
|
4
|
+
# 一次转接的共同读写循环;发送游标留在源会话中,调用结束后仍可继续。
|
|
5
|
+
class Relay
|
|
6
|
+
def initialize(sessions, timeout)
|
|
7
|
+
@sessions = sessions.uniq
|
|
8
|
+
@active = @sessions.dup
|
|
9
|
+
period = Expect.duration(timeout)
|
|
10
|
+
@deadline = period && (Expect.monotonic + period)
|
|
11
|
+
@buffers = @sessions.to_h { |session| [session, session.clear_buffer] }
|
|
12
|
+
@previous = @sessions.to_h { |session| [session, session.__send__(:interaction_buffer)] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
@sessions.each { |session| session.__send__(:interaction_buffer=, @buffers.fetch(session)) }
|
|
17
|
+
outputs.each(&:restart_timeout)
|
|
18
|
+
polled = false
|
|
19
|
+
loop do
|
|
20
|
+
idle = []
|
|
21
|
+
queued = false
|
|
22
|
+
@active.dup.each do |session|
|
|
23
|
+
next if session.pending_output?
|
|
24
|
+
|
|
25
|
+
if (callback = session.__send__(:relay_callback))
|
|
26
|
+
session.__send__(:relay_callback=, nil)
|
|
27
|
+
return session unless callback.first&.call
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
result = Expect.__send__(:relay_buffer, session, @buffers, final: session.eof?) do |data|
|
|
31
|
+
session.__send__(:queue_output, data)
|
|
32
|
+
end
|
|
33
|
+
return session unless result
|
|
34
|
+
|
|
35
|
+
if result == :pending
|
|
36
|
+
queued = true
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
idle << session
|
|
41
|
+
next unless session.eof?
|
|
42
|
+
|
|
43
|
+
callback = session.__send__(:sequences)[:eof]
|
|
44
|
+
return session unless callback&.call
|
|
45
|
+
|
|
46
|
+
@active.delete(session)
|
|
47
|
+
end
|
|
48
|
+
return nil if @active.empty?
|
|
49
|
+
|
|
50
|
+
return finish_timeout(idle) if polled && expired?
|
|
51
|
+
|
|
52
|
+
progress = advance_outputs || queued
|
|
53
|
+
|
|
54
|
+
pending = outputs
|
|
55
|
+
readers = read_sources(pending)
|
|
56
|
+
deadlines = [@deadline, *pending.map(&:deadline)].compact
|
|
57
|
+
remaining = deadlines.empty? ? nil : [deadlines.min - Expect.monotonic, 0].max
|
|
58
|
+
# 有进展时轮询输入再继续发送,避免大块输出饿死其他来源。
|
|
59
|
+
remaining = 0 if progress
|
|
60
|
+
begin
|
|
61
|
+
polled = true
|
|
62
|
+
ready = IO.select(readers.map(&:to_io), pending.filter_map(&:io).uniq, nil, remaining)
|
|
63
|
+
if ready
|
|
64
|
+
readers.each do |session|
|
|
65
|
+
next unless ready[0].include?(session.to_io)
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
if @buffers.key?(session)
|
|
69
|
+
session.__send__(:read_available, propagate: false, buffer: @buffers.fetch(session))
|
|
70
|
+
else
|
|
71
|
+
session.__send__(:read_available, propagate: false)
|
|
72
|
+
end
|
|
73
|
+
rescue Errno::EINTR
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
elsif !progress
|
|
78
|
+
return finish_timeout(idle) if expired?
|
|
79
|
+
|
|
80
|
+
pending.each(&:check_timeout!)
|
|
81
|
+
end
|
|
82
|
+
rescue Errno::EINTR
|
|
83
|
+
next
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
ensure
|
|
87
|
+
@previous.each { |session, buffer| session.__send__(:interaction_buffer=, buffer) }
|
|
88
|
+
@buffers.each { |session, buffer| session.__send__(:restore_relay_buffer, buffer) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def outputs = @sessions.flat_map { |session| session.__send__(:relay_outputs) }.reject(&:done?)
|
|
94
|
+
|
|
95
|
+
def advance_outputs(check_timeout: true)
|
|
96
|
+
progress = false
|
|
97
|
+
@sessions.each do |session|
|
|
98
|
+
pending = session.__send__(:relay_outputs)
|
|
99
|
+
pending.each { |output| progress = output.advance(check_timeout: check_timeout) || progress }
|
|
100
|
+
pending.reject!(&:done?)
|
|
101
|
+
end
|
|
102
|
+
progress
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def expired? = @deadline && Expect.monotonic >= @deadline
|
|
106
|
+
|
|
107
|
+
def read_sources(pending)
|
|
108
|
+
# 输出目标可能也在等待我们读取;这些来源即使有待发送数据也必须继续排空。
|
|
109
|
+
targets = pending.map(&:target).grep(Expect)
|
|
110
|
+
sources = @active.reject { |session| session.pending_output? && !targets.include?(session) }
|
|
111
|
+
(sources + targets).uniq(&:to_io).reject(&:eof?)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def finish_timeout(idle)
|
|
115
|
+
# 调度延迟可能让两种期限均已到达,仍按先到的期限决定结果。
|
|
116
|
+
outputs.each { |output| output.check_timeout! if output.deadline && output.deadline < @deadline }
|
|
117
|
+
idle.each do |session|
|
|
118
|
+
next if session.pending_output?
|
|
119
|
+
|
|
120
|
+
buffer = @buffers.fetch(session)
|
|
121
|
+
next if buffer.empty?
|
|
122
|
+
|
|
123
|
+
session.__send__(:queue_output, buffer.dup)
|
|
124
|
+
buffer.clear
|
|
125
|
+
end
|
|
126
|
+
# 到期后只尝试一次非阻塞写入,剩余游标留给下一次 interconnect。
|
|
127
|
+
advance_outputs(check_timeout: false)
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private_constant :Relay
|
|
133
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Expect
|
|
4
|
+
# 向单个转接目标写入数据,维护发送进度与期限;恢复时不重放已成功写出的前缀。
|
|
5
|
+
class RelayWriter
|
|
6
|
+
attr_reader :target, :deadline
|
|
7
|
+
|
|
8
|
+
def initialize(target, data)
|
|
9
|
+
@target = target
|
|
10
|
+
@data = data
|
|
11
|
+
@offset = 0
|
|
12
|
+
@flushed = false
|
|
13
|
+
restart_timeout
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def restart_timeout
|
|
17
|
+
period = target.write_timeout if target.is_a?(Expect)
|
|
18
|
+
@deadline = period && (Expect.monotonic + period)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def io
|
|
22
|
+
return target.writer if target.is_a?(Expect)
|
|
23
|
+
|
|
24
|
+
target if target.is_a?(IO)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def done? = @offset == @data.bytesize && @flushed
|
|
28
|
+
|
|
29
|
+
# 每轮每目标至多一次写入;真实 IO 永不在此等待,交给 Relay 的共同 select。
|
|
30
|
+
def advance(check_timeout: true)
|
|
31
|
+
return false if done?
|
|
32
|
+
raise IOError, "closed Expect session" if target.is_a?(Expect) && target.closed?
|
|
33
|
+
|
|
34
|
+
if @offset < @data.bytesize
|
|
35
|
+
bytes = @data.byteslice(@offset, READ_SIZE)
|
|
36
|
+
count = io ? io.write_nonblock(bytes, exception: false) : target.write(bytes)
|
|
37
|
+
if count == :wait_writable
|
|
38
|
+
check_timeout! if check_timeout
|
|
39
|
+
return false
|
|
40
|
+
end
|
|
41
|
+
unless count.is_a?(Integer) && count.positive? && count <= bytes.bytesize
|
|
42
|
+
raise IOError, "write must return the number of accepted bytes"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@offset += count
|
|
46
|
+
end
|
|
47
|
+
if @offset == @data.bytesize
|
|
48
|
+
# write_nonblock 已直接写到底层;普通 write 对象仍遵守自身 flush 协议。
|
|
49
|
+
target.flush if !io && target.respond_to?(:flush)
|
|
50
|
+
@flushed = true
|
|
51
|
+
end
|
|
52
|
+
true
|
|
53
|
+
rescue Errno::EINTR
|
|
54
|
+
check_timeout! if check_timeout
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_timeout!
|
|
59
|
+
return unless deadline && Expect.monotonic >= deadline
|
|
60
|
+
|
|
61
|
+
raise WriteTimeout.new("relay target write timed out", bytes_written: @offset)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private_constant :RelayWriter
|
|
66
|
+
end
|
data/lib/expect/result.rb
CHANGED
|
@@ -6,8 +6,10 @@ class Expect
|
|
|
6
6
|
Result = Struct.new(:number, :error, :match, :before, :after, :session, :captures, keyword_init: true) do
|
|
7
7
|
# 是否命中文本模式;事件不会返回模式序号。
|
|
8
8
|
def matched? = !number.nil?
|
|
9
|
+
|
|
9
10
|
# 是否因本次等待期限到达而返回。
|
|
10
11
|
def timeout? = error == :timeout
|
|
12
|
+
|
|
11
13
|
# 是否读取源已经结束;子进程是否退出仍应查询会话的 process_status。
|
|
12
14
|
def eof? = error == :eof
|
|
13
15
|
end
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 独立保存句柄、PID 和日志所有权,让终结器无需直接捕获会话即可清理遗弃资源。
|
|
5
|
-
class
|
|
6
|
-
attr_accessor :pid, :status, :
|
|
5
|
+
class SessionResources
|
|
6
|
+
attr_accessor :pid, :status, :owned_log
|
|
7
7
|
attr_reader :reader, :writer, :slave, :owner, :own
|
|
8
8
|
|
|
9
9
|
# 记录创建资源的进程;fork 后的副本不能向父进程拥有的子进程发信号。
|
|
@@ -43,7 +43,7 @@ class Expect
|
|
|
43
43
|
return unless owner == Process.pid
|
|
44
44
|
|
|
45
45
|
close_handles
|
|
46
|
-
|
|
46
|
+
owned_log.close if owned_log && !owned_log.closed?
|
|
47
47
|
reap
|
|
48
48
|
return unless pid
|
|
49
49
|
|
data/lib/expect/version.rb
CHANGED