Repository navigation
Conversation
Multipart uploads parsed by Rack::Multipart create tempfiles that were left open on disk after the request unless the host app (e.g. Rails) already ran Rack::TempfileReaper. Use it in every endpoint's stack so they are closed and unlinked once the response body is closed. Fixes ruby-grape#2487
22f3f9f to
f2dbebb
Compare
Danger ReportNo issues found. |
|
Answer: standalone Grape supports multipart uploads but currently does not guarantee cleanup of upload tempfiles when the response closes. When request handling triggers multipart parsing, Rack creates tempfiles; without — AI posting on behalf of @dblock. |
|
I fixed danger-changelog in 0.10.1, dblock/danger-changelog#85 |
|
Thanks for picking this up! I looked into where the reaper has to sit, and the endpoint stack turns out to be the wrong level. Within the endpoint stack, your position is right. Before or after
But being in the endpoint stack breaks cascading. When a route cascades, declines = Class.new(Grape::API) do
post('upload') do
params[:file][:tempfile]
error!('not mine', 404, 'X-Cascade' => 'pass')
end
end
answers = Class.new(Grape::API) do
post('upload') { params[:file][:tempfile].read }
end
app = Class.new(Grape::API) do
mount declines
mount answers
end
post '/upload', file: Rack::Test::UploadedFile.new(__FILE__, 'text/plain')
# master: 201, the file's content
# this branch: IOError: closed streamThe same applies to the ANY ( Suggestion: wrap the router once per API instead, in Specs. These go inside your context 'when the route that read the upload cascades' do
let(:app) do
paths = uploaded_paths
declines = Class.new(Grape::API) do
post 'upload' do
paths << params[:file][:tempfile].path
error!('not mine', 404, 'X-Cascade' => 'pass')
end
end
answers = Class.new(Grape::API) do
post('upload') { params[:file][:tempfile].read.bytesize.to_s }
end
Class.new(Grape::API) do
mount declines
mount answers
end
end
it 'leaves the tempfile open for the next route and unlinks it once the response is closed' do
post '/upload', file: upload
expect(last_response.status).to eq(201)
expect(last_response.body).to eq(File.size(__FILE__).to_s)
expect(uploaded_paths.size).to eq(1)
expect(File.exist?(uploaded_paths.first)).to be(false)
end
end
context 'when a mounted Rack app reads the upload' do
let(:app) do
paths = uploaded_paths
rack_app = lambda do |env|
paths << Rack::Request.new(env).POST['file'][:tempfile].path
[200, { 'content-type' => 'text/plain' }, ['ok']]
end
Class.new(Grape::API) do
mount rack_app => '/rack'
end
end
it 'unlinks the tempfile once the response is closed' do
post '/rack', file: upload
expect(last_response.body).to eq('ok')
expect(uploaded_paths.size).to eq(1)
expect(File.exist?(uploaded_paths.first)).to be(false)
end
endOn this branch both fail: the cascade one with One correction for the description: "the reaper itself closes them if an exception escapes" only holds on Rack 3.x. Rack 2.2's |
Requested by winshyon
Description
Multipart uploads parsed by
Rack::Multipartcreate tempfiles that are only cleaned up when something runsRack::TempfileReaper. Rails includes it by default, but a Grape API served directly by Rack (or by any non-Rails host) left those tempfiles on disk until GC/process exit, as described in #2487.This adds
Rack::TempfileReaperas the outermost middleware inGrape::Endpoint#build_stack(beforeRack::HeadandGrape::Middleware::Error), so the files are closed and unlinked once the response body is closed. Because it sits outside the error middleware, files are also reaped when the endpoint ends witherror!/a rescued exception, and the reaper itself closes them if an exception escapes.Notes for reviewers:
env['rack.tempfiles'] ||= []and both reapers justclose!the same tempfiles (Tempfile#close!on an already closed/unlinked file is a no-op). I checked this with an outerRack::TempfileReaperwrapping a Grape API on Rack 3.2.7 and Rack 2.2.24: 201 response, tempfile unlinked, no error.Rack::BodyProxyper request on the endpoint stack. I put it in the endpoint stack (rather than the API instance) to mirrorRack::Head, which already lives there; happy to move it if you prefer it elsewhere.spec/grape/integration/rack_spec.rb.Motivation / disclosure: I had some spare AI-assistant quota (Claude Code) and am using it to try to
help projects with open good-first-issues. The change was prepared with Claude Code and verified as listed
below. If it doesn't fit, isn't up to your bar, or you'd simply rather not take it — please feel free to
close it, no hard feelings at all 🙂
Related issue
Closes #2487
Checklist
bundle exec rake: RuboCop 358 files, no offenses; RSpec 3155 examples, 0 failures on currentmaster. Before rebasing, alsorspec spec --exclude-pattern=...withgemfiles/rack_2_2.gemfile(3079 examples, 0 failures) andgemfiles/rails_8_1.gemfile(3077 examples, 0 failures). The 2 new examples inspec/grape/integration/rack_spec.rbfail onmasterwithout the change on both Rack 3.2 and Rack 2.2)CHANGELOG.mdis updated (if applicable) — one line under 4.1.0 Features🤖 Generated with Claude Code