expect-pty 0.5.3 → 0.7.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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/README.md +122 -130
  4. data/docs/API.md +113 -0
  5. data/docs/MIGRATION.md +68 -0
  6. data/lib/expect/cleanup.rb +34 -0
  7. data/lib/expect/interaction.rb +228 -210
  8. data/lib/expect/logging.rb +117 -162
  9. data/lib/expect/matcher.rb +42 -38
  10. data/lib/expect/pattern.rb +10 -3
  11. data/lib/expect/pattern_list.rb +39 -9
  12. data/lib/expect/redactor.rb +12 -5
  13. data/lib/expect/relay.rb +106 -81
  14. data/lib/expect/relay_writer.rb +7 -5
  15. data/lib/expect/result.rb +33 -9
  16. data/lib/expect/session.rb +554 -0
  17. data/lib/expect/session_resources.rb +19 -18
  18. data/lib/expect/version.rb +2 -2
  19. data/lib/expect.rb +68 -578
  20. data/sig/expect.rbs +279 -0
  21. metadata +14 -123
  22. data/.rubocop.yml +0 -66
  23. data/CONTRIBUTING.md +0 -29
  24. data/Gemfile +0 -28
  25. data/Rakefile +0 -38
  26. data/benchmark/matching.rb +0 -98
  27. data/benchmark/redactor.rb +0 -64
  28. data/benchmark/relay.rb +0 -48
  29. data/benchmark/scaling.rb +0 -70
  30. data/benchmark/send_slow.rb +0 -41
  31. data/benchmark/support.rb +0 -114
  32. data/docs/COMPATIBILITY.md +0 -98
  33. data/docs/INTERNAL_CONTRACTS.md +0 -154
  34. data/docs/PERFORMANCE.md +0 -165
  35. data/docs/RELEASING.md +0 -84
  36. data/docs/VERIFICATION.md +0 -456
  37. data/examples/dialogue.rb +0 -30
  38. data/examples/kibitz/README.md +0 -81
  39. data/examples/kibitz/kibitz.rb +0 -142
  40. data/examples/kibitz/test_kibitz.rb +0 -37
  41. data/examples/ssh_auto.rb +0 -94
  42. data/examples/ssh_interact.rb +0 -159
  43. data/examples/ssh_login.rb +0 -64
  44. data/expect-pty.gemspec +0 -33
  45. data/lib/expect/configuration.rb +0 -122
  46. data/lib/expect/terminal.rb +0 -102
  47. data/script/ci +0 -121
  48. data/script/release.rb +0 -319
  49. data/test/buffer_accounting_test.rb +0 -83
  50. data/test/cleanup_test.rb +0 -251
  51. data/test/compare_upstream.rb +0 -157
  52. data/test/configuration_test.rb +0 -133
  53. data/test/deadline_test.rb +0 -237
  54. data/test/diagnostics_test.rb +0 -353
  55. data/test/edge_case_test.rb +0 -262
  56. data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
  57. data/test/fixtures/ssh_scripts/02_output.sh +0 -5
  58. data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
  59. data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
  60. data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
  61. data/test/initialization_failure_test.rb +0 -92
  62. data/test/integration/README.md +0 -109
  63. data/test/integration/ssh_scripts.rb +0 -94
  64. data/test/interact_test.rb +0 -253
  65. data/test/interconnect_test.rb +0 -425
  66. data/test/io_test.rb +0 -321
  67. data/test/kibitz_test.rb +0 -45
  68. data/test/lifecycle_contract_test.rb +0 -71
  69. data/test/literal_scan_test.rb +0 -97
  70. data/test/matching_test.rb +0 -211
  71. data/test/multi_session_test.rb +0 -66
  72. data/test/ownership_sequence_test.rb +0 -208
  73. data/test/pattern_offset_test.rb +0 -43
  74. data/test/process_interruption_test.rb +0 -296
  75. data/test/process_test.rb +0 -291
  76. data/test/redactor_test.rb +0 -183
  77. data/test/relay_recovery_test.rb +0 -451
  78. data/test/relay_reentrancy_test.rb +0 -159
  79. data/test/release_test.rb +0 -375
  80. data/test/ruby_api_test.rb +0 -515
  81. data/test/scan_reuse_test.rb +0 -109
  82. data/test/script_logging_test.rb +0 -124
  83. data/test/support/interact_probe.rb +0 -125
  84. data/test/support/kibitz_probe.rb +0 -177
  85. data/test/support/script_probe.rb +0 -158
  86. data/test/terminal_cleanup_test.rb +0 -349
  87. data/test/test_helper.rb +0 -58
  88. data/test/timeout_test.rb +0 -289
  89. data/test/write_contract_test.rb +0 -105
