Skip to content

Commit 5afd3c8

Browse files
authored
Differentiate #close and recovery (#21)
During recovery we may not be able to close - but we do want to toss all the Deflate objects, since they hold references to zlib resources.
1 parent 253abf3 commit 5afd3c8

9 files changed

+86
-1
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
* When rescuing a failed `write_file`, differentiate between `#close`
2+
and `#release_resources_on_failure!`. Closing a Writable can still try
3+
to do things to the Streamer output, it can try to write to the destination
4+
IO which is no longer accepting writes and so on. What we do want is to
5+
safely destroy the zlib deflaters.
6+
17
## 6.3.2
28

39
* Make sure `rollback!` correctly works with `write_file` and the original exception gets re-raised from `write_file` if

lib/zip_kit.rb

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ module ZipKit
2424
autoload :WriteShovel, File.dirname(__FILE__) + "/zip_kit/write_shovel.rb"
2525
autoload :RackChunkedBody, File.dirname(__FILE__) + "/zip_kit/rack_chunked_body.rb"
2626
autoload :RackTempfileBody, File.dirname(__FILE__) + "/zip_kit/rack_tempfile_body.rb"
27+
autoload :ZlibCleanup, File.dirname(__FILE__) + "/zip_kit/zlib_cleanup.rb"
2728

2829
require_relative "zip_kit/railtie" if defined?(::Rails)
2930
end

lib/zip_kit/streamer.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,7 @@ def yield_or_return_writable(writable, &block_to_pass_writable_to)
524524
yield(writable)
525525
writable.close
526526
rescue
527-
writable.close
527+
writable.release_resources_on_failure!
528528
rollback!
529529
raise
530530
end

lib/zip_kit/streamer/deflated_writer.rb

+5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# interchangeable with the StoredWriter in terms of interface.
66
class ZipKit::Streamer::DeflatedWriter
77
include ZipKit::WriteShovel
8+
include ZipKit::ZlibCleanup
89

910
# The amount of bytes we will buffer before computing the intermediate
1011
# CRC32 checksums. Benchmarks show that the optimum is 64KB (see
@@ -42,4 +43,8 @@ def finish
4243
ensure
4344
@deflater.close
4445
end
46+
47+
def release_resources_on_failure!
48+
safely_dispose_of_incomplete_deflater(@deflater)
49+
end
4550
end

lib/zip_kit/streamer/heuristic.rb

+7
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# on the Streamer passed into it once it knows which compression
1414
# method should be applied
1515
class ZipKit::Streamer::Heuristic < ZipKit::Streamer::Writable
16+
include ZipKit::ZlibCleanup
17+
1618
BYTES_WRITTEN_THRESHOLD = 128 * 1024
1719
MINIMUM_VIABLE_COMPRESSION = 0.75
1820

@@ -48,6 +50,11 @@ def close
4850
@winner.close
4951
end
5052

53+
def release_resources_on_failure!
54+
safely_dispose_of_incomplete_deflater(@deflater)
55+
@winner&.release_resources_on_failure!
56+
end
57+
5158
private def decide
5259
# Finish and then close the deflater - it has likely buffered some data
5360
@bytes_deflated += @deflater.finish.bytesize until @deflater.finished?

lib/zip_kit/streamer/stored_writer.rb

+4
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,8 @@ def finish
3636
@crc.flush
3737
{crc32: @crc_compute.to_i, compressed_size: @io.tell, uncompressed_size: @io.tell}
3838
end
39+
40+
def release_resources_on_failure!
41+
# Nothing to do
42+
end
3943
end

lib/zip_kit/streamer/writable.rb

+6
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,10 @@ def close
3333
@streamer.update_last_entry_and_write_data_descriptor(**@writer.finish)
3434
@closed = true
3535
end
36+
37+
def release_resources_on_failure!
38+
return if @closed
39+
@closed = true
40+
@writer.release_resources_on_failure!
41+
end
3642
end

lib/zip_kit/zlib_cleanup.rb

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# frozen_string_literal: true
2+
3+
module ZipKit::ZlibCleanup
4+
# This method is used to flush and close the native zlib handles
5+
# should an archiving routine encounter an error. This is necessary,
6+
# since otherwise unclosed deflaters may hang around in memory
7+
# indefinitely, creating leaks.
8+
#
9+
# @param [Zlib::Deflater?]deflater the deflater to safely finish and close
10+
# @return void
11+
def safely_dispose_of_incomplete_deflater(deflater)
12+
return unless deflater
13+
14+
# It can be a bit tricky to close and dealloc the deflater correctly.
15+
# We want to do the right things for it to be GCd, including the
16+
# native zlib handle. Also, leaving zlib handles dangling around
17+
# creates warnings with "...with N bytes remaining to read", which are an
18+
# eyesore. But they are there for a reason - so that we don't forget to do
19+
# exactly this.
20+
if !deflater.closed? && !deflater.finished?
21+
deflater.finish until deflater.finished?
22+
end
23+
deflater.close unless deflater.closed?
24+
end
25+
end

spec/zip_kit/zlib_cleanup_spec.rb

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
require_relative "../spec_helper"
2+
3+
describe "ZipKit::ZlibCleanup" do
4+
it "does not raise when cleaning up the deflater which is in any step" do
5+
cleaner = Object.new
6+
class << cleaner
7+
include ZipKit::ZlibCleanup
8+
end
9+
10+
steps = [
11+
->(flate) { flate.deflate(Random.bytes(14)) },
12+
->(flate) { flate.deflate(Random.bytes(14)) },
13+
->(flate) { flate.deflate(Random.bytes(14)) },
14+
->(flate) { flate.deflate(Random.bytes(14)) },
15+
->(flate) { flate.finish until flate.finished? },
16+
->(flate) { flate.close }
17+
]
18+
19+
safe_close = cleaner.method(:safely_dispose_of_incomplete_deflater).to_proc
20+
steps.length.times do |at_offset|
21+
steps_with_failure = steps.dup
22+
steps_with_failure.insert(at_offset, safe_close)
23+
24+
deflater = ::Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -::Zlib::MAX_WBITS)
25+
steps_with_failure.each do |step_proc|
26+
step_proc.call(deflater)
27+
break if step_proc == safe_close
28+
end
29+
end
30+
end
31+
end

0 commit comments

Comments
 (0)