Class: Expect

Inherits:
Object
  • Object
show all
Extended by:
Forwardable
Defined in:
lib/expect.rb,
lib/expect/relay.rb,
lib/expect/result.rb,
lib/expect/logging.rb,
lib/expect/matcher.rb,
lib/expect/pattern.rb,
lib/expect/version.rb,
lib/expect/redactor.rb,
lib/expect/terminal.rb,
lib/expect/interaction.rb,
lib/expect/pattern_list.rb,
lib/expect/relay_writer.rb,
lib/expect/configuration.rb,
lib/expect/session_resources.rb

Overview

为会话补充人工接管和多路 IO 转接;核心会话定义位于 lib/expect.rb。

Defined Under Namespace

Classes: Configuration, Matcher, Pattern, PatternList, Redactor, ReentrancyError, Result, SessionResources, SpawnError, WriteTimeout

Constant Summary collapse

CONTINUE =

回调控制符:分别表示重置期限后继续,或保留原期限继续。

:continue
CONTINUE_WITHOUT_RESET =
:continue_without_reset
READ_SIZE =
16_384
VERSION =

Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。

"0.5.3"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(*command, env: {}, chdir: nil) ⇒ Expect

创建 PTY,可立即启动命令,也可先让调用方配置 slave;构造失败时释放全部新句柄。



184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/expect.rb', line 184

def initialize(*command, env: {}, chdir: nil, **)
  master, slave = PTY.open
  initialize_session(master, writer: master, slave: slave, own: true, **)
  @tty_name = slave.path
  spawn(*command, env: env, chdir: chdir) unless command.empty?
  initialized = true
rescue Exception # rubocop:disable Lint/RescueException -- 构造异常时也要关闭已创建的资源并回收已启动的子进程。
  failed = true
  raise
ensure
  cleanup(failed:) { cleanup_session(master, writer: master, slave: slave, own: true) } unless initialized
end

Instance Attribute Details

#buffer_discarded_bytes ⇒ Object (readonly)

Returns the value of attribute buffer_discarded_bytes.



174
175
176
# File 'lib/expect.rb', line 174

def buffer_discarded_bytes
  @buffer_discarded_bytes
end

#command ⇒ Object (readonly)

Returns the value of attribute command.



174
175
176
# File 'lib/expect.rb', line 174

def command
  @command
end

#diagnostic_output ⇒ Object

读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。



9
10
11
# File 'lib/expect/logging.rb', line 9

def diagnostic_output
  @diagnostic_output
end

#last_result ⇒ Object (readonly)

Returns the value of attribute last_result.



174
175
176
# File 'lib/expect.rb', line 174

def last_result
  @last_result
end

#log_output ⇒ Object

读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。



9
10
11
# File 'lib/expect/logging.rb', line 9

def log_output
  @log_output
end

#slave ⇒ Object (readonly)

Returns the value of attribute slave.



174
175
176
# File 'lib/expect.rb', line 174

def slave
  @slave
end

#tty_name ⇒ Object (readonly)

Returns the value of attribute tty_name.



174
175
176
# File 'lib/expect.rb', line 174

def tty_name
  @tty_name
end

Class Method Details

.configuration ⇒ Object

读取冻结的默认配置;子类未单独配置时继承父类快照。



41
42
43
44
45
46
# File 'lib/expect.rb', line 41

def configuration
  return @configuration if defined?(@configuration)
  return superclass.configuration unless self == Expect

  CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new.freeze }
end

.configure ⇒ Object

基于旧快照构造可修改副本,全部赋值与配置块成功后才发布,异常时保留原配置。

Raises:

  • (ThreadError)


49
50
51
52
53
54
55
56
57
58
59
# File 'lib/expect.rb', line 49

