expect-pty 0.3.2 → 0.4.0

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.
@@ -2,9 +2,11 @@
2
2
 
3
3
  class Expect
4
4
  # 向单个转接目标写入数据,维护发送进度与期限;恢复时不重放已成功写出的前缀。
5
+ # 只借用目标,不 dup 描述符,也不负责读取;所有就绪等待统一交给 Relay 调度。
5
6
  class RelayWriter
6
7
  attr_reader :target, :deadline
7
8
 
9
+ # data 在本游标存活期间由上层保持不变,offset 始终以实际交付的字节数计量。
8
10
  def initialize(target, data)
9
11
  @target = target
10
12
  @data = data
@@ -13,17 +15,21 @@ class Expect
13
15
  restart_timeout
14
16
  end
15
17
 
18
+ # 初次排队或重新进入 interconnect 时开始新的写入预算;普通短写不会刷新期限。
19
+ # 只有 Expect 目标提供 write_timeout,原生 IO 和自定义目标只受转接总期限约束。
16
20
  def restart_timeout
17
21
  period = target.write_timeout if target.is_a?(Expect)
18
22
  @deadline = period && (Expect.monotonic + period)
19
23
  end
20
24
 
25
+ # 真实 IO 可参与共同 select;自定义可写对象返回 nil,沿用其同步 write/flush 协议。
21
26
  def io
22
27
  return target.writer if target.is_a?(Expect)
23
28
 
24
29
  target if target.is_a?(IO)
25
30
  end
26
31
 
32
+ # 数据交付完但 flush 失败仍未完成;重试只补 flush,不重新发送已接受的数据。
27
33
  def done? = @offset == @data.bytesize && @flushed
28
34
 
29
35
  # 每轮每目标至多一次写入;真实 IO 永不在此等待,交给 Relay 的共同 select。
@@ -52,11 +58,14 @@ class Expect
52
58
  end
53
59
  true
54
60
  rescue Errno::EINTR
61
+ # 未获得写入计数就不移动游标;先检查原期限,再让共同循环轮询输入并重试。
55
62
  check_timeout! if check_timeout
56
63
  true
57
64
  end
65
+
58
66
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
59
67
 
68
+ # 保留本数据块已交付的进度,便于调用方区分未写入和部分写入;不清空恢复用的游标。
60
69
  def check_timeout!
61
70
  return unless deadline && Expect.monotonic >= deadline
62
71
 
data/lib/expect/result.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  class Expect
4
4
  # 保存一次等待的结果,保留 Struct 原生数组、Hash 转换和模式解构能力。
5
5
  # number 仅在文本匹配时存在;error 为 :timeout、:eof 或原始 IO 异常,文本字段均为字节串。
6
+ # before/match/after 是产生结果时的快照,后续读取不追加到旧结果;captures 保留未参与捕获的 nil。
7
+ # 结果对象仍采用普通可变 Struct 语义;session 只引用来源,不取得 IO 或子进程所有权。
6
8
  Result = Struct.new(:number, :error, :match, :before, :after, :session, :captures, keyword_init: true) do
7
9
  # 是否命中文本模式;事件不会返回模式序号。
8
10
  def matched? = !number.nil?
@@ -2,7 +2,9 @@
2
2
 
3
3
  class Expect
4
4
  # 独立保存句柄、PID 和日志所有权,让终结器无需直接捕获会话即可清理遗弃资源。
5
+ # IO 是否关闭与子进程是否退出分别记录;不能仅凭句柄状态清空 PID 或伪造退出状态。
5
6
  class SessionResources
7
+ # owned_log 只保存库打开的文件;借用的 IO/日志回调留在会话中,不能成为终结器的引用根。
6
8
  attr_accessor :pid, :status, :owned_log
7
9
  attr_reader :reader, :writer, :slave, :owner, :own
8
10
 
@@ -16,12 +18,13 @@ class Expect
16
18
  end
17
19
 
18
20
  # 只关闭由本库拥有的 IO;借用的 reader、writer 由调用方管理。
21
+ # 常规关闭错误延后到所有句柄尝试完再抛出,失败句柄仍留在账本内供下一次关闭重试。
19
22
  def close_handles
20
23
  return unless own
21
24
 
