expect-pty 0.2.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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +29 -0
  3. data/CHANGELOG.md +25 -0
  4. data/Gemfile +10 -0
  5. data/LICENSE +21 -0
  6. data/README.md +264 -0
  7. data/Rakefile +38 -0
  8. data/docs/COMPATIBILITY.md +65 -0
  9. data/docs/RELEASING.md +55 -0
  10. data/docs/VERIFICATION.md +146 -0
  11. data/examples/dialogue.rb +30 -0
  12. data/examples/kibitz/README.md +73 -0
  13. data/examples/kibitz/kibitz.rb +139 -0
  14. data/examples/kibitz/test_kibitz.rb +37 -0
  15. data/examples/ssh_auto.rb +94 -0
  16. data/examples/ssh_interact.rb +159 -0
  17. data/examples/ssh_login.rb +64 -0
  18. data/expect-pty.gemspec +25 -0
  19. data/lib/expect/configuration.rb +113 -0
  20. data/lib/expect/engine.rb +141 -0
  21. data/lib/expect/interconnect.rb +170 -0
  22. data/lib/expect/pattern.rb +62 -0
  23. data/lib/expect/pattern_list.rb +90 -0
  24. data/lib/expect/pty.rb +4 -0
  25. data/lib/expect/resources.rb +63 -0
  26. data/lib/expect/result.rb +14 -0
  27. data/lib/expect/version.rb +6 -0
  28. data/lib/expect.rb +591 -0
  29. data/script/ci +44 -0
  30. data/script/release.rb +267 -0
  31. data/test/compare_upstream.rb +157 -0
  32. data/test/configuration_test.rb +90 -0
  33. data/test/edge_case_test.rb +183 -0
  34. data/test/fixtures/ssh_scripts/01_identity.sh +4 -0
  35. data/test/fixtures/ssh_scripts/02_output.sh +5 -0
  36. data/test/fixtures/ssh_scripts/03_delayed.sh +6 -0
  37. data/test/fixtures/ssh_scripts/04_failure.sh +2 -0
  38. data/test/fixtures/ssh_scripts/05_recovery.sh +3 -0
  39. data/test/integration/README.md +90 -0
  40. data/test/integration/ssh_scripts.rb +94 -0
  41. data/test/interact_test.rb +151 -0
  42. data/test/interconnect_test.rb +226 -0
  43. data/test/io_test.rb +184 -0
  44. data/test/kibitz_test.rb +45 -0
  45. data/test/matching_test.rb +178 -0
  46. data/test/multi_session_test.rb +66 -0
  47. data/test/process_test.rb +244 -0
  48. data/test/release_test.rb +153 -0
  49. data/test/ruby_api_test.rb +515 -0
  50. data/test/script_logging_test.rb +124 -0
  51. data/test/support/interact_probe.rb +125 -0
  52. data/test/support/kibitz_probe.rb +177 -0
  53. data/test/support/script_probe.rb +157 -0
  54. data/test/test_helper.rb +58 -0
  55. data/test/timeout_test.rb +170 -0
  56. metadata +97 -0
