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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  class Expect
4
4
  # 驱动一次单会话或多会话匹配,管理模式优先级、EOF 和共享期限,不接管 IO 所有权。
5
- class Engine
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
- @sessions.each { |session| session.__send__(:reset_result) }
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
- position = pattern.locate(buffer)
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
- data = session.__send__(:read_available)
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
 
@@ -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
- # 一次读取可能截断 UTF-8 字符。仅对完整前缀做本轮匹配,原缓冲保留残片等待后续字节。
25
- text = complete_prefix(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 complete_prefix(text)
41
- if text.encoding == Encoding::UTF_8
42
- # UTF-8 字符最多四字节,不完整后缀最多三字节。逐一验证头字节、续字节及剩余前缀。
43
- 1.upto([3, text.bytesize].min) do |length|
44
- prefix = text.byteslice(0, text.bytesize - length)
45
- suffix = text.byteslice(text.bytesize - length, length).b
46
- lead = suffix.getbyte(0)
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
@@ -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 Resources
6
- attr_accessor :pid, :status, :log, :own_log
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
- log.close if own_log && log && !log.closed?
46
+ owned_log.close if owned_log && !owned_log.closed?
47
47
  reap
48
48
  return unless pid
49
49
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
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/resources"
12
+ require_relative "expect/session_resources"
13
13
  require_relative "expect/pattern"
14
14
  require_relative "expect/pattern_list"
15
- require_relative "expect/engine"
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
- class WriteTimeout < IOError; end
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
- ready = IO.select(active.map(&:to_io), nil, nil, timeout)
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
- Engine.new(pattern_list.validate!, timeout).run
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
- count = writer.write_nonblock(data.byteslice(offset, READ_SIZE), exception: false)
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, "write timed out after #{write_timeout} seconds" if deadline && Expect.monotonic >= deadline
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
- ready = IO.select(readers, [writer], nil, remaining)
278
- raise WriteTimeout, "write timed out after #{write_timeout} seconds" unless ready
279
-
280
- read_available if ready[0].include?(to_io)
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
- def log_output = @resources.log
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
- # 库打开的文件由 Resources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
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 = Resources.new(reader, writer: writer, slave: slave, own: own)
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, Resources.finalizer(@resources))
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
- # 进行一次非阻塞读取并记录日志;accumulate/propagate 决定是否交给匹配缓冲和监听器。
509
- def read_available(propagate: true, accumulate: 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
- if accumulate
532
- @buffer << data
533
- trim_buffer
534
- end
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
- target.write(data)
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.own_log && previous && !previous.closed?
573
- @resources.log = target
574
- @resources.own_log = owned
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/interconnect"
666
+ require_relative "expect/interaction"