def configure(**)
  raise ThreadError, "nested configure is not supported" if CONFIGURATION_MUTEX.owned?

  # 初始化默认快照后,将整个读改写过程串行化,避免并发配置丢失更新。
  configuration
  CONFIGURATION_MUTEX.synchronize do
    updated = Configuration.new(**configuration.to_h, **)
    yield updated if block_given?
    @configuration = updated.freeze
  end
end

.continue(reset_timeout: true) ⇒ Object

返回继续等待的控制符,reset_timeout 决定是否重新计算匹配期限。



109
# File 'lib/expect.rb', line 109

def continue(reset_timeout: true) = reset_timeout ? CONTINUE : CONTINUE_WITHOUT_RESET

.duration(value) ⇒ Object

将秒数转换为有限的非负数,nil 表示无限;供配置和单次操作共用校验。

Raises:

  • (ArgumentError)


115
116
117
118
119
120
121
122
# File 'lib/expect.rb', line 115

def duration(value)
  return nil if value.nil?

  number = Float(value)
  raise ArgumentError, "duration must be finite and nonnegative" unless number.finite? && number >= 0

  number
end

.expect ⇒ Object

进行多会话匹配,返回命中的模式序号,超时、EOF 或读取错误返回 nil。



101
# File 'lib/expect.rb', line 101

def expect(...) = expect_result(...).number

.expect_result(*patterns, from: [], timeout: configuration.timeout, deadline: nil) ⇒ Object

多会话等待的完整结果入口;from: 提供默认来源,块内可分别指定每个模式的来源。



104
105
106
# File 'lib/expect.rb', line 104

def expect_result(*patterns, from: [], timeout: configuration.timeout, deadline: nil, &)
  run_expect(from, patterns, timeout, deadline: deadline, &)
end

.interconnect(*sessions, timeout: nil) ⇒ Object

按各会话 listeners 建立转发图,处理转义、EOF 和总期限,返回引发停止的会话或 nil。

Raises:

  • (ArgumentError)


81
82
83
84
85
86
87
# File 'lib/expect/interaction.rb', line 81

def self.interconnect(*sessions, timeout: nil)
  raise ArgumentError, "interconnect requires Expect sessions" if sessions.empty? || sessions.any? do |session|
    !session.is_a?(Expect)
  end

  Relay.new(sessions, timeout).run
end

.monotonic ⇒ Object

读取不受系统时间调整影响的单调时钟,所有相对超时共用此计时基准。



112
# File 'lib/expect.rb', line 112

def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)

.open(io, writer: io, own: false) ⇒ Object

适配已有 IO;own: true 接管关闭责任,初始化失败也释放接管的读写端。



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/expect.rb', line 81

def open(io, writer: io, own: false, **)
  session = allocate
  session.__send__(:initialize_session, io, writer: writer, own: own, **)
  initialized = true
  return session unless block_given?

  yield session
rescue Exception # rubocop:disable Lint/RescueException -- 初始化和块异常均须保留,清理失败不能替换原始原因。
  failed = true
  raise
ensure
  if session && (block_given? || !initialized)
    session.__send__(:cleanup, failed: failed) do
      session.__send__(:cleanup_session, io, writer: writer, own: own,
                                             graceful: initialized && session.graceful_close?)
    end
  end
end

.readable_sessions(*sessions, timeout: 0) ⇒ Object

等待并返回可读会话,不消费输入;去重并忽略已关闭会话,默认非阻塞。

Raises:

  • (ArgumentError)


125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/expect.rb', line 125

def readable_sessions(*sessions, timeout: 0)
  timeout = duration(timeout)
  raise ArgumentError, "readable_sessions requires Expect sessions" unless sessions.all?(Expect)

  active = sessions.uniq.reject(&:closed?)
  return [] if active.empty?

  deadline = timeout && (monotonic + timeout)
  polled = false
  ready = nil
  loop do
    remaining = deadline && [deadline - monotonic, 0].max
    return [] if polled && remaining&.zero?

    begin
      ready = IO.select(active.map(&:to_io), nil, nil, remaining)
      break
    rescue Errno::EINTR
      polled = true
    end
  end
  return [] unless ready

  active.select { |session| ready.first.include?(session.to_io) }
