expect-pty 0.3.1 → 0.3.2

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.
@@ -49,12 +49,18 @@ class Expect
49
49
  private
50
50
 
51
51
  # 按声明组、会话、模式的顺序寻找首个匹配,不按文本中的出现位置重新排序。
52
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 声明优先级与单轮快照须在同一次扫描保持一致。
52
53
  def find_match
53
- @patterns.groups.each do |sessions, patterns|
54
+ # 单组且来源不重复时无需缓存;重复来源才为本轮扫描建立快照表。
55
+ groups = @patterns.groups
56
+ if groups.size > 1 || (groups.first && groups.first.first.size > @sessions.size)
57
+ snapshots = {}.compare_by_identity
58
+ end
59
+ groups.each do |sessions, patterns|
54
60
  sessions.each do |session|
55
61
  next if @handled_eof.include?(session)
56
62
 
57
- buffer = session.buffer
63
+ buffer = snapshots ? (snapshots[session] ||= session.buffer) : session.buffer
58
64
  stalled = @stalled_matches[session]
59
65
  if stalled && stalled[:buffer] != buffer
60
66
  @stalled_matches.delete(session)
@@ -70,6 +76,7 @@ class Expect
70
76
  end
71
77
  nil
72
78
  end
79
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
73
80
 
74
81
  # 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
75
82
  def handle_match(session, pattern, position)
@@ -126,8 +133,13 @@ class Expect
126
133
 
127
134
  # 每个就绪 IO 读取一次,将异常归属到对应会话;仅显式启用时按接收数据刷新期限。
128
135
  def read_ready(readable, sessions)
136
+ # select 返回 IO 对象本身;共享同一 IO 时仍选择声明顺序中的首个会话。
137
+ if readable.size > 1
138
+ by_io = {}.compare_by_identity
139
+ sessions.each { |session| by_io[session.to_io] ||= session }
140
+ end
129
141
  readable.each do |io|
130
- session = sessions.find { |candidate| candidate.to_io.equal?(io) }
142
+ session = by_io ? by_io.fetch(io) : sessions.find { |candidate| candidate.to_io.equal?(io) }
131
143
  begin
132
144
  # 转接回调消费匹配内容,余下字节交回 Relay,不能在这里提前转发两次。
133
145
  data = session.__send__(:read_available, propagate: !@relay_buffers.key?(session))
@@ -28,9 +28,9 @@ class Expect
28
28
  found = value.match(text)
29
29
  return unless found
30
30
 
31
- # Ruby 正则偏移按字符计算,缓冲切片按字节计算,必须转换;捕获组也统一返回字节串。
32
- offset = text[0...found.begin(0)].bytesize
33
- return [offset, found[0].bytesize, found.captures.map { |capture| capture&.b }]
31
+ # 直接使用正则的字节范围,避免为转换字符偏移创建前缀切片;捕获组也返回字节串。
32
+ offset, finish = found.byteoffset(0)
33
+ return [offset, finish - offset, found.captures.map { |capture| capture&.b }]
34
34
  end
35
35
  nil
36
36
  end
data/lib/expect/relay.rb CHANGED
@@ -12,6 +12,7 @@ class Expect
12
12
  @previous = @sessions.to_h { |session| [session, session.__send__(:interaction_buffer)] }
13
13
  end
14
14
 
15
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 共享读写循环统一维护来源和目标期限。
15
16
  def run
16
17
  @sessions.each { |session| session.__send__(:interaction_buffer=, @buffers.fetch(session)) }
17
18
  outputs.each(&:restart_timeout)
@@ -66,7 +67,7 @@ class Expect
66
67
 
67
68
  begin
68
69
  if @buffers.key?(session)
69
- session.__send__(:read_available, propagate: false, buffer: @buffers.fetch(session))
70
+ session.__send__(:read_available, propagate: false, buffer: @buffers.fetch(session), trim: false)
70
71
  else
71
72
  session.__send__(:read_available, propagate: false)
72
73
  end
@@ -87,6 +88,7 @@ class Expect
87
88
  @previous.each { |session, buffer| session.__send__(:interaction_buffer=, buffer) }