22
- # PTY 的读写端可能是同一个对象,先去重,重复关闭也保持安全。
25
+ # 初始化校验失败时可能含无效参数,只关闭真实 IO;PTY 读写端也需要去重。
23
26
  failure = nil
24
- [reader, writer, slave].compact.uniq.each do |io|
27
+ [reader, writer, slave].grep(IO).uniq.each do |io|
25
28
  io.close unless io.closed?
26
29
  rescue IOError, SystemCallError => error
27
30
  failure ||= error
@@ -40,6 +43,7 @@ class Expect
40
43
  status
41
44
  rescue Errno::ECHILD
42
45
  # 子进程可能已被调用方或其他线程回收,不再保留可能被系统复用的 PID。
46
+ # 无法获取别人已取走的 Process::Status,保留未知状态而不是推断成功或失败。
43
47
  @pid = nil
44
48
  status
45
49
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ # 会话终端的模式和窗口尺寸接口;人工接管期间的临时恢复由 interaction.rb 负责。
6
+ class Expect
7
+ # 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
8
+ # 非终端返回空字符串;命令不存在或执行失败抛出 IOError,管道在所有退出路径关闭。
9
+ def stty(*modes)
10
+ return "" unless tty?
11
+
12
+ modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
13
+ modes = ["-g"] if modes.empty?
14
+ reader, sink = IO.pipe
15
+ child = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
16
+ sink.close
17
+ output = reader.read
18
+ _, status = Process.waitpid2(child)
19
+ raise IOError, "stty failed: #{output.strip}" unless status.success?
20
+
21
+ output.strip
22
+ rescue Errno::ENOENT
23
+ raise IOError, "stty executable not found in PATH; install the system terminal utilities"
24
+ ensure
25
+ reader&.close unless reader&.closed?
26
+ sink&.close unless sink&.closed?
27
+ end
28
+
29
+ # 读取终端的 [行数, 列数];底层并非终端或句柄已关闭时保留原生 IO 异常。
30
+ def winsize = to_io.winsize
31
+
32
+ # 更新终端尺寸,由内核通知前台进程。
33
+ def winsize=(size)
34
+ to_io.winsize = size
35
+ end
36
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.3.2"
5
+ VERSION = "0.4.0"
6
6
  end
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"
@@ -62,11 +61,19 @@ class Expect
62
61
  def spawn(*command, env: {}, chdir: nil, **)
63
62
  session = new(**)
64
63
  session.spawn(*command, env: env, chdir: chdir)
64
+ spawned = true
65
65
  return session unless block_given?
66
66
 
67
67
  yield session
68
+ rescue Exception # rubocop:disable Lint/RescueException -- 记录本次作用域的失败,清理后原样传播,包括非 StandardError 异常。
69
+ failed = true
70
+ raise
68
71
  ensure
69
- 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
70
77
  end
71
78
 
72
79
  # 适配已有 IO;own: true 接管关闭责任,初始化失败也释放接管的读写端。
@@ -77,11 +84,14 @@ class Expect
77
84
  return session unless block_given?
78
85
 
79
86
  yield session
87
+ rescue Exception # rubocop:disable Lint/RescueException -- 初始化和块异常均须保留,清理失败不能替换原始原因。
88
+ failed = true
89
+ raise
80
90
  ensure