end

.spawn(*command, env: {}, chdir: nil) ⇒ Object

创建并启动会话;有块时返回块结果并确保关闭,无块时由调用方负责生命周期。



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/expect.rb', line 62

def spawn(*command, env: {}, chdir: nil, **)
  session = new(**)
  session.spawn(*command, env: env, chdir: chdir)
  spawned = true
  return session unless block_given?

  yield session
rescue Exception # rubocop:disable Lint/RescueException -- 记录本次作用域的失败,清理后原样传播,包括非 StandardError 异常。
  failed = true
  raise
ensure
  if session && (block_given? || !spawned)
    session.__send__(:cleanup, failed: failed) do
      session.close(graceful: spawned && session.graceful_close?)
    end
  end
end

Instance Method Details

#<<(object) ⇒ Object

链式写入单个对象,返回当前会话。



384
385
386
387
# File 'lib/expect.rb', line 384

def <<(object)
  write(object)
  self
end

#after ⇒ Object



301
# File 'lib/expect.rb', line 301

def after = @last_result&.after

#alive? ⇒ Boolean

先刷新回收状态,再判断是否仍有未回收的子进程;不以 IO 是否关闭代替进程状态。

Returns:

  • (Boolean)


288
289
290
291
# File 'lib/expect.rb', line 288

def alive?
  process_status
  !pid.nil?
end

#before ⇒ Object

以下访问器读取最近一次等待结果;未发生匹配时捕获组返回空数组。



299
# File 'lib/expect.rb', line 299

def before = @last_result&.before

#buffer ⇒ Object

返回缓冲副本,防止调用方原地修改绕过裁剪规则。



312
# File 'lib/expect.rb', line 312

def buffer = @buffer.dup

#buffer=(value) ⇒ Object

复制并替换原始字节缓冲,应用当前上限;调用方后续修改原字符串不会影响会话。

Raises:

  • (ArgumentError)


315
316
317
318
319
320
321
# File 'lib/expect.rb', line 315

def buffer=(value)
  raise ArgumentError, "buffer must be a String" unless value.is_a?(String)

  @buffer = value.b
  @buffer_generation += 1
  trim_buffer
end

#buffer_limit=(value) ⇒ Object

校验并更新缓冲上限后,立即裁剪已接收的内容;校验失败不改变旧缓冲。



177
178
179
180
181
# File 'lib/expect.rb', line 177

def buffer_limit=(value)
  @configuration.buffer_limit = value
  trim_buffer
  value
end

#captures ⇒ Object



307
# File 'lib/expect.rb', line 307

def captures = @last_result&.captures || []

#clear_buffer ⇒ Object

移交旧缓冲并换上新的空字节串,供显式清空或人工转接接管数据。



324
325
326
327
328
329
# File 'lib/expect.rb', line 324

def clear_buffer
  previous = @buffer
  @buffer = "".b
  @buffer_generation += 1
  previous
end

#close(graceful: graceful_close?) ) ⇒ Object

通用生命周期清理:可先软关闭,ensure 中硬关闭兜底;正常完成返回 nil。



445
446
447
448
449
450
451
452
453
# File 'lib/expect.rb', line 445

def close(graceful: graceful_close?)
  soft_close if graceful
  nil
rescue Exception # rubocop:disable Lint/RescueException -- 软关闭的原始异常在硬关闭兜底后继续传播。
  failed = true
  raise
ensure
  cleanup(failed:) { hard_close }
end

#closed? ⇒ Boolean

区分会话关闭和输入结束,已关闭会话也不能继续读取。

Returns:

  • (Boolean)


294
# File 'lib/expect.rb', line 294

def closed? = @closed || to_io.closed?

#continue(reset_timeout: true) ⇒ Object

供实例回调返回继续控制符,语义与 Expect.continue 相同。