88
89
  @buffers.each { |session, buffer| session.__send__(:restore_relay_buffer, buffer) }
89
90
  end
91
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
90
92
 
91
93
  private
92
94
 
@@ -27,6 +27,7 @@ class Expect
27
27
  def done? = @offset == @data.bytesize && @flushed
28
28
 
29
29
  # 每轮每目标至多一次写入;真实 IO 永不在此等待,交给 Relay 的共同 select。
30
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 游标、短写、flush 和期限按单个目标推进。
30
31
  def advance(check_timeout: true)
31
32
  return false if done?
32
33
  raise IOError, "closed Expect session" if target.is_a?(Expect) && target.closed?
@@ -54,6 +55,7 @@ class Expect
54
55
  check_timeout! if check_timeout
55
56
  true
56
57
  end
58
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
57
59
 
58
60
  def check_timeout!
59
61
  return unless deadline && Expect.monotonic >= deadline
@@ -20,7 +20,13 @@ class Expect
20
20
  return unless own
21
21
 
22
22
  # PTY 的读写端可能是同一个对象,先去重,重复关闭也保持安全。
23
- [reader, writer, slave].compact.uniq.each { |io| io.close unless io.closed? }
23
+ failure = nil
24
+ [reader, writer, slave].compact.uniq.each do |io|
25
+ io.close unless io.closed?
26
+ rescue IOError, SystemCallError => error
27
+ failure ||= error
28
+ end
29
+ raise failure if failure
24
30
  end
25
31
 
26
32
  # 非阻塞回收直属子进程,缓存 Process::Status,成功后清空 PID 以支持重复查询。
@@ -42,15 +48,27 @@ class Expect
42
48
  def finalize
43
49
  return unless owner == Process.pid
44
50
 
45
- close_handles
46
- owned_log.close if owned_log && !owned_log.closed?
47
- reap
48
- return unless pid
49
-
50
- Process.kill("KILL", pid)
51
- # 将最终 wait 交给后台回收线程,避免在 GC 终结器中阻塞等待。
52
- Process.detach(pid)
53
- @pid = nil
51
+ begin
52
+ close_handles
53
+ ensure
54
+ begin
55
+ owned_log.close if owned_log && !owned_log.closed?
56
+ ensure
57
+ # 每个阶段独立收尾;句柄或日志关闭失败不能跳过进程回收。
58
+ reap
59
+ if pid
60
+ begin
61
+ Process.kill("KILL", pid)
62
+ rescue Errno::ESRCH
63
+ # 子进程可能刚好退出,仍需尝试 wait,不能留下僵尸进程。
64
+ nil
65
+ end
66
+ # 将最终 wait 交给后台回收线程,避免在 GC 终结器中阻塞等待。
67
+ Process.detach(pid)
68
+ @pid = nil
69
+ end
70
+ end
71
+ end
54
72
  rescue IOError, SystemCallError
55
73
  nil
56
74
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.3.1"
5
+ VERSION = "0.3.2"
6
6
  end
data/lib/expect.rb CHANGED
@@ -21,6 +21,8 @@ class Expect
21
21
  CONTINUE = :continue
22
22
  CONTINUE_WITHOUT_RESET = :continue_without_reset
23
23
  READ_SIZE = 16_384
24
+ CONFIGURATION_MUTEX = Mutex.new
25
+ private_constant :CONFIGURATION_MUTEX
24
26
 
25
27
  class SpawnError < StandardError; end
26
28
 
@@ -40,14 +42,20 @@ class Expect
40
42
  return @configuration if defined?(@configuration)
41
43
  return superclass.configuration unless self == Expect
42
44
 
43
- @configuration = Configuration.new.freeze
45
+ CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new.freeze }
44
46
  end
45
47
 
46
48
  # 基于旧快照构造可修改副本,全部赋值与配置块成功后才发布,异常时保留原配置。
47
49
  def configure(**)
48
- updated = Configuration.new(**configuration.to_h, **)
49
- yield updated if block_given?
50
- @configuration = updated.freeze
50
+ raise ThreadError, "nested configure is not supported" if CONFIGURATION_MUTEX.owned?
51
+
52
+ # 初始化默认快照后,将整个读改写过程串行化,避免并发配置丢失更新。
53
+ configuration
54
+ CONFIGURATION_MUTEX.synchronize do
55
+ updated = Configuration.new(**configuration.to_h, **)
56
+ yield updated if block_given?
57
+ @configuration = updated.freeze
58
+ end
51
59
  end
