expect-pty 0.5.1 → 0.5.3

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.
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "minitest/mock"
5
+
6
+ class OwnershipSequenceTest < ExpectTest
7
+ SEEDS = [20_260_927, 1, 42].freeze
8
+
9
+ def test_failed_initialization_then_failed_cleanup_preserves_ownership_and_primary_error
10
+ SEEDS.each do |seed|
11
+ [true, false].each do |own|
12
+ reader, writer = IO.pipe
13
+ @ios.push(reader, writer)
14
+ attempts = []
15
+ failure = seed.odd? ? Interrupt.new : SystemExit.new(17)
16
+ original = writer.method(:close)
17
+ Expect::SessionResources.stub(:new, ->(*, **) { raise failure }) do
18
+ reader.stub(:close, lambda {
19
+ attempts << :reader
20
+ raise IOError, "injected close failure"
21
+ }) do
22
+ writer.stub(:close, lambda {
23
+ attempts << :writer
24
+ original.call
25
+ }) do
26
+ error = assert_raises(failure.class) { Expect.open(reader, writer: writer, own: own) }
27
+ assert_same failure, error, "seed=#{seed} own=#{own}"
28
+ end
29
+ end
30
+ end
31
+ assert_equal(own ? %i[reader writer] : [], attempts, "seed=#{seed} own=#{own}")
32
+ assert_equal own, writer.closed?
33
+ refute reader.closed?
34
+ end
35
+ end
36
+ end
37
+
38
+ def test_short_write_eintr_timeout_and_listener_replacement_keep_per_target_progress
39
+ SEEDS.each do |seed|
40
+ source, = pipe_session
41
+ sink, target_writer = IO.pipe
42
+ @ios.push(sink, target_writer)
43
+ target, = pipe_session(writer: target_writer, write_timeout: 0.01)
44
+ first = StringIO.new
45
+ replacement = StringIO.new
46
+ payload = Random.new(seed).bytes(9)
47
+ source.listeners = [first, target]
48
+ source.buffer = payload
49
+ accepted = 2
50
+ attempts = 0
51
+ now = 0.0
52
+ original = target_writer.method(:write_nonblock)
53
+ Expect.stub(:monotonic, -> { now }) do
54
+ target_writer.stub(:write_nonblock, lambda { |bytes, **options|
55
+ attempts += 1
56
+ case attempts
57
+ when 1 then original.call(bytes.byteslice(0, accepted), **options)
58
+ when 2 then raise Errno::EINTR
59
+ else
60
+ now = 0.02
61
+ :wait_writable
62
+ end
63
+ }) do
64
+ error = assert_raises(Expect::WriteTimeout) { bounded { Expect.interconnect(source, timeout: 1) } }
65
+ assert_equal accepted, error.bytes_written, "seed=#{seed} attempts=#{attempts}"
66
+ end
67
+ end
68
+ prefix = bounded { sink.read(accepted) }
69
+ assert_equal payload.byteslice(0, accepted), prefix, "seed=#{seed} confirmed=#{accepted}"
70
+ assert_equal payload, first.string.b, "seed=#{seed} first target"
71
+ assert_empty source.buffer
72
+ assert source.pending_output?
73
+ source.listeners = [replacement]
74
+ source.buffer = "new"
75
+ Expect.interconnect(source, timeout: 0)
76
+ suffix = bounded { sink.read(payload.bytesize - accepted) }
77
+ assert_equal payload, prefix + suffix, "seed=#{seed} resumed target"
78
+ assert_equal payload, first.string.b, "seed=#{seed} first target replay"
79
+ assert_equal "new", replacement.string, "seed=#{seed} replacement target"
80
+ refute source.pending_output?
81
+ assert_empty source.buffer
82
+ end
83
+ end
84
+
85
+ def test_escape_delivery_nested_match_and_resume_account_for_all_source_bytes_once
86
+ SEEDS.each do |seed|
87
+ source, producer = pipe_session
88
+ prefix = "prefix-#{seed}\0".b
89
+ payload = "#{prefix}!consumetail".b
90
+ log = StringIO.new
91
+ sink = StringIO.new
92
+ source.log_to(log)
93
+ source.listeners = [sink]
94
+ calls = 0
95
+ source.on_sequence("!") do
96
+ calls += 1
97
+ assert_equal prefix, sink.string.b, "seed=#{seed} prefix not delivered"
98
+ assert_equal "consume", source.expect_result("consume", timeout: 0).match
99
+ false
100
+ end
101
+ producer.write(payload)
102
+ assert_same(source, bounded { Expect.interconnect(source, timeout: 1) })
103
+ assert_equal "tail", source.buffer
104
+ assert_equal 1, calls
105
+ assert_equal payload.bytesize, prefix.bytesize + 1 + "consume".bytesize + source.buffer.bytesize
106
+ Expect.interconnect(source, timeout: 0)
107
+ assert_equal "#{prefix}tail", sink.string.b, "seed=#{seed} delivered bytes"
108
+ assert_equal payload, log.string.b, "seed=#{seed} received log replay"
109
+ assert_equal 1, calls
110
+ refute source.pending_output?
111
+ assert_empty source.buffer
112
+ end
113
+ end
114
+
115
+ def test_refused_recursion_then_sequential_relay_preserves_bytes_and_releases_ownership
116
+ SEEDS.each do |seed|
117
+ source, = pipe_session
118
+ payload = Random.new(seed).bytes(7)
119
+ sink = StringIO.new
120
+ original = sink.method(:write)
121
+ errors = []
122
+ sink.define_singleton_method(:write) do |bytes|
123
+ count = original.call(bytes)
124
+ begin
125
+ Expect.interconnect(source, timeout: 0)
126
+ rescue Expect::ReentrancyError => error
127
+ errors << error
128
+ end
129
+ count
130
+ end
131
+ source.listeners = [sink]
132
+ 2.times do |index|
133
+ source.buffer = payload
134
+ Expect.interconnect(source, timeout: 0)
135
+ assert_equal payload * (index + 1), sink.string.b, "seed=#{seed} iteration=#{index}"
136
+ assert_equal index + 1, errors.size
137
+ assert_empty source.buffer
138
+ refute source.pending_output?
139
+ end
140
+ end
141
+ end
142
+
143
+ def test_eof_soft_close_hard_close_and_repeated_status_queries
144
+ reader, writer = IO.pipe
145
+ @ios.push(reader, writer)
146
+ session = Expect.open(reader, own: true)
147
+ @sessions << session
148
+ script = <<~RUBY
149
+ Signal.trap("HUP", "IGNORE")
150
+ Signal.trap("TERM", "IGNORE")
151
+ STDOUT.sync = true
152
+ print "tail"
153
+ STDOUT.reopen(File::NULL, "w")
154
+ sleep 60
155
+ RUBY
156
+ pid = Process.spawn(RbConfig.ruby, "--disable-gems", "-e", script, in: File::NULL, out: writer, err: File::NULL)
157
+ session.instance_variable_get(:@resources).pid = pid
158
+ writer.close
159
+ result = session.expect_result(:eof, timeout: 2)
160
+ assert result.eof?
161
+ assert_equal "tail", result.before
162
+ assert session.alive?
163
+ refute session.closed?
164
+ signals = []
165
+ original = Process.method(:kill)
166
+ Process.stub(:kill, lambda { |signal, child_pid|
167
+ signals << signal
168
+ original.call(signal, child_pid)
169
+ }) do
170
+ assert_nil session.soft_close(timeout: 0, term_timeout: 0)
171
+ end
172
+ assert_equal ["TERM"], signals
173
+ assert session.closed?
174
+ assert session.alive?
175
+ status = bounded { session.hard_close(timeout: 0) }
176
+ assert_equal Signal.list.fetch("KILL"), status.termsig
177
+ 3.times { assert_same status, session.process_status }
178
+ assert_nil session.pid
179
+ assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
180
+ ensure
181
+ session&.hard_close(timeout: 0)
182
+ end
183
+
184
+ def test_expired_deadline_dispatches_known_eof_in_order_without_consuming_new_text
185
+ SEEDS.each do |seed|
186
+ ended = Array.new(2) { pipe_session.first }
187
+ ended.each(&:close)
188
+ ended.shuffle!(random: Random.new(seed))
189
+ live, producer = pipe_session
190
+ live.buffer = "buffered"
191
+ producer.write("unread")
192
+ seen = []
193
+ Expect.stub(:monotonic, 1.0) do
194
+ result = Expect.expect_result(from: [*ended, live], deadline: 1.0) do
195
+ on("buffered") { flunk "seed=#{seed} consumed text after deadline" }
196
+ eof do |session|
197
+ seen << session
198
+ Expect.continue(reset_timeout: seed.odd?)
199
+ end
200
+ end
201
+ assert result.timeout?
202
+ end
203
+ assert_equal ended, seen, "seed=#{seed} EOF order"
204
+ assert_equal "buffered", live.buffer
205
+ assert_equal "unread", live.to_io.read_nonblock(6)
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "minitest/mock"
5
+
6
+ class ProcessInterruptionTest < ExpectTest
7
+ def test_hard_close_reaps_a_real_child_after_one_wait_interruption
8
+ session = child('Signal.trap("HUP", "IGNORE"); Signal.trap("TERM", "IGNORE"); puts "ready"; sleep 60',
9
+ raw_pty: true)
10
+ assert_equal 1, session.expect("ready", timeout: 2)
11
+ pid = session.pid
12
+ original = Process.method(:waitpid2)
13
+ interrupted = false
14
+ Process.stub(:waitpid2, lambda { |*args|
15
+ if args.first == pid && !interrupted
16
+ interrupted = true
17
+ raise Errno::EINTR
18
+ end
19
+ original.call(*args)
20
+ }) do
21
+ status = bounded { session.hard_close(timeout: 0) }
22
+ assert_instance_of Process::Status, status
23
+ assert_equal Signal.list.fetch("KILL"), status.termsig
24
+ assert_same status, session.process_status
25
+ end
26
+ assert interrupted
27
+ assert_nil session.pid
28
+ assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
29
+ ensure
30
+ session&.hard_close(timeout: 0)
31
+ end
32
+
33
+ def test_continuous_wait_interruptions_keep_the_original_deadline_and_pid
34
+ with_fake_child do |session, resources, calls|
35
+ with_clock(session) do |now|
36
+ Process.stub(:waitpid2, lambda { |*|
37
+ calls << :wait
38
+ raise Errno::EINTR
39
+ }) do
40
+ assert_nil(bounded { session.wait(timeout: 0.03) })
41
+ assert_equal 0.03, now.call
42
+ assert_operator calls.size, :<=, 5
43
+ assert_operator calls.size, :>=, 3
44
+ assert_equal 123_456, resources.pid
45
+ assert_nil resources.status
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ def test_continuous_wait_and_signal_interruptions_are_bounded_during_hard_close
52
+ with_fake_child do |session, resources, calls|
53
+ with_clock(session) do |now|
54
+ Process.stub(:waitpid2, lambda { |*|
55
+ calls << :wait
56
+ raise Errno::EINTR
57
+ }) do
58
+ Process.stub(:kill, lambda { |signal, *|
59
+ calls << signal
60
+ raise Errno::EINTR
61
+ }) do
62
+ assert_nil(bounded { session.hard_close(timeout: 0.02) })
63
+ assert_in_delta 1.04, now.call, 0.000001
64
+ assert_operator calls.count("TERM"), :>, 1
65
+ assert_operator calls.count("KILL"), :>, 1
66
+ assert_operator calls.size, :<, 230
67
+ assert_equal 123_456, resources.pid
68
+ assert_nil resources.status
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ def test_signal_interruption_retries_without_extending_the_soft_close_budget
76
+ with_fake_child do |session, resources, calls|
77
+ with_clock(session) do |now|
78
+ Process.stub(:kill, lambda { |signal, _pid|
79
+ calls << signal
80
+ raise Errno::EINTR if calls.count(signal) == 1
81
+ }) do
82
+ assert_nil session.soft_close(timeout: 0, term_timeout: 0.03)
83
+ assert_equal 0.03, now.call
84
+ assert_equal %w[TERM TERM], calls.grep(String)
85
+ assert_equal 123_456, resources.pid
86
+ assert_nil resources.status
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ def test_echild_clears_ownership_before_any_signal_or_detach
93
+ with_fake_child do |session, resources, calls|
94
+ Process.stub(:waitpid2, ->(*) { raise Errno::ECHILD }) do
95
+ assert_nil session.hard_close(timeout: 0)
96
+ resources.finalize
97
+ assert_nil resources.pid
98
+ assert_nil resources.status
99
+ assert_empty calls
100
+ end
101
+ end
102
+ end
103
+
104
+ def test_finalizer_continues_to_kill_and_detach_after_interrupted_reap
105
+ with_fake_child do |_session, resources, calls|
106
+ Process.stub(:waitpid2, lambda { |*|
107
+ calls << :wait
108
+ raise Errno::EINTR
109
+ }) do
110
+ bounded { resources.finalize }
111
+ end
112
+ assert_equal [:wait, "KILL", :detach], calls
113
+ assert_nil resources.pid
114
+ assert_nil resources.status
115
+ end
116
+ end
117
+
118
+ def test_finalizer_detaches_even_if_kill_is_continuously_interrupted
119
+ with_fake_child do |session, resources, calls|
120
+ session.log_to { flunk "finalizer invoked a user callback" }
121
+ Process.stub(:kill, lambda { |signal, *|
122
+ calls << signal
123
+ raise Errno::EINTR
124
+ }) do
125
+ bounded { resources.finalize }
126
+ end
127
+ assert_equal :detach, calls.last
128
+ assert_operator calls.count("KILL"), :>=, 1
129
+ assert_operator calls.count("KILL"), :<=, 2
130
+ assert_nil resources.pid
131
+ assert_nil resources.status
132
+ end
133
+ end
134
+
135
+ def test_finalizer_retains_unknown_pid_if_detach_also_fails
136
+ with_fake_child do |_session, resources, _calls|
137
+ Process.stub(:detach, ->(*) { raise Errno::EINTR }) { bounded { resources.finalize } }
138
+ assert_equal 123_456, resources.pid
139
+ assert_nil resources.status
140
+ end
141
+ end
142
+
143
+ def test_non_owner_never_waits_signals_or_detaches
144
+ with_fake_child do |session, resources, calls|
145
+ Process.stub(:pid, resources.owner + 1) do
146
+ resources.finalize
147
+ assert_nil session.wait(timeout: 0)
148
+ assert_nil session.hard_close(timeout: 0)
149
+ end
150
+ assert_empty calls
151
+ assert_equal 123_456, resources.pid
152
+ end
153
+ end
154
+
155
+ def test_forked_copy_cannot_reap_signal_or_detach_the_parent_child
156
+ session = child('puts "ready"; sleep 60', raw_pty: true)
157
+ assert_equal 1, session.expect("ready", timeout: 2)
158
+ reader, writer = IO.pipe
159
+ @ios.push(reader, writer)
160
+ probe = fork do
161
+ reader.close
162
+ forbidden = ->(*) { raise "non-owner attempted process cleanup" }
163
+ Process.stub(:waitpid2, forbidden) do
164
+ Process.stub(:kill, forbidden) do
165
+ Process.stub(:detach, forbidden) do
166
+ session.instance_variable_get(:@resources).finalize
167
+ session.hard_close(timeout: 0)
168
+ writer.write("ok")
169
+ end
170
+ end
171
+ end
172
+ exit! 0
173
+ rescue Exception # rubocop:disable Lint/RescueException -- 子进程只回传验证失败,父进程负责回收。
174
+ exit! 1
175
+ end
176
+ writer.close
177
+ assert_equal("ok", bounded { reader.read })
178
+ status = bounded { Process.waitpid2(probe).last }
179
+ probe = nil
180
+ assert status.success?
181
+ assert session.alive?
182
+ ensure
183
+ if probe
184
+ begin
185
+ Process.kill("KILL", probe)
186
+ rescue Errno::ESRCH
187
+ nil
188
+ end
189
+ begin
190
+ Process.waitpid(probe)
191
+ rescue Errno::ECHILD
192
+ nil
193
+ end
194
+ end
195
+ session&.hard_close(timeout: 0)
196
+ end
197
+
198
+ def test_finalizer_rechecks_ownership_before_detaching_after_a_signal
199
+ with_fake_child do |_session, resources, calls|
200
+ owner = resources.owner
201
+ Process.stub(:pid, -> { owner }) do
202
+ Process.stub(:kill, lambda { |signal, *|
203
+ calls << signal
204
+ owner += 1
205
+ }) do
206
+ resources.finalize
207
+ end
208
+ end
209
+ assert_equal [:wait, "KILL"], calls
210
+ assert_equal 123_456, resources.pid
211
+ assert_nil resources.status
212
+ end
213
+ end
214
+
215
+ def test_esrch_during_signal_still_reaps_without_inventing_status
216
+ with_fake_child do |session, resources, calls|
217
+ gone = false
218
+ Process.stub(:waitpid2, lambda { |*|
219
+ calls << :wait
220
+ raise Errno::ECHILD if gone
221
+ }) do
222
+ Process.stub(:kill, lambda { |signal, *|
223
+ calls << signal
224
+ gone = true
225
+ raise Errno::ESRCH
226
+ }) do
227
+ assert_nil session.hard_close(timeout: 0)
228
+ end
229
+ end
230
+ assert_equal [:wait, :wait, "TERM", :wait], calls
231
+ assert_nil resources.pid
232
+ assert_nil resources.status
233
+ end
234
+ end
235
+
236
+ def test_zero_budget_close_returns_status_reaped_after_esrch
237
+ status = child("exit 7").wait(timeout: 2)
238
+ assert_instance_of Process::Status, status
239
+ %i[soft_close hard_close].each do |method|
240
+ with_fake_child do |session, resources, calls|
241
+ gone = false
242
+ Process.stub(:waitpid2, lambda { |pid, *|
243
+ calls << :wait
244
+ [pid, status] if gone
245
+ }) do
246
+ Process.stub(:kill, lambda { |signal, *|
247
+ calls << signal
248
+ gone = true
249
+ raise Errno::ESRCH
250
+ }) do
251
+ options = { timeout: 0 }
252
+ options[:term_timeout] = 0 if method == :soft_close
253
+ assert_same status, session.public_send(method, **options)
254
+ end
255
+ end
256
+ assert_nil resources.pid
257
+ assert_same status, session.process_status
258
+ assert_equal [:wait, :wait, "TERM", :wait], calls
259
+ end
260
+ end
261
+ end
262
+
263
+ private
264
+
265
+ # 假 PID 只存在于系统调用全部被替换的作用域;ensure 先撤销 PID,再交给通用 teardown。
266
+ def with_fake_child
267
+ session, = pipe_session
268
+ resources = session.instance_variable_get(:@resources)
269
+ calls = []
270
+ originals = %i[waitpid2 kill detach].to_h { |name| [name, Process.method(name)] }
271
+ Process.define_singleton_method(:waitpid2) do |*|
272
+ calls << :wait
273
+ nil
274
+ end
275
+ Process.define_singleton_method(:kill) do |signal, *|
276
+ calls << signal
277
+ 1
278
+ end
279
+ Process.define_singleton_method(:detach) do |*|
280
+ calls << :detach
281
+ nil
282
+ end
283
+ resources.pid = 123_456
284
+ yield session, resources, calls
285
+ ensure
286
+ resources.pid = nil if resources
287
+ originals&.each { |name, method| Process.define_singleton_method(name, method) }
288
+ end
289
+
290
+ def with_clock(session)
291
+ now = 0.0
292
+ Expect.stub(:monotonic, -> { now }) do
293
+ session.stub(:sleep, ->(seconds) { now += seconds }) { yield -> { now } }
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "minitest/mock"
5
+
6
+ class RelayReentrancyTest < ExpectTest
7
+ def test_recursive_write_and_flush_cannot_duplicate_delivery_or_change_cursors
8
+ %i[write flush].each do |entry|
9
+ source, = pipe_session
10
+ output = StringIO.new
11
+ errors = []
12
+ snapshots = []
13
+ entered = false
14
+ original = output.method(entry)
15
+ output.define_singleton_method(entry) do |*arguments|
16
+ result = original.call(*arguments)
17
+ unless entered
18
+ entered = true
19
+ cursors = source.__send__(:relay_outputs)
20
+ snapshot = -> { [source.buffer, cursors.map { |c| c.instance_variable_get(:@offset) }] }
21
+ snapshots << snapshot.call
22
+ begin
23
+ Expect.interconnect(source, timeout: 0)
24
+ rescue StandardError => error
25
+ errors << error
26
+ end
27
+ snapshots << snapshot.call
28
+ end
29
+ result
30
+ end
31
+ source.listeners = [output]
32
+ source.buffer = "abc"
33
+ bounded { Expect.interconnect(source, timeout: 0) }
34
+ assert_equal "abc", output.string, "recursive #{entry} replayed bytes"
35
+ assert_equal(["Expect::ReentrancyError"], errors.map { |error| error.class.name })
36
+ assert_equal snapshots.first, snapshots.last
37
+ refute source.pending_output?
38
+ end
39
+ end
40
+
41
+ def test_escape_callback_cannot_enter_the_same_source_but_can_use_a_matcher
42
+ source, = pipe_session
43
+ output = StringIO.new
44
+ source.listeners = [output]
45
+ calls = 0
46
+ source.on_sequence("!") do
47
+ calls += 1
48
+ error = assert_raises(StandardError) { Expect.interconnect(source, timeout: 0) }
49
+ assert_equal "Expect::ReentrancyError", error.class.name
50
+ assert_equal "inside", source.expect_result("inside", timeout: 0).match
51
+ false
52
+ end
53
+ source.buffer = "prefix!insidetail"
54
+ assert_same source, Expect.interconnect(source, timeout: 1)
55
+ assert_equal 1, calls
56
+ assert_equal "prefix", output.string
57
+ assert_equal "tail", source.buffer
58
+ Expect.interconnect(source, timeout: 0)
59
+ assert_equal "prefixtail", output.string
60
+ end
61
+
62
+ def test_partial_overlap_does_not_steal_buffers_or_release_the_outer_owner
63
+ outer, = pipe_session
64
+ other, = pipe_session
65
+ other.buffer = "other"
66
+ output = StringIO.new
67
+ other.listeners = [output]
68
+ outer.on_sequence("!") do
69
+ 2.times do
70
+ error = assert_raises(StandardError) { Expect.interconnect(other, outer, timeout: 0) }
71
+ assert_equal "Expect::ReentrancyError", error.class.name
72
+ assert_equal "other", other.buffer
73
+ end
74
+ Expect.interconnect(other, timeout: 0)
75
+ false
76
+ end
77
+ outer.buffer = "!tail"
78
+ assert_same outer, Expect.interconnect(outer, timeout: 1)
79
+ assert_equal "tail", outer.buffer
80
+ assert_equal "other", output.string
81
+ end
82
+
83
+ def test_disjoint_sources_and_duplicate_arguments_are_supported
84
+ outer, = pipe_session
85
+ inner, = pipe_session
86
+ sink = StringIO.new
87
+ inner.listeners = [sink]
88
+ inner.buffer = "independent"
89
+ outer.buffer = "!tail"
90
+ outer.on_sequence("!") do
91
+ Expect.interconnect(inner, inner, timeout: 0)
92
+ false
93
+ end
94
+ assert_same outer, Expect.interconnect(outer, outer, timeout: 1)
95
+ assert_equal "independent", sink.string
96
+ assert_equal "tail", outer.buffer
97
+ end
98
+
99
+ def test_uncaught_reentry_propagates_and_sequential_retry_releases_the_owner
100
+ source, = pipe_session
101
+ sink = StringIO.new
102
+ entered = false
103
+ original = sink.method(:write)
104
+ sink.define_singleton_method(:write) do |data|
105
+ unless entered
106
+ entered = true
107
+ Expect.interconnect(source, timeout: 0)
108
+ end
109
+ original.call(data)
110
+ end
111
+ source.listeners = [sink]
112
+ source.buffer = "once"
113
+ error = assert_raises(StandardError) { Expect.interconnect(source, timeout: 0) }
114
+ assert_equal "Expect::ReentrancyError", error.class.name
115
+ assert source.pending_output?
116
+ Expect.interconnect(source, timeout: 0)
117
+ assert_equal "once", sink.string
118
+ refute source.pending_output?
119
+ end
120
+
121
+ def test_preparation_failure_restores_all_moved_buffers_and_releases_ownership
122
+ first, = pipe_session
123
+ second, = pipe_session
124
+ first.buffer = "first"
125
+ second.buffer = "second"
126
+ error = IOError.new("injected preparation failure")
127
+ second.stub(:clear_buffer, -> { raise error }) do
128
+ assert_same error, assert_raises(IOError) { Expect.interconnect(first, second, timeout: 0) }
129
+ end
130
+ assert_equal "first", first.buffer
131
+ assert_equal "second", second.buffer
132
+ sink = StringIO.new
133
+ first.listeners = second.listeners = [sink]
134
+ Expect.interconnect(first, second, timeout: 0)
135
+ assert_equal "firstsecond", sink.string
136
+ end
137
+
138
+ def test_invalid_arguments_leave_buffers_unchanged
139
+ source, = pipe_session
140
+ source.buffer = "retained"
141
+ assert_raises(ArgumentError) { Expect.interconnect(source, timeout: -1) }
142
+ assert_raises(ArgumentError) { Expect.interconnect(source, Object.new, timeout: 0) }
143
+ assert_equal "retained", source.buffer
144
+ refute source.pending_output?
145
+ end
146
+
147
+ def test_eof_and_callback_errors_release_ownership
148
+ source, producer = pipe_session
149
+ failure = Interrupt.new("callback interrupted")
150
+ source.on_sequence("!") { raise failure }
151
+ source.buffer = "!tail"
152
+ assert_same failure, assert_raises(Interrupt) { Expect.interconnect(source, timeout: 1) }
153
+ producer.close
154
+ sink = StringIO.new
155
+ source.listeners = [sink]
156
+ 2.times { assert_same source, Expect.interconnect(source, timeout: 1) }
157
+ assert_equal "tail", sink.string
158
+ end
159
+ end