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.
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"
data/script/ci CHANGED
@@ -6,9 +6,9 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.."
6
6
  bundle exec rake
7
7
  bundle exec ruby examples/dialogue.rb
8
8
 
9
- mkdir -p pkg/ci
9
+ mkdir -p tmp/ci
10
10
  gem_version="$(ruby -Ilib -rexpect/version -e 'print Expect::VERSION')"
11
- gem_file="pkg/ci/expect-pty-${gem_version}.gem"
11
+ gem_file="tmp/ci/expect-pty-${gem_version}.gem"
12
12
  if git rev-parse --git-dir >/dev/null 2>&1; then
13
13
  export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
14
14
  fi
@@ -17,20 +17,44 @@ gem build expect-pty.gemspec --output "$gem_file"
17
17
  # 在独立目录安装刚构建的包,避免源码路径或 Bundler 掩盖打包遗漏。
18
18
  smoke_dir="$(mktemp -d)"
19
19
  trap 'rm -rf "$smoke_dir"' EXIT
20
- gem install --local --no-document --install-dir "$smoke_dir/gems" "$gem_file"
20
+ gem_file="$(pwd)/$gem_file"
21
+ bundler_version="$(bundle exec ruby -rbundler -e 'print Bundler::VERSION')"
22
+ bundler_gem="$(bundle exec ruby -e 'print Gem.loaded_specs.fetch("bundler").cache_file')"
21
23
 
22
24
  (
23
25
  cd "$smoke_dir"
24
- unset BUNDLE_GEMFILE BUNDLE_BIN_PATH RUBYOPT RUBYLIB
26
+ # 清除 Bundler 的自动加载与路径设置;Ruby 4 还会通过 BUNDLER_SETUP 自动启用 Bundler。
27
+ for bundle_variable in ${!BUNDLE_@} ${!BUNDLER_@}; do
28
+ unset "$bundle_variable"
29
+ done
30
+ unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS
25
31
  export GEM_HOME="$smoke_dir/gems" GEM_PATH="$smoke_dir/gems"
26
- ruby -r expect/pty -r rbconfig <<'RUBY'
32
+ gem install --local --no-document "$gem_file"
33
+
34
+ cat > smoke.rb <<'RUBY'
35
+ require "expect/pty"
36
+ require "rbconfig"
37
+
27
38
  spec = Gem.loaded_specs.fetch("expect-pty")
28
39
  # 临时目录可能经由符号链接访问,先统一为实际路径再检查。
29
40
  install_root = File.realpath(ENV.fetch("GEM_HOME"))
30
41
  abort "gem was loaded outside the isolated install" unless File.realpath(spec.full_gem_path).start_with?("#{install_root}/")
42
+ abort "Bundler leaked into the plain gem smoke" if ARGV.first == "plain" && defined?(Bundler)
43
+ unexpected = %w[minitest rake rubocop parallel].select { |name| Gem.loaded_specs.key?(name) }
44
+ abort "development dependencies leaked into the application: #{unexpected.join(', ')}" unless unexpected.empty?
45
+ spec.runtime_dependencies.each do |dependency|
46
+ loaded = Gem.loaded_specs.fetch(dependency.name)
47
+ abort "incompatible dependency: #{dependency}" unless dependency.matches_spec?(loaded)
48
+ end
31
49
 
50
+ # 检查实际使用的标准库已在包元数据中声明,默认 gem 可加载不能代替依赖声明。
51
+ %w[forwardable io-console io-wait shellwords stringio].each do |name|
52
+ abort "undeclared runtime dependency: #{name}" unless spec.runtime_dependencies.any? { |dependency| dependency.name == name }
53
+ end
32
54
  child = 'STDOUT.sync = true; puts "ready"; puts "reply:#{$stdin.gets&.strip}"'
33
- Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, log_stdout: false) do |session|
55
+ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_stdout: false) do |session|
56
+ session.winsize = [30, 100]
57
+ abort "io-console terminal size failed" unless session.winsize == [30, 100]
34
58
  abort "installed gem did not receive the PTY prompt" unless session.expect("ready", timeout: 5)
35
59
 
36
60
  session.puts("expect-ruby")
@@ -39,6 +63,19 @@ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, log_stdout: false) do
39
63
  abort "installed gem child did not exit successfully" unless status&.success?
40
64
  end
41
65
 
42
- puts "Installed #{spec.full_name}: PTY dialogue and process cleanup passed"
66
+ puts "Installed #{spec.full_name}: #{ARGV.fetch(0)} PTY and runtime dependencies passed"
43
67
  RUBY
68
+ ruby smoke.rb plain
69
+
70
+ # 消费方仅声明当前 Gem,不能借用项目 Gemfile 中的测试和发布依赖。
71
+ # 单独安装本次开发检查使用的 Bundler,避免依赖宿主 Ruby 的默认 Bundler 副本。
72
+ if [ -f "$bundler_gem" ]; then
73
+ gem install --local --no-document "$bundler_gem"
74
+ else
75
+ gem install --no-document bundler --version "$bundler_version"
76
+ fi
77
+ export BUNDLE_IGNORE_CONFIG=true
78
+ printf 'source "https://rubygems.org"\ngem "expect-pty", "= %s", require: "expect/pty"\n' "$gem_version" > Gemfile
79
+ bundle lock --local
80
+ bundle exec ruby -rbundler -e 'Bundler.require; load "smoke.rb"' bundler
44
81
  )