52
60
 
53
61
  # 创建并启动会话;有块时返回块结果并确保关闭,无块时由调用方负责生命周期。
@@ -173,6 +181,7 @@ class Expect
173
181
  end
174
182
 
175
183
  # 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
184
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- PTY 子进程启动与失败回传共用一次生命周期。
176
185
  def spawn(*command, env: {}, chdir: nil)
177
186
  raise SpawnError, "cannot reuse a spawned session" if @command
178
187
  raise SpawnError, "only a new PTY session can spawn" unless @slave && !@slave.closed? && !closed?
@@ -223,6 +232,7 @@ class Expect
223
232
  from_child&.close unless from_child&.closed?
224
233
  to_parent&.close unless to_parent&.closed?
225
234
  end
235
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
226
236
 
227
237
  # 在当前会话等待文本或事件,返回模式序号或 nil。
228
238
  def expect(...) = expect_result(...).number
@@ -297,6 +307,7 @@ class Expect
297
307
  end
298
308
 
299
309
  # 按 Ruby to_s 规则原样写入所有字节,返回字节数;背压等待受 write_timeout 限制。
310
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 写入、背压排空和同一期限必须同步推进。
300
311
  def write(*objects)
301
312
  raise IOError, "closed Expect session" if closed? || writer.closed?
302
313
 
@@ -339,6 +350,7 @@ class Expect
339
350
  end
340
351
  data.bytesize
341
352
  end
353
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
342
354
 
343
355
  # 链式写入单个对象,返回当前会话。
344
356
  def <<(object)
@@ -364,7 +376,7 @@ class Expect
364
376
  object.to_s.each_char do |character|
365
377
  sleep(pause) if pause.positive?
366
378
  count += write(character)
367
- read_available if !eof? && to_io.wait_readable(0.01)
379
+ read_available if !eof? && to_io.wait_readable(0)
368
380
  end
369
381
  end
370
382
  count
@@ -497,27 +509,48 @@ class Expect
497
509
 
498
510
  # 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
499
511
  def finish_close(timeout:, term_timeout:, force:)
512
+ failure = nil
513
+ # 预期的清理错误延后传播,保证其余所属资源和直属子进程仍能完成清理。
514
+ cleanup = lambda do |&step|
515
+ step.call
516
+ rescue IOError, SystemCallError => error
517
+ failure ||= error
518
+ nil
519
+ end
500
520
  # IO 关闭与进程退出独立记录:软关闭可能已经 closed?,但仍保留活跃 PID。
501
- @resources.close_handles
521
+ cleanup.call { @resources.close_handles }
502
522
  @closed = true
503
- @interact_inputs&.each_value { |input| input.close(graceful: false) }
504
- @interact_inputs&.clear
523
+ @interact_inputs&.delete_if do |_io, input|
524
+ cleanup.call do
525
+ input.close(graceful: false)
526
+ true
527
+ end
528
+ end
505
529
  @interact_output = nil
506
530
  @relay_outputs&.clear
507
531
  @relay_history&.clear
508
532
  @relay_callback = nil
533
+ status = finish_child_close(timeout: timeout, term_timeout: term_timeout, force: force)
534
+ completed = true
535
+ status
536
+ ensure
537
+ cleanup.call { self.log_output = nil }
538
+ # 用本次流程的完成状态判断异常传播,不能误把调用者 rescue 中的异常当成当前错误。
539
+ raise failure if failure && completed
540
+ end
541
+
542
+ # 句柄清理失败不改变进程策略;未回收 PID 保留给重复关闭或终结器继续处理。
543
+ def finish_child_close(timeout:, term_timeout:, force:)
509
544
  return process_status unless @resources.owner == Process.pid && pid
510
545
  return process_status if wait(timeout: timeout)
511
546
 
512
547
  signal_child("TERM")
513
548
  return process_status if wait(timeout: term_timeout)
514
549
 
