puma: Puma 6.2 does not respond correctly when Rails app responds with empty body

Describe the bug

In one of my Rails 6.1 apps, I have code that boils down to:

class ThingsController < ApplicationController
  def show
    head :not_found
  end
end

With Puma 6.1.1 I get the following response:

curl
$ curl -i localhost:3000/thing
HTTP/1.1 404 Not Found
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Permitted-Cross-Domain-Policies: none
Referrer-Policy: strict-origin-when-cross-origin
Content-Type: text/html
Cache-Control: no-cache
X-Request-Id: 15463eac-5912-4c17-8044-5d884fde576b
X-Runtime: 0.003416
Transfer-Encoding: chunked
Rails log
=> Booting Puma
=> Rails 6.1.7.3 application starting in development 
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 6.1.1 (ruby 3.0.5-p211) ("The Way Up")
*  Min threads: 5
*  Max threads: 5
*  Environment: development
*          PID: 104668
* Listening on http://[::]:3000
Use Ctrl-C to stop
Started GET "/thing" for ::1 at 2023-03-30 16:39:55 +0200
Processing by ThingsController#show as */*
Completed 404 Not Found in 0ms (Allocations: 160)

After upgrading to Puma 6.2, this is the result:

curl
$ curl -i localhost:3000/thing
curl: (1) Received HTTP/0.9 when not allowed
Rails log
=> Booting Puma
=> Rails 6.1.7.3 application starting in development 
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 6.2.0 (ruby 3.0.5-p211) ("Speaking of Now")
*  Min threads: 5
*  Max threads: 5
*  Environment: development
*          PID: 105699
* Listening on http://[::]:3000
Use Ctrl-C to stop
Started GET "/thing" for ::1 at 2023-03-30 16:40:18 +0200
Processing by ThingsController#show as */*
Completed 404 Not Found in 0ms (Allocations: 231)

When pointing Firefox to http://localhost:3000/thing, the browser hangs for 20s, then prints a 0.

I’ve captured the network traffic: puma.pcap.gz

Puma config:

config/puma.rb

This is the unaltered config file generated by rails new:

# Puma can serve each request in a thread from an internal thread pool.
# The `threads` method setting takes two numbers: a minimum and maximum.
# Any libraries that use thread pools should be configured to match
# the maximum value specified for Puma. Default is set to 5 threads for minimum
# and maximum; this matches the default thread size of Active Record.
#
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count

# Specifies the `worker_timeout` threshold that Puma will use to wait before
# terminating a worker in development environments.
#
worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"

# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
#
port ENV.fetch("PORT") { 3000 }

# Specifies the `environment` that Puma will run in.
#
environment ENV.fetch("RAILS_ENV") { "development" }

# Specifies the `pidfile` that Puma will use.
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }

# Specifies the number of `workers` to boot in clustered mode.
# Workers are forked web server processes. If using threads and workers together
# the concurrency of the application would be max `threads` * `workers`.
# Workers do not work on JRuby or Windows (both of which do not support
# processes).
#
# workers ENV.fetch("WEB_CONCURRENCY") { 2 }

# Use the `preload_app!` method when specifying a `workers` number.
# This directive tells Puma to first boot the application and load code
# before forking the application. This takes advantage of Copy On Write
# process behavior so workers use less memory.

#
# preload_app!

# Allow puma to be restarted by `rails restart` command.
plugin :tmp_restart

The Rails app is started with bundle exec rails server -b '::'.

To Reproduce

I’ve prepared a repro-repo. You’ll need Ruby 3.0.5:

$ git clone https://github.com/dmke/puma-repro
$ cd puma-repro
$ git checkout puma-6.1.1
$ bundle install
$ bundle exec rails server -b '::'

To switch to Puma 6.2:

$ git checkout puma-6.2
$ bundle install
$ bundle exec rails server -b '::'

Expected behavior

head(:not_found) should work as expected.

Desktop (please complete the following information):

  • OS: Debian Linux (11/bullseye)
  • Puma Version: 6.2

About this issue

  • Original URL
  • State: closed
  • Created a year ago
  • Reactions: 8
  • Comments: 20 (10 by maintainers)

Most upvoted comments

I’m also experiencing this. I’ve narrowed it down to any response (regardless of status code) that returns an empty body. You can work around it by doing something like render(plain: "Not found", status: :not_found), but render(plain: "", status: :not_found) will trigger the issue in Puma. Reverting to 6.1.1 fixes it.

@collinsauve

will take me a while.

If you’d like to, but I’ve already got a test for it, or at least an app response for it. I’ll post in your PR.

#3113 seems to fix this issue.