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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -1
- data/README.md +122 -130
- data/docs/API.md +113 -0
- data/docs/MIGRATION.md +68 -0
- data/lib/expect/cleanup.rb +34 -0
- data/lib/expect/interaction.rb +228 -210
- data/lib/expect/logging.rb +117 -162
- data/lib/expect/matcher.rb +42 -38
- data/lib/expect/pattern.rb +10 -3
- data/lib/expect/pattern_list.rb +39 -9
- data/lib/expect/redactor.rb +12 -5
- data/lib/expect/relay.rb +106 -81
- data/lib/expect/relay_writer.rb +7 -5
- data/lib/expect/result.rb +33 -9
- data/lib/expect/session.rb +554 -0
- data/lib/expect/session_resources.rb +19 -18
- data/lib/expect/version.rb +2 -2
- data/lib/expect.rb +68 -578
- data/sig/expect.rbs +279 -0
- metadata +14 -123
- data/.rubocop.yml +0 -66
- data/CONTRIBUTING.md +0 -29
- data/Gemfile +0 -28
- data/Rakefile +0 -38
- data/benchmark/matching.rb +0 -98
- data/benchmark/redactor.rb +0 -64
- data/benchmark/relay.rb +0 -48
- data/benchmark/scaling.rb +0 -70
- data/benchmark/send_slow.rb +0 -41
- data/benchmark/support.rb +0 -114
- data/docs/COMPATIBILITY.md +0 -98
- data/docs/INTERNAL_CONTRACTS.md +0 -154
- data/docs/PERFORMANCE.md +0 -165
- data/docs/RELEASING.md +0 -84
- data/docs/VERIFICATION.md +0 -456
- data/examples/dialogue.rb +0 -30
- data/examples/kibitz/README.md +0 -81
- data/examples/kibitz/kibitz.rb +0 -142
- data/examples/kibitz/test_kibitz.rb +0 -37
- data/examples/ssh_auto.rb +0 -94
- data/examples/ssh_interact.rb +0 -159
- data/examples/ssh_login.rb +0 -64
- data/expect-pty.gemspec +0 -33
- data/lib/expect/configuration.rb +0 -122
- data/lib/expect/terminal.rb +0 -102
- data/script/ci +0 -121
- data/script/release.rb +0 -319
- data/test/buffer_accounting_test.rb +0 -83
- data/test/cleanup_test.rb +0 -251
- data/test/compare_upstream.rb +0 -157
- data/test/configuration_test.rb +0 -133
- data/test/deadline_test.rb +0 -237
- data/test/diagnostics_test.rb +0 -353
- data/test/edge_case_test.rb +0 -262
- data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
- data/test/fixtures/ssh_scripts/02_output.sh +0 -5
- data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
- data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
- data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
- data/test/initialization_failure_test.rb +0 -92
- data/test/integration/README.md +0 -109
- data/test/integration/ssh_scripts.rb +0 -94
- data/test/interact_test.rb +0 -253
- data/test/interconnect_test.rb +0 -425
- data/test/io_test.rb +0 -321
- data/test/kibitz_test.rb +0 -45
- data/test/lifecycle_contract_test.rb +0 -71
- data/test/literal_scan_test.rb +0 -97
- data/test/matching_test.rb +0 -211
- data/test/multi_session_test.rb +0 -66
- data/test/ownership_sequence_test.rb +0 -208
- data/test/pattern_offset_test.rb +0 -43
- data/test/process_interruption_test.rb +0 -296
- data/test/process_test.rb +0 -291
- data/test/redactor_test.rb +0 -183
- data/test/relay_recovery_test.rb +0 -451
- data/test/relay_reentrancy_test.rb +0 -159
- data/test/release_test.rb +0 -375
- data/test/ruby_api_test.rb +0 -515
- data/test/scan_reuse_test.rb +0 -109
- data/test/script_logging_test.rb +0 -124
- data/test/support/interact_probe.rb +0 -125
- data/test/support/kibitz_probe.rb +0 -177
- data/test/support/script_probe.rb +0 -158
- data/test/terminal_cleanup_test.rb +0 -349
- data/test/test_helper.rb +0 -58
- data/test/timeout_test.rb +0 -289
- 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
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
#
|
|
81
|
-
def
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
|
134
|
+
select_ready_sessions(active, ready.first)
|
|
149
135
|
end
|
|
150
136
|
|
|
151
137
|
private
|
|
152
138
|
|
|
153
|
-
#
|
|
154
|
-
def
|
|
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
|
|
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"
|