expect-pty 0.2.0 → 0.3.0
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 +9 -0
- data/README.md +22 -9
- data/docs/COMPATIBILITY.md +23 -1
- data/docs/RELEASING.md +21 -8
- data/docs/VERIFICATION.md +65 -1
- data/expect-pty.gemspec +1 -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 +3 -4
- 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/relay_recovery_test.rb +414 -0
- data/test/release_test.rb +67 -1
- data/test/support/script_probe.rb +1 -0
- metadata +9 -6
- data/lib/expect/interconnect.rb +0 -170
|
@@ -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
data/lib/expect.rb
CHANGED
|
@@ -9,10 +9,10 @@ require "forwardable"
|
|
|
9
9
|
require_relative "expect/version"
|
|
10
10
|
require_relative "expect/configuration"
|
|
11
11
|
require_relative "expect/result"
|
|
12
|
-
require_relative "expect/
|
|
12
|
+
require_relative "expect/session_resources"
|
|
13
13
|
require_relative "expect/pattern"
|
|
14
14
|
require_relative "expect/pattern_list"
|
|
15
|
-
require_relative "expect/
|
|
15
|
+
require_relative "expect/matcher"
|
|
16
16
|
|
|
17
17
|
# 自动化交互会话:可以拥有一个 PTY 子进程,也可以适配已有可 select 的 IO。
|
|
18
18
|
# 缓冲和匹配统一保留原始字节,配置、匹配结果与资源生命周期分别管理。
|
|
@@ -23,7 +23,16 @@ class Expect
|
|
|
23
23
|
READ_SIZE = 16_384
|
|
24
24
|
|
|
25
25
|
class SpawnError < StandardError; end
|
|
26
|
-
|
|
26
|
+
|
|
27
|
+
# 已被底层接受的字节不可撤回;调用方可据此只处理尚未写出的后缀。
|
|
28
|
+
class WriteTimeout < IOError
|
|
29
|
+
attr_reader :bytes_written
|
|
30
|
+
|
|
31
|
+
def initialize(message = "write timed out", bytes_written: 0)
|
|
32
|
+
@bytes_written = bytes_written
|
|
33
|
+
super(message)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
27
36
|
|
|
28
37
|
class << self
|
|
29
38
|
# 读取冻结的默认配置;子类未单独配置时继承父类快照。
|
|
@@ -78,6 +87,7 @@ class Expect
|
|
|
78
87
|
|
|
79
88
|
# 返回继续等待的控制符,reset_timeout 决定是否重新计算匹配期限。
|
|
80
89
|
def continue(reset_timeout: true) = reset_timeout ? CONTINUE : CONTINUE_WITHOUT_RESET
|
|
90
|
+
|
|
81
91
|
# 读取不受系统时间调整影响的单调时钟,所有相对超时共用此计时基准。
|
|
82
92
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
93
|
|
|
@@ -99,7 +109,20 @@ class Expect
|
|
|
99
109
|
active = sessions.uniq.reject(&:closed?)
|
|
100
110
|
return [] if active.empty?
|
|
101
111
|
|
|
102
|
-
|
|
112
|
+
deadline = timeout && (monotonic + timeout)
|
|
113
|
+
polled = false
|
|
114
|
+
ready = nil
|
|
115
|
+
loop do
|
|
116
|
+
remaining = deadline && [deadline - monotonic, 0].max
|
|
117
|
+
return [] if polled && remaining&.zero?
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
ready = IO.select(active.map(&:to_io), nil, nil, remaining)
|
|
121
|
+
break
|
|
122
|
+
rescue Errno::EINTR
|
|
123
|
+
polled = true
|
|
124
|
+
end
|
|
125
|
+
end
|
|
103
126
|
return [] unless ready
|
|
104
127
|
|
|
105
128
|
active.select { |session| ready.first.include?(session.to_io) }
|
|
@@ -116,7 +139,7 @@ class Expect
|
|
|
116
139
|
if block
|
|
117
140
|
block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
|
|
118
141
|
end
|
|
119
|
-
|
|
142
|
+
Matcher.new(pattern_list.validate!, timeout).run
|
|
120
143
|
end
|
|
121
144
|
end
|
|
122
145
|
|
|
@@ -214,14 +237,21 @@ class Expect
|
|
|
214
237
|
|
|
215
238
|
# 暴露底层读写 IO 与终端属性,供 select、终端设置及 IO 适配使用。
|
|
216
239
|
def to_io = @resources.reader
|
|
240
|
+
|
|
217
241
|
def writer = @resources.writer
|
|
242
|
+
|
|
218
243
|
def fileno = closed? ? nil : to_io.fileno
|
|
244
|
+
|
|
219
245
|
def tty? = !closed? && to_io.tty?
|
|
246
|
+
|
|
220
247
|
# 诊断时仅显示进程和描述符状态,避免默认对象展开泄露缓冲或日志内容。
|
|
221
248
|
def inspect = "#<#{self.class} pid=#{pid.inspect} fd=#{fileno.inspect} closed=#{closed?}>"
|
|
249
|
+
|
|
222
250
|
def pid = @resources.pid
|
|
251
|
+
|
|
223
252
|
# 非阻塞回收并缓存子进程状态;未退出或仅适配 IO 时返回 nil。
|
|
224
253
|
def process_status = @resources.reap
|
|
254
|
+
|
|
225
255
|
def exit_code = process_status&.exitstatus
|
|
226
256
|
|
|
227
257
|
# 先刷新回收状态,再判断是否仍有未回收的子进程;不以 IO 是否关闭代替进程状态。
|
|
@@ -232,14 +262,22 @@ class Expect
|
|
|
232
262
|
|
|
233
263
|
# 区分会话关闭和输入结束,已关闭会话也不能继续读取。
|
|
234
264
|
def closed? = @closed || to_io.closed?
|
|
265
|
+
|
|
235
266
|
def eof? = @eof || closed?
|
|
267
|
+
|
|
236
268
|
# 以下访问器读取最近一次等待结果;未发生匹配时捕获组返回空数组。
|
|
237
269
|
def before = @last_result&.before
|
|
270
|
+
|
|
238
271
|
def after = @last_result&.after
|
|
272
|
+
|
|
239
273
|
def match = @last_result&.match
|
|
274
|
+
|
|
240
275
|
def match_number = @last_result&.number
|
|
276
|
+
|
|
241
277
|
def captures = @last_result&.captures || []
|
|
278
|
+
|
|
242
279
|
def error = @last_result&.error
|
|
280
|
+
|
|
243
281
|
# 返回缓冲副本,防止调用方原地修改绕过裁剪规则。
|
|
244
282
|
def buffer = @buffer.dup
|
|
245
283
|
|
|
@@ -263,21 +301,38 @@ class Expect
|
|
|
263
301
|
raise IOError, "closed Expect session" if closed? || writer.closed?
|
|
264
302
|
|
|
265
303
|
data = objects.map { |object| object.to_s.b }.join
|
|
266
|
-
trace("sending #{data.inspect}", level: 2)
|
|
304
|
+
trace("sending #{data.inspect}", level: 2) if debug_level >= 2
|
|
267
305
|
deadline = write_timeout && (Expect.monotonic + write_timeout)
|
|
268
306
|
offset = 0
|
|
269
307
|
while offset < data.bytesize
|
|
270
|
-
|
|
308
|
+
begin
|
|
309
|
+
count = writer.write_nonblock(data.byteslice(offset, READ_SIZE), exception: false)
|
|
310
|
+
rescue Errno::EINTR
|
|
311
|
+
raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline
|
|
312
|
+
|
|
313
|
+
next
|
|
314
|
+
end
|
|
271
315
|
if count == :wait_writable
|
|
272
|
-
raise WriteTimeout
|
|
316
|
+
raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline
|
|
273
317
|
|
|
274
318
|
remaining = deadline && [deadline - Expect.monotonic, 0].max
|
|
275
319
|
# 子进程也可能因输出管道填满而停止读取;等可写时同时排空它的输出,避免双向死锁。
|
|
276
320
|
readers = eof? ? [] : [to_io]
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
321
|
+
begin
|
|
322
|
+
ready = IO.select(readers, [writer], nil, remaining)
|
|
323
|
+
raise WriteTimeout.new(bytes_written: offset) unless ready
|
|
324
|
+
|
|
325
|
+
if ready[0].include?(to_io)
|
|
326
|
+
begin
|
|
327
|
+
read_available
|
|
328
|
+
rescue WriteTimeout
|
|
329
|
+
# 日志或监听器可嵌套写入;对外报告本次写入进度,原异常通过 cause 保留。
|
|
330
|
+
raise WriteTimeout.new("write interrupted by an output timeout", bytes_written: offset)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
rescue Errno::EINTR
|
|
334
|
+
next
|
|
335
|
+
end
|
|
281
336
|
else
|
|
282
337
|
offset += count
|
|
283
338
|
end
|
|
@@ -316,7 +371,7 @@ class Expect
|
|
|
316
371
|
end
|
|
317
372
|
|
|
318
373
|
# 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
|
|
319
|
-
|
|
374
|
+
attr_reader :log_output
|
|
320
375
|
|
|
321
376
|
# 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
|
|
322
377
|
def log_output=(target)
|
|
@@ -335,8 +390,8 @@ class Expect
|
|
|
335
390
|
if target.respond_to?(:to_path) || target.is_a?(String)
|
|
336
391
|
raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
|
|
337
392
|
|
|
338
|
-
# 库打开的文件由
|
|
339
|
-
replace_log(File.open(target, "#{mode}b"), owned: true)
|
|
393
|
+
# 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
|
|
394
|
+
replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
|
|
340
395
|
else
|
|
341
396
|
raise ArgumentError, "provide a log target or a block" unless target
|
|
342
397
|
|
|
@@ -445,6 +500,12 @@ class Expect
|
|
|
445
500
|
# IO 关闭与进程退出独立记录:软关闭可能已经 closed?,但仍保留活跃 PID。
|
|
446
501
|
@resources.close_handles
|
|
447
502
|
@closed = true
|
|
503
|
+
@interact_inputs&.each_value { |input| input.close(graceful: false) }
|
|
504
|
+
@interact_inputs&.clear
|
|
505
|
+
@interact_output = nil
|
|
506
|
+
@relay_outputs&.clear
|
|
507
|
+
@relay_history&.clear
|
|
508
|
+
@relay_callback = nil
|
|
448
509
|
return process_status unless @resources.owner == Process.pid && pid
|
|
449
510
|
return process_status if wait(timeout: timeout)
|
|
450
511
|
|
|
@@ -464,15 +525,16 @@ class Expect
|
|
|
464
525
|
raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
|
|
465
526
|
raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
|
|
466
527
|
|
|
467
|
-
@resources =
|
|
528
|
+
@resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
|
|
468
529
|
@pty = reader.tty?
|
|
469
530
|
@slave = slave
|
|
470
531
|
@configuration = Configuration.new(**self.class.configuration.to_h, **)
|
|
471
532
|
@buffer = "".b
|
|
472
533
|
@listeners = []
|
|
473
534
|
@sequences = {}
|
|
535
|
+
@relay_outputs = []
|
|
474
536
|
@closed = @eof = false
|
|
475
|
-
ObjectSpace.define_finalizer(self,
|
|
537
|
+
ObjectSpace.define_finalizer(self, SessionResources.finalizer(@resources))
|
|
476
538
|
end
|
|
477
539
|
|
|
478
540
|
# 开始新一轮等待时清除旧结果并应用缓冲上限,尚未消费的输入继续保留。
|
|
@@ -505,10 +567,16 @@ class Expect
|
|
|
505
567
|
@last_result
|
|
506
568
|
end
|
|
507
569
|
|
|
508
|
-
#
|
|
509
|
-
def read_available(propagate: true,
|
|
570
|
+
# 先将读取字节交给匹配或转接缓冲,再记录日志;日志失败也能恢复输入。
|
|
571
|
+
def read_available(propagate: true, buffer: @buffer)
|
|
510
572
|
return nil if eof?
|
|
511
573
|
|
|
574
|
+
# 写入背压也会读取;转接期间统一交给转义处理器,不能直接转发或另存匹配缓冲。
|
|
575
|
+
if @interaction_buffer
|
|
576
|
+
buffer = @interaction_buffer
|
|
577
|
+
propagate = false
|
|
578
|
+
end
|
|
579
|
+
|
|
512
580
|
begin
|
|
513
581
|
data = to_io.read_nonblock(READ_SIZE, exception: false)
|
|
514
582
|
rescue Errno::EIO
|
|
@@ -528,12 +596,10 @@ class Expect
|
|
|
528
596
|
return nil
|
|
529
597
|
end
|
|
530
598
|
data = data.b
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
trace("received #{data.inspect}", level: 2)
|
|
536
|
-
trace("buffer #{@buffer.inspect}", level: 3)
|
|
599
|
+
buffer << data
|
|
600
|
+
trim_buffer if buffer.equal?(@buffer)
|
|
601
|
+
trace("received #{data.inspect}", level: 2) if debug_level >= 2
|
|
602
|
+
trace("buffer #{@buffer.inspect}", level: 3) if debug_level >= 3
|
|
537
603
|
# 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
|
|
538
604
|
write_log(data)
|
|
539
605
|
propagate(data) if propagate
|
|
@@ -554,7 +620,15 @@ class Expect
|
|
|
554
620
|
|
|
555
621
|
# 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
|
|
556
622
|
def emit(target, data)
|
|
557
|
-
|
|
623
|
+
offset = 0
|
|
624
|
+
while offset < data.bytesize
|
|
625
|
+
count = target.write(data.byteslice(offset..))
|
|
626
|
+
unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
|
|
627
|
+
raise IOError, "write must return the number of accepted bytes"
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
offset += count
|
|
631
|
+
end
|
|
558
632
|
target.flush if target.respond_to?(:flush)
|
|
559
633
|
end
|
|
560
634
|
|
|
@@ -569,9 +643,10 @@ class Expect
|
|
|
569
643
|
# 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
|
|
570
644
|
return target if previous.equal?(target)
|
|
571
645
|
|
|
572
|
-
previous.close if @resources.
|
|
573
|
-
|
|
574
|
-
@resources.
|
|
646
|
+
previous.close if @resources.owned_log && previous && !previous.closed?
|
|
647
|
+
# 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
|
|
648
|
+
@resources.owned_log = owned ? target : nil
|
|
649
|
+
@log_output = target
|
|
575
650
|
target
|
|
576
651
|
rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
|
|
577
652
|
target.close if owned && target && !target.closed?
|
|
@@ -588,4 +663,4 @@ class Expect
|
|
|
588
663
|
end
|
|
589
664
|
end
|
|
590
665
|
|
|
591
|
-
require_relative "expect/
|
|
666
|
+
require_relative "expect/interaction"
|