Repository: puma/puma (Ruby)

Issue:
`preload_app` doesn't default to `true` if more than 1 worker
**Describe the bug**
The [comment][d] and the [code][a] suggest that if there are more than 1 worker, `preload_app` should be `true`, but it isn't.

**Puma config:**
The command line option is `-w 2`.

**To Reproduce**
`a.sh`:

```sh
docker run --rm --init -it alpine:3.17 sh -euc '
  with_config=$1
  apk add build-base ruby ruby-dev ruby-bundler
  echo "
    source \"https://rubygems.org\"
    gem \"puma\", \"6.0.1\"
  " > Gemfile
  echo "
    run lambda { |env| [200, {\"content-type\" => \"text/plain\"}, [\"OK\"]] }
  " > config.ru
  echo "
    preload_app!
  " > config.rb
  bundle
  puma -w 2 ${with_config:+-C config.rb}
' - "${1-}"
```

```
$ sh a.sh
...
[136] Puma starting in cluster mode...
[136] * Puma version: 6.0.1 (ruby 3.1.3-p185) ("Sunflower")
[136] *  Min threads: 0
[136] *  Max threads: 5
[136] *  Environment: development
[136] *   Master PID: 136
[136] *      Workers: 2
[136] *     Restarts: (✔) hot (✔) phased
[136] * Listening on http://0.0.0.0:9292
[136] Use Ctrl-C to stop
[137] + Gemfile in context: /Gemfile
[138] + Gemfile in context: /Gemfile
[136] - Worker 1 (PID: 138) booted in 0.02s, phase: 0
[136] - Worker 0 (PID: 137) booted in 0.02s, phase: 0
```

```
$ sh a.sh with-config
[136] Puma starting in cluster mode...
[136] * Puma version: 6.0.1 (ruby 3.1.3-p185) ("Sunflower")
[136] *  Min threads: 0
[136] *  Max threads: 5
[136] *  Environment: development
[136] *   Master PID: 136
[136] *      Workers: 2
[136] *     Restarts: (✔) hot (✖) phased
[136] * Preloading application
[136] * Listening on http://0.0.0.0:9292
[136] Use Ctrl-C to stop
[137] + Gemfile in context: /Gemfile
[136] - Worker 0 (PID: 137) booted in 0.0s, phase: 0
[138] + Gemfile in context: /Gemfile
[136] - Worker 1 (PID: 138) booted in 0.0s, phase: 0
```

This happens because at [this point][a] `@options[:workers]` is 0, since file options become available [here][b], and user options [here][c].

Just in case, this has nothing to do with `docker`.

**Expected behavior**
Either it should default to `true`, or [that piece][a] should be removed.

**Desktop (please complete the following information):**
 - OS: Linux
 - Puma Version 6.0.1

[a]: https://github.com/puma/puma/blob/v6.0.1/lib/puma/configuration.rb#L182
[b]: https://github.com/puma/puma/blob/v6.0.1/lib/puma/launcher.rb#L54
[c]: https://github.com/puma/puma/blob/v6.0.1/lib/puma/cli.rb#L45
[d]: https://github.com/puma/puma/blob/v6.0.1/lib/puma/dsl.rb#L712



Discussion:
Thanks for this great summary. 

I've actually come to the same conclusion before, at https://github.com/puma/puma/pull/2481#discussion_r519086284 and in https://github.com/puma/puma/issues/2483. I think what was discussed in #2483 for Puma 6 must have been forgotten. Something for Puma 7? 😂 

Maybe this issue can be the issue tracking it this time...
So, Puma will preload your app if you start it using `WEB_CONCURRENCY=N puma ...` (N >= 2)
App preloading (in cluster mode) would solve the issue #2818 too as this would make the `localhost` gem available.
> Puma will preload your app if you start it using `WEB_CONCURRENCY=N puma ...`

...and it will disable phased restarts (which is the correct behaviour) – just noting this because https://github.com/puma/puma/issues/3484 was opened