data/lib/expect.rb CHANGED
@@ -2,34 +2,38 @@
2
2
 
3
3
  require "pty"
4
4
  require "io/console"
5
- require "io/wait"
6
5
  require "stringio"
7
- require "forwardable"
8
6
  require_relative "expect/version"
9
- require_relative "expect/configuration"
10
7
  require_relative "expect/result"
11
8
  require_relative "expect/session_resources"
12
9
  require_relative "expect/pattern"
13
10
  require_relative "expect/pattern_list"
14
11
  require_relative "expect/matcher"
12
+ require_relative "expect/cleanup"
13
+ require_relative "expect/session"
15
14
 
16
- # 自动化交互会话:可以拥有一个 PTY 子进程,也可以适配已有可 select 的 IO。
17
- # 缓冲和匹配统一保留原始字节,配置、匹配结果与资源生命周期分别管理。
18
- class Expect
19
- # 回调控制符:分别表示重置期限后继续,或保留原期限继续。
15
+ # PTY 会话的创建、匹配与转接入口;可变状态只属于返回的 Session。
16
+ module Expect
17
+ # 重置相对期限后继续等待。
20
18
  CONTINUE = :continue
19
+ # 保留原相对期限继续等待。
21
20
  CONTINUE_WITHOUT_RESET = :continue_without_reset
21
+ # 单轮非阻塞读写的最大字节数。
22
+ # @api private
22
23
  READ_SIZE = 16_384
23
- CONFIGURATION_MUTEX = Mutex.new
24
- private_constant :CONFIGURATION_MUTEX
25
24
 
25
+ # PTY 创建后命令启动失败。
26
26
  class SpawnError < StandardError; end
27
+
28
+ # 同一个来源不能同时交给两个 Relay。
27
29
  class ReentrancyError < StandardError; end
28
30
 
29
- # 已被底层接受的字节不可撤回;调用方可据此只处理尚未写出的后缀。
31
+ # 本次写入已经确认交付的进度;其他嵌套写入的异常保留在 cause。
30
32
  class WriteTimeout < IOError
33
+ # 本次 write 已被底层接受的字节数。
31
34
  attr_reader :bytes_written
32
35
 
36
+ # 构造包含已交付字节数的背压超时。
33
37
  def initialize(message = "write timed out", bytes_written: 0)
34
38
  @bytes_written = bytes_written
35
39
  super(message)
@@ -37,81 +41,63 @@ class Expect
37
41
  end
38
42
 
39
43
  class << self
