expect-pty 0.3.0 → 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.
data/expect-pty.gemspec CHANGED
@@ -15,9 +15,17 @@ Gem::Specification.new do |spec|
15
15
  spec.files = Dir[
16
16
  "lib/**/*.rb", "examples/**/*.rb", "examples/**/*.md", "docs/**/*.md", "benchmark/**/*.rb",
17
17
  "test/**/*.rb", "test/**/*.sh", "test/**/*.md", "Gemfile", "Rakefile",
18
- ".rubocop.yml", "expect-pty.gemspec", "README.md", "LICENSE", "CHANGELOG.md", "script/ci", "script/release.rb"
18
+ ".rubocop.yml", "expect-pty.gemspec", "README.md", "CONTRIBUTING.md", "LICENSE", "CHANGELOG.md",
19
+ "script/ci", "script/release.rb"
19
20
  ]
20
21
  spec.require_paths = ["lib"]
22
+ # 显式声明可独立升级的标准库 gem,供应用的 Bundler 解析完整运行时依赖。
23
+ # pty 是 Ruby 自带的 POSIX 扩展,不是独立 gem。
24
+ spec.add_dependency "forwardable", "~> 1.3"
25
+ spec.add_dependency "io-console", ">= 0.6", "< 1.0"
26
+ spec.add_dependency "io-wait", ">= 0.3", "< 1.0"
27
+ spec.add_dependency "shellwords", ">= 0.1", "< 1.0"
28
+ spec.add_dependency "stringio", "~> 3.0"
21
29
  spec.metadata["rubygems_mfa_required"] = "true"
22
30
  spec.metadata["source_code_uri"] = spec.homepage
23
31
  spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
@@ -87,14 +87,20 @@ class Expect
87
87
 
88
88
  # 转发一个会话的待处理缓冲并剔除转义;返回 false 表示应结束整个转接。
89
89
  # 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
90
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 转义匹配、前缀暂存和回调消费属于同一轮扫描。
90
91
  def self.relay_buffer(session, buffers, final: false)
91
92
  buffer = buffers.fetch(session)
92
93
  loop do
93
94
  sequences = session.__send__(:sequences).except(:eof)
94
95
  history = session.__send__(:relay_history)
96
+ text = nil
97
+ utf8_regexp = false
95
98
  matches = sequences.filter_map do |key, handler|
96
99
  if key.is_a?(Regexp)
97
- position = Pattern.new(value: key).locate(history + buffer, final: final)
100
+ # 仅在本轮扫描复用组合文本;回调可能更换规则或消费缓冲,下一轮必须重新构造。
101
+ text ||= history + buffer
102
+ utf8_regexp ||= key.fixed_encoding? && key.encoding == Encoding::UTF_8
103
+ position = Pattern.new(value: key).locate(text, final: final)
98
104
  if position
99
105
  offset, length, = position
100
106
  raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
@@ -148,12 +154,12 @@ class Expect
148
154
  data = buffer.byteslice(0, count)
149
155
  block_given? ? yield(data) : session.__send__(:propagate, data)
150
156
  end
151
- if sequences.keys.any?(Regexp)
157
+ if text
152
158
  history << buffer.byteslice(0, count)
153
159
  limit = session.buffer_limit || REGEXP_ESCAPE_HISTORY_LIMIT
154
160
  if history.bytesize > limit
155
161
  history.replace(history.byteslice(-limit, limit))
156
- if sequences.keys.any? { |key| key.is_a?(Regexp) && key.fixed_encoding? && key.encoding == Encoding::UTF_8 }
162
+ if utf8_regexp
157
163
  # 窗口可能从 UTF-8 续字节开始;丢弃不完整的首字符后再交给固定编码正则。
158
164
  history.slice!(0) while (byte = history.getbyte(0)) && (0x80..0xBF).cover?(byte)
159
165
  end
@@ -163,6 +169,7 @@ class Expect
163
169
  return block_given? && count.positive? ? :pending : true
164
170
  end
165
171
  end
172
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
166
173
 
167
174
  private_class_method :relay_buffer
168
175
 
@@ -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.0"
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"
@@ -17,19 +22,44 @@ gem build expect-pty.gemspec --output "$gem_file"
17
22
  # 在独立目录安装刚构建的包,避免源码路径或 Bundler 掩盖打包遗漏。
18
23
  smoke_dir="$(mktemp -d)"
19
24
  trap 'rm -rf "$smoke_dir"' EXIT