515
- if force
516
- signal_child("KILL")
517
- wait(timeout: 1)
518
- end
519
- ensure
520
- self.log_output = nil
550
+ return unless force
551
+
552
+ signal_child("KILL")
553
+ wait(timeout: 1)
521
554
  end
522
555
 
523
556
  # 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
@@ -568,13 +601,14 @@ class Expect
568
601
  end
569
602
 
570
603
  # 先将读取字节交给匹配或转接缓冲,再记录日志;日志失败也能恢复输入。
571
- def read_available(propagate: true, buffer: @buffer)
604
+ def read_available(propagate: true, buffer: @buffer, trim: true)
572
605
  return nil if eof?
573
606
 
574
607
  # 写入背压也会读取;转接期间统一交给转义处理器,不能直接转发或另存匹配缓冲。
575
608
  if @interaction_buffer
576
609
  buffer = @interaction_buffer
577
610
  propagate = false
611
+ trim = false
578
612
  end
579
613
 
580
614
  begin
@@ -597,7 +631,7 @@ class Expect
597
631
  end
598
632
  data = data.b
599
633
  buffer << data
600
- trim_buffer if buffer.equal?(@buffer)
634
+ trim_buffer if trim
601
635
  trace("received #{data.inspect}", level: 2) if debug_level >= 2
602
636
  trace("buffer #{@buffer.inspect}", level: 3) if debug_level >= 3
603
637
  # 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
data/script/ci CHANGED
@@ -6,6 +6,11 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.."
6
6
  bundle exec rake
7
7
  bundle exec ruby examples/dialogue.rb
8
8
 
9
+ # 只检查小规模工作负载的正确性,不为共享 CI 机器设置性能阈值。
10
+ for benchmark in matching relay send_slow; do
11
+ bundle exec ruby "benchmark/${benchmark}.rb" --smoke
12
+ done
13
+
9
14
  mkdir -p tmp/ci
10
15
  gem_version="$(ruby -Ilib -rexpect/version -e 'print Expect::VERSION')"
