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.
- checksums.yaml +7 -0
- data/.rubocop.yml +29 -0
- data/CHANGELOG.md +25 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +264 -0
- data/Rakefile +38 -0
- data/docs/COMPATIBILITY.md +65 -0
- data/docs/RELEASING.md +55 -0
- data/docs/VERIFICATION.md +146 -0
- data/examples/dialogue.rb +30 -0
- data/examples/kibitz/README.md +73 -0
- data/examples/kibitz/kibitz.rb +139 -0
- data/examples/kibitz/test_kibitz.rb +37 -0
- data/examples/ssh_auto.rb +94 -0
- data/examples/ssh_interact.rb +159 -0
- data/examples/ssh_login.rb +64 -0
- data/expect-pty.gemspec +25 -0
- data/lib/expect/configuration.rb +113 -0
- data/lib/expect/engine.rb +141 -0
- data/lib/expect/interconnect.rb +170 -0
- data/lib/expect/pattern.rb +62 -0
- data/lib/expect/pattern_list.rb +90 -0
- data/lib/expect/pty.rb +4 -0
- data/lib/expect/resources.rb +63 -0
- data/lib/expect/result.rb +14 -0
- data/lib/expect/version.rb +6 -0
- data/lib/expect.rb +591 -0
- data/script/ci +44 -0
- data/script/release.rb +267 -0
- data/test/compare_upstream.rb +157 -0
- data/test/configuration_test.rb +90 -0
- data/test/edge_case_test.rb +183 -0
- data/test/fixtures/ssh_scripts/01_identity.sh +4 -0
- data/test/fixtures/ssh_scripts/02_output.sh +5 -0
- data/test/fixtures/ssh_scripts/03_delayed.sh +6 -0
- data/test/fixtures/ssh_scripts/04_failure.sh +2 -0
- data/test/fixtures/ssh_scripts/05_recovery.sh +3 -0
- data/test/integration/README.md +90 -0
- data/test/integration/ssh_scripts.rb +94 -0
- data/test/interact_test.rb +151 -0
- data/test/interconnect_test.rb +226 -0
- data/test/io_test.rb +184 -0
- data/test/kibitz_test.rb +45 -0
- data/test/matching_test.rb +178 -0
- data/test/multi_session_test.rb +66 -0
- data/test/process_test.rb +244 -0
- data/test/release_test.rb +153 -0
- data/test/ruby_api_test.rb +515 -0
- data/test/script_logging_test.rb +124 -0
- data/test/support/interact_probe.rb +125 -0
- data/test/support/kibitz_probe.rb +177 -0
- data/test/support/script_probe.rb +157 -0
- data/test/test_helper.rb +58 -0
- data/test/timeout_test.rb +170 -0
- 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
|
+
)
|