expect-pty 0.3.1 → 0.3.3

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
@@ -3,7 +3,6 @@
3
3
  require "pty"
4
4
  require "io/console"
5
5
  require "io/wait"
6
- require "shellwords"
7
6
  require "stringio"
8
7
  require "forwardable"
9
8
  require_relative "expect/version"
@@ -21,6 +20,8 @@ class Expect
21
20
  CONTINUE = :continue
22
21
  CONTINUE_WITHOUT_RESET = :continue_without_reset
23
22
  READ_SIZE = 16_384
23
+ CONFIGURATION_MUTEX = Mutex.new
24
+ private_constant :CONFIGURATION_MUTEX
24
25
 
25
26
  class SpawnError < StandardError; end
26
27
 
@@ -40,25 +41,39 @@ class Expect
40
41
  return @configuration if defined?(@configuration)
41
42
  return superclass.configuration unless self == Expect
42
43
 
43
- @configuration = Configuration.new.freeze
44
+ CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new.freeze }
44
45
  end
45
46
 
46
47
  # 基于旧快照构造可修改副本,全部赋值与配置块成功后才发布,异常时保留原配置。
47
48
  def configure(**)
48
- updated = Configuration.new(**configuration.to_h, **)
49
- yield updated if block_given?
50
- @configuration = updated.freeze
49
+ raise ThreadError, "nested configure is not supported" if CONFIGURATION_MUTEX.owned?
50
+
51
+ # 初始化默认快照后,将整个读改写过程串行化,避免并发配置丢失更新。
52
+ configuration
53
+ CONFIGURATION_MUTEX.synchronize do
54
+ updated = Configuration.new(**configuration.to_h, **)
55
+ yield updated if block_given?
56
+ @configuration = updated.freeze
57
+ end
51
58
  end
52
59
 
53
60
  # 创建并启动会话;有块时返回块结果并确保关闭,无块时由调用方负责生命周期。
54
61
  def spawn(*command, env: {}, chdir: nil, **)
55
62
  session = new(**)
56
63
  session.spawn(*command, env: env, chdir: chdir)
64
+ spawned = true
57
65
  return session unless block_given?
58
66
 
59
67
  yield session
68
+ rescue Exception # rubocop:disable Lint/RescueException -- 记录本次作用域的失败,清理后原样传播,包括非 StandardError 异常。
69
+ failed = true
70
+ raise
60
71
  ensure
61
- session&.close if block_given? || (session && !session.pid)
72
+ if session && (block_given? || !spawned)
73
+ session.__send__(:cleanup_preserving_failure, failed) do
74
+ session.close(graceful: spawned && session.graceful_close?)
75
+ end
76
+ end
62
77
  end
63
78
 
64
79
  # 适配已有 IO;own: true 接管关闭责任,初始化失败也释放接管的读写端。
@@ -69,11 +84,14 @@ class Expect
69
84
  return session unless block_given?
70
85
 
71
86
  yield session
87
+ rescue Exception # rubocop:disable Lint/RescueException -- 初始化和块异常均须保留,清理失败不能替换原始原因。
88
+ failed = true
89
+ raise
72
90
  ensure
73
- if initialized
74
- session.close if block_given?
75
- elsif own
76
- [io, writer].uniq.each { |handle| handle.close if handle.is_a?(IO) && !handle.closed? }
91
+ if session && (block_given? || !initialized)
92
+ session.__send__(:cleanup_preserving_failure, failed) do
93
+ session.close(graceful: initialized && session.graceful_close?)
94
+ end
77
95
  end
78
96
  end
79
97
 
@@ -165,14 +183,15 @@ class Expect
165
183
  @tty_name = slave.path
166
184
  spawn(*command, env: env, chdir: chdir) unless command.empty?
167
185
  initialized = true
186
+ rescue Exception # rubocop:disable Lint/RescueException -- 构造异常时也要关闭已创建的资源并回收已启动的子进程。
187
+ failed = true
188
+ raise
168
189
  ensure
169
- unless initialized
170
- master&.close unless master&.closed?
171
- slave&.close unless slave&.closed?
172
- end
190
+ cleanup_preserving_failure(failed) { close(graceful: false) } if @resources && !initialized
173
191
  end
174
192
 
175
193
  # 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
194
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- PTY 子进程启动与失败回传共用一次生命周期。
176
195
  def spawn(*command, env: {}, chdir: nil)
177
196
  raise SpawnError, "cannot reuse a spawned session" if @command
178
197
  raise SpawnError, "only a new PTY session can spawn" unless @slave && !@slave.closed? && !closed?
