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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +28 -19
  4. data/docs/API.md +88 -0
  5. data/docs/MIGRATION.md +15 -0
  6. data/lib/expect/cleanup.rb +33 -0
  7. data/lib/expect/configuration.rb +87 -66
  8. data/lib/expect/interaction.rb +226 -209
  9. data/lib/expect/logging.rb +157 -156
  10. data/lib/expect/matcher.rb +30 -29
  11. data/lib/expect/pattern.rb +8 -2
  12. data/lib/expect/pattern_list.rb +35 -5
  13. data/lib/expect/redactor.rb +11 -4
  14. data/lib/expect/relay.rb +97 -73
  15. data/lib/expect/relay_writer.rb +2 -1
  16. data/lib/expect/result.rb +32 -8
  17. data/lib/expect/session.rb +508 -0
  18. data/lib/expect/session_resources.rb +10 -2
  19. data/lib/expect/terminal.rb +78 -78
  20. data/lib/expect/version.rb +1 -1
  21. data/lib/expect.rb +333 -520
  22. data/sig/expect.rbs +355 -0
  23. metadata +13 -106
  24. data/.rubocop.yml +0 -66
  25. data/CONTRIBUTING.md +0 -29
  26. data/Gemfile +0 -28
  27. data/Rakefile +0 -38
  28. data/benchmark/matching.rb +0 -98
  29. data/benchmark/redactor.rb +0 -64
  30. data/benchmark/relay.rb +0 -48
  31. data/benchmark/scaling.rb +0 -70
  32. data/benchmark/send_slow.rb +0 -41
  33. data/benchmark/support.rb +0 -114
  34. data/docs/COMPATIBILITY.md +0 -98
  35. data/docs/INTERNAL_CONTRACTS.md +0 -154
  36. data/docs/PERFORMANCE.md +0 -165
  37. data/docs/RELEASING.md +0 -84
  38. data/docs/VERIFICATION.md +0 -456
  39. data/examples/dialogue.rb +0 -30
  40. data/examples/kibitz/README.md +0 -81
  41. data/examples/kibitz/kibitz.rb +0 -142
  42. data/examples/kibitz/test_kibitz.rb +0 -37
  43. data/examples/ssh_auto.rb +0 -94
  44. data/examples/ssh_interact.rb +0 -159
  45. data/examples/ssh_login.rb +0 -64
  46. data/expect-pty.gemspec +0 -33
  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
@@ -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