40
- # 读取冻结的默认配置;子类未单独配置时继承父类快照。
41
- def configuration
42
- return @configuration if defined?(@configuration)
43
- return superclass.configuration unless self == Expect
44
-
45
- CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new.freeze }
46
- end
47
-
48
- # 基于旧快照构造可修改副本,全部赋值与配置块成功后才发布,异常时保留原配置。
49
- def configure(**)
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
44
+ # 创建并启动 Session;有块时返回块结果,并按 graceful 策略关闭所属资源。
45
+ # raw 只控制本次子进程 PTY;日志与输出对象始终借用。
46
+ def spawn(*command, env: {}, chdir: nil, raw: false, graceful: false,
47
+ timeout: nil, write_timeout: nil, buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
48
+ session = nil
49
+ spawned = false
50
+ cleanup = -> { session&.close(graceful: spawned && graceful) if block_given? || !spawned }
51
+ Cleanup.always(cleanup) do
52
+ session = Session.new(timeout:, write_timeout:, buffer_limit:, logger:, transcript:, outputs:)
53
+ session.spawn(*command, env:, chdir:, raw:)
54
+ spawned = true
55
+ block_given? ? yield(session) : session
58
56
  end
59
57
  end
60
58
 
61
- # 创建并启动会话;有块时返回块结果并确保关闭,无块时由调用方负责生命周期。
62
- def spawn(*command, env: {}, chdir: nil, **)
63
- session = new(**)
64
- session.spawn(*command, env: env, chdir: chdir)
65
- spawned = true
66
- return session unless block_given?
67
-
68
- yield session
69
- rescue Exception # rubocop:disable Lint/RescueException -- 记录本次作用域的失败,清理后原样传播,包括非 StandardError 异常。
70
- failed = true
71
- raise
72
- ensure
73
- if session && (block_given? || !spawned)
74
- session.__send__(:cleanup, failed: failed) do
75
- session.close(graceful: spawned && session.graceful_close?)
59
+ # 适配真实 IO;own 只决定读写端点的关闭责任,日志与输出目标仍由调用者管理。
60
+ # 有块时返回块结果;初始化失败也清理已取得的所属端点。
61
+ def open(io, writer: io, own: false, graceful: false,
62
+ timeout: nil, write_timeout: nil, buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
63
+ session = nil
64
+ initialized = false
65
+ cleanup = lambda do
66
+ if session && (block_given? || !initialized)
67
+ session.cleanup_session(io, writer:, own:, graceful: initialized && graceful)
68
+ elsif !session && own
69
+ SessionResources.close_handles(io, writer)
76
70
  end
77
71
  end
72
+ Cleanup.always(cleanup) do
73
+ session = Session.allocate
74
+ session.__send__(:initialize_io, io, writer:, own:, timeout:, write_timeout:, buffer_limit:,
75
+ logger:, transcript:, outputs:)
76
+ initialized = true
77
+ block_given? ? yield(session) : session
78
+ end
78
79
  end
79
80
 
80
- # 适配已有 IO;own: true 接管关闭责任,初始化失败也释放接管的读写端。
81
- def open(io, writer: io, own: false, **)
82
- session = allocate
83
- session.__send__(:initialize_session, io, writer: writer, own: own, **)
84
- initialized = true
85
- return session unless block_given?
81
+ # 按 outputs 建立转发图,返回引发停止的 Session;总期限到达返回 nil。
82
+ def interconnect(*sessions, timeout: nil)
83
+ raise ArgumentError, "interconnect requires Session objects" unless sessions.any? && sessions.all?(Session)
86
84
 
87
- yield session
88
- rescue Exception # rubocop:disable Lint/RescueException -- 初始化和块异常均须保留,清理失败不能替换原始原因。
89
- failed = true
90
- raise
91
- ensure
92
- if session && (block_given? || !initialized)
93
- session.__send__(:cleanup, failed: failed) do
94
- session.__send__(:cleanup_session, io, writer: writer, own: own,
95
- graceful: initialized && session.graceful_close?)
96
- end
97
- end
85
+ Relay.new(sessions, timeout).run
98
86
  end
99
87
 
100
- # 进行多会话匹配,返回命中的模式序号,超时、EOF 或读取错误返回 nil。
101
- def expect(...) = expect_result(...).number
102
-
103
- # 多会话等待的完整结果入口;from: 提供默认来源,块内可分别指定每个模式的来源。
104
- def expect_result(*patterns, from: [], timeout: configuration.timeout, deadline: nil, &)
105
- run_expect(from, patterns, timeout, deadline: deadline, &)
88
+ # 共同等待指定来源,返回不可变 Result。consume 控制本轮是否消费匹配文本。
89
+ # reset_timeout_on_read 仅重置相对期限,deadline 始终是不可延长的总期限。
90
+ def expect(*patterns, from: [], timeout: nil, deadline: nil, consume: true, reset_timeout_on_read: false, &)
91
+ run_expect(from, patterns, timeout, deadline:, consume:, reset_timeout_on_read:, &)
106
92
  end
107
93
 
108
- # 返回继续等待的控制符,reset_timeout 决定是否重新计算匹配期限。
94
+ # 返回回调继续控制符,reset_timeout 为 false 时保留原相对期限。
109
95
  def continue(reset_timeout: true) = reset_timeout ? CONTINUE : CONTINUE_WITHOUT_RESET
110
96
 
111
- # 读取不受系统时间调整影响的单调时钟,所有相对超时共用此计时基准。
97
+ # 当前单调时钟秒数;供跨多次等待共享 deadline。
112
98
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
99
 
114
- # 将秒数转换为有限的非负数,nil 表示无限;供配置和单次操作共用校验。
100
+ # 转换有限非负秒数,nil 表示无相对期限。
115
101
  def duration(value)
116
102
  return nil if value.nil?
117
103
 
@@ -121,12 +107,12 @@ class Expect
121
107
  number
122
108
  end
123
109
 
124
- # 等待并返回可读会话,不消费输入;去重并忽略已关闭会话,默认非阻塞。
110
+ # 返回可读 Session,不消费输入;去重、排除已关闭来源,并保留声明顺序。
125
111
  def readable_sessions(*sessions, timeout: 0)
126
112
  timeout = duration(timeout)
127
- raise ArgumentError, "readable_sessions requires Expect sessions" unless sessions.all?(Expect)
113
+ raise ArgumentError, "readable_sessions requires Session objects" unless sessions.all?(Session)
128
114
 
129
- active = sessions.uniq.reject(&:closed?)
115
+ active = sessions.uniq(&:object_id).reject(&:closed?)
130
116
  return [] if active.empty?
131
117
 
132
118
  deadline = timeout && (monotonic + timeout)
@@ -145,13 +131,19 @@ class Expect
145
131
  end
146
132
  return [] unless ready
147
133
 
148
- active.select { |session| ready.first.include?(session.to_io) }
134
+ select_ready_sessions(active, ready.first)
149
135
  end
150
136
 
151
137
  private
152
138
 
153
- # 先完成模式声明再启动引擎;无参数块支持简洁 DSL,有参数块保留调用方 self。
154
- def run_expect(sessions, patterns, timeout, deadline: nil, &block)
139
+ # 按对象身份归属就绪描述符,不能用 IO 的值相等规则合并来源。
140
+ def select_ready_sessions(sessions, readable)
141
+ by_io = readable.each_with_object({}.compare_by_identity) { |io, index| index[io] = true }
142
+ sessions.select { |session| by_io.key?(session.to_io) }
143
+ end
144
+
145
+ # 声明完成后固定模式;有参数块保留调用方 self,无参数块使用模式 DSL。
146
+ def run_expect(sessions, patterns, timeout, deadline:, consume:, reset_timeout_on_read:, &block)
155
147
  timeout = duration(timeout)
156
148
  deadline = Float(deadline) unless deadline.nil?
157
149
  raise ArgumentError, "deadline must be finite" if deadline && !deadline.finite?
@@ -161,509 +153,7 @@ class Expect
161
153
  if block
162
154
  block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
163
155
  end
164
- Matcher.new(pattern_list.validate!, timeout, deadline: deadline).run
165
- end
166
- end
167
-
168
- extend Forwardable
169
-
170
- # 普通属性委托给会话独立配置;缓冲上限的 setter 还需立即裁剪现有缓冲。
171
- def_delegators :@configuration, *Configuration::ATTRIBUTES, *Configuration::PREDICATES
172
- def_delegators :@configuration, *(Configuration::ATTRIBUTES - [:buffer_limit]).map { |name| :"#{name}=" }
173
-
174
- attr_reader :command, :last_result, :slave, :tty_name, :buffer_discarded_bytes
175
-
176
- # 校验并更新缓冲上限后,立即裁剪已接收的内容;校验失败不改变旧缓冲。
177
- def buffer_limit=(value)
178
- @configuration.buffer_limit = value
179
- trim_buffer
180
- value
181
- end
182
-
183
- # 创建 PTY,可立即启动命令,也可先让调用方配置 slave;构造失败时释放全部新句柄。
184
- def initialize(*command, env: {}, chdir: nil, **)
185
- master, slave = PTY.open
186
- initialize_session(master, writer: master, slave: slave, own: true, **)
187
- @tty_name = slave.path
188
- spawn(*command, env: env, chdir: chdir) unless command.empty?
189
- initialized = true
190
- rescue Exception # rubocop:disable Lint/RescueException -- 构造异常时也要关闭已创建的资源并回收已启动的子进程。
191
- failed = true
192
- raise
193
- ensure
194
- cleanup(failed:) { cleanup_session(master, writer: master, slave: slave, own: true) } unless initialized
195
- end
196
-
197
- # 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
198
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- PTY 子进程启动与失败回传共用一次生命周期。
199
- def spawn(*command, env: {}, chdir: nil)
200
- raise SpawnError, "cannot reuse a spawned session" if @command
201
- raise SpawnError, "only a new PTY session can spawn" unless @slave && !@slave.closed? && !closed?
202
- raise ArgumentError, "command is required" if command.empty?
203
- raise ArgumentError, "command arguments must be strings" unless command.all? do |part|
204
- part.is_a?(String) && !part.include?("\0")
205
- end
206
- raise ArgumentError, "command is empty" if command.first.empty?
207
-
208
- @slave.raw! if raw_pty?
209
- # 错误管道的写端在 exec 成功时自动关闭;父进程据此区分成功启动与 exec 前失败。
210
- from_child, to_parent = IO.pipe
211
- to_parent.close_on_exec = true
212
- @command = command.map { |part| part.dup.freeze }.freeze
213
- child = fork do
214
- from_child.close
215
- Process.setsid
216
- # 创建独立进程会话后重新打开 slave,使它成为子进程的控制终端。
217
- File.open(@tty_name, File::RDWR) do |terminal|
218
- # 重定向操作系统的标准描述符;即使宿主替换过 Ruby 标准流,也能正确连接子进程。
219
- # rubocop:disable Style/GlobalStdStream
220
- STDIN.reopen(terminal)
221
- STDOUT.reopen(terminal)
222
- STDERR.reopen(terminal)
223
- # rubocop:enable Style/GlobalStdStream
224
- end
225
- @resources.close_handles
226
- Dir.chdir(chdir) if chdir
227
- exec(env, *command, close_others: true)
228
- rescue Exception => error # rubocop:disable Lint/RescueException -- 子进程回传启动异常后立即退出。
229
- begin
230
- to_parent.write("#{error.class}: #{error.message}")
231
- ensure
232
- exit! 127
233
- end
234
- end
235
- @resources.pid = child
236
- to_parent.close
237
- @slave.close
238
- failure = from_child.read
239
- unless failure.empty?
240
- hard_close
241
- raise SpawnError, failure
242
- end
243
- trace("spawned pid=#{child}", event: :spawned)
244
- self
245
- ensure
246
- from_child&.close unless from_child&.closed?
247
- to_parent&.close unless to_parent&.closed?
248
- end
249
-
250
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
251
-
252
- # 在当前会话等待文本或事件,返回模式序号或 nil。
253
- def expect(...) = expect_result(...).number
254
-
255
- # 使用会话默认超时构造一次等待,返回含匹配内容、来源和错误的 Result。
256
- def expect_result(*patterns, timeout: self.timeout, deadline: nil, &)
257
- self.class.__send__(:run_expect, [self], patterns, timeout, deadline: deadline, &)
258
- end
259
-
260
- # 供实例回调返回继续控制符,语义与 Expect.continue 相同。
261
- def continue(reset_timeout: true) = Expect.continue(reset_timeout: reset_timeout)
262
-
263
- # 暴露底层读写 IO 与终端属性,供 select、终端设置及 IO 适配使用。
264
- def to_io = @resources.reader
265
-
266
- def writer = @resources.writer
267
-
268
- def fileno = closed? ? nil : to_io.fileno
269
-
270
- def tty? = !closed? && to_io.tty?
271
-
272
- # 诊断时仅显示进程和描述符状态,避免默认对象展开泄露缓冲或日志内容。
273
- def inspect = "#<#{self.class} pid=#{pid.inspect} fd=#{fileno.inspect} closed=#{closed?}>"
274
-
275
- def pid = @resources.pid
276
-
277
- # 非阻塞回收并缓存子进程状态;未退出或仅适配 IO 时返回 nil。
278
- def process_status
279
- @resources.reap
280
- rescue Errno::EINTR
281
- # 单次轮询被中断时状态仍未知;wait/close 会在原期限内继续,不在这里无限重试。
282
- @resources.status
283
- end
284
-
285
- def exit_code = process_status&.exitstatus
286
-
287
- # 先刷新回收状态,再判断是否仍有未回收的子进程;不以 IO 是否关闭代替进程状态。
288
- def alive?
289
- process_status
290
- !pid.nil?
291
- end
292
-
293
- # 区分会话关闭和输入结束,已关闭会话也不能继续读取。
294
- def closed? = @closed || to_io.closed?
295
-
296
- def eof? = @eof || closed?
297
-
298
- # 以下访问器读取最近一次等待结果;未发生匹配时捕获组返回空数组。
299
- def before = @last_result&.before
300
-
301
- def after = @last_result&.after
302
-
303
- def match = @last_result&.match
304
-
305
- def match_number = @last_result&.number
306
-
307
- def captures = @last_result&.captures || []
308
-
309
- def error = @last_result&.error
310
-
311
- # 返回缓冲副本,防止调用方原地修改绕过裁剪规则。
312
- def buffer = @buffer.dup
313
-
314
- # 复制并替换原始字节缓冲,应用当前上限;调用方后续修改原字符串不会影响会话。
315
- def buffer=(value)
316
- raise ArgumentError, "buffer must be a String" unless value.is_a?(String)
317
-
318
- @buffer = value.b
319
- @buffer_generation += 1
320
- trim_buffer
321
- end
322
-
323
- # 移交旧缓冲并换上新的空字节串,供显式清空或人工转接接管数据。
324
- def clear_buffer
325
- previous = @buffer
326
- @buffer = "".b
327
- @buffer_generation += 1
328
- previous
329
- end
330
-
331
- # 按 Ruby to_s 规则原样写入所有字节,返回字节数;背压等待受 write_timeout 限制。
332
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 写入、背压排空和同一期限必须同步推进。
333
- def write(*objects)
334
- raise IOError, "closed Expect session" if closed? || writer.closed?
335
-
336
- data = objects.map { |object| object.to_s.b }.join
337
- trace_data(:sending, data, level: 2) if debug_level >= 2
338
- deadline = write_timeout && (Expect.monotonic + write_timeout)
339
- offset = 0
340
- while offset < data.bytesize
341
- begin
342
- chunk = data.byteslice(offset, READ_SIZE)
343
- count = writer.write_nonblock(chunk, exception: false)
344
- rescue Errno::EINTR
345
- raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline
346
-
347
- next
348
- end
349
- if count == :wait_writable
350
- raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline
351
-
352
- remaining = deadline && [deadline - Expect.monotonic, 0].max
353
- # 子进程也可能因输出管道填满而停止读取;等可写时同时排空它的输出,避免双向死锁。
354
- readers = eof? ? [] : [to_io]
355
- begin
356
- ready = IO.select(readers, [writer], nil, remaining)
357
- raise WriteTimeout.new(bytes_written: offset) unless ready
358
-
359
- if ready[0].include?(to_io)
360
- begin
361
- read_available
362
- rescue WriteTimeout
363
- # 日志或监听器可嵌套写入;对外报告本次写入进度,原异常通过 cause 保留。
364
- raise WriteTimeout.new("write interrupted by an output timeout", bytes_written: offset)
365
- end
366
- end
367
- rescue Errno::EINTR
368
- next
369
- end
370
- else
371
- unless count.is_a?(Integer) && count.positive? && count <= chunk.bytesize
372
- raise IOError, "write must return the number of accepted bytes"
373
- end
374
-
375
- offset += count
376
- end
156
+ Matcher.new(pattern_list, timeout, deadline:, consume:, reset_timeout_on_read:).run
377
157
  end
378
- data.bytesize
379
- end
380
-
381
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
382
-
383
- # 链式写入单个对象,返回当前会话。
384
- def <<(object)
385
- write(object)
386
- self
387
- end
388
-
389
- # 委托 StringIO 处理换行、nil 和递归数组,再统一写入;返回 nil,与 Ruby puts 一致。
390
- def puts(*objects)
391
- output = StringIO.new("".b)
392
- output.puts(*objects)
393
- write(output.string)
394
- nil
395
- end
396
-
397
- # 逐字符延迟发送,同时收集回复,适配输入处理较慢的交互程序;返回写入字节数。
398
- def send_slow(*objects, delay:)
399
- pause = Expect.duration(delay)
400
- raise ArgumentError, "delay is required" unless pause
401
-
402
- count = 0
403
- objects.each do |object|
404
- object.to_s.each_char do |character|
405
- sleep(pause) if pause.positive?
406
- count += write(character)
407
- read_available if !eof? && to_io.wait_readable(0)
408
- end
409
- end
410
- count
411
- end
412
-
413
- # 轮询回收状态直到进程退出或期限到达;返回 Process::Status 或 nil,超时不丢弃 PID。
414
- def wait(timeout: nil)
415
- wait_for_child(Expect.duration(timeout))
416
- end
417
-
418
- # 先在自然退出期限内收集尾部输出,再关闭句柄并最多发送 TERM;不会发送 KILL。
419
- # 尚未退出时返回 nil 并保留 PID,调用方可以继续等待或随后硬关闭。
420
- def soft_close(timeout: 15, term_timeout: 1)
421
- period = Expect.duration(timeout)
422
- term_timeout = Expect.duration(term_timeout)
423
- raise ArgumentError, "term_timeout must be finite" unless term_timeout
424
-
425
- deadline = period && (Expect.monotonic + period)
426
- until eof?
427
- remaining = deadline && [deadline - Expect.monotonic, 0].max
428
- break if remaining&.zero? || !to_io.wait_readable(remaining)
429
-
430
- read_available
431
- end
432
- close_resources(timeout: deadline ? [deadline - Expect.monotonic, 0].max : nil,
433
- term_timeout: term_timeout, force: false)
434
- end
435
-
436
- # 立即关闭句柄,再分阶段等待、TERM、KILL;不收集剩余输出,返回已回收状态或 nil。
437
- def hard_close(timeout: 0.2)
438
- period = Expect.duration(timeout)
439
- raise ArgumentError, "hard_close timeout must be finite" unless period
440
-
441
- close_resources(timeout: period, term_timeout: period, force: true)
442
- end
443
-
444
- # 通用生命周期清理:可先软关闭,ensure 中硬关闭兜底;正常完成返回 nil。
445
- def close(graceful: graceful_close?)
446
- soft_close if graceful
447
- nil
448
- rescue Exception # rubocop:disable Lint/RescueException -- 软关闭的原始异常在硬关闭兜底后继续传播。
449
- failed = true
450
- raise
451
- ensure
452
- cleanup(failed:) { hard_close }
453
- end
454
-
455
- private
456
-
457
- # 账本发布前只按局部所有权清理;发布后沿用完整关闭流程,避免两套生命周期状态。
458
- def cleanup_session(reader, writer:, own:, slave: nil, graceful: false)
459
- if @resources
460
- close(graceful: graceful)
461
- elsif own
462
- SessionResources.close_handles(reader, writer, slave)
463
- end
464
- end
465
-
466
- # 仅在本次生命周期已有异常时抑制常规清理错误;调用者 rescue 中的旧异常不算本次失败。
467
- def cleanup(failed:)
468
- yield
469
- rescue IOError, SystemCallError
470
- raise unless failed
471
- end
472
-
473
- # 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
474
- def close_resources(timeout:, term_timeout:, force:)
475
- failure = nil
476
- # 预期的清理错误延后传播,保证其余所属资源和直属子进程仍能完成清理。
477
- cleanup = lambda do |&step|
478
- step.call
479
- rescue IOError, SystemCallError => error
480
- failure ||= error
481
- nil
482
- end
483
- # IO 关闭与进程退出独立记录:软关闭可能已经 closed?,但仍保留活跃 PID。
484
- cleanup.call { @resources.close_handles }
485
- @closed = true
486
- @interact_inputs&.delete_if do |_io, input|
487
- cleanup.call do
488
- input.close(graceful: false)
489
- true
490
- end
491
- end
492
- @interact_output = nil
493
- @relay_outputs&.clear
494
- @relay_history&.clear
495
- @relay_callback = nil
496
- status = close_child(timeout: timeout, term_timeout: term_timeout, force: force)
497
- completed = true
498
- status
499
- ensure
500
- begin
501
- cleanup.call { flush_diagnostics }
502
- ensure
503
- cleanup.call { self.log_output = nil }
504
- end
505
- # 用本次流程的完成状态判断异常传播,不能误把调用者 rescue 中的异常当成当前错误。
506
- raise failure if failure && completed
507
- end
508
-
509
- # 句柄清理失败不改变进程策略;未回收 PID 保留给重复关闭或终结器继续处理。
510
- def close_child(timeout:, term_timeout:, force:)
511
- return process_status unless @resources.owner == Process.pid && pid
512
-
513
- status = wait(timeout: timeout)
514
- return status if status || !pid
515
-
516
- status = wait_for_child(term_timeout, signal: "TERM")
517
- return status if status || !pid
518
- return unless force
519
-
520
- wait_for_child(1, signal: "KILL")
521
- end
522
-
523
- # 每阶段只计算一次期限;回收或信号被中断后仍沿用剩余预算,零预算也先做一次尝试。
524
- def wait_for_child(period, signal: nil)
525
- deadline = period && (Expect.monotonic + period)
526
- loop do
527
- status = process_status
528
- return status if status || !pid || @resources.owner != Process.pid
529
-
530
- begin
531
- signal_child(signal) if signal
532
- signal = nil
533
- rescue Errno::EINTR
534
- # 下轮先回收再重试信号,避免在无限等待或持续中断时忙等。
535
- nil
536
- end
537
- # ESRCH 后可能已完成回收;即使预算耗尽,也要返回刚获得的状态。
538
- return process_status unless pid
539
-
540
- remaining = deadline && (deadline - Expect.monotonic)
541
- return nil if remaining && remaining <= 0
542
-
543
- sleep(remaining ? [0.01, remaining].min : 0.01)
544
- end
545
- end
546
-
547
- # 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
548
- def initialize_session(reader, writer:, slave: nil, own: false, diagnostic_output: nil, **)
549
- # 先登记所有权,后续校验失败也使用同一个资源对象逐个清理所属 IO。
550
- @resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
551
- raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
552
- raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
553
-
554
- @pty = reader.tty?
555
- @slave = slave
556
- @configuration = Configuration.new(**self.class.configuration.to_h, **)
557
- @buffer = "".b
558
- @buffer_generation = 0
559
- @buffer_discarded_bytes = 0
560
- @listeners = []
561
- @sequences = {}
562
- @relay_outputs = []
563
- @closed = @eof = false
564
- self.diagnostic_output = diagnostic_output
565
- ObjectSpace.define_finalizer(self, SessionResources.finalizer(@resources))
566
- end
567
-
568
- # 开始新一轮等待时清除旧结果并应用缓冲上限,尚未消费的输入继续保留。
569
- def reset_result
570
- @last_result = nil
571
- trim_buffer
572
- end
573
-
574
- # 按字节偏移生成 before/match/after;通常只保留 after,preserve_buffer 开启时不消费。
575
- def record_match(pattern, position)
576
- offset, length, captures = position
577
- @last_result = Result.new(number: pattern.number, before: @buffer.byteslice(0, offset),
578
- match: @buffer.byteslice(offset, length), after: @buffer.byteslice((offset + length)..),
579
- session: self, captures: captures)
580
- unless preserve_buffer?
581
- @buffer = @last_result.after.dup
582
- @buffer_generation += 1
583
- end
584
- # 诊断回调可能嵌套等待;恢复本次结果后再交给正式模式回调,不能返回内层等待的结果。
585
- result = @last_result
586
- trace("matched pattern #{pattern.number}")
587
- @last_result = result
588
- end
589
-
590
- # 记录超时、EOF 或原始 IO 异常,保留当前缓冲快照并清除旧匹配及捕获组。
591
- def record_error(error)
592
- @last_result = Result.new(error: error, before: buffer, session: self, captures: [])
593
- end
594
-
595
- # 输入结束时将剩余缓冲放入 before 并清空,尝试回收但不终止仍活跃的子进程。
596
- def record_eof
597
- process_status
598
- record_error(:eof)
599
- clear_buffer
600
- @last_result
601
- end
602
-
603
- # 先将读取字节交给匹配或转接缓冲,再记录日志;日志失败也能恢复输入。
604
- def read_available(propagate: true, buffer: @buffer, trim: true)
605
- return nil if eof?
606
-
607
- # 写入背压也会读取;转接期间统一交给转义处理器,不能直接转发或另存匹配缓冲。
608
- if @interaction_buffer
609
- buffer = @interaction_buffer
610
- propagate = false
611
- trim = false
612
- end
613
-
614
- begin
615
- data = to_io.read_nonblock(READ_SIZE, exception: false)
616
- rescue Errno::EIO
617
- # 某些系统用 PTY 的 EIO 表示对端关闭;普通 IO 的同类错误仍按异常处理。
618
- raise unless @pty
619
-
620
- return mark_eof
621
- rescue EOFError
622
- return mark_eof
623
- end
624
- return nil if data == :wait_readable
625
-
626
- return mark_eof if data.nil?
627
-
628
- data = data.b
629
- buffer << data
630
- trim_buffer if trim
631
- trace_data(:received, data, level: 2) if debug_level >= 2
632
- trace_data(:buffer, @buffer, level: 3) if debug_level >= 3
633
- # 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
634
- write_log(data)
635
- propagate(data) if propagate
636
- data
637
- end
638
-
639
- def mark_eof
640
- @eof = true
641
- flush_log
642
- flush_diagnostics(:received)
643
- nil
644
- end
645
-
646
- # 缓冲超过上限时只保留最新尾部字节,不对编码做隐式修改。
647
- def trim_buffer
648
- limit = buffer_limit
649
- return unless limit && @buffer.bytesize > limit
650
-
651
- # 只累计匹配窗口裁剪,消费、清空及转接交接不算丢弃;关闭后仍可读取累计值。
652
- @buffer_discarded_bytes += @buffer.bytesize - limit
653
- @buffer = @buffer.byteslice(-limit, limit)
654
- @buffer_generation += 1
655
- end
656
-
657
- # 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
658
- def signal_child(signal)
659
- return unless pid && @resources.owner == Process.pid
660
-
661
- Process.kill(signal, pid)
662
- rescue Errno::ESRCH
663
- @resources.reap
664
158
  end
665
159
  end
666
-
667
- require_relative "expect/logging"
668
- require_relative "expect/terminal"
669
- require_relative "expect/interaction"