expect-pty 0.5.3 → 0.6.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 +28 -0
- data/README.md +28 -19
- data/docs/API.md +88 -0
- data/docs/MIGRATION.md +15 -0
- data/lib/expect/cleanup.rb +33 -0
- data/lib/expect/configuration.rb +87 -66
- data/lib/expect/interaction.rb +226 -209
- data/lib/expect/logging.rb +157 -156
- data/lib/expect/matcher.rb +30 -29
- data/lib/expect/pattern.rb +8 -2
- data/lib/expect/pattern_list.rb +35 -5
- data/lib/expect/redactor.rb +11 -4
- data/lib/expect/relay.rb +97 -73
- data/lib/expect/relay_writer.rb +2 -1
- data/lib/expect/result.rb +32 -8
- data/lib/expect/session.rb +508 -0
- data/lib/expect/session_resources.rb +10 -2
- data/lib/expect/terminal.rb +78 -78
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +333 -520
- data/sig/expect.rbs +355 -0
- metadata +13 -106
- 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/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
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "test_helper"
|
|
4
|
-
|
|
5
|
-
class TerminalCleanupTest < ExpectTest
|
|
6
|
-
def test_read_failures_preserve_identity_reap_helper_and_leave_main_session_usable
|
|
7
|
-
[IOError.new("read failed"), Interrupt.new("interrupted"), SystemExit.new(17)].each do |failure|
|
|
8
|
-
session = terminal_session
|
|
9
|
-
main_pid = session.pid
|
|
10
|
-
with_stty_pipe do |reader, sink, spawned|
|
|
11
|
-
reader.stub(:read, -> { raise failure }) do
|
|
12
|
-
assert_same failure, assert_raises(failure.class) { bounded { session.stty } }
|
|
13
|
-
end
|
|
14
|
-
assert reader.closed?
|
|
15
|
-
assert sink.closed?
|
|
16
|
-
assert_reaped spawned.fetch(:pid)
|
|
17
|
-
end
|
|
18
|
-
assert_main_session_usable session, main_pid
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def test_read_error_wins_over_close_error_and_helper_is_still_reaped
|
|
23
|
-
session = terminal_session
|
|
24
|
-
failure = IOError.new("read failed")
|
|
25
|
-
attempts = 0
|
|
26
|
-
with_stty_pipe do |reader, sink, spawned|
|
|
27
|
-
reader.stub(:read, -> { raise failure }) do
|
|
28
|
-
reader.stub(:close, lambda {
|
|
29
|
-
attempts += 1
|
|
30
|
-
raise IOError, "close failed"
|
|
31
|
-
}) do
|
|
32
|
-
assert_same failure, assert_raises(IOError) { bounded { session.stty } }
|
|
33
|
-
refute reader.closed?, "permanently failing close must not be reported as closed"
|
|
34
|
-
assert sink.closed?
|
|
35
|
-
assert_reaped spawned.fetch(:pid)
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
assert_equal 1, attempts
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def test_cleanup_error_after_success_is_visible_even_inside_caller_rescue
|
|
43
|
-
session = terminal_session
|
|
44
|
-
failure = IOError.new("close failed")
|
|
45
|
-
begin
|
|
46
|
-
raise ArgumentError, "already handled"
|
|
47
|
-
rescue ArgumentError
|
|
48
|
-
with_stty_pipe do |reader, sink, spawned|
|
|
49
|
-
reader.stub(:close, -> { raise failure }) do
|
|
50
|
-
assert_same failure, assert_raises(IOError) { bounded { session.stty } }
|
|
51
|
-
assert sink.closed?
|
|
52
|
-
assert_reaped spawned.fetch(:pid)
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def test_spawn_failure_attempts_both_closes_and_keeps_missing_command_cause
|
|
59
|
-
session = terminal_session
|
|
60
|
-
reader, sink = IO.pipe
|
|
61
|
-
@ios.push(reader, sink)
|
|
62
|
-
failure = Errno::ENOENT.new("missing stty")
|
|
63
|
-
IO.stub(:pipe, [reader, sink]) do
|
|
64
|
-
Process.stub(:spawn, ->(*) { raise failure }) do
|
|
65
|
-
Process.stub(:kill, ->(*) { flunk "no helper was spawned" }) do
|
|
66
|
-
reader.stub(:close, -> { raise IOError, "reader close failed" }) do
|
|
67
|
-
error = assert_raises(IOError) { session.stty }
|
|
68
|
-
assert_match "stty executable not found", error.message
|
|
69
|
-
assert_same failure, error.cause
|
|
70
|
-
assert sink.closed?
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def test_externally_reaped_helper_is_not_signalled_and_does_not_change_main_status
|
|
78
|
-
session = terminal_session
|
|
79
|
-
main_pid = session.pid
|
|
80
|
-
failure = IOError.new("read failed after external wait")
|
|
81
|
-
with_stty_pipe do |reader, _sink, spawned|
|
|
82
|
-
reader.stub(:read, lambda {
|
|
83
|
-
bounded { Process.waitpid2(spawned.fetch(:pid)) }
|
|
84
|
-
raise failure
|
|
85
|
-
}) do
|
|
86
|
-
Process.stub(:kill, ->(*) { flunk "must not signal an externally reaped PID" }) do
|
|
87
|
-
assert_same failure, assert_raises(IOError) { bounded { session.stty } }
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
assert_reaped spawned.fetch(:pid)
|
|
91
|
-
end
|
|
92
|
-
assert_main_session_usable session, main_pid
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def test_uncooperative_helper_is_killed_and_reaped_after_read_failure
|
|
96
|
-
session = terminal_session
|
|
97
|
-
failure = IOError.new("read failed")
|
|
98
|
-
command = [RbConfig.ruby, "--disable-gems", "-e",
|
|
99
|
-
'Signal.trap("TERM", "IGNORE"); STDOUT.sync = true; puts "ready"; sleep 60']
|
|
100
|
-
signals = []
|
|
101
|
-
kill = Process.method(:kill)
|
|
102
|
-
with_stty_pipe(command: command) do |reader, _sink, spawned|
|
|
103
|
-
read = reader.method(:gets)
|
|
104
|
-
reader.stub(:read, lambda {
|
|
105
|
-
assert_equal("ready\n", bounded { read.call })
|
|
106
|
-
raise failure
|
|
107
|
-
}) do
|
|
108
|
-
Process.stub(:kill, lambda { |signal, pid|
|
|
109
|
-
signals << [signal, pid]
|
|
110
|
-
kill.call(signal, pid)
|
|
111
|
-
}) do
|
|
112
|
-
assert_same failure, assert_raises(IOError) { bounded(2) { session.stty } }
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
assert_equal [["TERM", spawned.fetch(:pid)], ["KILL", spawned.fetch(:pid)]], signals
|
|
116
|
-
assert_reaped spawned.fetch(:pid)
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def test_normal_wait_retries_eintr_without_signalling_or_sleeping
|
|
121
|
-
session = terminal_session
|
|
122
|
-
wait = Process.method(:waitpid2)
|
|
123
|
-
attempts = 0
|
|
124
|
-
with_stty_pipe do |_reader, _sink, spawned|
|
|
125
|
-
Process.stub(:waitpid2, lambda { |pid, *flags|
|
|
126
|
-
assert_equal spawned.fetch(:pid), pid
|
|
127
|
-
attempts += 1
|
|
128
|
-
raise Errno::EINTR if attempts == 1
|
|
129
|
-
|
|
130
|
-
wait.call(pid, *flags)
|
|
131
|
-
}) do
|
|
132
|
-
session.stub(:sleep, ->(*) { flunk "successful stty must not add a fixed delay" }) do
|
|
133
|
-
Process.stub(:kill, ->(*) { flunk "successful helper must not be signalled" }) do
|
|
134
|
-
refute_empty session.stty
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
assert_equal 2, attempts
|
|
139
|
-
assert_reaped spawned.fetch(:pid)
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def test_continuous_cleanup_eintr_has_a_fixed_budget_and_detaches_real_helper
|
|
144
|
-
session = terminal_session
|
|
145
|
-
failure = IOError.new("read failed")
|
|
146
|
-
clock = 0.0
|
|
147
|
-
waiter = nil
|
|
148
|
-
detach = Process.method(:detach)
|
|
149
|
-
attempts = 0
|
|
150
|
-
with_stty_pipe do |reader, _sink, spawned|
|
|
151
|
-
reader.stub(:read, -> { raise failure }) do
|
|
152
|
-
Expect.stub(:monotonic, -> { clock }) do
|
|
153
|
-
session.stub(:sleep, ->(duration) { clock += duration }) do
|
|
154
|
-
Process.stub(:waitpid2, lambda { |pid, flags|
|
|
155
|
-
assert_equal spawned.fetch(:pid), pid
|
|
156
|
-
assert_equal Process::WNOHANG, flags
|
|
157
|
-
attempts += 1
|
|
158
|
-
raise Errno::EINTR
|
|
159
|
-
}) do
|
|
160
|
-
Process.stub(:kill, ->(*) { flunk "ownership has not been checked successfully" }) do
|
|
161
|
-
Process.stub(:detach, lambda { |pid|
|
|
162
|
-
assert_equal spawned.fetch(:pid), pid
|
|
163
|
-
waiter = detach.call(pid)
|
|
164
|
-
}) do
|
|
165
|
-
assert_same failure, assert_raises(IOError) { bounded { session.stty } }
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
assert_operator attempts, :>, 3
|
|
173
|
-
assert_in_delta 0.15, clock, 0.000001
|
|
174
|
-
refute_nil waiter
|
|
175
|
-
assert_instance_of(Process::Status, bounded { waiter.value })
|
|
176
|
-
assert_reaped spawned.fetch(:pid)
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def test_esrch_between_poll_and_signal_still_reaps_without_fabricating_status
|
|
181
|
-
session = terminal_session
|
|
182
|
-
main_pid = session.pid
|
|
183
|
-
failure = IOError.new("read failed")
|
|
184
|
-
clock = 0.0
|
|
185
|
-
signalled = false
|
|
186
|
-
wait = Process.method(:waitpid2)
|
|
187
|
-
with_stty_pipe do |reader, _sink, spawned|
|
|
188
|
-
reader.stub(:read, -> { raise failure }) do
|
|
189
|
-
Expect.stub(:monotonic, -> { clock }) do
|
|
190
|
-
session.stub(:sleep, ->(duration) { clock += duration }) do
|
|
191
|
-
Process.stub(:waitpid2, lambda { |pid, flags|
|
|
192
|
-
assert_equal Process::WNOHANG, flags
|
|
193
|
-
signalled ? wait.call(pid, flags) : nil
|
|
194
|
-
}) do
|
|
195
|
-
Process.stub(:kill, lambda { |signal, pid|
|
|
196
|
-
assert_equal "TERM", signal
|
|
197
|
-
assert_equal spawned.fetch(:pid), pid
|
|
198
|
-
bounded { wait.call(pid) }
|
|
199
|
-
signalled = true
|
|
200
|
-
raise Errno::ESRCH
|
|
201
|
-
}) do
|
|
202
|
-
Process.stub(:detach, ->(*) { flunk "ECHILD already released ownership" }) do
|
|
203
|
-
assert_same failure, assert_raises(IOError) { bounded { session.stty } }
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
assert signalled
|
|
211
|
-
assert_reaped spawned.fetch(:pid)
|
|
212
|
-
end
|
|
213
|
-
assert_main_session_usable session, main_pid
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def test_sink_close_failure_still_attempts_reader_close_and_reaps_helper
|
|
217
|
-
session = terminal_session
|
|
218
|
-
failure = IOError.new("sink close failed")
|
|
219
|
-
attempts = 0
|
|
220
|
-
with_stty_pipe do |reader, sink, spawned|
|
|
221
|
-
sink.stub(:close, lambda {
|
|
222
|
-
attempts += 1
|
|
223
|
-
raise failure
|
|
224
|
-
}) do
|
|
225
|
-
assert_same failure, assert_raises(IOError) { bounded { session.stty } }
|
|
226
|
-
assert reader.closed?
|
|
227
|
-
refute sink.closed?
|
|
228
|
-
assert_reaped spawned.fetch(:pid)
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
assert_equal 2, attempts
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def test_forked_copy_closes_local_pipes_without_reaping_or_signalling_parent_helper
|
|
235
|
-
session = terminal_session
|
|
236
|
-
current_pid = Process.pid
|
|
237
|
-
failure = IOError.new("unwind in forked copy")
|
|
238
|
-
with_stty_pipe do |reader, sink, _spawned|
|
|
239
|
-
Process.stub(:pid, -> { current_pid }) do
|
|
240
|
-
reader.stub(:read, lambda {
|
|
241
|
-
current_pid = -1 # 在资源登记后模拟 fork 子进程的身份,父进程仍拥有辅助 PID。
|
|
242
|
-
raise failure
|
|
243
|
-
}) do
|
|
244
|
-
Process.stub(:waitpid2, ->(*) { flunk "non-owner must not wait for the parent's helper" }) do
|
|
245
|
-
Process.stub(:kill, ->(*) { flunk "non-owner must not signal the parent's helper" }) do
|
|
246
|
-
Process.stub(:detach, ->(*) { flunk "non-owner must not detach the parent's helper" }) do
|
|
247
|
-
assert_same failure, assert_raises(IOError) { session.stty }
|
|
248
|
-
assert reader.closed?
|
|
249
|
-
assert sink.closed?
|
|
250
|
-
end
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
|
-
end
|
|
254
|
-
end
|
|
255
|
-
end
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
def test_ledger_initialization_failure_still_closes_both_pipes
|
|
259
|
-
session = terminal_session
|
|
260
|
-
[IOError.new("ledger failed"), Interrupt.new("interrupted registration")].each do |failure|
|
|
261
|
-
with_stty_pipe do |reader, sink, spawned|
|
|
262
|
-
Expect::SessionResources.stub(:new, ->(*) { raise failure }) do
|
|
263
|
-
assert_same failure, assert_raises(failure.class) { session.stty }
|
|
264
|
-
assert reader.closed?
|
|
265
|
-
assert sink.closed?
|
|
266
|
-
assert_empty spawned
|
|
267
|
-
end
|
|
268
|
-
end
|
|
269
|
-
end
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
def test_fork_during_cleanup_wait_stops_process_cleanup
|
|
273
|
-
session = terminal_session
|
|
274
|
-
current_pid = Process.pid
|
|
275
|
-
failure = IOError.new("read failed")
|
|
276
|
-
with_stty_pipe do |reader, sink, _spawned|
|
|
277
|
-
Process.stub(:pid, -> { current_pid }) do
|
|
278
|
-
reader.stub(:read, -> { raise failure }) do
|
|
279
|
-
session.stub(:sleep, ->(*) { current_pid = -1 }) do
|
|
280
|
-
Process.stub(:waitpid2, ->(*) {}) do
|
|
281
|
-
Process.stub(:kill, ->(*) { flunk "fork during wait must release inherited process ownership" }) do
|
|
282
|
-
Process.stub(:detach, ->(*) { flunk "non-owner must not detach" }) do
|
|
283
|
-
assert_same failure, assert_raises(IOError) { bounded { session.stty } }
|
|
284
|
-
assert reader.closed?
|
|
285
|
-
assert sink.closed?
|
|
286
|
-
end
|
|
287
|
-
end
|
|
288
|
-
end
|
|
289
|
-
end
|
|
290
|
-
end
|
|
291
|
-
end
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
private
|
|
296
|
-
|
|
297
|
-
def terminal_session
|
|
298
|
-
session = child(<<~'RUBY', raw_pty: true)
|
|
299
|
-
puts "ready"
|
|
300
|
-
while (line = STDIN.gets)
|
|
301
|
-
puts "reply:#{line.strip}"
|
|
302
|
-
end
|
|
303
|
-
RUBY
|
|
304
|
-
assert_equal 1, session.expect("ready", timeout: 2)
|
|
305
|
-
session
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
def assert_main_session_usable(session, main_pid)
|
|
309
|
-
assert_equal main_pid, session.pid
|
|
310
|
-
assert_nil session.process_status
|
|
311
|
-
refute session.closed?
|
|
312
|
-
session.puts "still usable"
|
|
313
|
-
assert_equal 1, session.expect("reply:still usable", timeout: 2)
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
def assert_reaped(pid)
|
|
317
|
-
assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
# 只替换本次 stty 的管道;即使断言失败,恢复全局 stub 后仍由探针回收自己的真实子进程。
|
|
321
|
-
def with_stty_pipe(command: nil)
|
|
322
|
-
reader, sink = IO.pipe
|
|
323
|
-
@ios.push(reader, sink)
|
|
324
|
-
spawned = {}
|
|
325
|
-
spawn = Process.method(:spawn)
|
|
326
|
-
IO.stub(:pipe, [reader, sink]) do
|
|
327
|
-
Process.stub(:spawn, lambda { |*arguments, **options|
|
|
328
|
-
spawned[:pid] = spawn.call(*(command || arguments), **options)
|
|
329
|
-
}) do
|
|
330
|
-
yield reader, sink, spawned
|
|
331
|
-
end
|
|
332
|
-
end
|
|
333
|
-
ensure
|
|
334
|
-
reap_probe_child(spawned[:pid]) if spawned && spawned[:pid]
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def reap_probe_child(pid)
|
|
338
|
-
return if Process.waitpid(pid, Process::WNOHANG)
|
|
339
|
-
|
|
340
|
-
begin
|
|
341
|
-
Process.kill("KILL", pid)
|
|
342
|
-
rescue Errno::ESRCH
|
|
343
|
-
nil
|
|
344
|
-
end
|
|
345
|
-
bounded { Process.waitpid(pid) }
|
|
346
|
-
rescue Errno::ECHILD
|
|
347
|
-
nil
|
|
348
|
-
end
|
|
349
|
-
end
|
data/test/test_helper.rb
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "minitest/autorun"
|
|
4
|
-
require "timeout"
|
|
5
|
-
require "stringio"
|
|
6
|
-
require "tempfile"
|
|
7
|
-
require "tmpdir"
|
|
8
|
-
require "socket"
|
|
9
|
-
require_relative "../lib/expect"
|
|
10
|
-
|
|
11
|
-
class ExpectTest < Minitest::Test
|
|
12
|
-
def setup
|
|
13
|
-
@sessions = []
|
|
14
|
-
@ios = []
|
|
15
|
-
@threads = []
|
|
16
|
-
@configuration = Expect.configuration
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def teardown
|
|
20
|
-
@threads.each { |thread| thread.kill.join if thread.alive? }
|
|
21
|
-
@sessions.reverse_each { |session| session.hard_close(timeout: 0.03) }
|
|
22
|
-
@ios.each { |io| io.close unless io.closed? }
|
|
23
|
-
Expect.configure(**@configuration.to_h)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def child(script, **)
|
|
27
|
-
session = Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", "STDOUT.sync = true; STDERR.sync = true; #{script}",
|
|
28
|
-
log_stdout: false, **)
|
|
29
|
-
@sessions << session
|
|
30
|
-
session
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def pipe_session(**)
|
|
34
|
-
reader, writer = IO.pipe
|
|
35
|
-
@ios.push(reader, writer)
|
|
36
|
-
session = Expect.open(reader, **)
|
|
37
|
-
@sessions << session
|
|
38
|
-
[session, writer]
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def background(&)
|
|
42
|
-
@threads << Thread.new(&)
|
|
43
|
-
@threads.last
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def bounded(seconds = 5, &)
|
|
47
|
-
Timeout.timeout(seconds, &)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def terminal_configuration(session)
|
|
51
|
-
state = session.stty
|
|
52
|
-
return state unless RUBY_PLATFORM.include?("darwin")
|
|
53
|
-
|
|
54
|
-
# Darwin marks pending-input retyping after tcsetattr. PENDIN is a kernel
|
|
55
|
-
# state bit (sys/termios.h), not a changed terminal configuration.
|
|
56
|
-
state.sub(/lflag=([0-9a-f]+)/) { "lflag=#{(Regexp.last_match(1).to_i(16) & ~0x20000000).to_s(16)}" }
|
|
57
|
-
end
|
|
58
|
-
end
|
data/test/timeout_test.rb
DELETED
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "test_helper"
|
|
4
|
-
|
|
5
|
-
class TimeoutTest < ExpectTest
|
|
6
|
-
def test_default_timeout_and_explicit_infinite_timeout
|
|
7
|
-
session, writer = pipe_session
|
|
8
|
-
session.timeout = 0.01
|
|
9
|
-
assert_nil session.expect("ready")
|
|
10
|
-
background do
|
|
11
|
-
sleep 0.04
|
|
12
|
-
writer.write("ready")
|
|
13
|
-
end
|
|
14
|
-
assert_equal(1, bounded { session.expect("ready", timeout: nil) })
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def test_zero_timeout_polls_existing_data_without_waiting
|
|
18
|
-
session, writer = pipe_session
|
|
19
|
-
writer.write("ready")
|
|
20
|
-
assert_equal 1, session.expect("ready", timeout: 0)
|
|
21
|
-
start = Expect.monotonic
|
|
22
|
-
assert_nil session.expect("missing", timeout: 0)
|
|
23
|
-
assert_operator Expect.monotonic - start, :<, 0.1
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def test_continue_resets_timeout
|
|
27
|
-
session, writer = pipe_session
|
|
28
|
-
number = with_timed_input(writer, [[7, "A"], [16, "B"]]) do
|
|
29
|
-
session.expect(timeout: 13) do
|
|
30
|
-
on("A") { Expect.continue }
|
|
31
|
-
on("B")
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
assert_equal 2, number
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def test_continue_timeout_preserves_deadline
|
|
38
|
-
session, writer = pipe_session
|
|
39
|
-
number = with_timed_input(writer, [[7, "A"], [19, "B"]]) do
|
|
40
|
-
result = session.expect(timeout: 14) do
|
|
41
|
-
on("A") { Expect.continue(reset_timeout: false) }
|
|
42
|
-
on("B")
|
|
43
|
-
end
|
|
44
|
-
assert_equal 14, Expect.monotonic
|
|
45
|
-
result
|
|
46
|
-
end
|
|
47
|
-
assert_nil number
|
|
48
|
-
assert_equal :timeout, session.error
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def test_eof_continuation_without_reset_observes_the_deadline_before_matching_again
|
|
52
|
-
first, = pipe_session
|
|
53
|
-
second, = pipe_session
|
|
54
|
-
first.close
|
|
55
|
-
now = 0.0
|
|
56
|
-
result = Expect.stub(:monotonic, -> { now }) do
|
|
57
|
-
Expect.expect_result(timeout: 1) do
|
|
58
|
-
eof(from: first) do
|
|
59
|
-
now = 2.0
|
|
60
|
-
second.buffer = "ready"
|
|
61
|
-
Expect.continue(reset_timeout: false)
|
|
62
|
-
end
|
|
63
|
-
on("ready", from: second)
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
assert result.timeout?
|
|
68
|
-
assert_same second, result.session
|
|
69
|
-
assert_equal "ready", second.buffer
|
|
70
|
-
assert first.last_result.eof?
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def test_eof_continuation_can_reset_an_expired_deadline
|
|
74
|
-
first, = pipe_session
|
|
75
|
-
second, = pipe_session
|
|
76
|
-
first.close
|
|
77
|
-
now = 0.0
|
|
78
|
-
result = Expect.stub(:monotonic, -> { now }) do
|
|
79
|
-
Expect.expect_result(timeout: 1) do
|
|
80
|
-
eof(from: first) do
|
|
81
|
-
now = 2.0
|
|
82
|
-
second.buffer = "ready"
|
|
83
|
-
Expect.continue
|
|
84
|
-
end
|
|
85
|
-
on("ready", from: second)
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
assert_equal "ready", result.match
|
|
90
|
-
assert_same second, result.session
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def test_last_eof_returns_even_when_continuation_deadline_has_expired
|
|
94
|
-
session, = pipe_session
|
|
95
|
-
session.close
|
|
96
|
-
now = 0.0
|
|
97
|
-
result = Expect.stub(:monotonic, -> { now }) do
|
|
98
|
-
session.expect_result(timeout: 1) do
|
|
99
|
-
eof do
|
|
100
|
-
now = 2.0
|
|
101
|
-
Expect.continue(reset_timeout: false)
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
assert result.eof?
|
|
107
|
-
assert_same session, result.session
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def test_expired_eof_continuation_delivers_all_known_eof_events
|
|
111
|
-
sessions = Array.new(3) { pipe_session.first }
|
|
112
|
-
sessions.each(&:close)
|
|
113
|
-
seen = []
|
|
114
|
-
result = Expect.expect_result(from: sessions, timeout: 0) do
|
|
115
|
-
eof do |session|
|
|
116
|
-
seen << session
|
|
117
|
-
Expect.continue(reset_timeout: false)
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
assert result.eof?
|
|
122
|
-
assert_equal sessions, seen
|
|
123
|
-
assert(sessions.all? { |session| session.last_result.eof? })
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def test_expired_eof_continuation_handles_known_eof_before_timing_out_live_sources
|
|
127
|
-
first, = pipe_session
|
|
128
|
-
second, = pipe_session
|
|
129
|
-
live, = pipe_session
|
|
130
|
-
[first, second].each(&:close)
|
|
131
|
-
seen = []
|
|
132
|
-
timed_out = nil
|
|
133
|
-
result = Expect.expect_result(timeout: 0) do
|
|
134
|
-
eof(from: [first, second]) do |session|
|
|
135
|
-
seen << session
|
|
136
|
-
live.buffer = "ready"
|
|
137
|
-
Expect.continue(reset_timeout: false)
|
|
138
|
-
end
|
|
139
|
-
on("ready", from: live)
|
|
140
|
-
timeout { |sessions| timed_out = sessions }
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
assert result.timeout?
|
|
144
|
-
assert_equal [first, second], seen
|
|
145
|
-
assert_equal [live], timed_out
|
|
146
|
-
assert_equal "ready", live.buffer
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def test_reset_after_expired_eof_continuation_resumes_text_matching
|
|
150
|
-
%i[eof timeout].each do |reset_event|
|
|
151
|
-
first, = pipe_session
|
|
152
|
-
second, = pipe_session
|
|
153
|
-
live, = pipe_session
|
|
154
|
-
[first, second].each(&:close)
|
|
155
|
-
result = Expect.expect_result(timeout: 0) do
|
|
156
|
-
eof(from: first) do
|
|
157
|
-
live.buffer = "ready"
|
|
158
|
-
Expect.continue(reset_timeout: false)
|
|
159
|
-
end
|
|
160
|
-
eof(from: second) { Expect.continue(reset_timeout: reset_event == :eof) }
|
|
161
|
-
on("ready", from: live)
|
|
162
|
-
timeout { Expect.continue }
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
assert_equal "ready", result.match
|
|
166
|
-
assert_same live, result.session
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def test_restart_timeout_on_receive
|
|
171
|
-
session, writer = pipe_session
|
|
172
|
-
session.reset_timeout_on_read = true
|
|
173
|
-
number = with_timed_input(writer, [[6, "."], [12, "."], [18, "."], [24, ".done"]]) do
|
|
174
|
-
session.expect("done", timeout: 10)
|
|
175
|
-
end
|
|
176
|
-
assert_equal 1, number
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def test_receive_keeps_deadline_without_reset
|
|
180
|
-
session, writer = pipe_session
|
|
181
|
-
result = with_timed_input(writer, [[6, "."], [12, "done"]]) do
|
|
182
|
-
session.expect_result("done", timeout: 10)
|
|
183
|
-
end
|
|
184
|
-
assert result.timeout?
|
|
185
|
-
assert_equal ".", session.buffer
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def test_timeout_callback_receives_group_and_can_retry
|
|
189
|
-
session, = pipe_session
|
|
190
|
-
count = 0
|
|
191
|
-
groups = []
|
|
192
|
-
argument = :value
|
|
193
|
-
number = session.expect(timeout: 0.01) do
|
|
194
|
-
timeout do |objects|
|
|
195
|
-
groups << [objects, argument]
|
|
196
|
-
count += 1
|
|
197
|
-
count < 3 ? Expect.continue : nil
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
assert_nil number
|
|
201
|
-
assert_equal 3, count
|
|
202
|
-
assert_equal [[[session], :value]] * 3, groups
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def test_callback_exception_propagates
|
|
206
|
-
session, = pipe_session
|
|
207
|
-
session.buffer = "ready"
|
|
208
|
-
assert_raises(RuntimeError) do
|
|
209
|
-
session.expect(timeout: 0) { on("ready") { raise "handler failed" } }
|
|
210
|
-
end
|
|
211
|
-
assert_equal "ready", session.match
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def test_continuation_through_buffered_states
|
|
215
|
-
session, = pipe_session
|
|
216
|
-
session.buffer = "A B C D End"
|
|
217
|
-
states = []
|
|
218
|
-
number = session.expect(timeout: 1) do
|
|
219
|
-
on(/[ABCD]/) do |connection|
|
|
220
|
-
states << connection.match
|
|
221
|
-
connection.continue
|
|
222
|
-
end
|
|
223
|
-
on("End")
|
|
224
|
-
end
|
|
225
|
-
assert_equal 2, number
|
|
226
|
-
assert_equal %w[A B C D], states
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def test_absolute_timeout_even_with_continuous_unmatched_output
|
|
230
|
-
session = child('loop { print "x" * 16384 }', raw_pty: true, buffer_limit: 1024)
|
|
231
|
-
start = Expect.monotonic
|
|
232
|
-
result = bounded(2) { session.expect_result("missing", timeout: 0.05) }
|
|
233
|
-
assert result.timeout?
|
|
234
|
-
assert_operator Expect.monotonic - start, :<, 0.4
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def test_repeated_select_interruptions_do_not_extend_the_deadline
|
|
238
|
-
session, = pipe_session
|
|
239
|
-
interrupted = lambda do |*|
|
|
240
|
-
sleep 0.01
|
|
241
|
-
raise Errno::EINTR
|
|
242
|
-
end
|
|
243
|
-
result = bounded(1) do
|
|
244
|
-
IO.stub(:select, interrupted) { session.expect_result("missing", timeout: 0.02) }
|
|
245
|
-
end
|
|
246
|
-
assert result.timeout?
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
def test_interrupted_read_retries_without_losing_input
|
|
250
|
-
session, writer = pipe_session
|
|
251
|
-
writer.write("ready")
|
|
252
|
-
original = session.to_io.method(:read_nonblock)
|
|
253
|
-
interrupted = true
|
|
254
|
-
read = lambda do |*args, **options|
|
|
255
|
-
if interrupted
|
|
256
|
-
interrupted = false
|
|
257
|
-
raise Errno::EINTR
|
|
258
|
-
end
|
|
259
|
-
original.call(*args, **options)
|
|
260
|
-
end
|
|
261
|
-
session.to_io.stub(:read_nonblock, read) do
|
|
262
|
-
assert_equal 1, session.expect("ready", timeout: 1)
|
|
263
|
-
end
|
|
264
|
-
assert_equal "ready", session.match
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
private
|
|
268
|
-
|
|
269
|
-
# 按虚拟时间向真实管道写入数据;select 仍按调用方给定的期限决定就绪或超时。
|
|
270
|
-
# 这样可精确检查期限是否重置,不依赖 CI 线程能否在几十毫秒内获得调度。
|
|
271
|
-
def with_timed_input(writer, events, &block)
|
|
272
|
-
now = 0.0
|
|
273
|
-
pending = events.dup
|
|
274
|
-
select = lambda do |readers, _writers, _errors, timeout|
|
|
275
|
-
deadline = now + timeout
|
|
276
|
-
if pending.any? && pending.first[0] <= deadline
|
|
277
|
-
now, data = pending.shift
|
|
278
|
-
writer.write(data)
|
|
279
|
-
[readers, [], []]
|
|
280
|
-
else
|
|
281
|
-
now = deadline
|
|
282
|
-
nil
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
Expect.stub(:monotonic, -> { now }) do
|
|
286
|
-
IO.stub(:select, select, &block)
|
|
287
|
-
end
|
|
288
|
-
end
|
|
289
|
-
end
|