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
data/script/release.rb DELETED
@@ -1,319 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require "digest"
5
- require "fileutils"
6
- require "json"
7
- require "net/http"
8
- require "open3"
9
- require "optparse"
10
- require "rubygems/package"
11
- require "tmpdir"
12
- require_relative "../lib/expect/version"
13
-
14
- # 本地使用 gem / gh 已有的登录状态;CI 传入测试过的包,发布阶段不重新构建。
15
- class Release
16
- REPOSITORY = "gatework/expect-ruby"
17
- GEM_HOST = "https://rubygems.org"
18
-
19
- def initialize(artifact: nil, dry_run: false, rubygems_only: false)
20
- @version = Expect::VERSION
21
- @tag = "v#{@version}"
22
- @artifact = File.expand_path(artifact || "tmp/ci/expect-pty-#{@version}.gem")
23
- @dry_run = dry_run
24
- @rubygems_only = rubygems_only
25
- @build = artifact.nil?
26
- end
27
-
28
- def run
29
- notes = self.class.read_notes("CHANGELOG.md", @version)
30
- unless @dry_run || capture("git", "status", "--porcelain").empty?
31
- raise "Commit all source changes before publishing"
32
- end
33
-
34
- @commit = capture("git", "rev-parse", "HEAD") unless @dry_run
35
-
36
- command("bash", "script/ci") if @build
37
- # 独占目录中的副本贯穿校验和上传,其他构建不会改变本次发布的字节。
38
- directory = File.join("tmp", "release", @version)
39
- FileUtils.mkdir_p(directory)
40
- directory = Dir.mktmpdir("candidate-", directory)
41
- candidate = File.join(directory, "expect-pty-#{@version}.gem")
42
- FileUtils.cp(@artifact, candidate)
43
- @artifact = File.expand_path(candidate)
44
- verify_package
45
- @sha256 = Digest::SHA256.file(@artifact).hexdigest
46
- @checksum_file = File.join(directory, "SHA256SUMS")
47
- @notes_file = File.join(directory, "release-notes.md")
48
- File.write(@checksum_file, "#{@sha256} #{File.basename(@artifact)}\n")
49
- File.write(@notes_file, "#{notes}\n", encoding: "UTF-8")
50
- puts "Verified #{@tag}: #{@sha256}\nArtifact: #{@artifact}"
51
- return puts "Dry run complete: #{@artifact}" if @dry_run
52
-
53
- if @rubygems_only
54
- verify_local_source
55
- else
56
- verify_remote_source
57
- verify_registry_checksum(registry_version)
58
- publish_github
59
- end
60
- publish_rubygems
61
- end
62
-
63
- # 有未归档的变更时拒绝发布,避免把新接口放进旧版本或遗漏发布说明。
64
- def self.read_notes(path, version)
65
- changelog = File.read(path, encoding: "UTF-8")
66
- raise "Invalid UTF-8 in #{path}" unless changelog.valid_encoding?
67
-
68
- release_notes(changelog, version)
69
- end
70
-
71
- # 纯章节解析与文件编码边界分离,本地入口和工作流共用 read_notes。
72
- def self.release_notes(changelog, version)
73
- raise "Use a stable X.Y.Z version" unless /\A\d+\.\d+\.\d+\z/.match?(version)
74
-
75
- sections = changelog.split(/^## /).drop(1).map do |section|
76
- heading, body = section.split("\n", 2)
77
- [heading.strip, body]
78
- end
79
- unreleased = sections.find { |heading, _body| heading == "Unreleased" }
80
- if unreleased && !unreleased[1].to_s.strip.empty?
81
- raise "Move Unreleased changes into the versioned changelog before releasing"
82
- end
83
-
84
- section = sections.find do |heading, _body|
85
- /\A#{Regexp.escape(version)}(?: - \d{4}-\d{2}-\d{2})?\z/.match?(heading)
86
- end
87
- raise "Missing release notes for #{version}" unless section && !section[1].to_s.strip.empty?
88
-
89
- section[1].strip
90
- end
91
-
92
- private
93
-
94
- def capture(*)
95
- capture_bytes(*).strip
96
- end
97
-
98
- # Git blob 是任意字节,不能沿用命令文本的 strip,尾部换行和 NUL 也属于发布内容。
99
- def capture_bytes(*arguments)
100
- output, error, status = Open3.capture3(*arguments, binmode: true)
101
- raise "#{arguments.first} failed: #{error.strip}" unless status.success?
102
-
103
- output
104
- end
105
-
106
- def command(*arguments)
107
- raise "#{arguments.first} failed" unless system(*arguments)
108
- end
109
-
110
- def github(path, missing: false)
111
- output, error, status = Open3.capture3("gh", "api", "repos/#{REPOSITORY}/#{path}")
112
- return nil if missing && !status.success? && error.include?("HTTP 404")
113
- raise "GitHub API failed: #{error.strip}" unless status.success?
114
-
115
- JSON.parse(output)
116
- end
117
-
118
- def verify_local_source
119
- return if capture("git", "rev-parse", "HEAD") == @commit && capture("git", "status", "--porcelain").empty?
120
-
121
- raise "Source changed during verification; commit the changes and start again"
122
- end
123
-
124
- def verify_remote_source
125
- verify_local_source
126
-
127
- comparison = github("compare/#{@commit}...main")
128
- raise "Push this commit to #{REPOSITORY}/main first" unless %w[ahead identical].include?(comparison.fetch("status"))
129
-
130
- return unless github("git/ref/tags/#{@tag}", missing: true)
131
- return if github("compare/#{@tag}...#{@commit}").fetch("status") == "identical"
132
-
133
- raise "Remote tag #{@tag} points to another commit"
134
- end
135
-
136
- # 检查包内每个文件,防止拿旧包或其他提交的产物发布到当前标签。
137
- def verify_package
138
- package = Gem::Package.new(@artifact)
139
- expected = Gem::Specification.load(File.expand_path("expect-pty.gemspec"))
140
- fields = %i[name version platform summary description authors licenses homepage required_ruby_version
141
- required_rubygems_version require_paths metadata dependencies extensions executables bindir
142
- post_install_message]
143
- unless fields.all? { |field| package.spec.public_send(field) == expected.public_send(field) }
144
- raise "Artifact metadata does not match the gemspec"
145
- end
146
- raise "Artifact file list differs from the source" unless package.contents.sort == expected.files.sort
147
-
148
- committed = committed_files if @commit
149
- # 直接检查归档中的权限,避免解包时本机 umask 改写执行位。
150
- File.open(@artifact, "rb") do |io|
151
- Gem::Package::TarReader.new(io) do |archive|
152
- data = archive.find { |entry| entry.full_name == "data.tar.gz" }
153
- package.open_tar_gz(data) do |tar|
154
- tar.each do |entry|
155
- file = entry.full_name
156
- bytes = entry.read
157
- raise "Artifact differs from source: #{file}" unless entry.file? && bytes == File.binread(file)
158
-
159
- verify_committed_file(file, bytes, entry.header.mode, committed) if committed
160
- next if entry.header.mode & 0o111 == File.stat(file).mode & 0o111
161
-
162
- raise "Artifact executable permissions differ: #{file}"
163
- end
164
- end
165
- end
166
- end
167
- end
168
-
169
- # 工作区干净不代表所有打包文件都来自提交:ignore、assume-unchanged 和 filemode 均可隐藏差异。
170
- # 只在正式发布时核对提交树;dry-run 仍允许检验尚未提交的开发源码。
171
- def committed_files
172
- capture_bytes("git", "ls-tree", "-rz", "--full-tree", @commit).split("\0").to_h do |record|
173
- metadata, file = record.split("\t", 2)
174
- mode, type, object = metadata.split
175
- [file, [mode.to_i(8), type, object]]
176
- end
177
- end
178
-
179
- def verify_committed_file(file, bytes, mode, committed)
180
- committed_mode, type, object = committed[file.b]
181
- unless type == "blob" && committed_mode & 0o170000 == 0o100000
182
- raise "Artifact file is not in release commit: #{file}"
183
- end
184
- unless bytes == capture_bytes("git", "cat-file", "blob", object)
185
- raise "Artifact differs from release commit: #{file}"
186
- end
187
- return if mode & 0o111 == committed_mode & 0o111
188
-
189
- raise "Artifact executable permissions differ from release commit: #{file}"
190
- end
191
-
192
- def get(path)
193
- uri = URI("#{GEM_HOST}#{path}")
194
- Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
195
- http.get(uri.request_uri)
196
- end
197
- end
198
-
199
- def registry_version
200
- response = get("/api/v2/rubygems/expect-pty/versions/#{@version}.json")
201
- return nil if response.code == "404"
202
- raise "RubyGems lookup failed: HTTP #{response.code}" unless response.code == "200"
203
-
204
- JSON.parse(response.body)
205
- end
206
-
207
- def verify_registry_checksum(version)
208
- return unless version
209
- return if !version.fetch("yanked") && version.fetch("sha") == @sha256
210
-
211
- raise "RubyGems already has different or yanked bytes for #{@version}; use the original artifact or a new version"
212
- end
213
-
214
- # gh 同时查找已发布版本和遗留草稿,REST 按标签查询只能找到已发布版本。
215
- def github_release
216
- output, error, status = Open3.capture3("gh", "release", "view", @tag, "--repo", REPOSITORY,
217
- "--json", "isDraft,assets")
218
- return nil if !status.success? && error.strip == "release not found"
219
- raise "GitHub Release lookup failed: #{error.strip}" unless status.success?
220
-
221
- JSON.parse(output)
222
- end
223
-
224
- # 先补齐 GitHub Release;RubyGems 认证失败时,已上传的原包仍可用于重试。
225
- def publish_github
226
- release = github_release
227
- assets = [@artifact, @checksum_file]
228
- if release
229
- existing, missing = assets.partition do |asset|
230
- entry = release.fetch("assets").find { |item| item.fetch("name") == File.basename(asset) }
231
- if entry && entry.fetch("state") != "uploaded"
232
- raise "Incomplete GitHub asset: #{entry.fetch("name")}; stop any active upload, remove the incomplete " \
233
- "asset in GitHub Release, then retry with the same artifact"
234
- end
235
- entry
236
- end
237
- verify_github_assets(existing)
238
- missing.each { |asset| command("gh", "release", "upload", @tag, asset, "--repo", REPOSITORY) }
239
- verify_github_assets(missing)
240
- if release.fetch("isDraft")
241
- command("gh", "release", "edit", @tag, "--repo", REPOSITORY, "--target", @commit, "--draft=false")
242
- end
243
- else
244
- command("gh", "release", "create", @tag, *assets, "--repo", REPOSITORY, "--target", @commit,
245
- "--title", @tag, "--notes-file", @notes_file)
246
- verify_github_assets(assets)
247
- end
248
- unless github("compare/#{@tag}...#{@commit}").fetch("status") == "identical"
249
- raise "Published tag points to another commit"
250
- end
251
-
252
- puts "GitHub Release: https://github.com/#{REPOSITORY}/releases/tag/#{@tag}"
253
- end
254
-
255
- def verify_github_assets(assets)
256
- # 新上传的附件也读回核验,工作流成功不代替远端产物校验。
257
- Dir.mktmpdir("expect-release-readback-") do |directory|
258
- assets.each do |asset|
259
- name = File.basename(asset)
260
- command("gh", "release", "download", @tag, "--repo", REPOSITORY, "--pattern", name, "--dir", directory)
261
- unless Digest::SHA256.file(File.join(directory, name)).hexdigest == Digest::SHA256.file(asset).hexdigest
262
- raise "GitHub Release asset verification failed: #{name}"
263
- end
264
- end
265
- end
266
- end
267
-
268
- def publish_rubygems
269
- version = registry_version
270
- verify_registry_checksum(version)
271
- unless version
272
- if ENV["GITHUB_ACTIONS"] == "true" && ENV.fetch("GEM_HOST_API_KEY", "").empty?
273
- raise "Set the repository Actions secret RUBYGEMS_API_KEY, or publish locally with the existing gem login"
274
- end
275
-
276
- pushed = system("gem", "push", @artifact, "--host", GEM_HOST)
277
- # 推送返回失败也先读取远端,避免连接中断后盲目重复上传。
278
- version = registry_version
279
- verify_registry_checksum(version)
280
- raise "gem push failed; check RubyGems authentication and retry with the same artifact" unless pushed || version
281
- end
282
-
283
- 6.times do |attempt|
284
- response = get("/downloads/expect-pty-#{@version}.gem")
285
- if response.code == "200"
286
- raise "Downloaded RubyGems artifact checksum differs" unless Digest::SHA256.hexdigest(response.body) == @sha256
287
-
288
- return puts "RubyGems: #{GEM_HOST}/gems/expect-pty/versions/#{@version} (SHA256 verified)"
289
- end
290
- raise "RubyGems download failed: HTTP #{response.code}" unless response.code == "404"
291
-
292
- sleep 2 unless attempt == 5
293
- end
294
- raise "RubyGems download is not available yet; retry with the same artifact"
295
- end
296
- end
297
-
298
- if $PROGRAM_NAME == __FILE__
299
- options = {}
300
- parser = OptionParser.new do |arguments|
301
- arguments.banner = "Usage: ruby script/release.rb [--rubygems-only] [--dry-run] [--artifact PATH]"
302
- arguments.on("--rubygems-only", "Publish only to RubyGems using the existing gem login; no GitHub access") do
303
- options[:rubygems_only] = true
304
- end
305
- arguments.on("--artifact PATH", "Publish an already verified gem without rebuilding") do |path|
306
- options[:artifact] = File.expand_path(path)
307
- end
308
- arguments.on("--dry-run", "Build and verify locally without publishing") { options[:dry_run] = true }
309
- end
310
- begin
311
- parser.parse!
312
- raise OptionParser::InvalidArgument, ARGV.join(" ") unless ARGV.empty?
313
-
314
- Dir.chdir(File.expand_path("..", __dir__)) { Release.new(**options).run }
315
- rescue StandardError => error
316
- warn "Release aborted: #{error.message}"
317
- exit 1
318
- end
319
- end
@@ -1,83 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "test_helper"
4
-
5
- class BufferAccountingTest < ExpectTest
6
- def test_limit_discards_are_cumulative_and_distinct_from_consumption
7
- session, = pipe_session(buffer_limit: 4)
8
- assert_equal 0, session.buffer_discarded_bytes
9
- session.buffer = "abcdef"
10
- assert_equal "cdef", session.buffer
11
- assert_equal 2, session.buffer_discarded_bytes
12
- session.buffer_limit = 2
13
- assert_equal "ef", session.buffer
14
- assert_equal 4, session.buffer_discarded_bytes
15
- assert_equal 1, session.expect("e", timeout: 0)
16
- assert_equal "f", session.clear_buffer
17
- session.close
18
- assert_equal 4, session.buffer_discarded_bytes
19
- refute_respond_to session, :buffer_discarded_bytes=
20
- end
21
-
22
- def test_invalid_limit_and_buffer_leave_accounting_unchanged
23
- session, = pipe_session(buffer_limit: 4)
24
- session.buffer = "abcdef"
25
- assert_raises(ArgumentError) { session.buffer_limit = 0 }
26
- assert_raises(ArgumentError) { session.buffer = nil }
27
- assert_equal "cdef", session.buffer
28
- assert_equal 2, session.buffer_discarded_bytes
29
- session.buffer_limit = nil
30
- session.buffer = "unlimited"
31
- assert_equal 2, session.buffer_discarded_bytes
32
- end
33
-
34
- def test_continuous_output_keeps_a_bounded_window_and_complete_log
35
- session, writer = pipe_session(buffer_limit: 64)
36
- logged = 0
37
- session.log_to { |data| logged += data.bytesize }
38
- 32.times do
39
- writer.write("x" * 4096)
40
- assert_nil session.expect("missing", timeout: 0)
41
- assert_equal 64, session.buffer.bytesize
42
- end
43
- assert_equal 131_072, logged
44
- assert_equal logged - 64, session.buffer_discarded_bytes
45
- end
46
-
47
- def test_preserved_match_does_not_count_as_discard
48
- session, = pipe_session(buffer_limit: 4, preserve_buffer: true)
49
- session.buffer = "abcdef"
50
- 2.times { assert_equal 1, session.expect("ef", timeout: 0) }
51
- assert_equal "cdef", session.buffer
52
- assert_equal 2, session.buffer_discarded_bytes
53
- end
54
-
55
- def test_relay_tail_is_only_counted_when_matching_applies_the_limit
56
- session, writer = pipe_session(buffer_limit: 2)
57
- output = StringIO.new
58
- session.listeners = [output]
59
- session.on_sequence("STOP")
60
- writer.write("prefixSTOPtail")
61
- assert_same session, Expect.interconnect(session, timeout: 1)
62
- assert_equal "prefix", output.string
63
- assert_equal "tail", session.buffer
64
- assert_equal 0, session.buffer_discarded_bytes
65
- assert_equal 1, session.expect("il", timeout: 0)
66
- assert_equal 2, session.buffer_discarded_bytes
67
- session.expect(timeout: 0)
68
- assert_equal 2, session.buffer_discarded_bytes
69
- end
70
-
71
- def test_logging_failure_preserves_the_retained_input_and_discard_count
72
- session, writer = pipe_session(buffer_limit: 4)
73
- failure = IOError.new("log failed")
74
- session.log_to { raise failure }
75
- writer.write("abcdef")
76
- assert_same failure, session.expect_result("ef", timeout: 1).error
77
- assert_equal "cdef", session.buffer
78
- assert_equal 2, session.buffer_discarded_bytes
79
- session.log_output = nil
80
- assert_equal 1, session.expect("ef", timeout: 0)
81
- assert_equal 2, session.buffer_discarded_bytes
82
- end
83
- end
data/test/cleanup_test.rb DELETED
@@ -1,251 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "test_helper"
4
-
5
- class CleanupTest < ExpectTest
6
- def test_failed_open_initialization_attempts_all_owned_handles_and_preserves_validation_error
7
- reader, writer = IO.pipe
8
- @ios.push(reader, writer)
9
- reader.stub(:close, -> { raise IOError, "reader close failed" }) do
10
- error = assert_raises(ArgumentError) { Expect.open(reader, writer: writer, own: true, unknown: true) }
11
- assert_match(/unknown/, error.message)
12
- assert writer.closed?
13
- end
14
- end
15
-
16
- def test_failed_new_initialization_attempts_all_pty_handles_and_preserves_validation_error
17
- master, slave = PTY.open
18
- @ios.push(master, slave)
19
- PTY.stub(:open, [master, slave]) do
20
- master.stub(:close, -> { raise IOError, "master close failed" }) do
21
- error = assert_raises(ArgumentError) { Expect.new(unknown: true) }
22
- assert_match(/unknown/, error.message)
23
- assert slave.closed?
24
- end
25
- end
26
- end
27
-
28
- def test_failed_open_closes_real_owned_handles_when_an_argument_is_not_io
29
- [true, false].each do |invalid_reader|
30
- reader, writer = IO.pipe
31
- @ios.push(reader, writer)
32
- invalid = Object.new
33
- invalid.define_singleton_method(:close) { raise "invalid IO must not be closed" }
34
- incoming = invalid_reader ? invalid : reader
35
- outgoing = invalid_reader ? writer : invalid
36
- assert_raises(ArgumentError) { Expect.open(incoming, writer: outgoing, own: true) }
37
- assert(invalid_reader ? writer.closed? : reader.closed?)
38
- end
39
- end
40
-
41
- def test_open_block_error_is_preserved_when_owned_cleanup_also_fails
42
- [ArgumentError.new("block failed"), Interrupt.new("interrupted"), SystemExit.new(17)].each do |failure|
43
- reader, writer = IO.pipe
44
- @ios.push(reader, writer)
45
- reader.stub(:close, -> { raise IOError, "reader close failed" }) do
46
- error = assert_raises(failure.class) do
47
- Expect.open(reader, writer: writer, own: true) { raise failure }
48
- end
49
- assert_same failure, error
50
- assert writer.closed?
51
- end
52
- end
53
- end
54
-
55
- def test_open_block_break_still_reports_cleanup_failure
56
- reader, writer = IO.pipe
57
- @ios.push(reader, writer)
58
- failure = IOError.new("reader close failed")
59
- reader.stub(:close, -> { raise failure }) do
60
- error = assert_raises(IOError) { Expect.open(reader, writer: writer, own: true) { break :done } }
61
- assert_same failure, error
62
- assert writer.closed?
63
- end
64
- end
65
-
66
- def test_spawn_block_error_is_preserved_and_child_reaped_when_cleanup_also_fails
67
- session = stubborn_child
68
- failure = ArgumentError.new("block failed")
69
- Expect.stub(:new, session) do
70
- session.stub(:spawn, session) do
71
- session.to_io.stub(:close, -> { raise IOError, "reader close failed" }) do
72
- error = assert_raises(ArgumentError) { Expect.spawn("already started") { raise failure } }
73
- assert_same failure, error
74
- assert_nil session.pid
75
- end
76
- end
77
- end
78
- end
79
-
80
- def test_graceful_close_preserves_logging_error_when_handle_cleanup_also_fails
81
- session = stubborn_child
82
- failure = ArgumentError.new("log failed")
83
- session.log_output = ->(_) { raise failure }
84
- session.to_io.stub(:wait_readable, true) do
85
- session.to_io.stub(:read_nonblock, "last output") do
86
- session.to_io.stub(:close, -> { raise IOError, "reader close failed" }) do
87
- assert_same failure, assert_raises(ArgumentError) { session.close(graceful: true) }
88
- assert_nil session.pid
89
- end
90
- end
91
- end
92
- end
93
-
94
- def test_close_handles_attempts_every_owned_handle_and_preserves_first_error
95
- reader, writer = IO.pipe
96
- extra, peer = IO.pipe
97
- @ios.push(reader, writer, extra, peer)
98
- resources = Expect::SessionResources.new(reader, writer: writer, slave: extra, own: true)
99
- failure = IOError.new("reader close failed")
100
- reader.stub(:close, -> { raise failure }) do
101
- assert_same failure, assert_raises(IOError) { resources.close_handles }
102
- assert writer.closed?
103
- assert extra.closed?
104
- end
105
- resources.close_handles
106
- assert reader.closed?
107
- resources.close_handles
108
- end
109
-
110
- def test_hard_close_finishes_wrappers_log_and_child_after_handle_failure
111
- session = stubborn_child
112
- pid = session.pid
113
- first, = pipe_session
114
- second, = pipe_session
115
- session.instance_variable_set(:@interact_inputs, { first.to_io => first, second.to_io => second })
116
- Dir.mktmpdir do |dir|
117
- log = session.log_to(File.join(dir, "owned.log"))
118
- failure = IOError.new("handle close failed")
119
- session.to_io.stub(:close, -> { raise failure }) do
120
- first.stub(:close, ->(**) { raise IOError, "wrapper close failed" }) do
121
- error = assert_raises(IOError) { bounded { session.hard_close(timeout: 0.01) } }
122
- assert_same failure, error
123
- assert second.closed?
124
- refute second.to_io.closed?, "borrowed wrapper IO must remain open"
125
- assert log.closed?
126
- assert_nil session.log_output
127
- assert_nil session.pid
128
- assert_equal Signal.list.fetch("KILL"), session.process_status.termsig
129
- assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
130
- end
131
- end
132
- end
133
- ensure
134
- session&.hard_close(timeout: 0)
135
- end
136
-
137
- def test_owned_log_close_failure_is_visible_after_child_reaping_and_retryable
138
- session = stubborn_child
139
- Dir.mktmpdir do |dir|
140
- log = session.log_to(File.join(dir, "owned.log"))
141
- log.stub(:close, -> { raise IOError, "log close failed" }) do
142
- assert_raises(IOError) { bounded { session.hard_close(timeout: 0.01) } }
143
- assert_nil session.pid
144
- assert_same log, session.log_output
145
- end
146
- status = session.hard_close(timeout: 0)
147
- assert_same session.process_status, status
148
- assert log.closed?
149
- assert_nil session.log_output
150
- end
151
- ensure
152
- session&.hard_close(timeout: 0)
153
- end
154
-
155
- def test_explicit_close_inside_rescue_does_not_hide_a_new_cleanup_error
156
- session, = pipe_session(own: true)
157
- failure = IOError.new("close failed inside caller rescue")
158
- begin
159
- raise ArgumentError, "already handled"
160
- rescue ArgumentError
161
- session.to_io.stub(:close, -> { raise failure }) do
162
- assert_same failure, assert_raises(IOError) { session.hard_close(timeout: 0) }
163
- end
164
- end
165
- end
166
-
167
- def test_unexpected_process_error_is_not_replaced_by_cleanup_failure
168
- session = stubborn_child
169
- failure = RuntimeError.new("process wait failed")
170
- session.to_io.stub(:close, -> { raise IOError, "close failed" }) do
171
- session.stub(:wait, ->(**) { raise failure }) do
172
- assert_same failure, assert_raises(RuntimeError) { session.hard_close(timeout: 0) }
173
- end
174
- end
175
- ensure
176
- session&.hard_close(timeout: 0)
177
- end
178
-
179
- def test_soft_close_handle_failure_still_sends_term_but_never_kill
180
- session = stubborn_child
181
- signals = []
182
- original = Process.method(:kill)
183
- Process.stub(:kill, lambda { |signal, pid|
184
- signals << signal
185
- original.call(signal, pid)
186
- }) do
187
- session.to_io.stub(:close, -> { raise Errno::EIO, "close failed" }) do
188
- assert_raises(Errno::EIO) { bounded { session.soft_close(timeout: 0, term_timeout: 0) } }
189
- end
190
- end
191
- assert_equal ["TERM"], signals
192
- assert session.alive?
193
- assert session.closed?
194
- ensure
195
- session&.hard_close(timeout: 0)
196
- end
197
-
198
- def test_finalizer_reaps_after_both_handle_and_log_close_fail
199
- session = stubborn_child
200
- pid = session.pid
201
- resources = session.instance_variable_get(:@resources)
202
- log_attempted = false
203
- Dir.mktmpdir do |dir|
204
- log = session.log_to(File.join(dir, "owned.log"))
205
- session.to_io.stub(:close, -> { raise IOError, "handle failed" }) do
206
- log.stub(:close, lambda {
207
- log_attempted = true
208
- raise Errno::EIO, "log failed"
209
- }) do
210
- bounded { resources.finalize }
211
- end
212
- end
213
- assert log_attempted
214
- assert_nil resources.pid
215
- bounded do
216
- loop do
217
- Process.kill(0, pid)
218
- sleep 0.005
219
- rescue Errno::ESRCH
220
- break
221
- end
222
- end
223
- end
224
- ensure
225
- session&.hard_close(timeout: 0)
226
- end
227
-
228
- def test_non_owner_cleanup_never_signals_and_finalizer_leaves_handles_alone
229
- session = stubborn_child
230
- resources = session.instance_variable_get(:@resources)
231
- Process.stub(:pid, -1) do
232
- Process.stub(:kill, ->(*) { flunk "non-owner sent a signal" }) do
233
- resources.finalize
234
- refute session.to_io.closed?
235
- assert_nil session.hard_close(timeout: 0)
236
- end
237
- end
238
- assert session.alive?
239
- ensure
240
- session&.hard_close(timeout: 0)
241
- end
242
-
243
- private
244
-
245
- def stubborn_child
246
- session = child('Signal.trap("HUP", "IGNORE"); Signal.trap("TERM", "IGNORE"); puts "ready"; sleep 60',
247
- raw_pty: true)
248
- assert_equal 1, session.expect("ready", timeout: 2)
249
- session
250
- end
251
- end