@@ -223,6 +242,7 @@ class Expect
223
242
  from_child&.close unless from_child&.closed?
224
243
  to_parent&.close unless to_parent&.closed?
225
244
  end
245
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
226
246
 
227
247
  # 在当前会话等待文本或事件,返回模式序号或 nil。
228
248
  def expect(...) = expect_result(...).number
@@ -297,6 +317,7 @@ class Expect
297
317
  end
298
318
 
299
319
  # 按 Ruby to_s 规则原样写入所有字节,返回字节数;背压等待受 write_timeout 限制。
320
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 写入、背压排空和同一期限必须同步推进。
300
321
  def write(*objects)
301
322
  raise IOError, "closed Expect session" if closed? || writer.closed?
302
323
 
@@ -339,6 +360,7 @@ class Expect
339
360
  end
340
361
  data.bytesize
341
362
  end
363
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
342
364
 
343
365
  # 链式写入单个对象,返回当前会话。
344
366
  def <<(object)
@@ -364,88 +386,12 @@ class Expect
364
386
  object.to_s.each_char do |character|
365
387
  sleep(pause) if pause.positive?
366
388
  count += write(character)
367
- read_available if !eof? && to_io.wait_readable(0.01)
389
+ read_available if !eof? && to_io.wait_readable(0)
368
390
  end
369
391
  end
370
392
  count
371
393
  end
372
394
 
373
- # 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
374
- attr_reader :log_output
375
-
376
- # 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
377
- def log_output=(target)
378
- unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
379
- raise ArgumentError, "log output must support write or call, or be nil"
380
- end
381
-
382
- replace_log(target)
383
- end
384
-
385
- # 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
386
- def log_to(target = nil, mode: "a", &block)
387
- raise ArgumentError, "provide a log target or a block, not both" if block && target
388
-
389
- target = block if block
390
- if target.respond_to?(:to_path) || target.is_a?(String)
391
- raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
392
-
393
- # 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
394
- replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
395
- else
396
- raise ArgumentError, "provide a log target or a block" unless target
397
-
398
- self.log_output = target
399
- end
400
- end
401
-
402
- # 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
403
- def write_log(*objects)
404
- target = log_output
405
- return unless target
406
-
407
- data = objects.map { |object| object.to_s.b }.join
408
- target.respond_to?(:call) ? target.call(data) : emit(target, data)
409
- end
410
-
411
- # 返回监听器列表副本,避免外部原地修改转发关系。
412
- def listeners = @listeners.dup
413
-
414
- # 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
415
- def listeners=(outputs)
416
- outputs = Array(outputs)
417
- raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
418
-
419
- @listeners = outputs.dup
420
- end
421
-
422
- # 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
423
- def stty(*modes)
424
- return "" unless tty?
425
-
426
- modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
427
- modes = ["-g"] if modes.empty?
428
- reader, sink = IO.pipe
429
- child = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
430
- sink.close
431
- output = reader.read
432
- _, status = Process.waitpid2(child)
433
- raise IOError, "stty failed: #{output.strip}" unless status.success?
434
-
435
- output.strip
436
- ensure
437
- reader&.close unless reader&.closed?
438
- sink&.close unless sink&.closed?
439
- end
440
-
441
- # 读取终端的 [行数, 列数]。
442
- def winsize = to_io.winsize
443
-
444
- # 更新终端尺寸,由内核通知前台进程。
445
- def winsize=(size)
446
- to_io.winsize = size
447
- end
448
-
449
395
  # 轮询回收状态直到进程退出或期限到达;返回 Process::Status 或 nil,超时不丢弃 PID。
450
396
  def wait(timeout: nil)
451
397
  period = Expect.duration(timeout)
@@ -489,43 +435,75 @@ class Expect
489
435
  def close(graceful: graceful_close?)
490
436
  soft_close if graceful
491
437
  nil
438
+ rescue Exception # rubocop:disable Lint/RescueException -- 软关闭的原始异常在硬关闭兜底后继续传播。
439
+ failed = true
440
+ raise
492
441
  ensure
493
- hard_close
442
+ cleanup_preserving_failure(failed) { hard_close }
494
443
  end
495
444
 
496
445
  private
497
446
 
447
+ # 仅在本次生命周期已有异常时抑制常规清理错误;调用者 rescue 中的旧异常不算本次失败。
448
+ def cleanup_preserving_failure(failed)
449
+ yield
450
+ rescue IOError, SystemCallError
451
+ raise unless failed
452
+ end
453
+
498
454
  # 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
499
455
  def finish_close(timeout:, term_timeout:, force:)