261
# File 'lib/expect.rb', line 261

def continue(reset_timeout: true) = Expect.continue(reset_timeout: reset_timeout)

#eof? ⇒ Boolean

Returns:

  • (Boolean)


296
# File 'lib/expect.rb', line 296

def eof? = @eof || closed?

#error ⇒ Object



309
# File 'lib/expect.rb', line 309

def error = @last_result&.error

#exit_code ⇒ Object



285
# File 'lib/expect.rb', line 285

def exit_code = process_status&.exitstatus

#expect ⇒ Object

在当前会话等待文本或事件,返回模式序号或 nil。



253
# File 'lib/expect.rb', line 253

def expect(...) = expect_result(...).number

#expect_result(*patterns, timeout: self.timeout, deadline: nil) ⇒ Object

使用会话默认超时构造一次等待,返回含匹配内容、来源和错误的 Result。



256
257
258
# File 'lib/expect.rb', line 256

def expect_result(*patterns, timeout: self.timeout, deadline: nil, &)
  self.class.__send__(:run_expect, [self], patterns, timeout, deadline: deadline, &)
end

#fileno ⇒ Object



268
# File 'lib/expect.rb', line 268

def fileno = closed? ? nil : to_io.fileno

#hard_close(timeout: 0.2) ⇒ Object

立即关闭句柄,再分阶段等待、TERM、KILL;不收集剩余输出,返回已回收状态或 nil。

Raises:

  • (ArgumentError)


437
438
439
440
441
442
# File 'lib/expect.rb', line 437

def hard_close(timeout: 0.2)
  period = Expect.duration(timeout)
  raise ArgumentError, "hard_close timeout must be finite" unless period

  close_resources(timeout: period, term_timeout: period, force: true)
end

#inspect ⇒ Object

诊断时仅显示进程和描述符状态,避免默认对象展开泄露缓冲或日志内容。



273
# File 'lib/expect.rb', line 273

def inspect = "#<#{self.class} pid=#{pid.inspect} fd=#{fileno.inspect} closed=#{closed?}>"

#interact(input: $stdin, escape: nil, output: nil, timeout: nil) ⇒ Object

临时将输入、会话和输出相连,实现人工接管;结束时恢复双方监听器、日志开关和转义设置。



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/expect/interaction.rb', line 53

def interact(input: $stdin, escape: nil, output: nil, timeout: nil)
  source = interact_source(input)
  output ||= input.equal?($stdin) ? $stdout : input
  saved_self = [listeners, log_stdout, log_listeners]
  saved_source = [source.listeners, source.log_stdout, source.log_listeners, source.sequences.dup]
  terminal_state = prepare_interact_terminal(source)
  display = interact_display(source, output, terminal_state)
  # 临时建立“用户输入 -> 子进程 -> 显示输出”的双向连接,原监听关系在 ensure 中恢复。
  self.listeners = [display]
  self.log_stdout = false
  self.log_listeners = true
  source.listeners = [self]
  source.log_stdout = false
  source.log_listeners = true
  source.on_sequence(escape) if escape
  Expect.interconnect(self, source, timeout: timeout)
ensure
  begin
    if saved_self
      self.listeners, self.log_stdout, self.log_listeners = saved_self
      source.listeners, source.log_stdout, source.log_listeners, source.sequences = saved_source
    end
  ensure
    restore_interact_terminal(terminal_state)
  end
end

#listeners ⇒ Object

返回监听器列表副本,避免外部原地修改转发关系。



81
# File 'lib/expect/logging.rb', line 81

def listeners = @listeners.dup

#listeners=(outputs) ⇒ Object

校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。

Raises:

  • (ArgumentError)


84
85
86
87
88
89
# File 'lib/expect/logging.rb', line 84

def listeners=(outputs)
  outputs = Array(outputs)
  raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }

  @listeners = outputs.dup
end

#log_to(target = nil, mode: "a", &block) ⇒ Object