data/lib/expect.rb ADDED
@@ -0,0 +1,591 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pty"
4
+ require "io/console"
5
+ require "io/wait"
6
+ require "shellwords"
7
+ require "stringio"
8
+ require "forwardable"
9
+ require_relative "expect/version"
10
+ require_relative "expect/configuration"
11
+ require_relative "expect/result"
12
+ require_relative "expect/resources"
13
+ require_relative "expect/pattern"
14
+ require_relative "expect/pattern_list"
15
+ require_relative "expect/engine"
16
+
17
+ # 自动化交互会话:可以拥有一个 PTY 子进程,也可以适配已有可 select 的 IO。
18
+ # 缓冲和匹配统一保留原始字节,配置、匹配结果与资源生命周期分别管理。
19
+ class Expect
20
+ # 回调控制符:分别表示重置期限后继续,或保留原期限继续。
21
+ CONTINUE = :continue
22
+ CONTINUE_WITHOUT_RESET = :continue_without_reset
23
+ READ_SIZE = 16_384
24
+
25
+ class SpawnError < StandardError; end
26
+ class WriteTimeout < IOError; end
27
+
28
+ class << self
29
+ # 读取冻结的默认配置;子类未单独配置时继承父类快照。
30
+ def configuration
31
+ return @configuration if defined?(@configuration)
32
+ return superclass.configuration unless self == Expect
33
+
34
+ @configuration = Configuration.new.freeze
35
+ end
36
+
37
+ # 基于旧快照构造可修改副本,全部赋值与配置块成功后才发布,异常时保留原配置。
38
+ def configure(**)
39
+ updated = Configuration.new(**configuration.to_h, **)
40
+ yield updated if block_given?
41
+ @configuration = updated.freeze
42
+ end
43
+
44
+ # 创建并启动会话;有块时返回块结果并确保关闭,无块时由调用方负责生命周期。
45
+ def spawn(*command, env: {}, chdir: nil, **)
46
+ session = new(**)
47
+ session.spawn(*command, env: env, chdir: chdir)
48
+ return session unless block_given?
49
+
50
+ yield session
51
+ ensure
52
+ session&.close if block_given? || (session && !session.pid)
53
+ end
54
+
55
+ # 适配已有 IO;own: true 接管关闭责任,初始化失败也释放接管的读写端。
56
+ def open(io, writer: io, own: false, **)
57
+ session = allocate
58
+ session.__send__(:initialize_session, io, writer: writer, own: own, **)
59
+ initialized = true
60
+ return session unless block_given?
61
+
62
+ yield session
63
+ ensure
64
+ if initialized
65
+ session.close if block_given?
66
+ elsif own
67
+ [io, writer].uniq.each { |handle| handle.close if handle.is_a?(IO) && !handle.closed? }
68
+ end
69
+ end
70
+
71
+ # 进行多会话匹配,返回命中的模式序号,超时、EOF 或读取错误返回 nil。
72
+ def expect(...) = expect_result(...).number
73
+
74
+ # 多会话等待的完整结果入口;from: 提供默认来源,块内可分别指定每个模式的来源。
75
+ def expect_result(*patterns, from: [], timeout: configuration.timeout, &)
76
+ run_expect(from, patterns, timeout, &)
77
+ end
78
+
79
+ # 返回继续等待的控制符,reset_timeout 决定是否重新计算匹配期限。
80
+ def continue(reset_timeout: true) = reset_timeout ? CONTINUE : CONTINUE_WITHOUT_RESET
81
+ # 读取不受系统时间调整影响的单调时钟,所有相对超时共用此计时基准。
82
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
+
84
+ # 将秒数转换为有限的非负数,nil 表示无限;供配置和单次操作共用校验。
85
+ def duration(value)
86
+ return nil if value.nil?
87
+
88
+ number = Float(value)
89
+ raise ArgumentError, "duration must be finite and nonnegative" unless number.finite? && number >= 0
90
+
91
+ number
92
+ end
93
+
94
+ # 等待并返回可读会话,不消费输入;去重并忽略已关闭会话,默认非阻塞。
95
+ def readable_sessions(*sessions, timeout: 0)
96
+ timeout = duration(timeout)
97
+ raise ArgumentError, "readable_sessions requires Expect sessions" unless sessions.all?(Expect)
98
+
99
+ active = sessions.uniq.reject(&:closed?)
100
+ return [] if active.empty?
101
+
102
+ ready = IO.select(active.map(&:to_io), nil, nil, timeout)
103
+ return [] unless ready
104
+
105
+ active.select { |session| ready.first.include?(session.to_io) }
106
+ end
107
+
108
+ private
109
+
110
+ # 先完成模式声明再启动引擎;无参数块支持简洁 DSL,有参数块保留调用方 self。
111
+ def run_expect(sessions, patterns, timeout, &block)
112
+ timeout = duration(timeout)
113
+ raise ArgumentError, "provide patterns or a pattern block, not both" if block_given? && !patterns.empty?
114
+
115
+ pattern_list = PatternList.new(sessions, patterns)
116
+ if block
117
+ block.parameters.empty? ? pattern_list.instance_exec(&block) : block.call(pattern_list)
118
+ end
119
+ Engine.new(pattern_list.validate!, timeout).run
120
+ end
121
+ end
122
+
123
+ extend Forwardable
124
+
125
+ # 普通属性委托给会话独立配置;缓冲上限的 setter 还需立即裁剪现有缓冲。
126
+ def_delegators :@configuration, *Configuration::ATTRIBUTES, *Configuration::PREDICATES
127
+ def_delegators :@configuration, *(Configuration::ATTRIBUTES - [:buffer_limit]).map { |name| :"#{name}=" }
128
+
129
+ attr_reader :command, :last_result, :slave, :tty_name
130
+
131
+ # 校验并更新缓冲上限后,立即裁剪已接收的内容;校验失败不改变旧缓冲。
132
+ def buffer_limit=(value)
133
+ @configuration.buffer_limit = value
134
+ trim_buffer
135
+ value
136
+ end
137
+
138
+ # 创建 PTY,可立即启动命令,也可先让调用方配置 slave;构造失败时释放全部新句柄。
139
+ def initialize(*command, env: {}, chdir: nil, **)
140
+ master, slave = PTY.open
141
+ initialize_session(master, writer: master, slave: slave, own: true, **)
142
+ @tty_name = slave.path
143
+ spawn(*command, env: env, chdir: chdir) unless command.empty?
144
+ initialized = true
145
+ ensure
146
+ unless initialized
147
+ master&.close unless master&.closed?
148
+ slave&.close unless slave&.closed?
149
+ end
150
+ end
151
+
152
+ # 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
153
+ def spawn(*command, env: {}, chdir: nil)
154
+ raise SpawnError, "cannot reuse a spawned session" if @command
155
+ raise SpawnError, "only a new PTY session can spawn" unless @slave && !@slave.closed? && !closed?
156
+ raise ArgumentError, "command is required" if command.empty?
157
+ raise ArgumentError, "command arguments must be strings" unless command.all? do |part|
158
+ part.is_a?(String) && !part.include?("\0")
159
+ end
160
+ raise ArgumentError, "command is empty" if command.first.empty?
161
+
162
+ @slave.raw! if raw_pty?
163
+ # 错误管道的写端在 exec 成功时自动关闭;父进程据此区分成功启动与 exec 前失败。
164
+ from_child, to_parent = IO.pipe
165
+ to_parent.close_on_exec = true
166
+ @command = command.map { |part| part.dup.freeze }.freeze
167
+ child = fork do
168
+ from_child.close
169
+ Process.setsid
170
+ # 创建独立进程会话后重新打开 slave,使它成为子进程的控制终端。
171
+ File.open(@tty_name, File::RDWR) do |terminal|
172
+ # 重定向操作系统的标准描述符;即使宿主替换过 Ruby 标准流,也能正确连接子进程。
173
+ # rubocop:disable Style/GlobalStdStream
174
+ STDIN.reopen(terminal)
175
+ STDOUT.reopen(terminal)
176
+ STDERR.reopen(terminal)
177
+ # rubocop:enable Style/GlobalStdStream
178
+ end
179
+ @resources.close_handles
180
+ Dir.chdir(chdir) if chdir
181
+ exec(env, *command, close_others: true)
182
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 子进程回传启动异常后立即退出。
183
+ begin
184
+ to_parent.write("#{error.class}: #{error.message}")
185
+ ensure
186
+ exit! 127
187
+ end
188
+ end
189
+ @resources.pid = child
190
+ to_parent.close
191
+ @slave.close
192
+ failure = from_child.read
193
+ unless failure.empty?
194
+ hard_close
195
+ raise SpawnError, failure
196
+ end
197
+ trace("spawned pid=#{child}")
198
+ self
199
+ ensure
200
+ from_child&.close unless from_child&.closed?
201
+ to_parent&.close unless to_parent&.closed?
202
+ end
203
+
204
+ # 在当前会话等待文本或事件,返回模式序号或 nil。
205
+ def expect(...) = expect_result(...).number
206
+
207
+ # 使用会话默认超时构造一次等待,返回含匹配内容、来源和错误的 Result。
208
+ def expect_result(*patterns, timeout: self.timeout, &)
209
+ self.class.__send__(:run_expect, [self], patterns, timeout, &)
210
+ end
211
+
212
+ # 供实例回调返回继续控制符,语义与 Expect.continue 相同。
213
+ def continue(reset_timeout: true) = Expect.continue(reset_timeout: reset_timeout)
214
+
215
+ # 暴露底层读写 IO 与终端属性,供 select、终端设置及 IO 适配使用。
216
+ def to_io = @resources.reader
217
+ def writer = @resources.writer
218
+ def fileno = closed? ? nil : to_io.fileno
219
+ def tty? = !closed? && to_io.tty?
220
+ # 诊断时仅显示进程和描述符状态,避免默认对象展开泄露缓冲或日志内容。
221
+ def inspect = "#<#{self.class} pid=#{pid.inspect} fd=#{fileno.inspect} closed=#{closed?}>"
222
+ def pid = @resources.pid
223
+ # 非阻塞回收并缓存子进程状态;未退出或仅适配 IO 时返回 nil。
224
+ def process_status = @resources.reap
225
+ def exit_code = process_status&.exitstatus
226
+
227
+ # 先刷新回收状态,再判断是否仍有未回收的子进程;不以 IO 是否关闭代替进程状态。
228
+ def alive?
229
+ process_status
230
+ !pid.nil?
231
+ end
232
+
233
+ # 区分会话关闭和输入结束,已关闭会话也不能继续读取。
234
+ def closed? = @closed || to_io.closed?
235
+ def eof? = @eof || closed?
236
+ # 以下访问器读取最近一次等待结果;未发生匹配时捕获组返回空数组。
237
+ def before = @last_result&.before
238
+ def after = @last_result&.after
239
+ def match = @last_result&.match
240
+ def match_number = @last_result&.number
241
+ def captures = @last_result&.captures || []
242
+ def error = @last_result&.error
243
+ # 返回缓冲副本,防止调用方原地修改绕过裁剪规则。
244
+ def buffer = @buffer.dup
245
+
246
+ # 复制并替换原始字节缓冲,应用当前上限;调用方后续修改原字符串不会影响会话。
247
+ def buffer=(value)
248
+ raise ArgumentError, "buffer must be a String" unless value.is_a?(String)
249
+
250
+ @buffer = value.b
251
+ trim_buffer
252
+ end
253
+
254
+ # 移交旧缓冲并换上新的空字节串,供显式清空或人工转接接管数据。
255
+ def clear_buffer
256
+ previous = @buffer
257
+ @buffer = "".b
258
+ previous
259
+ end
260
+
261
+ # 按 Ruby to_s 规则原样写入所有字节,返回字节数;背压等待受 write_timeout 限制。
262
+ def write(*objects)
263
+ raise IOError, "closed Expect session" if closed? || writer.closed?
264
+
265
+ data = objects.map { |object| object.to_s.b }.join
266
+ trace("sending #{data.inspect}", level: 2)
267
+ deadline = write_timeout && (Expect.monotonic + write_timeout)
268
+ offset = 0
269
+ while offset < data.bytesize
270
+ count = writer.write_nonblock(data.byteslice(offset, READ_SIZE), exception: false)
271
+ if count == :wait_writable
272
+ raise WriteTimeout, "write timed out after #{write_timeout} seconds" if deadline && Expect.monotonic >= deadline
273
+
274
+ remaining = deadline && [deadline - Expect.monotonic, 0].max
275
+ # 子进程也可能因输出管道填满而停止读取;等可写时同时排空它的输出,避免双向死锁。
276
+ readers = eof? ? [] : [to_io]
277
+ ready = IO.select(readers, [writer], nil, remaining)
278
+ raise WriteTimeout, "write timed out after #{write_timeout} seconds" unless ready
279
+
280
+ read_available if ready[0].include?(to_io)
281
+ else
282
+ offset += count
283
+ end
284
+ end
285
+ data.bytesize
286
+ end
287
+
288
+ # 链式写入单个对象,返回当前会话。
289
+ def <<(object)
290
+ write(object)
291
+ self
292
+ end
293
+
294
+ # 委托 StringIO 处理换行、nil 和递归数组,再统一写入;返回 nil,与 Ruby puts 一致。
295
+ def puts(*objects)
296
+ output = StringIO.new("".b)
297
+ output.puts(*objects)
298
+ write(output.string)
299
+ nil
300
+ end
301
+
302
+ # 逐字符延迟发送,同时收集回复,适配输入处理较慢的交互程序;返回写入字节数。
303
+ def send_slow(*objects, delay:)
304
+ pause = Expect.duration(delay)
305
+ raise ArgumentError, "delay is required" unless pause
306
+
307
+ count = 0
308
+ objects.each do |object|
309
+ object.to_s.each_char do |character|
310
+ sleep(pause) if pause.positive?
311
+ count += write(character)
312
+ read_available if !eof? && to_io.wait_readable(0.01)
313
+ end
314
+ end
315
+ count
316
+ end
317
+
318
+ # 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
319
+ def log_output = @resources.log
320
+
321
+ # 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
322
+ def log_output=(target)
323
+ unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
324
+ raise ArgumentError, "log output must support write or call, or be nil"
325
+ end
326
+
327
+ replace_log(target)
328
+ end
329
+
330
+ # 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
331
+ def log_to(target = nil, mode: "a", &block)
332
+ raise ArgumentError, "provide a log target or a block, not both" if block && target
333
+
334
+ target = block if block
335
+ if target.respond_to?(:to_path) || target.is_a?(String)
336
+ raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
337
+
338
+ # 库打开的文件由 Resources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
339
+ replace_log(File.open(target, "#{mode}b"), owned: true)
340
+ else
341
+ raise ArgumentError, "provide a log target or a block" unless target
342
+
343
+ self.log_output = target
344
+ end
345
+ end
346
+
347
+ # 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
348
+ def write_log(*objects)
349
+ target = log_output
350
+ return unless target
351
+
352
+ data = objects.map { |object| object.to_s.b }.join
353
+ target.respond_to?(:call) ? target.call(data) : emit(target, data)
354
+ end
355
+
356
+ # 返回监听器列表副本,避免外部原地修改转发关系。
357
+ def listeners = @listeners.dup
358
+
359
+ # 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
360
+ def listeners=(outputs)
361
+ outputs = Array(outputs)
362
+ raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
363
+
364
+ @listeners = outputs.dup
365
+ end
366
+
367
+ # 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
368
+ def stty(*modes)
369
+ return "" unless tty?
370
+
371
+ modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
372
+ modes = ["-g"] if modes.empty?
373
+ reader, sink = IO.pipe
374
+ child = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
375
+ sink.close
376
+ output = reader.read
377
+ _, status = Process.waitpid2(child)
378
+ raise IOError, "stty failed: #{output.strip}" unless status.success?
379
+
380
+ output.strip
381
+ ensure
382
+ reader&.close unless reader&.closed?
383
+ sink&.close unless sink&.closed?
384
+ end
385
+
386
+ # 读取终端的 [行数, 列数]。
387
+ def winsize = to_io.winsize
388
+
389
+ # 更新终端尺寸,由内核通知前台进程。
390
+ def winsize=(size)
391
+ to_io.winsize = size
392
+ end
393
+
394
+ # 轮询回收状态直到进程退出或期限到达;返回 Process::Status 或 nil,超时不丢弃 PID。
395
+ def wait(timeout: nil)
396
+ period = Expect.duration(timeout)
397
+ deadline = period && (Expect.monotonic + period)
398
+ loop do
399
+ status = process_status
400
+ return status if status || !pid
401
+ return nil if deadline && Expect.monotonic >= deadline
402
+
403
+ sleep(deadline ? [0.01, deadline - Expect.monotonic].min.clamp(0, 0.01) : 0.01)
404
+ end
405
+ end
406
+
407
+ # 先在自然退出期限内收集尾部输出,再关闭句柄并最多发送 TERM;不会发送 KILL。
408
+ # 尚未退出时返回 nil 并保留 PID,调用方可以继续等待或随后硬关闭。
409
+ def soft_close(timeout: 15, term_timeout: 1)
410
+ period = Expect.duration(timeout)
411
+ term_timeout = Expect.duration(term_timeout)
412
+ raise ArgumentError, "term_timeout must be finite" unless term_timeout
413
+
414
+ deadline = period && (Expect.monotonic + period)
415
+ until eof?
416
+ remaining = deadline && [deadline - Expect.monotonic, 0].max
417
+ break if remaining&.zero? || !to_io.wait_readable(remaining)
418
+
419
+ read_available
420
+ end
421
+ finish_close(timeout: deadline ? [deadline - Expect.monotonic, 0].max : nil,
422
+ term_timeout: term_timeout, force: false)
423
+ end
424
+
425
+ # 立即关闭句柄,再分阶段等待、TERM、KILL;不收集剩余输出,返回已回收状态或 nil。
426
+ def hard_close(timeout: 0.2)
427
+ period = Expect.duration(timeout)
428
+ raise ArgumentError, "hard_close timeout must be finite" unless period
429
+
430
+ finish_close(timeout: period, term_timeout: period, force: true)
431
+ end
432
+
433
+ # 通用生命周期清理:可先软关闭,ensure 中硬关闭兜底;正常完成返回 nil。
434
+ def close(graceful: graceful_close?)
435
+ soft_close if graceful
436
+ nil
437
+ ensure
438
+ hard_close
439
+ end
440
+
441
+ private
442
+
443
+ # 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
444
+ def finish_close(timeout:, term_timeout:, force:)
445
+ # IO 关闭与进程退出独立记录:软关闭可能已经 closed?,但仍保留活跃 PID。
446
+ @resources.close_handles
447
+ @closed = true
448
+ return process_status unless @resources.owner == Process.pid && pid
449
+ return process_status if wait(timeout: timeout)
450
+
451
+ signal_child("TERM")
452
+ return process_status if wait(timeout: term_timeout)
453
+
454
+ if force
455
+ signal_child("KILL")
456
+ wait(timeout: 1)
457
+ end
458
+ ensure
459
+ self.log_output = nil
460
+ end
461
+
462
+ # 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
463
+ def initialize_session(reader, writer:, slave: nil, own: false, **)
464
+ raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
465
+ raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
466
+
467
+ @resources = Resources.new(reader, writer: writer, slave: slave, own: own)
468
+ @pty = reader.tty?
469
+ @slave = slave
470
+ @configuration = Configuration.new(**self.class.configuration.to_h, **)
471
+ @buffer = "".b
472
+ @listeners = []
473
+ @sequences = {}
474
+ @closed = @eof = false
475
+ ObjectSpace.define_finalizer(self, Resources.finalizer(@resources))
476
+ end
477
+
478
+ # 开始新一轮等待时清除旧结果并应用缓冲上限,尚未消费的输入继续保留。
479
+ def reset_result
480
+ @last_result = nil
481
+ trim_buffer
482
+ end
483
+
484
+ # 按字节偏移生成 before/match/after;通常只保留 after,preserve_buffer 开启时不消费。
485
+ def record_match(pattern, position)
486
+ offset, length, captures = position
487
+ @last_result = Result.new(number: pattern.number, before: @buffer.byteslice(0, offset),
488
+ match: @buffer.byteslice(offset, length), after: @buffer.byteslice((offset + length)..),
489
+ session: self, captures: captures)
490
+ @buffer = @last_result.after.dup unless preserve_buffer?
491
+ trace("matched pattern #{pattern.number}")
492
+ @last_result
493
+ end
494
+
495
+ # 记录超时、EOF 或原始 IO 异常,保留当前缓冲快照并清除旧匹配及捕获组。
496
+ def record_error(error)
497
+ @last_result = Result.new(error: error, before: buffer, session: self, captures: [])
498
+ end
499
+
500
+ # 输入结束时将剩余缓冲放入 before 并清空,尝试回收但不终止仍活跃的子进程。
501
+ def record_eof
502
+ process_status
503
+ record_error(:eof)
504
+ clear_buffer
505
+ @last_result
506
+ end
507
+
508
+ # 进行一次非阻塞读取并记录日志;accumulate/propagate 决定是否交给匹配缓冲和监听器。
509
+ def read_available(propagate: true, accumulate: true)
510
+ return nil if eof?
511
+
512
+ begin
513
+ data = to_io.read_nonblock(READ_SIZE, exception: false)
514
+ rescue Errno::EIO
515
+ # 某些系统用 PTY 的 EIO 表示对端关闭;普通 IO 的同类错误仍按异常处理。
516
+ raise unless @pty
517
+
518
+ @eof = true
519
+ return nil
520
+ rescue EOFError
521
+ @eof = true
522
+ return nil
523
+ end
524
+ return nil if data == :wait_readable
525
+
526
+ if data.nil?
527
+ @eof = true
528
+ return nil
529
+ end
530
+ data = data.b
531
+ if accumulate
532
+ @buffer << data
533
+ trim_buffer
534
+ end
535
+ trace("received #{data.inspect}", level: 2)
536
+ trace("buffer #{@buffer.inspect}", level: 3)
537
+ # 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
538
+ write_log(data)
539
+ propagate(data) if propagate
540
+ data
541
+ end
542
+
543
+ # 缓冲超过上限时只保留最新尾部字节,不对编码做隐式修改。
544
+ def trim_buffer
545
+ limit = buffer_limit
546
+ @buffer = @buffer.byteslice(-limit, limit) if limit&.positive? && @buffer.bytesize > limit
547
+ end
548
+
549
+ # 按各自开关将接收字节转发到 stdout 和监听器,不重复写日志。
550
+ def propagate(data)
551
+ emit($stdout, data) if log_stdout?
552
+ @listeners.each { |listener| emit(listener, data) } if log_listeners?
553
+ end
554
+
555
+ # 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
556
+ def emit(target, data)
557
+ target.write(data)
558
+ target.flush if target.respond_to?(:flush)
559
+ end
560
+
561
+ # 按诊断级别向 stderr 输出会话标识和消息。
562
+ def trace(message, level: 1)
563
+ warn("#{inspect}: #{message}") if debug_level >= level
564
+ end
565
+
566
+ # 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
567
+ def replace_log(target, owned: false)
568
+ previous = log_output
569
+ # 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
570
+ return target if previous.equal?(target)
571
+
572
+ previous.close if @resources.own_log && previous && !previous.closed?
573
+ @resources.log = target
574
+ @resources.own_log = owned
575
+ target
576
+ rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
577
+ target.close if owned && target && !target.closed?
578
+ raise
579
+ end
580
+
581
+ # 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
582
+ def signal_child(signal)
583
+ return unless alive? && @resources.owner == Process.pid
584
+
585
+ Process.kill(signal, pid)
586
+ rescue Errno::ESRCH
587
+ @resources.reap
588
+ end
589
+ end
590
+
591
+ require_relative "expect/interconnect"
data/script/ci ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "${BASH_SOURCE[0]}")/.."
5
+
6
+ bundle exec rake
7
+ bundle exec ruby examples/dialogue.rb
8
+
9
+ mkdir -p pkg/ci
10
+ gem_version="$(ruby -Ilib -rexpect/version -e 'print Expect::VERSION')"
11
+ gem_file="pkg/ci/expect-pty-${gem_version}.gem"
12
+ if git rev-parse --git-dir >/dev/null 2>&1; then
13
+ export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
14
+ fi
15
+ gem build expect-pty.gemspec --output "$gem_file"
16
+
17
+ # 在独立目录安装刚构建的包,避免源码路径或 Bundler 掩盖打包遗漏。
18
+ smoke_dir="$(mktemp -d)"
19
+ trap 'rm -rf "$smoke_dir"' EXIT
20
+ gem install --local --no-document --install-dir "$smoke_dir/gems" "$gem_file"
21
+
22
+ (
23
+ cd "$smoke_dir"
24
+ unset BUNDLE_GEMFILE BUNDLE_BIN_PATH RUBYOPT RUBYLIB
25
+ export GEM_HOME="$smoke_dir/gems" GEM_PATH="$smoke_dir/gems"
26
+ ruby -r expect/pty -r rbconfig <<'RUBY'
27
+ spec = Gem.loaded_specs.fetch("expect-pty")
28
+ # 临时目录可能经由符号链接访问,先统一为实际路径再检查。
29
+ install_root = File.realpath(ENV.fetch("GEM_HOME"))
30
+ abort "gem was loaded outside the isolated install" unless File.realpath(spec.full_gem_path).start_with?("#{install_root}/")
31
+
32
+ child = 'STDOUT.sync = true; puts "ready"; puts "reply:#{$stdin.gets&.strip}"'
33
+ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, log_stdout: false) do |session|
34
+ abort "installed gem did not receive the PTY prompt" unless session.expect("ready", timeout: 5)
35
+
36
+ session.puts("expect-ruby")
37
+ abort "installed gem PTY dialogue failed" unless session.expect("reply:expect-ruby", timeout: 5)
38
+ status = session.soft_close(timeout: 5)
39
+ abort "installed gem child did not exit successfully" unless status&.success?
40
+ end
41
+
42
+ puts "Installed #{spec.full_name}: PTY dialogue and process cleanup passed"
43
+ RUBY
44
+ )