data/script/release.rb CHANGED
@@ -16,11 +16,12 @@ class Release
16
16
  REPOSITORY = "gatework/expect-ruby"
17
17
  GEM_HOST = "https://rubygems.org"
18
18
 
19
- def initialize(artifact: nil, dry_run: false)
19
+ def initialize(artifact: nil, dry_run: false, rubygems_only: false)
20
20
  @version = Expect::VERSION
21
21
  @tag = "v#{@version}"
22
- @artifact = File.expand_path(artifact || "pkg/ci/expect-pty-#{@version}.gem")
22
+ @artifact = File.expand_path(artifact || "tmp/ci/expect-pty-#{@version}.gem")
23
23
  @dry_run = dry_run
24
+ @rubygems_only = rubygems_only
24
25
  @build = artifact.nil?
25
26
  end
26
27
 
@@ -34,7 +35,7 @@ class Release
34
35
 
35
36
  command("bash", "script/ci") if @build
36
37
  # 独占目录中的副本贯穿校验和上传,其他构建不会改变本次发布的字节。
37
- directory = File.join("pkg", "release", @version)
38
+ directory = File.join("tmp", "release", @version)
38
39
  FileUtils.mkdir_p(directory)
39
40
  directory = Dir.mktmpdir("candidate-", directory)
40
41
  candidate = File.join(directory, "expect-pty-#{@version}.gem")
@@ -49,9 +50,13 @@ class Release
49
50
  puts "Verified #{@tag}: #{@sha256}\nArtifact: #{@artifact}"
50
51
  return puts "Dry run complete: #{@artifact}" if @dry_run
51
52
 
52
- verify_remote_source
53
- verify_registry_checksum(registry_version)
54
- publish_github
53
+ if @rubygems_only
54
+ verify_local_source
55
+ else
56
+ verify_remote_source
57
+ verify_registry_checksum(registry_version)
58
+ publish_github
59
+ end
55
60
  publish_rubygems
56
61
  end
57
62
 
@@ -97,10 +102,14 @@ class Release
97
102
  JSON.parse(output)
98
103
  end
99
104
 
105
+ def verify_local_source
106
+ return if capture("git", "rev-parse", "HEAD") == @commit && capture("git", "status", "--porcelain").empty?
107
+
108
+ raise "Source changed during verification; commit the changes and start again"
109
+ end
110
+
100
111
  def verify_remote_source
101
- unless capture("git", "rev-parse", "HEAD") == @commit && capture("git", "status", "--porcelain").empty?
102
- raise "Source changed during verification; commit the changes and start again"
103
- end
112
+ verify_local_source
104
113
 
105
114
  comparison = github("compare/#{@commit}...main")
106
115
  raise "Push this commit to #{REPOSITORY}/main first" unless %w[ahead identical].include?(comparison.fetch("status"))
@@ -249,7 +258,10 @@ end
249
258
  if $PROGRAM_NAME == __FILE__
250
259
  options = {}
251
260
  parser = OptionParser.new do |arguments|
252
- arguments.banner = "Usage: ruby script/release.rb [--dry-run] [--artifact PATH]"
261
+ arguments.banner = "Usage: ruby script/release.rb [--rubygems-only] [--dry-run] [--artifact PATH]"
262
+ arguments.on("--rubygems-only", "Publish only to RubyGems using the existing gem login; no GitHub access") do
263
+ options[:rubygems_only] = true
264
+ end
253
265
  arguments.on("--artifact PATH", "Publish an already verified gem without rebuilding") do |path|
254
266
  options[:artifact] = File.expand_path(path)
255
267
  end
@@ -13,6 +13,74 @@ class EdgeCaseTest < ExpectTest
13
13
  assert result.timeout?
14
14
  end
15
15
 
