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
@@ -0,0 +1,554 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "logging"
4
+ require_relative "interaction"
5
+
6
+ module Expect
7
+ # 一个真实 PTY 或 IO 会话;缓冲、最近结果和所属进程都保存在本对象中。
8
+ # SessionResources 保存可独立回收的资源账本;Matcher、Relay 仅在操作期间借用会话状态。
9
+ # 日志与协议目标属于调用方,显式关闭和 GC 均不得接管这些借用对象。
10
+ class Session
11
+ include Logging
12
+ include Interaction
13
+
14
+ # 启动命令、最近结果及 PTY 端点的只读状态;文本结果属于不可变快照。
15
+ attr_reader :command, :last_result, :slave, :tty_name, :buffer_discarded_bytes
16
+ # 会话的默认等待期限、写入背压期限及缓冲上限。
17
+ attr_reader :timeout, :write_timeout, :buffer_limit
18
+
19
+ # 预建 PTY;命令、env、chdir 与 raw 模式由 spawn 显式设置。
20
+ def initialize(timeout: nil, write_timeout: nil, buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
21
+ master = slave = nil
22
+ Cleanup.on_failure(-> { cleanup_session(master, writer: master, slave:, own: true) }) do
23
+ master, slave = PTY.open
24
+ initialize_io(master, writer: master, slave:, own: true, timeout:, write_timeout:, buffer_limit:,
25
+ logger:, transcript:, outputs:)
26
+ end
27
+ end
28
+
29
+ # 设置本会话后续 expect 默认使用的相对秒数,nil 表示无限等待。
30
+ def timeout=(value)
31
+ @timeout = Expect.duration(value)
32
+ end
33
+
34
+ # 设置写入遇到背压或 EINTR 时的等待秒数;成功短写不受总耗时限制。
35
+ def write_timeout=(value)
36
+ @write_timeout = Expect.duration(value)
37
+ end
38
+
39
+ # 校验并更新缓冲上限,立即裁剪现有尾部;非法值不改变配置和内容。
40
+ def buffer_limit=(value)
41
+ unless value.nil? || (value.is_a?(Integer) && value.positive?)
42
+ raise ArgumentError, "buffer_limit must be a positive Integer or nil"
43
+ end
44
+
45
+ @buffer_limit = value
46
+ trim_buffer
47
+ end
48
+
49
+ # 等待自身输入,返回 Result;匹配消费和接收重置策略只对本轮有效。
50
+ def expect(*patterns, timeout: self.timeout, deadline: nil, consume: true, reset_timeout_on_read: false, &)
51
+ Expect.__send__(:run_expect, [self], patterns, timeout, deadline:, consume:, reset_timeout_on_read:, &)
52
+ end
53
+
54
+ # 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
55
+ def spawn(*command, env: {}, chdir: nil, raw: false)
56
+ validate_spawn!(command)
57
+
58
+ @slave.raw! if raw
59
+ from_child = to_parent = nil
60
+ Cleanup.always(-> { SessionResources.close_handles(from_child, to_parent) }) do
61
+ # 错误管道的写端在 exec 成功时自动关闭;父进程据此区分成功启动与 exec 前失败。
62
+ from_child, to_parent = IO.pipe
63
+ to_parent.close_on_exec = true
64
+ @command = command.map { |part| part.dup.freeze }.freeze
65
+ # 原生 fork 返回后先登记 PID,再交付线程中断或终止,避免清理时遗漏新子进程。
66
+ child = Thread.handle_interrupt(Object => :never) do
67
+ @resources.pid = fork do
68
+ # 子进程不继承父侧登记临界区,执行前仍可立即响应异步异常。
69
+ Thread.handle_interrupt(Object => :immediate) { exec_child(command, env:, chdir:, from_child:, to_parent:) }
70
+ end
71
+ end
72
+ to_parent.close
73
+ @slave.close
74
+ failure = from_child.read
75
+ Cleanup.always(-> { hard_close }) { raise SpawnError, failure } unless failure.empty?
76
+ trace("spawned pid=#{child}", event: :spawned)
77
+ self
78
+ end
79
+ end
80
+
81
+ # 暴露底层读写 IO 与终端属性,供 select、终端设置及 IO 适配使用。
82
+ def to_io = @resources.reader
83
+
84
+ # 实际写入端点,可能不同于 to_io 返回的读端。
85
+ def writer = @resources.writer
86
+
87
+ # 当前读端描述符;会话关闭后为 nil。
88
+ def fileno = closed? ? nil : to_io.fileno
89
+
90
+ # 读端仍打开且属于终端时为 true。
91
+ def tty? = !closed? && to_io.tty?
92
+
93
+ # 诊断时仅显示进程和描述符状态,避免默认对象展开泄露缓冲或日志内容。
94
+ def inspect = "#<#{self.class} pid=#{pid.inspect} fd=#{fileno.inspect} closed=#{closed?}>"
95
+
96
+ # 尚未完成回收的直属子进程 PID;已有 IO 会话为 nil。
97
+ def pid = @resources.pid
98
+
99
+ # 非阻塞回收并缓存子进程状态;未退出或仅适配 IO 时返回 nil。
100
+ def process_status
101
+ @resources.reap
102
+ rescue Errno::EINTR
103
+ # 单次轮询被中断时状态仍未知;wait/close 会在原期限内继续,不在这里无限重试。
104
+ @resources.status
105
+ end
106
+
107
+ # 已知进程退出码;尚未回收或信号退出时为 nil。
108
+ def exit_code = process_status&.exitstatus
109
+
110
+ # 先刷新回收状态,再判断是否仍有未回收的子进程;不以 IO 是否关闭代替进程状态。
111
+ def alive?
112
+ process_status
113
+ !pid.nil?
114
+ end
115
+
116
+ # 区分会话关闭和输入结束,已关闭会话也不能继续读取。
117
+ def closed? = @closed || to_io.closed?
118
+
119
+ # 输入已结束或会话已关闭时为 true。
120
+ def eof? = @eof || closed?
121
+
122
+ # 以下访问器读取最近一次等待结果;未发生匹配时捕获组返回空数组。
123
+ def before = @last_result&.before
124
+
125
+ # 最近一次匹配之后的不可变字节快照。
126
+ def after = @last_result&.after
127
+
128
+ # 最近一次匹配命中的不可变字节快照。
129
+ def match = @last_result&.match
130
+
131
+ # 最近命中的模式序号;EOF、超时或尚无结果时为 nil。
132
+ def match_number = @last_result&.number
133
+
134
+ # 最近捕获值的不可变数组;未参与的捕获为 nil。
135
+ def captures = @last_result&.captures || []
136
+
137
+ # 最近的 EOF、超时事件或原始 IO 异常。
138
+ def error = @last_result&.error
139
+
140
+ # 返回缓冲副本,防止调用方原地修改绕过裁剪规则。
141
+ def buffer = @buffer.dup
142
+
143
+ # 复制并替换原始字节缓冲,应用当前上限;调用方后续修改原字符串不会影响会话。
144
+ def buffer=(value)
145
+ raise ArgumentError, "buffer must be a String" unless value.is_a?(String)
146
+
147
+ @buffer = value.b
148
+ @buffer_generation += 1
149
+ trim_buffer
150
+ end
151
+
152
+ # 移交旧缓冲并换上新的空字节串,供显式清空或人工转接接管数据。
153
+ def clear_buffer
154
+ previous = @buffer
155
+ @buffer = "".b
156
+ @buffer_generation += 1
157
+ previous
158
+ end
159
+
160
+ # 按 Ruby to_s 规则原样写入所有字节,返回字节数;背压等待受 write_timeout 限制。
161
+ def write(*objects)
162
+ raise IOError, "closed Expect session" if closed? || writer.closed?
163
+
164
+ begin
165
+ data = objects.map { |object| object.to_s.b }.join
166
+ trace_data(:sending, data)
167
+ rescue WriteTimeout
168
+ # 转换或诊断中的嵌套写入不属于当前命令;此时尚未向 writer 发送任何字节。
169
+ raise WriteTimeout.new("write interrupted before sending data", bytes_written: 0)
170
+ end
171
+ deadline = write_timeout && (Expect.monotonic + write_timeout)
172
+ offset = 0
173
+ while offset < data.bytesize
174
+ count = write_chunk(data, offset, deadline)
175
+ next unless count
176
+
177
+ if count == :wait_writable
178
+ raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline
179
+
180
+ wait_writable(offset, deadline)
181
+ else
182
+ offset += count
183
+ end
184
+ end
185
+ data.bytesize
186
+ end
187
+
188
+ # 链式写入单个对象,返回当前会话。
189
+ def <<(object)
190
+ write(object)
191
+ self
192
+ end
193
+
194
+ # 委托 StringIO 处理换行、nil 和递归数组,再统一写入;返回 nil,与 Ruby puts 一致。
195
+ def puts(*objects)
196
+ output = StringIO.new("".b)
197
+ output.puts(*objects)
198
+ write(output.string)
199
+ nil
200
+ end
201
+
202
+ # 逐字符延迟发送,同时收集回复,适配输入处理较慢的交互程序;返回写入字节数。
203
+ def send_slow(*objects, delay:)
204
+ pause = Expect.duration(delay)
205
+ raise ArgumentError, "delay is required" unless pause
206
+
207
+ count = 0
208
+ objects.each do |object|
209
+ object.to_s.each_char do |character|
210
+ sleep(pause) if pause.positive?
211
+ count += write(character)
212
+ read_available if !eof? && to_io.wait_readable(0)
213
+ end
214
+ end
215
+ count
216
+ end
217
+
218
+ # 轮询回收状态直到进程退出或期限到达;返回 Process::Status 或 nil,超时不丢弃 PID。
219
+ def wait(timeout: nil)
220
+ wait_for_child(Expect.duration(timeout))
221
+ end
222
+
223
+ # 先在自然退出期限内收集尾部输出,再关闭句柄并最多发送 TERM;不会发送 KILL。
224
+ # 尚未退出时返回 nil 并保留 PID,调用方可以继续等待或随后硬关闭。
225
+ def soft_close(timeout: 15, term_timeout: 1)
226
+ period = Expect.duration(timeout)
227
+ term_timeout = Expect.duration(term_timeout)
228
+ raise ArgumentError, "term_timeout must be finite" unless term_timeout
229
+
230
+ deadline = period && (Expect.monotonic + period)
231
+ until eof?
232
+ remaining = deadline && [deadline - Expect.monotonic, 0].max
233
+ break if remaining&.zero? || !to_io.wait_readable(remaining)
234
+
235
+ read_available
236
+ end
237
+ close_resources(timeout: deadline ? [deadline - Expect.monotonic, 0].max : nil,
238
+ term_timeout:, force: false)
239
+ end
240
+
241
+ # 立即关闭句柄,再分阶段等待、TERM、KILL;不收集剩余输出,返回已回收状态或 nil。
242
+ def hard_close(timeout: 0.2)
243
+ period = Expect.duration(timeout)
244
+ raise ArgumentError, "hard_close timeout must be finite" unless period
245
+
246
+ close_resources(timeout: period, term_timeout: period, force: true)
247
+ end
248
+
249
+ # 通用生命周期清理:可先软关闭,ensure 中硬关闭兜底;正常完成返回 nil。
250
+ def close(graceful: false)
251
+ Cleanup.always(-> { hard_close }) do
252
+ soft_close if graceful
253
+ nil
254
+ end
255
+ end
256
+
257
+ # 账本发布前只按局部所有权清理;发布后沿用完整关闭流程,避免两套生命周期状态。
258
+ # @api private
259
+ def cleanup_session(reader, writer:, own:, slave: nil, graceful: false)
260
+ if @resources
261
+ close(graceful:)
262
+ elsif own
263
+ SessionResources.close_handles(reader, writer, slave)
264
+ end
265
+ end
266
+
267
+ # 开始新一轮等待时清除旧结果并应用缓冲上限,尚未消费的输入继续保留。
268
+ # @api private
269
+ def reset_result
270
+ @last_result = nil
271
+ trim_buffer
272
+ end
273
+
274
+ # 按字节偏移生成 before/match/after;通常只保留 after,consume 为 false 时不消费。
275
+ # @api private
276
+ def record_match(pattern, position, consume: true)
277
+ offset, length, captures = position
278
+ @last_result = Result.new(number: pattern.number, before: @buffer.byteslice(0, offset),
279
+ match: @buffer.byteslice(offset, length), after: @buffer.byteslice((offset + length)..),
280
+ session: self, captures:)
281
+ if consume
282
+ @buffer = @last_result.after.dup
283
+ @buffer_generation += 1
284
+ end
285
+ # 诊断回调可能嵌套等待;恢复本次结果后再交给正式模式回调,不能返回内层等待的结果。
286
+ result = @last_result
287
+ trace("matched pattern #{pattern.number}")
288
+ @last_result = result
289
+ end
290
+
291
+ # 记录超时、EOF 或原始 IO 异常,保留当前缓冲快照并清除旧匹配及捕获组。
292
+ # @api private
293
+ def record_error(error)
294
+ @last_result = Result.new(error:, before: buffer, session: self, captures: [])
295
+ end
296
+
297
+ # 输入结束时将剩余缓冲放入 before 并清空,尝试回收但不终止仍活跃的子进程。
298
+ # @api private
299
+ def record_eof
300
+ process_status
301
+ record_error(:eof)
302
+ clear_buffer
303
+ @last_result
304
+ end
305
+
306
+ # 先将读取字节交给匹配或转接缓冲,再记录日志;日志失败也能恢复输入。
307
+ # @api private
308
+ def read_available(propagate: true, buffer: @buffer, trim: true)
309
+ return nil if eof?
310
+
311
+ # 写入背压也会读取;转接期间统一交给转义处理器,不能直接转发或另存匹配缓冲。
312
+ if @interaction_buffer
313
+ buffer = @interaction_buffer
314
+ propagate = false
315
+ trim = false
316
+ end
317
+
318
+ begin
319
+ data = to_io.read_nonblock(READ_SIZE, exception: false)
320
+ rescue Errno::EIO
321
+ # 某些系统用 PTY 的 EIO 表示对端关闭;普通 IO 的同类错误仍按异常处理。
322
+ raise unless @pty
323
+
324
+ return mark_eof
325
+ rescue EOFError
326
+ return mark_eof
327
+ end
328
+ return nil if data == :wait_readable
329
+
330
+ return mark_eof if data.nil?
331
+
332
+ data = data.b
333
+ buffer << data
334
+ trim_buffer if trim
335
+ trace_data(:received, data)
336
+ # 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
337
+ write_transcript(data)
338
+ propagate(data) if propagate
339
+ data
340
+ end
341
+
342
+ private
343
+
344
+ # 初始化前先登记所有权;参数校验失败也能释放已取得的端点。
345
+ def initialize_io(reader, writer:, slave: nil, own: false, timeout: nil, write_timeout: nil,
346
+ buffer_limit: nil, logger: nil, transcript: nil, outputs: [])
347
+ @resources = SessionResources.new(reader, writer:, slave:, own:)
348
+ raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
349
+ raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
350
+
351
+ @pty = reader.tty?
352
+ @slave = slave
353
+ @tty_name = slave.path if slave
354
+ @buffer = "".b
355
+ @buffer_generation = 0
356
+ @buffer_discarded_bytes = 0
357
+ @outputs = []
358
+ @sequences = {}
359
+ @pending_writes = []
360
+ @interact_inputs = {}.compare_by_identity
361
+ @interact_output = nil
362
+ @interaction_buffer = @relay_owner = @relay_callback = nil
363
+ @relay_history = "".b
364
+ @relay_history_sequences = {}
365
+ @secrets = @transcript_redactor = nil
366
+ @diagnostic_redactors = {}
367
+ @logger = @transcript = nil
368
+ @last_result = @command = nil
369
+ @closed = @eof = false
370
+ self.timeout = timeout
371
+ self.write_timeout = write_timeout
372
+ self.buffer_limit = buffer_limit
373
+ self.logger = logger
374
+ self.transcript = transcript
375
+ self.outputs = outputs
376
+ ObjectSpace.define_finalizer(self, @resources.method(:finalize))
377
+ end
378
+
379
+ # 参数校验先于任何进程和终端修改。
380
+ def validate_spawn!(command)
381
+ raise SpawnError, "cannot reuse a spawned session" if @command
382
+ raise SpawnError, "only a new PTY session can spawn" unless @slave && !@slave.closed? && !closed?
383
+ raise ArgumentError, "command is required" if command.empty?
384
+ raise ArgumentError, "command arguments must be strings" unless command.all? do |part|
385
+ part.is_a?(String) && !part.include?("\0")
386
+ end
387
+ raise ArgumentError, "command is empty" if command.first.empty?
388
+ end
389
+
390
+ # 子进程独占控制终端;成功 exec 关闭错误管道,失败时回传后立即退出。
391
+ def exec_child(command, env:, chdir:, from_child:, to_parent:)
392
+ from_child.close
393
+ Process.setsid
394
+ # 创建独立进程会话后重新打开 slave,使它成为子进程的控制终端。
395
+ File.open(@tty_name, File::RDWR) do |terminal|
396
+ # 重定向操作系统的标准描述符;即使宿主替换过 Ruby 标准流,也能正确连接子进程。
397
+ # rubocop:disable Style/GlobalStdStream
398
+ STDIN.reopen(terminal)
399
+ STDOUT.reopen(terminal)
400
+ STDERR.reopen(terminal)
401
+ # rubocop:enable Style/GlobalStdStream
402
+ end
403
+ @resources.close_handles
404
+ Dir.chdir(chdir) if chdir
405
+ exec(env, *command, close_others: true)
406
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 子进程回传启动异常后立即退出。
407
+ begin
408
+ to_parent.write("#{error.class}: #{error.message}")
409
+ ensure
410
+ exit! 127
411
+ end
412
+ end
413
+
414
+ # EINTR 未确认交付时不移动游标;其他写入计数必须落在当前块范围内。
415
+ def write_chunk(data, offset, deadline)
416
+ chunk = data.byteslice(offset, READ_SIZE)
417
+ count = writer.write_nonblock(chunk, exception: false)
418
+ return count if count == :wait_writable
419
+ unless count.is_a?(Integer) && count.positive? && count <= chunk.bytesize
420
+ raise IOError, "write must return the number of accepted bytes"
421
+ end
422
+
423
+ count
424
+ rescue Errno::EINTR
425
+ raise WriteTimeout.new(bytes_written: offset) if deadline && Expect.monotonic >= deadline
426
+
427
+ nil
428
+ end
429
+
430
+ # 背压等待同时排空读端,期限不因 EINTR 重算,异常始终报告外层写入进度。
431
+ def wait_writable(offset, deadline)
432
+ remaining = deadline && [deadline - Expect.monotonic, 0].max
433
+ # 子进程也可能因输出管道填满而停止读取;等可写时同时排空它的输出,避免双向死锁。
434
+ readers = eof? ? [] : [to_io]
435
+ begin
436
+ ready = IO.select(readers, [writer], nil, remaining)
437
+ raise WriteTimeout.new(bytes_written: offset) unless ready
438
+
439
+ if ready[0].include?(to_io)
440
+ begin
441
+ read_available
442
+ rescue WriteTimeout
443
+ # 日志或监听器可嵌套写入;对外报告本次写入进度,原异常通过 cause 保留。
444
+ raise WriteTimeout.new("write interrupted by an output timeout", bytes_written: offset)
445
+ end
446
+ end
447
+ rescue Errno::EINTR
448
+ nil
449
+ end
450
+ end
451
+
452
+ # 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
453
+ def close_resources(timeout:, term_timeout:, force:)
454
+ failure = nil
455
+ # 预期的清理错误延后传播,保证其余所属资源和直属子进程仍能完成清理。
456
+ cleanup = lambda do |&step|
457
+ step.call
458
+ rescue IOError, SystemCallError => error
459
+ failure ||= error
460
+ nil
461
+ end
462
+ # IO 关闭与进程退出独立记录:软关闭可能已经 closed?,但仍保留活跃 PID。
463
+ cleanup.call { @resources.close_handles }
464
+ @closed = true
465
+ @interact_inputs&.delete_if do |_io, input|
466
+ cleanup.call do
467
+ input.close(graceful: false)
468
+ true
469
+ end
470
+ end
471
+ @interact_output = nil
472
+ @pending_writes&.clear
473
+ @relay_history&.clear
474
+ @relay_callback = nil
475
+ status = close_child(timeout:, term_timeout:, force:)
476
+ completed = true
477
+ status
478
+ ensure
479
+ begin
480
+ cleanup.call { flush_diagnostics }
481
+ ensure
482
+ cleanup.call { self.transcript = nil }
483
+ end
484
+ # 用本次流程的完成状态判断异常传播,不能误把调用者 rescue 中的异常当成当前错误。
485
+ raise failure if failure && completed
486
+ end
487
+
488
+ # 句柄清理失败不改变进程策略;未回收 PID 保留给重复关闭或终结器继续处理。
489
+ def close_child(timeout:, term_timeout:, force:)
490
+ return process_status unless @resources.owner == Process.pid && pid
491
+
492
+ status = wait(timeout:)
493
+ return status if status || !pid
494
+
495
+ status = wait_for_child(term_timeout, signal: "TERM")
496
+ return status if status || !pid
497
+ return unless force
498
+
499
+ wait_for_child(1, signal: "KILL")
500
+ end
501
+
502
+ # 每阶段只计算一次期限;回收或信号被中断后仍沿用剩余预算,零预算也先做一次尝试。
503
+ def wait_for_child(period, signal: nil)
504
+ deadline = period && (Expect.monotonic + period)
505
+ loop do
506
+ status = process_status
507
+ return status if status || !pid || @resources.owner != Process.pid
508
+
509
+ begin
510
+ signal_child(signal) if signal
511
+ signal = nil
512
+ rescue Errno::EINTR
513
+ # 下轮先回收再重试信号,避免在无限等待或持续中断时忙等。
514
+ nil
515
+ end
516
+ # ESRCH 后可能已完成回收;即使预算耗尽,也要返回刚获得的状态。
517
+ return process_status unless pid
518
+
519
+ remaining = deadline && (deadline - Expect.monotonic)
520
+ return nil if remaining && remaining <= 0
521
+
522
+ sleep(remaining ? [0.01, remaining].min : 0.01)
523
+ end
524
+ end
525
+
526
+ # 只结束读取方向并冲刷接收记录;EOF 不代表子进程已经退出或写端已经关闭。
527
+ def mark_eof
528
+ @eof = true
529
+ flush_transcript
530
+ flush_diagnostics(:received)
531
+ nil
532
+ end
533
+
534
+ # 缓冲超过上限时只保留最新尾部字节,不对编码做隐式修改。
535
+ def trim_buffer
536
+ limit = buffer_limit
537
+ return unless limit && @buffer.bytesize > limit
538
+
539
+ # 只累计匹配窗口裁剪,消费、清空及转接交接不算丢弃;关闭后仍可读取累计值。
540
+ @buffer_discarded_bytes += @buffer.bytesize - limit
541
+ @buffer = @buffer.byteslice(-limit, limit)
542
+ @buffer_generation += 1
543
+ end
544
+
545
+ # 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
546
+ def signal_child(signal)
547
+ return unless pid && @resources.owner == Process.pid
548
+
549
+ Process.kill(signal, pid)
550
+ rescue Errno::ESRCH
551
+ @resources.reap
552
+ end
553
+ end
554
+ end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Expect
4
- # 独立保存句柄、PID 和日志所有权,让终结器无需直接捕获会话即可清理遗弃资源。
3
+ module Expect
4
+ # 独立保存句柄和 PID 所有权,让终结器无需直接捕获会话即可清理遗弃资源。
5
5
  # IO 是否关闭与子进程是否退出分别记录;不能仅凭句柄状态清空 PID 或伪造退出状态。
6
+ # @api private
6
7
  class SessionResources
7
- # owned_log 只保存库打开的文件;借用的 IO/日志回调留在会话中,不能成为终结器的引用根。
8
- attr_accessor :pid, :status, :owned_log
9
- attr_reader :reader, :writer, :slave, :owner, :own
8
+ # 缓存已取得的状态;被外部回收的进程仍保留未知状态,不伪造成功。
9
+ attr_accessor :status
10
+ # own 控制句柄关闭责任,owner 控制进程回收责任;fork 后两者不能混为一谈。
11
+ attr_reader :pid, :reader, :writer, :slave, :owner, :own
10
12
 
11
13
  # 记录创建资源的进程;fork 后的副本不能向父进程拥有的子进程发信号。
12
14
  def initialize(reader, writer:, slave: nil, own: false)
@@ -17,6 +19,13 @@ class Expect
17
19
  @owner = Process.pid
18
20
  end
19
21
 
22
+ # 新进程由实际启动者负责;预先建立的 PTY 可能在 fork 后才启动命令。
23
+ # 仅继承已有 PID 的副本仍保留原所有者,不能清理父进程的子进程。
24
+ def pid=(value)
25
+ @owner = Process.pid if value
26
+ @pid = value
27
+ end
28
+
20
29
  # 只关闭由本库拥有的 IO;借用的 reader、writer 由调用方管理。
21
30
  # 常规关闭错误延后到所有句柄尝试完再抛出,失败句柄仍留在账本内供下一次关闭重试。
22
31
  def close_handles
@@ -51,29 +60,21 @@ class Expect
51
60
  status
52
61
  end
53
62
 
54
- # GC 兜底关闭所属句柄和日志,并强制终止尚存活的子进程;不执行软关闭等待。
55
- def finalize
63
+ # GC 兜底关闭所属句柄并终止尚存活的子进程;不执行用户代码或软关闭等待。
64
+ # 可直接用绑定到资源对象的 Method 注册终结器,忽略 Ruby 传来的被回收对象 ID。
65
+ def finalize(_object_id = nil)
56
66
  return unless owner == Process.pid
57
67
 
58
68
  begin
59
69
  close_handles
60
70
  ensure
61
- begin
62
- owned_log.close if owned_log && !owned_log.closed?
63
- ensure
64
- # 每个阶段独立收尾;句柄或日志关闭失败不能跳过进程回收。
65
- finalize_child
66
- end
71
+ # 句柄关闭失败不能跳过进程回收。
72
+ finalize_child
67
73
  end
68
74
  rescue IOError, SystemCallError
69
75
  nil
70
76
  end
71
77
 
72
- # 构造只持有资源对象的终结回调,避免闭包中的 self 绑定到会话而妨碍回收。
73
- def self.finalizer(resources)
74
- proc { resources.finalize }
75
- end
76
-
77
78
  private
78
79
 
79
80
  # GC 只做一次非阻塞回收和最多两次信号尝试;失败也把等待交给 detach,不运行用户回调。
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Expect
3
+ module Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.5.3"
5
+ VERSION = "0.7.1"
6
6
  end