11
16
  gem_file="tmp/ci/expect-pty-${gem_version}.gem"
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class CleanupTest < ExpectTest
6
+ def test_close_handles_attempts_every_owned_handle_and_preserves_first_error
7
+ reader, writer = IO.pipe
8
+ extra, peer = IO.pipe
9
+ @ios.push(reader, writer, extra, peer)
10
+ resources = Expect::SessionResources.new(reader, writer: writer, slave: extra, own: true)
11
+ failure = IOError.new("reader close failed")
12
+ reader.stub(:close, -> { raise failure }) do
13
+ assert_same failure, assert_raises(IOError) { resources.close_handles }
14
+ assert writer.closed?
15
+ assert extra.closed?
16
+ end
17
+ resources.close_handles
18
+ assert reader.closed?
19
+ resources.close_handles
20
+ end
21
+
22
+ def test_hard_close_finishes_wrappers_log_and_child_after_handle_failure
23
+ session = stubborn_child
24
+ pid = session.pid
25
+ first, = pipe_session
26
+ second, = pipe_session
27
+ session.instance_variable_set(:@interact_inputs, { first.to_io => first, second.to_io => second })
28
+ Dir.mktmpdir do |dir|
29
+ log = session.log_to(File.join(dir, "owned.log"))
30
+ failure = IOError.new("handle close failed")
31
+ session.to_io.stub(:close, -> { raise failure }) do
32
+ first.stub(:close, ->(**) { raise IOError, "wrapper close failed" }) do
33
+ error = assert_raises(IOError) { bounded { session.hard_close(timeout: 0.01) } }
34
+ assert_same failure, error
35
+ assert second.closed?
36
+ refute second.to_io.closed?, "borrowed wrapper IO must remain open"
37
+ assert log.closed?
38
+ assert_nil session.log_output
39
+ assert_nil session.pid
40
+ assert_equal Signal.list.fetch("KILL"), session.process_status.termsig
41
+ assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
42
+ end
43
+ end
44
+ end
45
+ ensure
46
+ session&.hard_close(timeout: 0)
47
+ end
48
+
49
+ def test_owned_log_close_failure_is_visible_after_child_reaping_and_retryable
50
+ session = stubborn_child
51
+ Dir.mktmpdir do |dir|
52
+ log = session.log_to(File.join(dir, "owned.log"))
53
+ log.stub(:close, -> { raise IOError, "log close failed" }) do
54
+ assert_raises(IOError) { bounded { session.hard_close(timeout: 0.01) } }
55
+ assert_nil session.pid
56
+ assert_same log, session.log_output
57
+ end
58
+ status = session.hard_close(timeout: 0)
59
+ assert_same session.process_status, status
60
+ assert log.closed?
61
+ assert_nil session.log_output
62
+ end
63
+ ensure
64
+ session&.hard_close(timeout: 0)
65
+ end
66
+
67
+ def test_explicit_close_inside_rescue_does_not_hide_a_new_cleanup_error
68
+ session, = pipe_session(own: true)
69
+ failure = IOError.new("close failed inside caller rescue")
70
+ begin
71
+ raise ArgumentError, "already handled"
72
+ rescue ArgumentError
73
+ session.to_io.stub(:close, -> { raise failure }) do
74
+ assert_same failure, assert_raises(IOError) { session.hard_close(timeout: 0) }
75
+ end
76
+ end
77
+ end
78
+
79
+ def test_unexpected_process_error_is_not_replaced_by_cleanup_failure
80
+ session = stubborn_child
81
+ failure = RuntimeError.new("process wait failed")
82
+ session.to_io.stub(:close, -> { raise IOError, "close failed" }) do
83
+ session.stub(:wait, ->(**) { raise failure }) do
84
+ assert_same failure, assert_raises(RuntimeError) { session.hard_close(timeout: 0) }
85
+ end
86
+ end
87
+ ensure
88
+ session&.hard_close(timeout: 0)
89
+ end
90
+
91
+ def test_soft_close_handle_failure_still_sends_term_but_never_kill
92
+ session = stubborn_child
93
+ signals = []
94
+ original = Process.method(:kill)
95
+ Process.stub(:kill, lambda { |signal, pid|
96
+ signals << signal
97
+ original.call(signal, pid)
98
+ }) do
99
+ session.to_io.stub(:close, -> { raise Errno::EIO, "close failed" }) do
100
+ assert_raises(Errno::EIO) { bounded { session.soft_close(timeout: 0, term_timeout: 0) } }
101
+ end
102
+ end
103
+ assert_equal ["TERM"], signals
104
+ assert session.alive?
105
+ assert session.closed?
106
+ ensure
107
+ session&.hard_close(timeout: 0)
108
+ end
109
+
110
+ def test_finalizer_reaps_after_both_handle_and_log_close_fail
111
+ session = stubborn_child
112
+ pid = session.pid
113
+ resources = session.instance_variable_get(:@resources)
114
+ log_attempted = false
115
+ Dir.mktmpdir do |dir|
116
+ log = session.log_to(File.join(dir, "owned.log"))
117
+ session.to_io.stub(:close, -> { raise IOError, "handle failed" }) do
118
+ log.stub(:close, lambda {
119
+ log_attempted = true
120
+ raise Errno::EIO, "log failed"
121
+ }) do
122
+ bounded { resources.finalize }
123
+ end
124
+ end
125
+ assert log_attempted
126
+ assert_nil resources.pid
127
+ bounded do
128
+ loop do
129
+ Process.kill(0, pid)
130
+ sleep 0.005
131
+ rescue Errno::ESRCH
132
+ break
133
+ end
134
+ end
135
+ end
136
+ ensure
137
+ session&.hard_close(timeout: 0)
138
+ end
139
+
140
+ def test_non_owner_cleanup_never_signals_and_finalizer_leaves_handles_alone
141
+ session = stubborn_child
142
+ resources = session.instance_variable_get(:@resources)
143
+ Process.stub(:pid, -1) do
144
+ Process.stub(:kill, ->(*) { flunk "non-owner sent a signal" }) do
145
+ resources.finalize
146
+ refute session.to_io.closed?
147
+ assert_nil session.hard_close(timeout: 0)
148
+ end
149
+ end
150
+ assert session.alive?
151
+ ensure
152
+ session&.hard_close(timeout: 0)
153
+ end
154
+
155
+ private
156
+
157
+ def stubborn_child
158
+ session = child('Signal.trap("HUP", "IGNORE"); Signal.trap("TERM", "IGNORE"); puts "ready"; sleep 60',
159
+ raw_pty: true)
160
+ assert_equal 1, session.expect("ready", timeout: 2)
161
+ session
162
+ end
163
+ end
@@ -56,6 +56,49 @@ class ConfigurationTest < ExpectTest
56
56
  assert_equal 2, Expect.configuration.timeout
