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.
@@ -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 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.1"
6
6
  end