打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。

Raises:

  • (ArgumentError)


47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/expect/logging.rb', line 47

def log_to(target = nil, mode: "a", &block)
  raise ArgumentError, "provide a log target or a block, not both" if block && target

  target = block if block
  if target.respond_to?(:to_path) || target.is_a?(String)
    raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)

    # 先交付旧过滤尾部,再允许新路径截断;同一文件不能在截断后被旧句柄写回。
    flush_log
    # 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
    replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
  else
    raise ArgumentError, "provide a log target or a block" unless target

    self.log_output = target
  end
end

#match ⇒ Object



303
# File 'lib/expect.rb', line 303

def match = @last_result&.match

#match_number ⇒ Object



305
# File 'lib/expect.rb', line 305

def match_number = @last_result&.number

#on_sequence(sequence, &block) ⇒ Object

注册字面、正则转义或 :eof 事件;回调用闭包保存上下文,nil/false 停止,其余值继续。

Raises:

  • (ArgumentError)


40
41
42
43
44
45
46
47
48
49
50
# File 'lib/expect/interaction.rb', line 40

def on_sequence(sequence, &block)
  key = case sequence
        when :eof, Regexp then sequence
        when String then sequence.b.freeze
        else raise ArgumentError, "sequence must be a String, Regexp or :eof"
        end
  raise ArgumentError, "escape sequence must not be empty" if key == ""

  @sequences[key] = block
  self
end

#pending_output? ⇒ Boolean

超时或异常后仍有未交付的数据;重新 interconnect 同一源会话可继续发送。

Returns:

  • (Boolean)


90
# File 'lib/expect/interaction.rb', line 90

def pending_output? = @relay_outputs.any? { |output| !output.done? }

#pid ⇒ Object



275
# File 'lib/expect.rb', line 275

def pid = @resources.pid

#process_status ⇒ Object

非阻塞回收并缓存子进程状态;未退出或仅适配 IO 时返回 nil。



278
279
280
281
282
283
# File 'lib/expect.rb', line 278

def process_status
  @resources.reap
rescue Errno::EINTR
  # 单次轮询被中断时状态仍未知;wait/close 会在原期限内继续,不在这里无限重试。
  @resources.status
end

#puts(*objects) ⇒ Object

委托 StringIO 处理换行、nil 和递归数组,再统一写入;返回 nil,与 Ruby puts 一致。



390
391
392
393
394
395
# File 'lib/expect.rb', line 390

def puts(*objects)
  output = StringIO.new("".b)
  output.puts(*objects)
  write(output.string)
  nil
end

#redact(*secrets) ⇒ Object

秘密仅作用于本会话的日志和诊断,不改写匹配、stdout 显示或 listeners 的协议字节。 先校验全部值再发布;注册是追加操作,应在首次通信前完成,不能追溯已交付的日志。



26
27
28
29
30
31
32
33
34
35
# File 'lib/expect/logging.rb', line 26

def redact(*secrets)
  unless secrets.any? && secrets.all? { |secret| secret.is_a?(String) && !secret.empty? }
    raise ArgumentError, "secrets must be nonempty Strings"
  end

  @secrets = ((@secrets || []) + secrets.map { |secret| secret.b.freeze }).uniq.freeze
  @log_redactor.patterns = @secrets if @log_redactor
  @diagnostic_redactors&.each_value { |redactor| redactor.patterns = @secrets }
  self
end

#send_slow(*objects, delay:) ⇒ Object

逐字符延迟发送,同时收集回复,适配输入处理较慢的交互程序;返回写入字节数。

Raises:

  • (ArgumentError)


398
399
400
401
402
403
404
405
406
407
408
409
410
411
# File 'lib/expect.rb', line 398

def send_slow(*objects, delay:)
  pause = Expect.duration(delay)
  raise ArgumentError, "delay is required" unless pause

  count = 0
  objects.each do |object|
    object.to_s.each_char do |character|
      sleep(pause) if pause.positive?
      count += write(character)
      read_available if !eof? && to_io.wait_readable(0)
    end
  end
  count