81
- if initialized
82
- session.close if block_given?
83
- elsif own
84
- [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
85
95
  end
86
96
  end
87
97
 
@@ -89,8 +99,8 @@ class Expect
89
99
  def expect(...) = expect_result(...).number
90
100
 
91
101
  # 多会话等待的完整结果入口;from: 提供默认来源,块内可分别指定每个模式的来源。
92
- def expect_result(*patterns, from: [], timeout: configuration.timeout, &)
93
- run_expect(from, patterns, timeout, &)
102
+ def expect_result(*patterns, from: [], timeout: configuration.timeout, deadline: nil, &)
103
+ run_expect(from, patterns, timeout, deadline: deadline, &)
94
104
  end
95
105
 
96
106
  # 返回继续等待的控制符,reset_timeout 决定是否重新计算匹配期限。
@@ -139,15 +149,17 @@ class Expect
139
149
  private
140
150
 
141
151
  # 先完成模式声明再启动引擎;无参数块支持简洁 DSL,有参数块保留调用方 self。
142
- def run_expect(sessions, patterns, timeout, &block)
152
+ def run_expect(sessions, patterns, timeout, deadline: nil, &block)
143
153
  timeout = duration(timeout)
154
+ deadline = Float(deadline) unless deadline.nil?
155
+ raise ArgumentError, "deadline must be finite" if deadline && !deadline.finite?
144
156
  raise ArgumentError, "provide patterns or a pattern block, not both" if block_given? && !patterns.empty?
145
157
 
146
158
  pattern_list = PatternList.new(sessions, patterns)
147
159
  if block
148
160
  block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
149
161
  end
150
- Matcher.new(pattern_list.validate!, timeout).run
162
+ Matcher.new(pattern_list.validate!, timeout, deadline: deadline).run
151
163
  end
152
164
  end
153
165
 
@@ -157,7 +169,7 @@ class Expect
157
169
  def_delegators :@configuration, *Configuration::ATTRIBUTES, *Configuration::PREDICATES
158
170
  def_delegators :@configuration, *(Configuration::ATTRIBUTES - [:buffer_limit]).map { |name| :"#{name}=" }
159
171
 
160
- attr_reader :command, :last_result, :slave, :tty_name
172
+ attr_reader :command, :last_result, :slave, :tty_name, :buffer_discarded_bytes
161
173
 
162
174
  # 校验并更新缓冲上限后,立即裁剪已接收的内容;校验失败不改变旧缓冲。
163
175
  def buffer_limit=(value)
@@ -173,11 +185,11 @@ class Expect
173
185
  @tty_name = slave.path
174
186
  spawn(*command, env: env, chdir: chdir) unless command.empty?
175
187
  initialized = true
188
+ rescue Exception # rubocop:disable Lint/RescueException -- 构造异常时也要关闭已创建的资源并回收已启动的子进程。
189
+ failed = true
190
+ raise
176
191
  ensure
177
- unless initialized
178
- master&.close unless master&.closed?
179
- slave&.close unless slave&.closed?
180
- end
192
+ cleanup_preserving_failure(failed) { close(graceful: false) } if @resources && !initialized
181
193
  end
182
194
 
183
195
  # 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
@@ -226,20 +238,21 @@ class Expect
226
238
  hard_close
227
239
  raise SpawnError, failure
228
240
  end
229
- trace("spawned pid=#{child}")
241
+ trace("spawned pid=#{child}", event: :spawned)
230
242
  self
231
243
  ensure
232
244
  from_child&.close unless from_child&.closed?
233
245
  to_parent&.close unless to_parent&.closed?
234
246
  end
247
+
235
248
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
236
249
 
237
250
  # 在当前会话等待文本或事件,返回模式序号或 nil。
238
251
  def expect(...) = expect_result(...).number
239
252
 
240
253
  # 使用会话默认超时构造一次等待,返回含匹配内容、来源和错误的 Result。
241
- def expect_result(*patterns, timeout: self.timeout, &)
242
- self.class.__send__(:run_expect, [self], patterns, timeout, &)
254
+ def expect_result(*patterns, timeout: self.timeout, deadline: nil, &)
255
+ self.class.__send__(:run_expect, [self], patterns, timeout, deadline: deadline, &)
243
256
  end
244
257
 
245
258
  # 供实例回调返回继续控制符,语义与 Expect.continue 相同。
@@ -296,6 +309,7 @@ class Expect
296
309
  raise ArgumentError, "buffer must be a String" unless value.is_a?(String)
297
310
 
298
311
  @buffer = value.b
312
+ @buffer_generation += 1
299
313
  trim_buffer
300
314
  end
301
315
 
@@ -303,6 +317,7 @@ class Expect
303
317
  def clear_buffer
304
318
  previous = @buffer
305
319
  @buffer = "".b
320
+ @buffer_generation += 1
306
321
  previous
307
322
  end
308
323
 
@@ -312,7 +327,7 @@ class Expect
312
327
  raise IOError, "closed Expect session" if closed? || writer.closed?
313
328
 
314
329
  data = objects.map { |object| object.to_s.b }.join
315
- trace("sending #{data.inspect}", level: 2) if debug_level >= 2
330
+ trace_data(:sending, data, level: 2) if debug_level >= 2
316
331
  deadline = write_timeout && (Expect.monotonic + write_timeout)
317
332
  offset = 0
318
333
  while offset < data.bytesize
@@ -350,6 +365,7 @@ class Expect
350
365
  end
351
366
  data.bytesize
352
367
  end
368
+
353
369
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
354
370
 
355
371
  # 链式写入单个对象,返回当前会话。
@@ -382,82 +398,6 @@ class Expect
382
398
  count
383
399
  end
384
400
 
385
- # 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
386
- attr_reader :log_output
387
-
388
- # 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
389
- def log_output=(target)
390
- unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
391
- raise ArgumentError, "log output must support write or call, or be nil"
392
- end
393
-
394
- replace_log(target)
395
- end
396
-
397
- # 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
398
- def log_to(target = nil, mode: "a", &block)
399
- raise ArgumentError, "provide a log target or a block, not both" if block && target
400
-
401
- target = block if block
402
- if target.respond_to?(:to_path) || target.is_a?(String)
403
- raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
404
-
405
- # 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
406
- replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
407
- else
408
- raise ArgumentError, "provide a log target or a block" unless target
409
-
410
- self.log_output = target
411
- end
412
- end
413
-
414
- # 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
415
- def write_log(*objects)
416
- target = log_output
417
- return unless target
418
-
419
- data = objects.map { |object| object.to_s.b }.join
420
- target.respond_to?(:call) ? target.call(data) : emit(target, data)
421
- end
422
-
423
- # 返回监听器列表副本,避免外部原地修改转发关系。
424
- def listeners = @listeners.dup
425
-
426
- # 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
427
- def listeners=(outputs)
428
- outputs = Array(outputs)
429
- raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
430
-
431
- @listeners = outputs.dup
432
- end
433
-
434
- # 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
435
- def stty(*modes)
436
- return "" unless tty?
437
-
438
- modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
439
- modes = ["-g"] if modes.empty?
440
- reader, sink = IO.pipe
441
- child = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
442
- sink.close
443
- output = reader.read
444
- _, status = Process.waitpid2(child)
445
- raise IOError, "stty failed: #{output.strip}" unless status.success?
446
-
447
- output.strip
448
- ensure
449
- reader&.close unless reader&.closed?
450
- sink&.close unless sink&.closed?
451
- end
452
-
453
- # 读取终端的 [行数, 列数]。
454
- def winsize = to_io.winsize
455
-
456
- # 更新终端尺寸,由内核通知前台进程。
457
- def winsize=(size)
458
- to_io.winsize = size
459
- end
460
-
461
401
  # 轮询回收状态直到进程退出或期限到达;返回 Process::Status 或 nil,超时不丢弃 PID。
462
402
  def wait(timeout: nil)
463
403
  period = Expect.duration(timeout)
@@ -501,12 +441,22 @@ class Expect
501
441
  def close(graceful: graceful_close?)
502
442
  soft_close if graceful
503
443
  nil
444
+ rescue Exception # rubocop:disable Lint/RescueException -- 软关闭的原始异常在硬关闭兜底后继续传播。
445
+ failed = true
446
+ raise
504
447
  ensure
505
- hard_close
448
+ cleanup_preserving_failure(failed) { hard_close }
506
449
  end
507
450
 
508
451
  private
509
452
 
453
+ # 仅在本次生命周期已有异常时抑制常规清理错误;调用者 rescue 中的旧异常不算本次失败。
454
+ def cleanup_preserving_failure(failed)
455
+ yield
456
+ rescue IOError, SystemCallError
457
+ raise unless failed
458
+ end
459
+
510
460
  # 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
511
461
  def finish_close(timeout:, term_timeout:, force:)
512
462
  failure = nil
@@ -534,7 +484,11 @@ class Expect
534
484
  completed = true
535
485
  status
536
486
  ensure
537
- cleanup.call { self.log_output = nil }
487
+ begin
488
+ cleanup.call { flush_diagnostics }
489
+ ensure
490
+ cleanup.call { self.log_output = nil }
491
+ end
538
492
  # 用本次流程的完成状态判断异常传播,不能误把调用者 rescue 中的异常当成当前错误。
539
493
  raise failure if failure && completed
540
494
  end
@@ -554,19 +508,23 @@ class Expect
554
508
  end
555
509
 
556
510
  # 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
557
- def initialize_session(reader, writer:, slave: nil, own: false, **)
511
+ def initialize_session(reader, writer:, slave: nil, own: false, diagnostic_output: nil, **)
512
+ # 先登记所有权,后续校验失败也使用同一个资源对象逐个清理所属 IO。
513
+ @resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
558
514
  raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
559
515
  raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
560
516
 
561
- @resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
562
517
  @pty = reader.tty?
563
518
  @slave = slave
564
519
  @configuration = Configuration.new(**self.class.configuration.to_h, **)
565
520
  @buffer = "".b
521
+ @buffer_generation = 0
522
+ @buffer_discarded_bytes = 0
566
523
  @listeners = []
567
524
  @sequences = {}
568
525
  @relay_outputs = []
569
526
  @closed = @eof = false
527
+ self.diagnostic_output = diagnostic_output
570
528
  ObjectSpace.define_finalizer(self, SessionResources.finalizer(@resources))
571
529
  end
572
530
 
@@ -582,9 +540,14 @@ class Expect
582
540
  @last_result = Result.new(number: pattern.number, before: @buffer.byteslice(0, offset),
583
541
  match: @buffer.byteslice(offset, length), after: @buffer.byteslice((offset + length)..),
584
542
  session: self, captures: captures)
585
- @buffer = @last_result.after.dup unless preserve_buffer?
543
+ unless preserve_buffer?
544
+ @buffer = @last_result.after.dup
545
+ @buffer_generation += 1
546
+ end
547
+ # 诊断回调可能嵌套等待;恢复本次结果后再交给正式模式回调,不能返回内层等待的结果。
548
+ result = @last_result
586
549
  trace("matched pattern #{pattern.number}")
587
- @last_result
550
+ @last_result = result
588
551
  end
589
552
 
590
553
  # 记录超时、EOF 或原始 IO 异常,保留当前缓冲快照并清除旧匹配及捕获组。
@@ -617,74 +580,41 @@ class Expect
617
580
  # 某些系统用 PTY 的 EIO 表示对端关闭;普通 IO 的同类错误仍按异常处理。
618
581
  raise unless @pty
619
582
 
620
- @eof = true
621
- return nil
583
+ return finish_read
622
584
  rescue EOFError
623
- @eof = true
624
- return nil
585
+ return finish_read
625
586
  end
626
587
  return nil if data == :wait_readable
627
588
 
628
- if data.nil?
629
- @eof = true
630
- return nil
631
- end
589
+ return finish_read if data.nil?
590
+
632
591
  data = data.b
633
592
  buffer << data
634
593
  trim_buffer if trim
635
- trace("received #{data.inspect}", level: 2) if debug_level >= 2
636
- trace("buffer #{@buffer.inspect}", level: 3) if debug_level >= 3
594
+ trace_data(:received, data, level: 2) if debug_level >= 2
595
+ trace_data(:buffer, @buffer, level: 3) if debug_level >= 3
637
596
  # 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
638
597
  write_log(data)
639
598
  propagate(data) if propagate
640
599
  data
641
600
  end
642
601
 
602
+ def finish_read
603
+ @eof = true
604
+ flush_log
605
+ flush_diagnostics(:received)
606
+ nil
607
+ end
608
+
643
609
  # 缓冲超过上限时只保留最新尾部字节,不对编码做隐式修改。
644
610
  def trim_buffer
645
611
  limit = buffer_limit
646
- @buffer = @buffer.byteslice(-limit, limit) if limit&.positive? && @buffer.bytesize > limit
647
- end
648
-
649
- # 按各自开关将接收字节转发到 stdout 和监听器,不重复写日志。
650
- def propagate(data)
651
- emit($stdout, data) if log_stdout?
652
- @listeners.each { |listener| emit(listener, data) } if log_listeners?
653
- end
612
+ return unless limit && @buffer.bytesize > limit
654
613
 
655
- # 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
656
- def emit(target, data)
657
- offset = 0
658
- while offset < data.bytesize
659
- count = target.write(data.byteslice(offset..))
660
- unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
661
- raise IOError, "write must return the number of accepted bytes"
662
- end
663
-
664
- offset += count
665
- end
666
- target.flush if target.respond_to?(:flush)
667
- end
668
-
669
- # 按诊断级别向 stderr 输出会话标识和消息。
670
- def trace(message, level: 1)
671
- warn("#{inspect}: #{message}") if debug_level >= level
672
- end
673
-
674
- # 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
675
- def replace_log(target, owned: false)
676
- previous = log_output
677
- # 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
678
- return target if previous.equal?(target)
679
-
680
- previous.close if @resources.owned_log && previous && !previous.closed?
681
- # 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
682
- @resources.owned_log = owned ? target : nil
683
- @log_output = target
684
- target
685
- rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
686
- target.close if owned && target && !target.closed?
687
- raise
614
+ # 只累计匹配窗口裁剪,消费、清空及转接交接不算丢弃;关闭后仍可读取累计值。
615
+ @buffer_discarded_bytes += @buffer.bytesize - limit
616
+ @buffer = @buffer.byteslice(-limit, limit)
617
+ @buffer_generation += 1
688
618
  end
689
619
 
690
620
  # 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
@@ -697,4 +627,6 @@ class Expect
697
627
  end
698
628
  end
699
629
 
630
+ require_relative "expect/logging"
631
+ require_relative "expect/terminal"
700
632
  require_relative "expect/interaction"
data/script/ci CHANGED
@@ -7,7 +7,7 @@ bundle exec rake
7
7
  bundle exec ruby examples/dialogue.rb
8
8
 
9
9
  # 只检查小规模工作负载的正确性,不为共享 CI 机器设置性能阈值。
10
- for benchmark in matching relay send_slow; do
10
+ for benchmark in matching relay send_slow scaling; do
11
11
  bundle exec ruby "benchmark/${benchmark}.rb" --smoke
12
12
  done
13
13
 
@@ -45,7 +45,7 @@ spec = Gem.loaded_specs.fetch("expect-pty")
45
45
  install_root = File.realpath(ENV.fetch("GEM_HOME"))
46
46
  abort "gem was loaded outside the isolated install" unless File.realpath(spec.full_gem_path).start_with?("#{install_root}/")
47
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) }
48
+ unexpected = %w[minitest rake rubocop parallel logger].select { |name| Gem.loaded_specs.key?(name) }
49
49
  abort "development dependencies leaked into the application: #{unexpected.join(', ')}" unless unexpected.empty?
