From cfde8a7918f071a0300bfed714daa4387c443109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20B=C3=A4lter?= Date: Thu, 27 Jan 2022 22:10:58 +0100 Subject: [PATCH 1/3] Revert "Revert "Always send lowlevel_error response to client (#2731)" (#2809)" This reverts commit 7008a61ac9f87f71f4c688a6ab5d3cf2b6c8151a. --- lib/puma/request.rb | 76 ++++++++++++++++++++++-------------- lib/puma/server.rb | 20 +++++----- test/test_puma_server.rb | 11 ++++++ test/test_response_header.rb | 4 +- 4 files changed, 71 insertions(+), 40 deletions(-) diff --git a/lib/puma/request.rb b/lib/puma/request.rb index 948992895f..8950cf282a 100644 --- a/lib/puma/request.rb +++ b/lib/puma/request.rb @@ -46,11 +46,7 @@ def handle_request(client, lines, requests) env[HIJACK_P] = true env[HIJACK] = client - body = client.body - - head = env[REQUEST_METHOD] == HEAD - - env[RACK_INPUT] = body + env[RACK_INPUT] = client.body env[RACK_URL_SCHEME] ||= default_server_port(env) == PORT_443 ? HTTPS : HTTP if @early_hints @@ -69,36 +65,58 @@ def handle_request(client, lines, requests) # A rack extension. If the app writes #call'ables to this # array, we will invoke them when the request is done. # - after_reply = env[RACK_AFTER_REPLY] = [] + env[RACK_AFTER_REPLY] = [] begin - begin - status, headers, res_body = @thread_pool.with_force_shutdown do - @app.call(env) + status, headers, res_body = @thread_pool.with_force_shutdown do + @app.call(env) + end + + return :async if client.hijacked + + status = status.to_i + + if status == -1 + unless headers.empty? and res_body == [] + raise "async response must have empty headers and body" end - return :async if client.hijacked + return :async + end + rescue ThreadPool::ForceShutdown => e + @events.unknown_error e, client, "Rack app" + @events.log "Detected force shutdown of a thread" - status = status.to_i + status, headers, res_body = lowlevel_error(e, env, 503) + rescue Exception => e + @events.unknown_error e, client, "Rack app" - if status == -1 - unless headers.empty? and res_body == [] - raise "async response must have empty headers and body" - end + status, headers, res_body = lowlevel_error(e, env, 500) + end - return :async - end - rescue ThreadPool::ForceShutdown => e - @events.unknown_error e, client, "Rack app" - @events.log "Detected force shutdown of a thread" + write_response(status, headers, res_body, lines, requests, client) + end - status, headers, res_body = lowlevel_error(e, env, 503) - rescue Exception => e - @events.unknown_error e, client, "Rack app" + # Does the actual response writing for Request#handle_request and Server#client_error + # + # @param status [Integer] the status returned by the Rack application + # @param headers [Hash] the headers returned by the Rack application + # @param res_body [Array] the body returned by the Rack application + # @param lines [Puma::IOBuffer] modified in place + # @param requests [Integer] number of inline requests handled + # @param client [Puma::Client] + # @return [Boolean,:async] + def write_response(status, headers, res_body, lines, requests, client) + env = client.env + io = client.io - status, headers, res_body = lowlevel_error(e, env, 500) - end + return false if closed_socket?(io) + lines.clear + head = env[REQUEST_METHOD] == HEAD + after_reply = env[RACK_AFTER_REPLY] || [] + + begin res_info = {} res_info[:content_length] = nil res_info[:no_body] = head @@ -149,9 +167,9 @@ def handle_request(client, lines, requests) res_body.each do |part| next if part.bytesize.zero? if chunked - fast_write io, (part.bytesize.to_s(16) << line_ending) - fast_write io, part # part may have different encoding - fast_write io, line_ending + fast_write io, (part.bytesize.to_s(16) << line_ending) + fast_write io, part # part may have different encoding + fast_write io, line_ending else fast_write io, part end @@ -169,7 +187,7 @@ def handle_request(client, lines, requests) ensure uncork_socket io - body.close + client.body.close if client.body client.tempfile.unlink if client.tempfile res_body.close if res_body.respond_to? :close diff --git a/lib/puma/server.rb b/lib/puma/server.rb index f4aed5dfc5..88cee7cefe 100644 --- a/lib/puma/server.rb +++ b/lib/puma/server.rb @@ -476,7 +476,7 @@ def process_client(client, buffer) end true rescue StandardError => e - client_error(e, client) + client_error(e, client, buffer, requests) # The ensure tries to close +client+ down requests > 0 ensure @@ -504,34 +504,36 @@ def with_force_shutdown(client, &block) # :nocov: # Handle various error types thrown by Client I/O operations. - def client_error(e, client) + def client_error(e, client, buffer = ::Puma::IOBuffer.new, requests = 1) # Swallow, do not log return if [ConnectionError, EOFError].include?(e.class) - lowlevel_error(e, client.env) case e when MiniSSL::SSLError @events.ssl_error e, client.io when HttpParserError - client.write_error(400) + status, headers, res_body = lowlevel_error(e, client.env, 400) + write_response(status, headers, res_body, buffer, requests, client) @events.parse_error e, client else - client.write_error(500) + status, headers, res_body = lowlevel_error(e, client.env) + write_response(status, headers, res_body, buffer, requests, client) @events.unknown_error e, nil, "Read" end end # A fallback rack response if +@app+ raises as exception. # - def lowlevel_error(e, env, status=500) + def lowlevel_error(e, env, status = 500) if handler = @options[:lowlevel_error_handler] if handler.arity == 1 - return handler.call(e) + handler_status, headers, res_body = handler.call(e) elsif handler.arity == 2 - return handler.call(e, env) + handler_status, headers, res_body = handler.call(e, env) else - return handler.call(e, env, status) + handler_status, headers, res_body = handler.call(e, env, status) end + return [handler_status || status, headers || {}, res_body || []] end if @leak_stack_on_error diff --git a/test/test_puma_server.rb b/test/test_puma_server.rb index 1856a29788..a73e0311b7 100644 --- a/test/test_puma_server.rb +++ b/test/test_puma_server.rb @@ -368,6 +368,17 @@ def test_lowlevel_error_message assert (data.size > 0), "Expected response message to be not empty" end + def test_lowlevel_error_handler_custom_response + options = { lowlevel_error_handler: ->(_err) { [500, {}, ["error page"]] } } + # setting the headers argument to nil will trigger exception inside Puma + broken_app = ->(_env) { [200, nil, []] } + server_run(**options, &broken_app) + + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match %r{HTTP/1.0 500 Internal Server Error\r\nContent-Length: 10\r\n\r\nerror page}, data + end + def test_force_shutdown_error_default server_run(force_shutdown_after: 2) do @server.stop diff --git a/test/test_response_header.rb b/test/test_response_header.rb index 508bbcd78c..03fb43ca54 100644 --- a/test/test_response_header.rb +++ b/test/test_response_header.rb @@ -45,7 +45,7 @@ def test_integer_key server_run app: ->(env) { [200, { 1 => 'Boo'}, []] } data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" - assert_match(/HTTP\/1.1 500 Internal Server Error/, data) + assert_match(/HTTP\/1.0 500 Internal Server Error/, data) end # The header must respond to each @@ -53,7 +53,7 @@ def test_nil_header server_run app: ->(env) { [200, nil, []] } data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" - assert_match(/HTTP\/1.1 500 Internal Server Error/, data) + assert_match(/HTTP\/1.0 500 Internal Server Error/, data) end # The values of the header must be Strings From df6512cfbf71a36eae8be7134cdd576939309374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20B=C3=A4lter?= Date: Thu, 27 Jan 2022 22:22:27 +0100 Subject: [PATCH 2/3] Fixup write_response refactoring bug #2808 --- lib/puma/request.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/puma/request.rb b/lib/puma/request.rb index 8950cf282a..6cde60dec0 100644 --- a/lib/puma/request.rb +++ b/lib/puma/request.rb @@ -110,11 +110,8 @@ def write_response(status, headers, res_body, lines, requests, client) env = client.env io = client.io - return false if closed_socket?(io) - lines.clear - head = env[REQUEST_METHOD] == HEAD - after_reply = env[RACK_AFTER_REPLY] || [] + after_reply = env[RACK_AFTER_REPLY] ||= [] begin res_info = {} From d3fc0cfb430808bb37d2522954efad95e5a58b1e Mon Sep 17 00:00:00 2001 From: MSP-Greg Date: Fri, 28 Jan 2022 22:18:04 -0600 Subject: [PATCH 3/3] Fixup 2812 --- lib/puma/request.rb | 114 +++++++++++++++++++-------------------- lib/puma/server.rb | 6 +-- test/test_puma_server.rb | 2 +- 3 files changed, 57 insertions(+), 65 deletions(-) diff --git a/lib/puma/request.rb b/lib/puma/request.rb index 6cde60dec0..ea5cf0c072 100644 --- a/lib/puma/request.rb +++ b/lib/puma/request.rb @@ -113,85 +113,79 @@ def write_response(status, headers, res_body, lines, requests, client) head = env[REQUEST_METHOD] == HEAD after_reply = env[RACK_AFTER_REPLY] ||= [] - begin - res_info = {} - res_info[:content_length] = nil - res_info[:no_body] = head - - res_info[:content_length] = if res_body.kind_of? Array and res_body.size == 1 - res_body[0].bytesize - else - nil - end + res_info = {} + res_info[:no_body] = head - cork_socket io + res_info[:content_length] = res_body.kind_of?(Array) && res_body.size == 1 ? + res_body[0].bytesize : nil - str_headers(env, status, headers, res_info, lines, requests, client) + cork_socket io - line_ending = LINE_END + str_headers(env, status, headers, res_info, lines, requests, client) - content_length = res_info[:content_length] - response_hijack = res_info[:response_hijack] + line_ending = LINE_END - if res_info[:no_body] - if content_length and status != 204 - lines.append CONTENT_LENGTH_S, content_length.to_s, line_ending - end + content_length = res_info[:content_length] + response_hijack = res_info[:response_hijack] - lines << LINE_END - fast_write io, lines.to_s - return res_info[:keep_alive] - end - - if content_length + if res_info[:no_body] + if content_length and status != 204 lines.append CONTENT_LENGTH_S, content_length.to_s, line_ending - chunked = false - elsif !response_hijack and res_info[:allow_chunked] - lines << TRANSFER_ENCODING_CHUNKED - chunked = true end - lines << line_ending - + lines << LINE_END fast_write io, lines.to_s + return res_info[:keep_alive] + end - if response_hijack - response_hijack.call io - return :async - end + if content_length + lines.append CONTENT_LENGTH_S, content_length.to_s, line_ending + chunked = false + elsif !response_hijack and res_info[:allow_chunked] + lines << TRANSFER_ENCODING_CHUNKED + chunked = true + end - begin - res_body.each do |part| - next if part.bytesize.zero? - if chunked - fast_write io, (part.bytesize.to_s(16) << line_ending) - fast_write io, part # part may have different encoding - fast_write io, line_ending - else - fast_write io, part - end - io.flush - end + lines << line_ending + fast_write io, lines.to_s + + if response_hijack + response_hijack.call io + return :async + end + + begin + res_body.each do |part| + next if part.bytesize.zero? if chunked - fast_write io, CLOSE_CHUNKED - io.flush + fast_write io, (part.bytesize.to_s(16) << line_ending) + fast_write io, part # part may have different encoding + fast_write io, line_ending + else + fast_write io, part end - rescue SystemCallError, IOError - raise ConnectionError, "Connection error detected during write" + io.flush end - ensure - uncork_socket io - - client.body.close if client.body - client.tempfile.unlink if client.tempfile - res_body.close if res_body.respond_to? :close - - after_reply.each { |o| o.call } + if chunked + fast_write io, CLOSE_CHUNKED + io.flush + end + rescue SystemCallError, IOError + raise ConnectionError, "Connection error detected during write" end - res_info[:keep_alive] + + ensure + uncork_socket io + # lines must be reset if an error is raised + lines.reset + client.body.close if client.body + client.tempfile.unlink if client.tempfile + res_body.close if res_body.respond_to? :close + + after_reply.each { |o| o.call } end # @param env [Hash] see Puma::Client#env, from request diff --git a/lib/puma/server.rb b/lib/puma/server.rb index 88cee7cefe..59ef16fec2 100644 --- a/lib/puma/server.rb +++ b/lib/puma/server.rb @@ -533,10 +533,8 @@ def lowlevel_error(e, env, status = 500) else handler_status, headers, res_body = handler.call(e, env, status) end - return [handler_status || status, headers || {}, res_body || []] - end - - if @leak_stack_on_error + [handler_status || status, headers || {}, res_body || []] + elsif @leak_stack_on_error backtrace = e.backtrace.nil? ? '' : e.backtrace.join("\n") [status, {}, ["Puma caught this error: #{e.message} (#{e.class})\n#{backtrace}"]] else diff --git a/test/test_puma_server.rb b/test/test_puma_server.rb index a73e0311b7..db1b47ce2b 100644 --- a/test/test_puma_server.rb +++ b/test/test_puma_server.rb @@ -376,7 +376,7 @@ def test_lowlevel_error_handler_custom_response data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" - assert_match %r{HTTP/1.0 500 Internal Server Error\r\nContent-Length: 10\r\n\r\nerror page}, data + assert_match %r{\AHTTP/1.0 500 Internal Server Error\r\nContent-Length: 10\r\n\r\nerror page}, data end def test_force_shutdown_error_default