end

#soft_close(timeout: 15, term_timeout: 1) ⇒ Object

先在自然退出期限内收集尾部输出,再关闭句柄并最多发送 TERM;不会发送 KILL。 尚未退出时返回 nil 并保留 PID,调用方可以继续等待或随后硬关闭。

Raises:

  • (ArgumentError)


420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
# File 'lib/expect.rb', line 420

def soft_close(timeout: 15, term_timeout: 1)
  period = Expect.duration(timeout)
  term_timeout = Expect.duration(term_timeout)
  raise ArgumentError, "term_timeout must be finite" unless term_timeout

  deadline = period && (Expect.monotonic + period)
  until eof?
    remaining = deadline && [deadline - Expect.monotonic, 0].max
    break if remaining&.zero? || !to_io.wait_readable(remaining)

    read_available
  end
  close_resources(timeout: deadline ? [deadline - Expect.monotonic, 0].max : nil,
                  term_timeout: term_timeout, force: false)
end

#spawn(*command, env: {}, chdir: nil) ⇒ Object

在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。 rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- PTY 子进程启动与失败回传共用一次生命周期。



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/expect.rb', line 199

def spawn(*command, env: {}, chdir: nil)
  raise SpawnError, "cannot reuse a spawned session" if @command
  raise SpawnError, "only a new PTY session can spawn" unless @slave && !@slave.closed? && !closed?
  raise ArgumentError, "command is required" if command.empty?
  raise ArgumentError, "command arguments must be strings" unless command.all? do |part|
    part.is_a?(String) && !part.include?("\0")
  end
  raise ArgumentError, "command is empty" if command.first.empty?

  @slave.raw! if raw_pty?
  # 错误管道的写端在 exec 成功时自动关闭;父进程据此区分成功启动与 exec 前失败。
  from_child, to_parent = IO.pipe
  to_parent.close_on_exec = true
  @command = command.map { |part| part.dup.freeze }.freeze
  child = fork do
    from_child.close
    Process.setsid
    # 创建独立进程会话后重新打开 slave,使它成为子进程的控制终端。
    File.open(@tty_name, File::RDWR) do |terminal|
      # 重定向操作系统的标准描述符;即使宿主替换过 Ruby 标准流,也能正确连接子进程。
      # rubocop:disable Style/GlobalStdStream
      STDIN.reopen(terminal)
      STDOUT.reopen(terminal)
      STDERR.reopen(terminal)
      # rubocop:enable Style/GlobalStdStream
    end
    @resources.close_handles
    Dir.chdir(chdir) if chdir
    exec(env, *command, close_others: true)
  rescue Exception => error # rubocop:disable Lint/RescueException -- 子进程回传启动异常后立即退出。
    begin
      to_parent.write("#{error.class}: #{error.message}")
    ensure
      exit! 127
    end
  end
  @resources.pid = child
  to_parent.close
  @slave.close
  failure = from_child.read
  unless failure.empty?
    hard_close
    raise SpawnError, failure
  end
  trace("spawned pid=#{child}", event: :spawned)
  self
ensure
  from_child&.close unless from_child&.closed?
  to_parent&.close unless to_parent&.closed?
end

#stty(*modes) ⇒ Object

查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。 辅助进程与管道独立记账,不能覆盖主会话 PID;失败时保留原异常并有界回收。



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/expect/terminal.rb', line 9

def stty(*modes)
  return "" unless tty?

  modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
  modes = ["-g"] if modes.empty?
  reader, sink = IO.pipe
  resources = SessionResources.new(reader, writer: sink, own: true)
  resources.pid = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
  sink.close
  output = reader.read
  status = wait_stty(resources.pid)
  resources.pid = nil
  raise IOError, "stty failed: #{output.strip}" unless status.success?

  output.strip
