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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/CONTRIBUTING.md +10 -4
- data/README.md +13 -4
- data/docs/INTERNAL_CONTRACTS.md +21 -0
- data/docs/RELEASING.md +26 -12
- data/docs/VERIFICATION.md +98 -0
- data/examples/kibitz/README.md +28 -20
- data/examples/kibitz/kibitz.rb +1 -0
- data/lib/expect/interaction.rb +2 -0
- data/lib/expect/relay.rb +37 -7
- data/lib/expect/session_resources.rb +39 -14
- data/lib/expect/terminal.rb +1 -1
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +70 -33
- data/script/release.rb +10 -2
- data/test/initialization_failure_test.rb +92 -0
- data/test/integration/README.md +42 -23
- data/test/ownership_sequence_test.rb +208 -0
- data/test/process_interruption_test.rb +296 -0
- data/test/relay_reentrancy_test.rb +159 -0
- data/test/release_test.rb +47 -0
- data/test/write_contract_test.rb +105 -0
- metadata +7 -2
|
@@ -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
|