Skip to content

Commit c16db28

Browse files
fix: Fix FileDownloader and directory transfer failure paths (#3434)
Follow-up PR to #3407. Fixes `download_file` leaving truncated file at the destination when a part fails with a non-StandardError, `download_file` leaving a stray temp file behind when a caller-provided executor rejects a part mid-download, and `upload_directory`/`download_directory` hanging forever when listing files or objects fails with a non-StandardError (such as from a `:filter_callback`). --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. 1. To make sure we include your contribution in the release notes, please make sure to add description entry for your changes in the "unreleased changes" section of the `CHANGELOG.md` file (at corresponding gem). For the description entry, please make sure it lives in one line and starts with `Feature` or `Issue` in the correct format. 2. For generated code changes, please checkout below instructions first: https://github.com/aws/aws-sdk-ruby/blob/version-3/CONTRIBUTING.md Thank you for your contribution!
1 parent 1113d9b commit c16db28

8 files changed

Lines changed: 95 additions & 17 deletions

‎gems/aws-sdk-s3/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
Unreleased Changes
22
------------------
33

4+
* Issue - Fix `download_file` on `TransferManager` and `Aws::S3::Object` leaving a corrupt file at the destination when a part raises a non-`StandardError`, and leaving a temp file behind when a caller-provided executor rejects a part.
5+
6+
* Issue - Prevent `upload_directory` and `download_directory` on `TransferManager` from hanging when listing files or objects raises a non-`StandardError`, such as from a `:filter_callback`.
7+
48
1.233.1 (2026-10-01)
59
------------------
610

‎gems/aws-sdk-s3/lib/aws-sdk-s3/directory_downloader.rb‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ def each
148148
stream_objects
149149
@object_queue << DONE_MARKER
150150
rescue ClosedQueueError
151-
# abort requested
152-
rescue StandardError => e
151+
nil # abort requested
152+
# Any error, or the consumer waits on the queue forever
153+
rescue Exception => e # rubocop:disable Lint/RescueException
153154
close
154155
raise e
155156
end

‎gems/aws-sdk-s3/lib/aws-sdk-s3/directory_uploader.rb‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ def each
156156
# encountered a traversal error, we must abort immediately
157157
close
158158
raise DirectoryUploadError, "Directory traversal failed for '#{@source_dir}': #{e.message}"
159+
# Any other error, or the consumer waits on the queue forever
160+
rescue Exception => e # rubocop:disable Lint/RescueException
161+
close
162+
raise e
159163
end
160164

161165
while (file = @file_queue.shift) && file != DONE_MARKER

‎gems/aws-sdk-s3/lib/aws-sdk-s3/file_downloader.rb‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def cleanup_temp_file(opts)
6060
end
6161

6262
def download_with_executor(part_list, total_size, opts)
63-
download_attempts = 0
63+
queued_parts = 0
6464
completion_queue = Queue.new
6565
abort_download = false
6666
error = nil
@@ -69,24 +69,31 @@ def download_with_executor(part_list, total_size, opts)
6969
while (part = part_list.shift)
7070
break if abort_download
7171

72-
download_attempts += 1
73-
@executor.post(part) do |p|
74-
update_progress(progress, p)
75-
resp = @client.get_object(p.params)
76-
range = extract_range(resp.content_range)
77-
validate_range(range, p.params[:range]) if p.params[:range]
78-
write(resp.body, range, opts)
79-
80-
execute_checksum_callback(resp, opts)
72+
begin
73+
@executor.post(part) do |p|
74+
update_progress(progress, p)
75+
resp = @client.get_object(p.params)
76+
range = extract_range(resp.content_range)
77+
validate_range(range, p.params[:range]) if p.params[:range]
78+
write(resp.body, range, opts)
79+
80+
execute_checksum_callback(resp, opts)
81+
# Any error, or a corrupt file replaces the destination
82+
rescue Exception => e # rubocop:disable Lint/RescueException
83+
abort_download = true
84+
error = e
85+
ensure
86+
completion_queue << :done
87+
end
88+
queued_parts += 1
8189
rescue StandardError => e
82-
abort_download = true
90+
# Rejected by the executor, wait for queued parts so none write after cleanup
8391
error = e
84-
ensure
85-
completion_queue << :done
92+
break
8693
end
8794
end
8895

89-
download_attempts.times { completion_queue.pop }
96+
queued_parts.times { completion_queue.pop }
9097
raise error unless error.nil?
9198
end
9299

‎gems/aws-sdk-s3/lib/aws-sdk-s3/multipart_file_uploader.rb‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,6 @@ def upload_with_executor(pending, completed, options)
169169
queued_parts += 1
170170
rescue StandardError => e
171171
# Rejected by the executor; abort rather than orphan the upload.
172-
abort_upload = true
173172
errors << e
174173
break
175174
end

‎gems/aws-sdk-s3/spec/directory_downloader_spec.rb‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,14 @@ module S3
131131

132132
expect(result[:completed_downloads]).to eq(2)
133133
end
134+
135+
it 'raises instead of hanging when the callback raises a non-StandardError',
136+
thread_report_on_exception: false do
137+
filter = ->(obj) { obj.key == 'file2.json' ? raise(SystemStackError, 'filter failed') : true }
138+
download = Thread.new { downloader.download(temp_dir, bucket: 'test-bucket', filter_callback: filter) }
139+
140+
expect { download.join(5) }.to raise_error(SystemStackError, 'filter failed')
141+
end
134142
end
135143

136144
context 'request callbacks' do

‎gems/aws-sdk-s3/spec/directory_uploader_spec.rb‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ module S3
128128
expect(uploaded_keys).not_to include('huge.bin')
129129
expect(result[:completed_uploads]).to eq(4)
130130
end
131+
132+
it 'raises instead of hanging when the callback raises a non-StandardError',
133+
thread_report_on_exception: false do
134+
filter_callback = ->(_path, file) { file == 'medium.log' ? raise(SystemStackError, 'filter failed') : true }
135+
upload = Thread.new { uploader.upload(temp_dir, 'test-bucket', filter_callback: filter_callback) }
136+
137+
expect { upload.join(5) }.to raise_error(SystemStackError, 'filter failed')
138+
end
131139
end
132140

133141
context 'request callbacks', :jruby_flaky do

‎gems/aws-sdk-s3/spec/file_downloader_spec.rb‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,53 @@ module S3
270270
expect(File.exist?(path)).to be(true)
271271
expect(File.read(path)).to eq('existing content')
272272
end
273+
274+
it 'does not overwrite existing file when a part raises a non-StandardError' do
275+
# Held locally: `path` alone is a bare string, and the Tempfile backing it
276+
# has no other reference, so it can be GC'd and unlinked before the assertion.
277+
destination = Tempfile.new('destination')
278+
path = destination.path
279+
File.write(path, 'existing content')
280+
client.stub_responses(:get_object, lambda { |context|
281+
first, last = context.params[:range].scan(/\d+/).map(&:to_i)
282+
raise NoMemoryError, 'part 2 failed' if first == 5 * one_meg
283+
284+
{ body: 'x' * (last - first + 1), content_range: "bytes #{first}-#{last}/#{15 * one_meg}" }
285+
})
286+
287+
expect { subject.download(path, range_params.merge(chunk_size: 5 * one_meg, mode: 'get_range')) }
288+
.to raise_error(NoMemoryError, 'part 2 failed')
289+
expect(File.read(path)).to eq('existing content')
290+
expect(Dir.glob("#{path}.s3tmp.*")).to be_empty
291+
end
292+
293+
it 'does not leave a temp file behind when the executor rejects a task mid-download' do
294+
executor = DefaultExecutor.new
295+
calls = 0
296+
allow(executor).to receive(:post).and_wrap_original do |original, *args, &blk|
297+
calls += 1
298+
raise DefaultExecutor::RejectedExecutionError if calls == 2
299+
300+
original.call(*args, &blk)
301+
end
302+
downloader = FileDownloader.new(client: client, executor: executor)
303+
written = Queue.new
304+
allow(downloader).to receive(:write).and_wrap_original do |original, *args|
305+
sleep(0.1)
306+
original.call(*args)
307+
written << :part
308+
end
309+
client.stub_responses(:get_object, lambda { |context|
310+
first, last = context.params[:range].scan(/\d+/).map(&:to_i)
311+
{ body: 'x' * (last - first + 1), content_range: "bytes #{first}-#{last}/#{15 * one_meg}" }
312+
})
313+
314+
expect { downloader.download(path, range_params.merge(chunk_size: 5 * one_meg, mode: 'get_range')) }
315+
.to raise_error(DefaultExecutor::RejectedExecutionError)
316+
expect(written.size).to eq(1)
317+
expect(Dir.glob("#{path}.s3tmp.*")).to be_empty
318+
executor.shutdown
319+
end
273320
end
274321
end
275322
end

0 commit comments

Comments
 (0)