456
+ failure = nil
457
+ # 预期的清理错误延后传播,保证其余所属资源和直属子进程仍能完成清理。
458
+ cleanup = lambda do |&step|
459
+ step.call
460
+ rescue IOError, SystemCallError => error
461
+ failure ||= error
462
+ nil
463
+ end
500
464
  # IO 关闭与进程退出独立记录:软关闭可能已经 closed?,但仍保留活跃 PID。
501
- @resources.close_handles
465
+ cleanup.call { @resources.close_handles }
502
466
  @closed = true
503
- @interact_inputs&.each_value { |input| input.close(graceful: false) }
504
- @interact_inputs&.clear
467
+ @interact_inputs&.delete_if do |_io, input|
468
+ cleanup.call do
469
+ input.close(graceful: false)
470
+ true
471
+ end
472
+ end
505
473
  @interact_output = nil
506
474
  @relay_outputs&.clear
507
475
  @relay_history&.clear
508
476
  @relay_callback = nil
477
+ status = finish_child_close(timeout: timeout, term_timeout: term_timeout, force: force)
478
+ completed = true
479
+ status
480
+ ensure
481
+ cleanup.call { self.log_output = nil }
482
+ # 用本次流程的完成状态判断异常传播,不能误把调用者 rescue 中的异常当成当前错误。
483
+ raise failure if failure && completed
484
+ end
485
+
486
+ # 句柄清理失败不改变进程策略;未回收 PID 保留给重复关闭或终结器继续处理。
487
+ def finish_child_close(timeout:, term_timeout:, force:)
509
488
  return process_status unless @resources.owner == Process.pid && pid
510
489
  return process_status if wait(timeout: timeout)
511
490
 
512
491
  signal_child("TERM")
513
492
  return process_status if wait(timeout: term_timeout)
514
493
 
515
- if force
516
- signal_child("KILL")
517
- wait(timeout: 1)
518
- end
519
- ensure
520
- self.log_output = nil
494
+ return unless force
495
+
496
+ signal_child("KILL")
497
+ wait(timeout: 1)
521
498
  end
522
499
 
523
500
  # 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
524
501
  def initialize_session(reader, writer:, slave: nil, own: false, **)
502
+ # 先登记所有权,后续校验失败也使用同一个资源对象逐个清理所属 IO。
503
+ @resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
525
504
  raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
526
505
  raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
527
506
 
528
- @resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
529
507
  @pty = reader.tty?
530
508
  @slave = slave
531
509
  @configuration = Configuration.new(**self.class.configuration.to_h, **)
@@ -568,13 +546,14 @@ class Expect
568
546
  end
569
547
 
570
548
  # 先将读取字节交给匹配或转接缓冲,再记录日志;日志失败也能恢复输入。
571
- def read_available(propagate: true, buffer: @buffer)
549
+ def read_available(propagate: true, buffer: @buffer, trim: true)
572
550
  return nil if eof?
573
551
 
574
552
  # 写入背压也会读取;转接期间统一交给转义处理器,不能直接转发或另存匹配缓冲。
575
553
  if @interaction_buffer
576
554
  buffer = @interaction_buffer
577
555
  propagate = false
556
+ trim = false
578
557
  end
579
558
 
580
559
  begin
@@ -597,7 +576,7 @@ class Expect
597
576
  end
598
577
  data = data.b
599
578
  buffer << data
600
- trim_buffer if buffer.equal?(@buffer)
579
+ trim_buffer if trim
601
580
  trace("received #{data.inspect}", level: 2) if debug_level >= 2
602
581
  trace("buffer #{@buffer.inspect}", level: 3) if debug_level >= 3
603
582
  # 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
@@ -612,47 +591,6 @@ class Expect
612
591
  @buffer = @buffer.byteslice(-limit, limit) if limit&.positive? && @buffer.bytesize > limit
613
592
  end
614
593
 