Patch:
diff --git a/README.md b/README.md
index 790908c5ad..3e8ebd4f6f 100644
--- a/README.md
+++ b/README.md
@@ -124,7 +124,7 @@ For an in-depth discussion of the tradeoffs of thread and process count settings
 
 In cluster mode, Puma can "preload" your application. This loads all the application code *prior* to forking. Preloading reduces total memory usage of your application via an operating system feature called [copy-on-write](https://en.wikipedia.org/wiki/Copy-on-write).
 
-If the `WEB_CONCURRENCY` environment variable is set to a value > 1 (and `--prune-bundler` has not been specified), preloading will be enabled by default. Otherwise, you can use the `--preload` flag from the command line:
+If the number of workers is greater than 1 (and `--prune-bundler` has not been specified), preloading will be enabled by default. Otherwise, you can use the `--preload` flag from the command line:
 
 ```
 $ puma -w 3 --preload
diff --git a/lib/puma/cli.rb b/lib/puma/cli.rb
index 20dfb325cc..650eae4fc8 100644
--- a/lib/puma/cli.rb
+++ b/lib/puma/cli.rb
@@ -39,10 +39,8 @@ def initialize(argv, log_writer = LogWriter.stdio, events = Events.new, env: ENV
       @control_url = nil
       @control_options = {}
 
-      setup_options env
-
       begin
-        @parser.parse! @argv
+        setup_options env
 
         if file = @argv.shift
           @conf.configure do |user_config, file_config|
@@ -240,7 +238,7 @@ def setup_options(env = ENV)
             $stdout.puts o
             exit 0
           end
-        end
+        end.parse! @argv
       end
     end
   end
diff --git a/lib/puma/configuration.rb b/lib/puma/configuration.rb
index 3e1379d9fb..08ccae6df0 100644
--- a/lib/puma/configuration.rb
+++ b/lib/puma/configuration.rb
@@ -186,10 +186,6 @@ def initialize(user_options={}, default_options = {}, env = ENV, &block)
       @file_dsl    = DSL.new(@_options.file_options, self)
       @default_dsl = DSL.new(@_options.default_options, self)
 
-      if !@_options[:prune_bundler]
-        default_options[:preload_app] = (@_options[:workers] > 1) && Puma.forkable?
-      end
-
       @puma_bundler_pruned = env.key? 'PUMA_BUNDLER_PRUNED'
 
       if block
@@ -282,8 +278,9 @@ def config_files
     # This also calls load if it hasn't been called yet.
     def clamp
       load unless @loaded
+      set_conditional_default_options
+      @_options.finalize_values
       @clamped = true
-      options.finalize_values
       warn_hooks
       options
     end
@@ -426,6 +423,11 @@ def load_rackup
       rack_app
     end
 
+    def set_conditional_default_options
+      @_options.default_options[:preload_app] = !@_options[:prune_bundler] &&
+        (@_options[:workers] > 1) && Puma.forkable?
+    end
+
     def warn_hooks
       return if options[:workers] > 0
       return if options[:silence_fork_callback_warning]


Test patch:
diff --git a/test/config/worker_respawn.rb b/test/config/worker_respawn.rb
new file mode 100644
index 0000000000..f4424ca415
--- /dev/null
+++ b/test/config/worker_respawn.rb
@@ -0,0 +1,4 @@
+worker_shutdown_timeout 2
+# Workers may be respawned with either :TERM or phased restart (:USR1),
+# preloading is not compatible with the latter.
+preload_app! false
diff --git a/test/config/worker_shutdown_timeout_2.rb b/test/config/worker_shutdown_timeout_2.rb
deleted file mode 100644
index ddb8c6453d..0000000000
--- a/test/config/worker_shutdown_timeout_2.rb
+++ /dev/null
@@ -1,1 +0,0 @@
-worker_shutdown_timeout 2
diff --git a/test/config/workers_0.rb b/test/config/workers_0.rb
new file mode 100644
index 0000000000..b2cdf8009a
--- /dev/null
+++ b/test/config/workers_0.rb
@@ -0,0 +1,1 @@
+workers 0
diff --git a/test/config/workers_2.rb b/test/config/workers_2.rb
new file mode 100644
index 0000000000..988555fade
--- /dev/null
+++ b/test/config/workers_2.rb
@@ -0,0 +1,1 @@
+workers 2
diff --git a/test/test_cli.rb b/test/test_cli.rb
index 09f6ac6395..e65d0b57f6 100644
--- a/test/test_cli.rb
+++ b/test/test_cli.rb
@@ -349,7 +349,6 @@ def test_environment_app_env
     ENV['APP_ENV'] = 'test'
 
     cli = Puma::CLI.new []
-    cli.send(:setup_options)
 
     conf = cli.instance_variable_get(:@conf)
     conf.clamp
@@ -364,7 +363,6 @@ def test_environment_rack_env
     ENV['RACK_ENV'] = @environment
 
     cli = Puma::CLI.new []
-    cli.send(:setup_options)
 
     conf = cli.instance_variable_get(:@conf)
     conf.clamp
@@ -377,7 +375,6 @@ def test_environment_rails_env
     ENV['RAILS_ENV'] = @environment
 
     cli = Puma::CLI.new []
-    cli.send(:setup_options)
 
     conf = cli.instance_variable_get(:@conf)
     conf.clamp
@@ -389,7 +386,6 @@ def test_environment_rails_env
 
   def test_silent
     cli = Puma::CLI.new ['--silent']
-    cli.send(:setup_options)
 
     log_writer = cli.instance_variable_get(:@log_writer)
 
@@ -402,9 +398,26 @@ def test_plugins
     assert_empty Puma::Plugins.instance_variable_get(:@plugins)
 
     cli = Puma::CLI.new ['--plugin', 'tmp_restart', '--plugin', 'systemd']
-    cli.send(:setup_options)
 
     assert Puma::Plugins.find("tmp_restart")
     assert Puma::Plugins.find("systemd")
   end
+
+  def test_config_does_not_preload_app_with_workers
+    skip_unless :fork
+
+    cli = Puma::CLI.new ['-w 0']
+    config = Puma.cli_config
+
+    assert_equal false, config.options[:preload_app]
+  end
+
+  def test_config_preloads_app_with_workers
+    skip_unless :fork
+
+    cli = Puma::CLI.new ['-w 2']
+    config = Puma.cli_config
+
+    assert_equal true, config.options[:preload_app]
+  end
 end
diff --git a/test/test_config.rb b/test/test_config.rb
index 8a341673b1..ea79488349 100644
--- a/test/test_config.rb
+++ b/test/test_config.rb
@@ -651,6 +651,45 @@ def test_config_files_succeeds_when_clamped
     assert_kind_of Array, conf.config_files
   end
 
+  def test_config_does_not_preload_app_if_not_using_workers
+    conf = Puma::Configuration.new({ workers: 0 })
+    conf.clamp
+
+    assert_equal false, conf.options.default_options[:preload_app]
+  end
+
+  def test_config_preloads_app_if_using_workers
+    conf = Puma::Configuration.new({ workers: 2 })
+    conf.clamp
+    preload = Puma.forkable?
+
+    assert_equal preload, conf.options.default_options[:preload_app]
+  end
+
+  def test_config_does_not_preload_app_if_using_workers_and_prune_bundler
+    conf = Puma::Configuration.new({ workers: 2 }) do |c|
+      c.prune_bundler
+    end
+    conf.clamp
+
+    assert_equal false, conf.options.default_options[:preload_app]
+  end
+
+  def test_config_file_does_not_preload_app_if_not_using_workers
+    conf = Puma::Configuration.new { |c| c.load 'test/config/workers_0.rb' }
+    conf.clamp
+
+    assert_equal false, conf.options.default_options[:preload_app]
+  end
+
+  def test_config_file_preloads_app_if_using_workers
+    conf = Puma::Configuration.new { |c| c.load 'test/config/workers_2.rb' }
+    conf.clamp
+    preload = Puma.forkable?
+
+    assert_equal preload, conf.options.default_options[:preload_app]
+  end
+
   private
 
   def assert_run_hooks(hook_name, options = {})
diff --git a/test/test_integration_cluster.rb b/test/test_integration_cluster.rb
index 233b1407ec..8a6a01ba8e 100644
--- a/test/test_integration_cluster.rb
+++ b/test/test_integration_cluster.rb
@@ -34,22 +34,22 @@ def test_hot_restart_does_not_drop_connections
 
   def test_phased_restart_does_not_drop_connections_threads
     restart_does_not_drop_connections num_threads: 10, total_requests: 3_000,
-      signal: :USR1
+      signal: :USR1, config: "preload_app! false"
   end
 
   def test_phased_restart_does_not_drop_connections
     restart_does_not_drop_connections num_threads: 1, total_requests: 1_000,
-      signal: :USR1
+      signal: :USR1, config: "preload_app! false"
   end
 
   def test_phased_restart_does_not_drop_connections_threads_fork_worker
     restart_does_not_drop_connections num_threads: 10, total_requests: 3_000,
-      signal: :USR1, config: 'fork_worker'
+      signal: :USR1, config: "fork_worker; preload_app! false"
   end
 
   def test_phased_restart_does_not_drop_connections_unix
     restart_does_not_drop_connections num_threads: 1, total_requests: 1_000,
-      signal: :USR1, unix: true
+      signal: :USR1, unix: true, config: "preload_app! false"
   end
 
   def test_pre_existing_unix
@@ -74,7 +74,7 @@ def test_pre_existing_unix_stop_after_restart
 
     File.open(@bind_path, mode: 'wb') { |f| f.puts 'pre existing' }
 
-    cli_server "-w #{workers} -q test/rackup/sleep_step.ru", unix: :unix
+    cli_server "-w #{workers} -q test/rackup/sleep_step.ru", unix: :unix, config: "preload_app! false"
     connection = connect(nil, unix: true)
     restart_server connection
 
@@ -387,6 +387,7 @@ def test_phased_restart_with_fork_worker_and_high_worker_count
       # to simulate worker 0 timeout, total boot time for all workers
       # needs to exceed single worker timeout
       workers #{worker_count}
+      preload_app! false
     CONFIG
 
     get_worker_pids 0, worker_count
@@ -687,7 +688,7 @@ def term_closes_listeners(unix: false)
     end
   end
 
-  def worker_respawn(phase = 1, size = workers, config = 'test/config/worker_shutdown_timeout_2.rb')
+  def worker_respawn(phase = 1, size = workers, config = 'test/config/worker_respawn.rb')
     threads = []
 
     cli_server "-w #{workers} -t 1:1 -C #{config} test/rackup/sleep_pid.ru"
diff --git a/test/test_integration_pumactl.rb b/test/test_integration_pumactl.rb
index a717d71eab..0ae10fd1e8 100644
--- a/test/test_integration_pumactl.rb
+++ b/test/test_integration_pumactl.rb
@@ -58,7 +58,12 @@ def ctl_unix(signal='stop')
 
   def test_phased_restart_cluster
     skip_unless :fork
-    cli_server "-q -w #{workers} test/rackup/sleep.ru #{set_pumactl_args unix: true} -S #{@state_path}", unix: true
+    cli_server "test/rackup/sleep.ru #{set_pumactl_args unix: true}", unix: true, config: <<~RUBY
+      quiet
+      workers #{workers}
+      preload_app! false
+      state_path "#{@state_path}"
+    RUBY
 
     start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
 
diff --git a/test/test_plugin_systemd.rb b/test/test_plugin_systemd.rb
index 7ff86b3cdd..7baea9100c 100644
--- a/test/test_plugin_systemd.rb
+++ b/test/test_plugin_systemd.rb
@@ -80,7 +80,10 @@ def test_systemd_cluster_notify
 
   def assert_restarts_with_systemd(signal, workers: 2)
     skip_unless(:fork) unless workers.zero?
-    cli_server "-w#{workers} test/rackup/hello.ru", env: @env
+    cli_server "test/rackup/hello.ru", env: @env, config: <<~CONFIG
+      workers #{workers}
+      #{"preload_app! false" if signal == :USR1}
+    CONFIG
     get_worker_pids(0, workers) if workers == 2
     assert_message 'READY=1'