rescue Errno::ENOENT
  failed = true
  raise IOError, "stty executable not found in PATH; install the system terminal utilities"
rescue Exception # rubocop:disable Lint/RescueException -- 中断同样先回收辅助进程,再原样传播;不读取调用者的旧 $!。
  failed = true
  raise
ensure
  cleanup(failed:) { cleanup_stty(resources, reader, sink) }
end

#to_io ⇒ Object

暴露底层读写 IO 与终端属性,供 select、终端设置及 IO 适配使用。



264
# File 'lib/expect.rb', line 264

def to_io = @resources.reader

#tty? ⇒ Boolean

Returns:

  • (Boolean)


270
# File 'lib/expect.rb', line 270

def tty? = !closed? && to_io.tty?

#wait(timeout: nil) ⇒ Object

轮询回收状态直到进程退出或期限到达;返回 Process::Status 或 nil,超时不丢弃 PID。



414
415
416
# File 'lib/expect.rb', line 414

def wait(timeout: nil)
  wait_for_child(Expect.duration(timeout))
end

#winsize ⇒ Object

读取终端的 [行数, 列数];底层并非终端或句柄已关闭时保留原生 IO 异常。



35
# File 'lib/expect/terminal.rb', line 35

def winsize = to_io.winsize

#winsize=(size) ⇒ Object

更新终端尺寸,由内核通知前台进程。



38
39
40
# File 'lib/expect/terminal.rb', line 38

def winsize=(size)
  to_io.winsize = size
end

#write(*objects) ⇒ Object

按 Ruby to_s 规则原样写入所有字节,返回字节数;背压等待受 write_timeout 限制。 rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 写入、背压排空和同一期限必须同步推进。

Raises:

  • (IOError)


333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
# File 'lib/expect.rb', line 333

def write(*objects)
  raise IOError, "closed Expect session" if closed? || writer.closed?

  data = objects.map { |object| object.to_s.b }.join
  trace_data(:sending, data, level: 2) if debug_level >= 2
  deadline = write_timeout && (Expect.monotonic + write_timeout)
  offset = 0
  while offset < data.bytesize
    begin
      chunk = data.byteslice(offset, READ_SIZE)
      count = writer.write_nonblock(chunk, exception: false)
    rescue Errno::EINTR
      raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline

      next
    end
    if count == :wait_writable
      raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline

      remaining = deadline && [deadline - Expect.monotonic, 0].max
      # 子进程也可能因输出管道填满而停止读取;等可写时同时排空它的输出,避免双向死锁。
      readers = eof? ? [] : [to_io]
      begin
        ready = IO.select(readers, [writer], nil, remaining)
        raise WriteTimeout.new(bytes_written: offset) unless ready

        if ready[0].include?(to_io)
          begin
            read_available
          rescue WriteTimeout
            # 日志或监听器可嵌套写入;对外报告本次写入进度,原异常通过 cause 保留。
            raise WriteTimeout.new("write interrupted by an output timeout", bytes_written: offset)
          end
        end
      rescue Errno::EINTR
        next
      end
    else
      unless count.is_a?(Integer) && count.positive? && count <= chunk.bytesize
        raise IOError, "write must return the number of accepted bytes"
      end

      offset += count
    end
  end
  data.bytesize
end

#write_log(*objects) ⇒ Object

向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。 启用脱敏后可能暂存末尾字节,因此一次调用不保证触发一次日志写入或回调。



67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/expect/logging.rb', line 67

def write_log(*objects)
  target = log_output
  return unless target

  data = objects.map { |object| object.to_s.b }.join
  if @secrets
    @log_redactor ||= Redactor.new(@secrets)
    data = @log_redactor.append(data)
    return if data.empty?
  end
  target.respond_to?(:call) ? target.call(data) : emit(target, data)
end

#writer ⇒ Object



266
# File 'lib/expect.rb', line 266

def writer = @resources.writer