From b20c644b16ec3c9d56bead66ae7ee4fa25ac9ffa Mon Sep 17 00:00:00 2001 From: Andrew Fader Date: Fri, 23 Oct 2026 03:03:25 -0400 Subject: [PATCH] Close a partially read request body once the response has been sent. Signed-off-by: Andrew Fader Co-Authored-By: Claude Opus 5.5 --- lib/protocol/rack/adapter/generic.rb | 28 ++++++++++- lib/protocol/rack/input.rb | 13 +++++ test/protocol/rack/adapter/rack3.rb | 75 ++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/lib/protocol/rack/adapter/generic.rb b/lib/protocol/rack/adapter/generic.rb index ebf7aa9..632268f 100644 --- a/lib/protocol/rack/adapter/generic.rb +++ b/lib/protocol/rack/adapter/generic.rb @@ -10,6 +10,8 @@ require_relative "../response" require_relative "../rewindable" +require "protocol/http/body/completable" + module Protocol module Rack module Adapter @@ -177,6 +179,9 @@ def handle_error(env, status, headers, body, error) def call(request) env = self.make_environment(request) + # Keep the input we created, in case the application replaces it: + input = env[RACK_INPUT] + status, headers, body = @app.call(env) # The status must always be an integer: @@ -191,11 +196,32 @@ def call(request) headers, meta = self.wrap_headers(headers) - return Response.wrap(env, status, headers, meta, body, request) + response = Response.wrap(env, status, headers, meta, body, request) + + # If the application read part of the request body, close the rest once the response has been sent, otherwise the server will wait for it: + if partially_read?(input) + if response.body + response.body = ::Protocol::HTTP::Body::Completable.new(response.body, proc{input.close}) + else + input.close + end + end + + return response rescue => error + input.close if partially_read?(input) + return self.handle_error(env, status, headers, body, error) end + # Whether the application read part of the given input, but not all of it. + # + # @parameter input [Input | Nil] The input given to the application. + # @returns [Boolean] + def partially_read?(input) + input.is_a?(Input) and input.partially_read? + end + # Generate a suitable response for the given exception. # # @parameter exception [Exception] The exception that occurred. diff --git a/lib/protocol/rack/input.rb b/lib/protocol/rack/input.rb index 2c2c3e3..0c32af2 100644 --- a/lib/protocol/rack/input.rb +++ b/lib/protocol/rack/input.rb @@ -21,6 +21,7 @@ class Input def initialize(body, ...) @body = body @closed = false + @reading = false super(...) end @@ -70,6 +71,16 @@ def rewind return false end + # Whether the application has started reading the input. + def reading? + @reading + end + + # Whether the application has read part of the input, but not all of it. + def partially_read? + @reading and !closed? + end + # Whether the stream has been closed. def closed? @closed or @body.nil? @@ -88,6 +99,8 @@ def flush def sysread(size, buffer) if @body + @reading = true + # User's may forget to call #close... if chunk = @body.read # If the user reads exactly the content length, we close the stream automatically: diff --git a/test/protocol/rack/adapter/rack3.rb b/test/protocol/rack/adapter/rack3.rb index ece9476..b573d1c 100644 --- a/test/protocol/rack/adapter/rack3.rb +++ b/test/protocol/rack/adapter/rack3.rb @@ -73,6 +73,81 @@ expect(response.read).to be == "Hello World!" end end + + with "a request body" do + let(:body) {Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"])} + let(:request) {Protocol::HTTP::Request.new("https", "example.com", "POST", "/", "http/1.1", Protocol::HTTP::Headers.new, body)} + + with "that is partially read" do + let(:app) do + ->(env) do + @input = env["rack.input"] + @input.read(5) + + [400, {}, ["Rejected"]] + end + end + + it "closes the input once the response has been sent" do + expect(response.status).to be == 400 + expect(@input).not.to be(:closed?) + + expect(response.read).to be == "Rejected" + + expect(@input).to be(:closed?) + end + end + + with "that is partially read and no response body" do + let(:app) do + ->(env) do + @input = env["rack.input"] + @input.read(5) + + [204, {}, []] + end + end + + it "closes the input immediately" do + expect(response.body).to be_nil + expect(@input).to be(:closed?) + end + end + + with "that is partially read when the application fails" do + let(:app) do + ->(env) do + @input = env["rack.input"] + @input.read(5) + + raise StandardError, "Invalid upload" + end + end + + it "closes the input" do + expect(response.status).to be == 500 + expect(@input).to be(:closed?) + end + end + + with "that is not read" do + let(:app) do + ->(env) do + @input = env["rack.input"] + + [200, {}, ["Ignored"]] + end + end + + it "leaves the input for the server" do + expect(response.body).to be_a(Protocol::HTTP::Body::Buffered) + + response.close + + expect(@input).not.to be(:closed?) + end + end + end end with "#make_response" do