57
57
  end
58
58
 
59
+ def test_concurrent_configure_updates_are_serialized
60
+ subclass = Class.new(Expect)
61
+ entered = Queue.new
62
+ second_started = Queue.new
63
+ release = Queue.new
64
+ first = Thread.new do
65
+ subclass.configure do |config|
66
+ entered << true
67
+ release.pop
68
+ config.timeout = 1
69
+ end
70
+ end
71
+ entered.pop
72
+ second = Thread.new do
73
+ second_started << true
74
+ subclass.configure(debug_level: 2)
75
+ end
76
+ second_started.pop
77
+ # 第一轮尚未发布时,第二轮不能从同一旧快照完成更新。
78
+ refute second.join(0.05), "concurrent configure published before the first update finished"
79
+ release << true
80
+ first.value
81
+ second.value
82
+ assert_equal 1, subclass.configuration.timeout
83
+ assert_equal 2, subclass.configuration.debug_level
84
+ ensure
85
+ release&.push(true)
86
+ first&.join
87
+ second&.join
88
+ end
89
+
90
+ def test_nested_configure_fails_without_publishing_partial_changes
91
+ subclass = Class.new(Expect)
92
+ previous = subclass.configuration
93
+ assert_raises(ThreadError) do
94
+ subclass.configure do |config|
95
+ config.timeout = 1
96
+ subclass.configure(debug_level: 2)
97
+ end
98
+ end
99
+ assert_same previous, subclass.configuration
100
+ end
101
+
59
102
  def test_attribute_validation_preserves_the_current_value_and_buffer
60
103
  session, = pipe_session(buffer_limit: 8, timeout: 1, write_timeout: 2, debug_level: 1)
61
104
  session.buffer = "contents"
data/test/io_test.rb CHANGED
@@ -133,6 +133,41 @@ class IOTest < ExpectTest
133
133
  assert_equal 1, session.expect("ABC", timeout: 1)
134
134
  end
135
135
 
136
+ def test_send_slow_zero_delay_only_polls_and_later_reply_remains_readable
137
+ client, peer = Socket.pair(:UNIX, :STREAM, 0)
138
+ @ios.push(client, peer)
139
+ session = Expect.open(client, log_stdout: false)
140
+ @sessions << session
141
+ waits = []
142
+ original = client.method(:wait_readable)
143
+ client.stub(:wait_readable, lambda { |timeout|
144
+ waits << timeout
145
+ original.call(0)
146
+ }) do
147
+ assert_equal 8, session.send_slow("a中😀", delay: 0)
148
+ end
149
+ assert_equal [0, 0, 0], waits
150
+ assert_equal("a中😀".b, bounded { peer.read(8) })
151
+ peer.write("late reply")
152
+ assert_equal 1, session.expect("late reply", timeout: 1)
153
+ assert_empty session.buffer
154
+ end
155
+
156
+ def test_send_slow_preserves_character_delay_and_write_timeout
157
+ session, = pipe_session
158
+ calls = []
159
+ failure = Expect::WriteTimeout.new(bytes_written: 0)
160
+ session.stub(:sleep, ->(duration) { calls << [:sleep, duration] }) do
161
+ session.stub(:write, lambda { |data|
162
+ calls << [:write, data]
163
+ raise failure
164
+ }) do
165
+ assert_same failure, assert_raises(Expect::WriteTimeout) { session.send_slow("中b", delay: 0.25) }
166
+ end
167
+ end
168
+ assert_equal [[:sleep, 0.25], [:write, "中"]], calls
169
+ end
170
+
136
171
  def test_large_bidirectional_write_does_not_deadlock