50
50
  spec.runtime_dependencies.each do |dependency|
51
51
  loaded = Gem.loaded_specs.fetch(dependency.name)
@@ -68,7 +68,34 @@ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_st
68
68
  abort "installed gem child did not exit successfully" unless status&.success?
69
69
  end
70
70
 
71
- puts "Installed #{spec.full_name}: #{ARGV.fetch(0)} PTY and runtime dependencies passed"
71
+ # 使用已安装包验证内部过滤器随包分发,且结构化诊断无需加载可选的 Logger。
72
+ reader, producer = IO.pipe
73
+ events = []
74
+ transcript = StringIO.new
75
+ begin
76
+ Expect.open(reader, own: true, buffer_limit: 5, debug_level: 2,
77
+ diagnostic_output: ->(event) { events << event }) do |session|
78
+ session.redact("secret")
79
+ session.log_to(transcript)
80
+ session.buffer = "ready"
81
+ expired = session.expect_result("ready", deadline: Expect.monotonic)
82
+ abort "expired deadline consumed buffered text" unless expired.timeout? && session.buffer == "ready"
83
+ abort "zero timeout lost buffered text" unless session.expect("ready", timeout: 0) == 1
84
+ producer.write("secret!")
85
+ producer.close
86
+ result = session.expect_result(:eof, deadline: Expect.monotonic + 5)
87
+ abort "installed gem did not preserve capped input" unless result.eof? && result.before == "cret!"
88
+ abort "incorrect installed buffer accounting" unless session.buffer_discarded_bytes == 2
89
+ abort "installed redaction failed" unless transcript.string == "[FILTERED]!"
90
+ abort "diagnostics leaked secret" if events.any? { |event| event[:message].include?("secret") }
91
+ abort "filtered diagnostic missing" unless events.any? { |event| event[:message].include?("[FILTERED]") }
92
+ end
93
+ ensure
94
+ reader.close unless reader.closed?
95
+ producer.close unless producer.closed?
96
+ end
97
+
98
+ puts "Installed #{spec.full_name}: #{ARGV.fetch(0)} PTY, core contracts and runtime dependencies passed"
72
99
  RUBY
73
100
  ruby smoke.rb plain
74
101