16
+ def test_zero_width_continuation_waits_for_new_input_before_matching_again
17
+ session, = pipe_session
18
+ session.buffer = "ready"
19
+ calls = 0
20
+
21
+ result = bounded(1) do
22
+ session.expect_result(timeout: 0.01) do
23
+ on(/(?=ready)/) do
24
+ calls += 1
25
+ Expect.continue
26
+ end
27
+ end
28
+ end
29
+
30
+ assert result.timeout?
31
+ assert_equal 1, calls
32
+ end
33
+
34
+ def test_stalled_pattern_can_match_again_after_new_input
35
+ session, writer = pipe_session
36
+ session.buffer = "ready"
37
+ first_match = Queue.new
38
+ background do
39
+ first_match.pop
40
+ writer.write("!")
41
+ end
42
+ calls = 0
43
+
44
+ result = bounded(1) do
45
+ session.expect_result(timeout: 0.5) do
46
+ on(/(?=ready)/) do
47
+ calls += 1
48
+ first_match << true if calls == 1
49
+ calls == 1 ? Expect.continue : nil
50
+ end
51
+ end
52
+ end
53
+
54
+ assert result.matched?
55
+ assert_equal 2, calls
56
+ assert_equal "ready!", session.buffer
57
+ end
58
+
59
+ def test_stalled_pattern_can_match_again_after_timeout_callback_changes_buffer
60
+ session, = pipe_session
61
+ session.buffer = "ready"
62
+ calls = 0
63
+ timeouts = 0
64
+
65
+ result = bounded(1) do
66
+ session.expect_result(timeout: 0.01) do
67
+ on(/(?=ready)/) do
68
+ calls += 1
69
+ calls == 1 ? Expect.continue : nil
70
+ end
71
+ timeout do
72
+ timeouts += 1
73
+ session.buffer = "ready!"
74
+ timeouts == 1 ? Expect.continue : nil
75
+ end
76
+ end
77
+ end
78
+
79
+ assert result.matched?
80
+ assert_equal 2, calls
81
+ assert_equal 1, timeouts
82
+ end
83
+
16
84
  def test_stdout_works_with_utf8_banner_and_ascii_regexp
17
85
  session, writer = pipe_session
18
86
  writer.write("欢迎登录\nprompt>")
@@ -130,11 +198,44 @@ class EdgeCaseTest < ExpectTest
130
198
  end
131
199
 
132
200
  def test_gc_reclaims_abandoned_child
201
+ assert_gc_reclaims_abandoned_child
202
+ end
203
+
204
+ def test_gc_reclaims_abandoned_child_with_log_callback_capturing_session
205
+ assert_gc_reclaims_abandoned_child(log_callback: true)
206
+ end
207
+
208
+ def test_invalid_options_raise_before_spawning
209
+ assert_raises(ArgumentError) { Expect.new(typo: true) }
210
+ session, = pipe_session
211
+ assert_raises(ArgumentError) { session.expect(0, "x") }
212
+ assert_raises(ArgumentError) { session.expect(["-unknown", "x"], timeout: 0) }
213
+ assert_raises(ArgumentError) { session.on_sequence("") }
214
+ end
215
+
216
+ def test_entrypoint_can_coexist_with_standard_library_expect
217
+ entrypoint = File.expand_path("../lib/expect/pty.rb", __dir__)
218
+ script = <<~RUBY
219
+ require "rbconfig"
220
+ require File.join(RbConfig::CONFIG.fetch("rubylibdir"), "expect.rb")
221
+ require ARGV.fetch(0)
222
+ puts [IO.method_defined?(:expect), defined?(Expect), Expect::VERSION].join(":")
223
+ RUBY
224
+ environment = { "RUBYOPT" => nil, "RUBYLIB" => nil, "BUNDLE_GEMFILE" => nil }
225
+ output, status = Open3.capture2e(environment, RbConfig.ruby, "-e", script, entrypoint)
226
+ assert status.success?, output
227
+ assert_equal "true:constant:#{Expect::VERSION}\n", output
228
+ end
229
+
230
+ private
231
+
232
+ def assert_gc_reclaims_abandoned_child(log_callback: false)
133
233
  script = <<~RUBY
134
234
  require "expect"
135
235
  require "rbconfig"
136
236
  def abandoned
137
237
  session = Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", "sleep 60", log_stdout: false)
238
+ session.log_to { |bytes| [session.pid, bytes] } if #{log_callback}
138
239
  session.pid
139
240
  end
140
241
  # Ruby 的保守 GC 可能扫描到创建线程栈上残留的引用;先结束该线程,确保会话确实不可达。
@@ -158,26 +259,4 @@ class EdgeCaseTest < ExpectTest
158
259
  assert status.success?, output
159
260
  assert_equal "reaped\n", output
160
261
  end
161
-
162
- def test_invalid_options_raise_before_spawning
163
- assert_raises(ArgumentError) { Expect.new(typo: true) }
164
- session, = pipe_session
165
- assert_raises(ArgumentError) { session.expect(0, "x") }
166
- assert_raises(ArgumentError) { session.expect(["-unknown", "x"], timeout: 0) }
167
- assert_raises(ArgumentError) { session.on_sequence("") }
168
- end
169
-
170
- def test_entrypoint_can_coexist_with_standard_library_expect
171
- entrypoint = File.expand_path("../lib/expect/pty.rb", __dir__)
172
- script = <<~RUBY
173
- require "rbconfig"
174
- require File.join(RbConfig::CONFIG.fetch("rubylibdir"), "expect.rb")
175
- require ARGV.fetch(0)
176
- puts [IO.method_defined?(:expect), defined?(Expect), Expect::VERSION].join(":")
177
- RUBY
178
- environment = { "RUBYOPT" => nil, "RUBYLIB" => nil, "BUNDLE_GEMFILE" => nil }
179
- output, status = Open3.capture2e(environment, RbConfig.ruby, "-e", script, entrypoint)
180
- assert status.success?, output
181
- assert_equal "true:constant:#{Expect::VERSION}\n", output
182
- end
183
262
  end