Skip to content

Commit 9ad875a

Browse files
authored
Set PUMA_PERSISTENT_TIMEOUT for Rails, add Puma version warnings and errors. (heroku#1645)
* Refactor default config vars * Fix PUMA_PERSISTENT_TIMEOUT not working on Rails Rails does not inherit from Rack, they both inherit from Ruby. GUS-W-19585440 * Add test for .profile.d/ (launch/runtime) env * Set default PUMA_PERSISTENT_TIMEOUT profile.d * Harden .profile.d check By checking the full output, it means any change will cause the build to fail rather than needing to explicitly check individual files. * Fix spelling * Refactor default_config_vars Standardize on using `out` as the variable name. Add documentation stating where it's inheriting from. * Remove outdated comment The "buildpack-env-args" refers to the ability of customers to have access to their config vars at build time, which used to not be possible. It has been on and the default for many years now. We can remove the comment. The comment also assumed implementation details of how it would work once implemented. The `||=` here means that any buildpacks that export an environment variable before us will take precedence, which is behavior that we want. These `default_config_vars` should not take precedence over another buildpack explicitly writing an environment variable to their `export`. * Refactor `default_env_vars` method out This method is used in a handful of places. We can replace that usage with the literal contents. * Remove `config_vars` from bin/release This field is not currently documented at https://devcenter.heroku.com/articles/buildpack-api#bin-release, but it is used for setting config vars for customers. The caveat is that it only affects apps on the first push, which isn't very useful. That's why it's not even documented anymore. The "correct" way to set an environment variable is: - `.profile.d/<script>.sh` Sets the value at launch/runtime - `export` Sets the value at build time for future buildpacks Previously, the `def default_config_vars` was used in relationship with `bin/release` to set `config_vars`. But, since that's not used (as of this commit) it is repurposed as an interface for writing default values to `.profile.d` scripts. All these values were already being written, but now it's centralized (i.e. this .profile.d part of the change is a literal refactor, it doesn't change any behavior). There are some implications with the API: Any value that comes out of `def default_config_vars` will be persisted to disk and will take precedent if someone runs `heroku config:unset <env var>` for example. Previously, the values also contained the user's environment variable value i.e. if someone set `heroku config:set RAILS_ENV=my_custom_env` then that change would be propagated to `bin/release`. Now we don't want to write a value like ` export RACK_ENV=${RACK_ENV:-my_custom_env}` otherwise. We want the default to fall back to OUR default. Therefore only literal values should be used in this API. That part is unexpected and unsafe, so I'm likely going to move/change how the interface works. But I will do that in future commits. * Add a test for the current buildpack `export` The `export` file writes environment variables that are visible to future buildpacks. This adds a test to assert the current behavior. * Add default_config_vars to export automatically * Remove set empty env var This env var was set for Bundler <= 2.1.4 compat in this commit heroku@655e246 which explains the behavior. It has since been reduced over the years to no longer set it to a `1` and instead set it to an empty string. It's also only exported and not set for build time, therefore it can be safely removed. As to **why** it isn't a problem any more. We list our oldest available Ruby version as 3.1: https://devcenter.heroku.com/articles/ruby-support-reference#unsupported-ruby-versions And the default bundler version for that version of ruby is 2.3 which is > 2.1 ``` $ gem list | grep bundle bundler (2.6.9, 2.5.22, default: 2.3.27) ``` * Whitespace * Flatten inheritance The `default_config_vars` was using inheritance and calling `super`, and it was very difficult to follow what actual behavior was. This commit flattens the behavior and puts it all into `ruby.rb`. * Remove un-used inheritance With the `def default_config_vars` logic flattened, these files no longer serve a purpose and can be deleted. * Remove un-needed method definition * Introduce isolated DefaultEnvVars.call function This provides more protection (isolation) than the comment above `def default_config_vars` to help protect future modification from accidentally persisting customer config vars to disk. * Replace logic with DefaultEnvVars.call function * Refactor * Apply suggestions from code review Signed-off-by: Richard Schneeman <richard.schneeman+no-recruiters@gmail.com> --------- Signed-off-by: Richard Schneeman <richard.schneeman+no-recruiters@gmail.com>
1 parent 0102a10 commit 9ad875a

20 files changed

Lines changed: 533 additions & 157 deletions

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
- Set `export PUMA_PERSISTENT_TIMEOUT=95` to match recommended router 2.0 settings for Rails applications (https://github.com/heroku/heroku-buildpack-ruby/pull/1645)
6+
- Warn when using Puma prior to 7.0.0 for Router 2.0 compatibility (https://github.com/heroku/heroku-buildpack-ruby/pull/1645)
7+
- Error when using Puma 7.0.0 to 7.0.2 (inclusive) to prevent runtime error with `PUMA_PERSISTENT_TIMEOUT` (https://github.com/heroku/heroku-buildpack-ruby/pull/1645)
8+
- The `config_vars` field is no longer set in `bin/release`, this feature only affected the first deploy and is redundant with `.profile.d` usage that already exists. (https://github.com/heroku/heroku-buildpack-ruby/pull/1645)
59

610
## [v321] - 2025-09-16
711

@@ -10,7 +14,7 @@
1014

1115
## [v320] - 2025-09-09
1216

13-
- Set `export PUMA_PERSISTENT_TIMEOUT=95` to match recommended router 2.0 settings (https://github.com/heroku/heroku-buildpack-ruby/pull/1641)
17+
- Set `export PUMA_PERSISTENT_TIMEOUT=95` to match recommended router 2.0 settings for Rack applications (https://github.com/heroku/heroku-buildpack-ruby/pull/1641)
1418

1519
## [v319] - 2025-08-27
1620

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
## Rails apps now have `PUMA_PERSISTENT_TIMEOUT=95` set by default
2+
3+
Previously, this setting was only applied to [Rack applications](https://devcenter.heroku.com/changelog-items/3391). With this change, all Ruby applications now have the `PUMA_PERSISTENT_TIMEOUT` environment variable set to a default value of `95`.
4+
5+
Puma [7.0.3+](https://github.com/puma/puma/pull/3378) introduced the ability to configure the `persistent_timeout` value via an environment variable. Router 2.0 uses an idle timeout value of 90s https://devcenter.heroku.com/articles/http-routing#keepalives. To avoid a situation where a request is sent right before Puma closes the connection, the value needs to be slightly higher than the Router's value.
6+
7+
Applications that are not on Puma 7.0.3+ can use it manually in their `config/puma.rb` file:
8+
9+
```ruby
10+
# config/puma.rb
11+
12+
# Only required for Puma 6 and below
13+
persistent_timeout(ENV.fetch("PUMA_PERSISTENT_TIMEOUT") { 95 }.to_i)
14+
```
15+
16+
Other web server users can use this as a stable interface to retrieve a suggested idle timeout setting.

lib/language_pack.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def self.gemfile_lock(app_path: )
2222

2323
# detects which language pack to use
2424
def self.detect(app_path:, cache_path:, gemfile_lock: )
25-
pack_klass = [ Rails8, Rails7, Rails6, Rails5, Rails42, Rails41, Rails4, Rails3, Rails2, Rack, Ruby ].detect do |klass|
25+
pack_klass = [ Rails8, Rails7, Rails6, Rails5, Rails4, Rails3, Rails2, Rack, Ruby ].detect do |klass|
2626
klass.use?
2727
end
2828

@@ -48,7 +48,9 @@ def self.detect(app_path:, cache_path:, gemfile_lock: )
4848
require "language_pack/helpers/bundle_list"
4949
require "language_pack/helpers/rake_runner"
5050
require "language_pack/helpers/rails_runner"
51+
require "language_pack/helpers/puma_warn_error"
5152
require "language_pack/helpers/bundler_wrapper"
53+
require "language_pack/helpers/default_env_vars"
5254
require "language_pack/helpers/outdated_ruby_version"
5355
require "language_pack/helpers/download_presence"
5456
require "language_pack/installers/heroku_ruby_installer"
@@ -58,8 +60,6 @@ def self.detect(app_path:, cache_path:, gemfile_lock: )
5860
require "language_pack/rails2"
5961
require "language_pack/rails3"
6062
require "language_pack/rails4"
61-
require "language_pack/rails41"
62-
require "language_pack/rails42"
6363
require "language_pack/rails5"
6464
require "language_pack/rails6"
6565
require "language_pack/rails7"

lib/language_pack/base.rb

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,6 @@ def default_addons
6262
raise "must subclass"
6363
end
6464

65-
# config vars to be set on first push.
66-
# @return [Hash] the result
67-
# @not: this is only set the first time an app is pushed to.
68-
def default_config_vars
69-
raise "must subclass"
70-
end
71-
7265
# process types to provide for the app
7366
# Ex. for rails we provide a web process
7467
# @return [Hash] the result
@@ -96,7 +89,6 @@ def compile
9689
def build_release
9790
release = {}
9891
release["addons"] = default_addons
99-
release["config_vars"] = default_config_vars
10092
release["default_process_types"] = default_process_types
10193

10294
release
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
2+
module LanguagePack::Helpers::DefaultEnvVars
3+
# Returns a hash of default environment variables for the given inputs
4+
#
5+
# Values will be written to disk as defaults
6+
#
7+
# i.e. `export RAILS_ENV=${RAILS_ENV:-production}`
8+
#
9+
# Therefore it's important that we don't return any values from user provided config vars
10+
# or customers will not be able to `heroku config:unset` them.
11+
#
12+
# @param is_jruby [Boolean] whether the app is using JRuby
13+
# @param rack_version [Gem::Version] the version of the rack gem
14+
# @param rails_version [Gem::Version] the version of the rails gem
15+
# @param secret_key_base [String] the secret key base for the app
16+
# @return [Hash] a hash of default environment variables
17+
def self.call(is_jruby:, rack_version: , rails_version:, secret_key_base:)
18+
out = {}
19+
out["LANG"] = "en_US.UTF-8"
20+
out["PUMA_PERSISTENT_TIMEOUT"] = "95"
21+
22+
if is_jruby
23+
out["JRUBY_OPTS"] = "-Xcompile.invokedynamic=false"
24+
end
25+
26+
if rack_version
27+
out["RACK_ENV"] = "production"
28+
end
29+
30+
if rails_version
31+
out["RAILS_ENV"] = "production"
32+
end
33+
34+
if rails_version&. >= Gem::Version.new("4.1.0.beta1")
35+
if secret_key_base = secret_key_base&.to_s
36+
out["SECRET_KEY_BASE"] = secret_key_base
37+
else
38+
raise ArgumentError, "secret_key_base is required for rails 4.1+. Provided: #{secret_key_base.inspect}"
39+
end
40+
end
41+
42+
if rails_version&. >= Gem::Version.new("4.2.0")
43+
out["RAILS_SERVE_STATIC_FILES"] = "enabled"
44+
end
45+
46+
if rails_version&. >= Gem::Version.new("5.0.0")
47+
out["RAILS_LOG_TO_STDOUT"] = "enabled"
48+
end
49+
50+
out
51+
end
52+
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
# Checks for Puma specific warnings and errors
4+
class LanguagePack::Helpers::PumaWarnError
5+
attr_reader :warnings, :error, :puma_version
6+
7+
def initialize(puma_version:, env:)
8+
@warnings = []
9+
@error = nil
10+
@env = env
11+
@puma_version = puma_version
12+
13+
warn_router_2_0_compatibility
14+
error_persistent_timeout
15+
end
16+
17+
private def warn_router_2_0_compatibility
18+
return if @puma_version >= Gem::Version.new("7.0.3")
19+
warnings << <<~WARNING
20+
Heroku recommends using Puma 7.0.3+ for compatibility with Router 2.0
21+
22+
Please upgrade your application to Puma 7.0.3+ by running the following commands:
23+
24+
```
25+
$ gem install puma
26+
$ bundle update puma
27+
$ git add Gemfile.lock && git commit -m "Upgrade Puma to 7.0.3+"
28+
```
29+
WARNING
30+
end
31+
32+
private def error_persistent_timeout
33+
return if @puma_version >= Gem::Version.new("7.0.3")
34+
return if @puma_version < Gem::Version.new("7.0.0")
35+
# If they manually set it, it is likely being used in their `config/puma.rb` file
36+
return if @env["PUMA_PERSISTENT_TIMEOUT"]
37+
38+
@error = <<~ERROR
39+
Your application is using Puma #{puma_version}.
40+
41+
This has a known issue with the `PUMA_PERSISTENT_TIMEOUT` environment variable.
42+
43+
This is fixed in Puma 7.0.3+ via https://github.com/puma/puma/pull/3749.
44+
Please upgrade your application to Puma 7.0.3+ by running the following commands:
45+
46+
```
47+
$ gem install puma
48+
$ bundle update puma
49+
$ git add Gemfile.lock && git commit -m "Upgrade Puma to 7.0.3+"
50+
```
51+
ERROR
52+
end
53+
end

lib/language_pack/rack.rb

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,6 @@ def name
1414
"Ruby/Rack"
1515
end
1616

17-
def default_config_vars
18-
out = super
19-
out["RACK_ENV"] = env("RACK_ENV") || "production"
20-
out["PUMA_PERSISTENT_TIMEOUT"] = env("PUMA_PERSISTENT_TIMEOUT") || "95"
21-
out
22-
end
23-
2417
def default_process_types
2518
# let's special case thin here if we detect it
2619
web_process = bundler.has_gem?("thin") ?
@@ -31,12 +24,4 @@ def default_process_types
3124
"web" => web_process
3225
})
3326
end
34-
35-
private
36-
37-
# sets up the profile.d script for this buildpack
38-
def setup_profiled(**args)
39-
super(**args)
40-
set_env_default "RACK_ENV", "production"
41-
end
4227
end

lib/language_pack/rails2.rb

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,6 @@ def name
2323
"Ruby/Rails"
2424
end
2525

26-
def default_env_vars
27-
{
28-
"RAILS_ENV" => "production",
29-
"RACK_ENV" => "production"
30-
}
31-
end
32-
33-
def default_config_vars
34-
config_vars = super
35-
default_env_vars.map do |key, value|
36-
config_vars[key] = env(key) || value
37-
end
38-
config_vars
39-
end
40-
4126
def default_process_types
4227
web_process = bundler.has_gem?("thin") ?
4328
"bundle exec thin start -e $RAILS_ENV -p ${PORT:-5000}" :
@@ -76,12 +61,4 @@ def install_plugins
7661
topic "Rails plugin injection"
7762
LanguagePack::Helpers::PluginsInstaller.new(plugins).install
7863
end
79-
80-
# sets up the profile.d script for this buildpack
81-
def setup_profiled(**args)
82-
super(**args)
83-
default_env_vars.each do |key, value|
84-
set_env_default key, value
85-
end
86-
end
8764
end

lib/language_pack/rails3.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ def default_process_types
3030
end
3131

3232
def rake_env
33-
default_env_vars.merge("RAILS_GROUPS" => "assets").merge(super)
33+
{
34+
"RAILS_ENV" => "production",
35+
"RACK_ENV" => "production",
36+
"RAILS_GROUPS" => "assets",
37+
}.merge(super)
3438
end
3539

3640
def compile

lib/language_pack/rails4.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def self.use?
1111
rails_version = bundler.gem_version('railties')
1212
return false unless rails_version
1313
is_rails4 = rails_version >= Gem::Version.new('4.0.0.beta') &&
14-
rails_version < Gem::Version.new('4.1.0.beta1')
14+
rails_version < Gem::Version.new('5.x')
1515
return is_rails4
1616
end
1717

0 commit comments

Comments
 (0)