20
- gem install --local --no-document --install-dir "$smoke_dir/gems" "$gem_file"
25
+ gem_file="$(pwd)/$gem_file"
26
+ bundler_version="$(bundle exec ruby -rbundler -e 'print Bundler::VERSION')"
27
+ bundler_gem="$(bundle exec ruby -e 'print Gem.loaded_specs.fetch("bundler").cache_file')"
21
28
 
22
29
  (
23
30
  cd "$smoke_dir"
24
- unset BUNDLE_GEMFILE BUNDLE_BIN_PATH RUBYOPT RUBYLIB
31
+ # 清除 Bundler 的自动加载与路径设置;Ruby 4 还会通过 BUNDLER_SETUP 自动启用 Bundler。
32
+ for bundle_variable in ${!BUNDLE_@} ${!BUNDLER_@}; do
33
+ unset "$bundle_variable"
34
+ done
35
+ unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS
25
36
  export GEM_HOME="$smoke_dir/gems" GEM_PATH="$smoke_dir/gems"
26
- ruby -r expect/pty -r rbconfig <<'RUBY'
37
+ gem install --local --no-document "$gem_file"
38
+
39
+ cat > smoke.rb <<'RUBY'
40
+ require "expect/pty"
41
+ require "rbconfig"
42
+
27
43
  spec = Gem.loaded_specs.fetch("expect-pty")
28
44
  # 临时目录可能经由符号链接访问,先统一为实际路径再检查。
29
45
  install_root = File.realpath(ENV.fetch("GEM_HOME"))
30
46
  abort "gem was loaded outside the isolated install" unless File.realpath(spec.full_gem_path).start_with?("#{install_root}/")
47
+ abort "Bundler leaked into the plain gem smoke" if ARGV.first == "plain" && defined?(Bundler)
48
+ unexpected = %w[minitest rake rubocop parallel].select { |name| Gem.loaded_specs.key?(name) }
49
+ abort "development dependencies leaked into the application: #{unexpected.join(', ')}" unless unexpected.empty?
50
+ spec.runtime_dependencies.each do |dependency|
51
+ loaded = Gem.loaded_specs.fetch(dependency.name)
52
+ abort "incompatible dependency: #{dependency}" unless dependency.matches_spec?(loaded)
53
+ end
54
+
55
+ # 检查实际使用的标准库已在包元数据中声明,默认 gem 可加载不能代替依赖声明。
56
+ %w[forwardable io-console io-wait shellwords stringio].each do |name|
57
+ abort "undeclared runtime dependency: #{name}" unless spec.runtime_dependencies.any? { |dependency| dependency.name == name }
58
+ end
31
59
  child = 'STDOUT.sync = true; puts "ready"; puts "reply:#{$stdin.gets&.strip}"'
32
- Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, log_stdout: false) do |session|
60
+ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_stdout: false) do |session|
61
+ session.winsize = [30, 100]
62
+ abort "io-console terminal size failed" unless session.winsize == [30, 100]
33
63
  abort "installed gem did not receive the PTY prompt" unless session.expect("ready", timeout: 5)
34
64
 
35
65
  session.puts("expect-ruby")
@@ -38,6 +68,19 @@ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, log_stdout: false) do
38
68
  abort "installed gem child did not exit successfully" unless status&.success?
39
69
  end
40
70
 
41
- puts "Installed #{spec.full_name}: PTY and isolated installation passed"
71
+ puts "Installed #{spec.full_name}: #{ARGV.fetch(0)} PTY and runtime dependencies passed"
42
72
  RUBY
73
+ ruby smoke.rb plain
74
+
75
+ # 消费方仅声明当前 Gem,不能借用项目 Gemfile 中的测试和发布依赖。
76
+ # 单独安装本次开发检查使用的 Bundler,避免依赖宿主 Ruby 的默认 Bundler 副本。
77
+ if [ -f "$bundler_gem" ]; then
78
+ gem install --local --no-document "$bundler_gem"
79
+ else
80
+ gem install --no-document bundler --version "$bundler_version"
81
+ fi
82
+ export BUNDLE_IGNORE_CONFIG=true
83
+ printf 'source "https://rubygems.org"\ngem "expect-pty", "= %s", require: "expect/pty"\n' "$gem_version" > Gemfile
84
+ bundle lock --local
85
+ bundle exec ruby -rbundler -e 'Bundler.require; load "smoke.rb"' bundler
43
86
  )
@@ -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"