615
- # 按各自开关将接收字节转发到 stdout 和监听器,不重复写日志。
616
- def propagate(data)
617
- emit($stdout, data) if log_stdout?
618
- @listeners.each { |listener| emit(listener, data) } if log_listeners?
619
- end
620
-
621
- # 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
622
- def emit(target, 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
632
- target.flush if target.respond_to?(:flush)
633
- end
634
-
635
- # 按诊断级别向 stderr 输出会话标识和消息。
636
- def trace(message, level: 1)
637
- warn("#{inspect}: #{message}") if debug_level >= level
638
- end
639
-
640
- # 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
641
- def replace_log(target, owned: false)
642
- previous = log_output
643
- # 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
644
- return target if previous.equal?(target)
645
-
646
- previous.close if @resources.owned_log && previous && !previous.closed?
647
- # 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
648
- @resources.owned_log = owned ? target : nil
649
- @log_output = target
650
- target
651
- rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
652
- target.close if owned && target && !target.closed?
653
- raise
654
- end
655
-
656
594
  # 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
657
595
  def signal_child(signal)
658
596
  return unless alive? && @resources.owner == Process.pid
@@ -663,4 +601,6 @@ class Expect
663
601
  end
664
602
  end
665
603
 
604
+ require_relative "expect/logging"
605
+ require_relative "expect/terminal"
666
606
  require_relative "expect/interaction"
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"
data/script/release.rb CHANGED
@@ -83,11 +83,16 @@ class Release
83
83
 
84
84
  private
85
85
 
86
- def capture(*arguments)
87
- output, error, status = Open3.capture3(*arguments)
86
+ def capture(*)
87
+ capture_bytes(*).strip
88
+ end
89
+
90
+ # Git blob 是任意字节,不能沿用命令文本的 strip,尾部换行和 NUL 也属于发布内容。
91
+ def capture_bytes(*arguments)
92
+ output, error, status = Open3.capture3(*arguments, binmode: true)
88
93
  raise "#{arguments.first} failed: #{error.strip}" unless status.success?
89
94
 
90
- output.strip
95
+ output
91
96
  end
92
97
 
93
98
  def command(*arguments)
@@ -124,7 +129,7 @@ class Release
124
129
  def verify_package
125
130
  package = Gem::Package.new(@artifact)
126
131
  expected = Gem::Specification.load(File.expand_path("expect-pty.gemspec"))
127
- fields = %i[name version platform summary description authors licenses required_ruby_version
132
+ fields = %i[name version platform summary description authors licenses homepage required_ruby_version
128
133
  required_rubygems_version require_paths metadata dependencies extensions executables bindir
129
134
  post_install_message]
130
135
  unless fields.all? { |field| package.spec.public_send(field) == expected.public_send(field) }
@@ -132,6 +137,7 @@ class Release
132
137
  end
133
138
  raise "Artifact file list differs from the source" unless package.contents.sort == expected.files.sort
134
139
 
140
+ committed = committed_files if @commit
135
141
  # 直接检查归档中的权限,避免解包时本机 umask 改写执行位。
136
142
  File.open(@artifact, "rb") do |io|
137
143
  Gem::Package::TarReader.new(io) do |archive|
@@ -139,7 +145,10 @@ class Release
139
145
  package.open_tar_gz(data) do |tar|
140
146
  tar.each do |entry|
141
147
  file = entry.full_name
142
- raise "Artifact differs from source: #{file}" unless entry.file? && entry.read == File.binread(file)
148
+ bytes = entry.read
149
+ raise "Artifact differs from source: #{file}" unless entry.file? && bytes == File.binread(file)
150
+
151
+ verify_committed_file(file, bytes, entry.header.mode, committed) if committed
143
152
  next if entry.header.mode & 0o111 == File.stat(file).mode & 0o111
144
153
 
145
154
  raise "Artifact executable permissions differ: #{file}"
@@ -149,6 +158,29 @@ class Release
149
158
  end
150
159
  end
151
160
 
161
+ # 工作区干净不代表所有打包文件都来自提交:ignore、assume-unchanged 和 filemode 均可隐藏差异。
162
+ # 只在正式发布时核对提交树;dry-run 仍允许检验尚未提交的开发源码。
163
+ def committed_files
164
+ capture_bytes("git", "ls-tree", "-rz", "--full-tree", @commit).split("\0").to_h do |record|
165
+ metadata, file = record.split("\t", 2)
166
+ mode, type, object = metadata.split
167
+ [file, [mode.to_i(8), type, object]]
168
+ end
169
+ end
170
+
171
+ def verify_committed_file(file, bytes, mode, committed)
172
+ committed_mode, type, object = committed[file.b]
173
+ unless type == "blob" && committed_mode & 0o170000 == 0o100000
174
+ raise "Artifact file is not in release commit: #{file}"
175
+ end
176
+ unless bytes == capture_bytes("git", "cat-file", "blob", object)
177
+ raise "Artifact differs from release commit: #{file}"
178
+ end
179
+ return if mode & 0o111 == committed_mode & 0o111
180
+
181
+ raise "Artifact executable permissions differ from release commit: #{file}"
182
+ end
183
+
152
184
  def get(path)
153
185
  uri = URI("#{GEM_HOST}#{path}")
154
186
  Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|