137
172
  session = child("STDIN.binmode; STDOUT.binmode; loop { print STDIN.readpartial(4096) }", raw_pty: true,
138
173
  write_timeout: 3)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class PatternOffsetTest < ExpectTest
6
+ def test_byte_offsets_captures_and_buffer_consumption
7
+ cases = [
8
+ ["abENDtail", /END/, 2, 3, []],
9
+ ["中文终点尾", /(终)(点)/, 6, 6, ["终".b, "点".b]],
10
+ ["😀中😀尾", /(😀)(x)?/, 0, 4, ["😀".b, nil]],
11
+ ["😀中END尾", /END/, 7, 3, []],
12
+ ["a\0b", /(\0)/n, 1, 1, ["\0".b]],
13
+ ["\xFF\x00\xFE".b, /(\xFE)/n, 2, 1, ["\xFE".b]],
14
+ ["中文尾", /(?=尾)/, 6, 0, []],
15
+ ["😀", /$/u, 4, 0, []]
16
+ ]
17
+ session, = pipe_session
18
+ cases.each do |text, regexp, offset, length, captures|
19
+ bytes = text.b
20
+ assert_equal [offset, length, captures], Expect::Pattern.new(value: regexp).locate(bytes)
21
+ session.buffer = bytes
22
+ result = session.expect_result(regexp, timeout: 0)
23
+ assert_equal bytes.byteslice(0, offset), result.before
24
+ assert_equal bytes.byteslice(offset, length), result.match
25
+ assert_equal bytes.byteslice((offset + length)..), result.after
26
+ assert_equal captures, result.captures
27
+ assert_equal result.after, session.buffer
28
+ end
29
+ end
30
+
31
+ def test_incomplete_utf8_defers_matching_and_eof_still_rejects_it
32
+ pattern = Expect::Pattern.new(value: /中(?=😀)/)
33
+ bytes = "前中😀".b
34
+ (1..3).each do |missing|
35
+ partial = bytes.byteslice(0, bytes.bytesize - missing)
36
+ assert_nil pattern.locate(partial)
37
+ assert_raises(EncodingError) { pattern.locate(partial, final: true) }
38
+ end
39
+ assert_equal [3, 3, []], pattern.locate(bytes)
40
+ end
41
+ end
@@ -234,6 +234,43 @@ class RelayRecoveryTest < ExpectTest
234
234
  end
235
235
  end
236
236
 
237
+ def test_split_literal_and_short_write_failures_preserve_delivery_and_callback_order
238
+ [1, 2, 3].product([1, 2, 3], [0, 2]).each do |split, step, failure_offset|
239
+ source, writer = pipe_session
240
+ fast = StringIO.new
241
+ received = "".b
242
+ failed = false
243
+ slow = Object.new
244
+ slow.define_singleton_method(:write) do |data|
245
+ if !failed && received.bytesize == failure_offset
246
+ failed = true
247
+ raise IOError, "injected partial delivery"
248
+ end
249
+ count = [step, data.bytesize].min
250
+ count = [count, failure_offset - received.bytesize].min unless failed
251
+ received << data.byteslice(0, count)
252
+ count
253
+ end
254
+ callbacks = []
255
+ source.listeners = [fast, slow]
256
+ source.on_sequence("STOP") do
257
+ callbacks << [fast.string.dup, received.dup]
258
+ false
259
+ end
260
+ source.buffer = "head#{"STOP".byteslice(0, split)}"
261
+ writer.write("#{"STOP".byteslice(split..)}tail")
262
+ assert_raises(IOError) { bounded { Expect.interconnect(source, timeout: 1) } }
263
+ assert_empty callbacks
264
+ assert source.pending_output?
265
+ assert_same(source, bounded { Expect.interconnect(source, timeout: 1) })
266
+ assert_equal [%w[head head]], callbacks
267
+ assert_equal "tail", source.buffer
268
+ assert_equal "head", fast.string
269
+ assert_equal "head", received
270
+ refute source.pending_output?
271
+ end
272
+ end
273
+
237
274
  def test_flush_failure_retries_flush_without_replaying_data
238
275
  source, = pipe_session
239
276
  output = StringIO.new