From c6656836dfa685ac8fb0eff69783cd9475bcf284 Mon Sep 17 00:00:00 2001 From: Cole Robertson Date: Thu, 10 Sep 2026 03:29:29 +0000 Subject: [PATCH] Give the Puma plugin the pid of the litestream process Since 0.14 the plugin forked a Ruby child that called Commands.replicate(async: true), which forked again and exec'd the binary. The plugin held the outer pid, which exited as a zombie while the daemon was reparented to init. On Puma stop the hook fired, sent INT to the dead pid, and replication kept running. Commands.replicate(async: true) now uses Process.spawn and returns the daemon's pid; the plugin holds that pid and its INT-then-wait works. The extra monitor_puma thread went with the Ruby child; it could only run inside a Ruby process, and it only covered the case where Puma died without running its stop hooks. --- CHANGELOG.md | 2 ++ lib/litestream/commands.rb | 12 ++++++++---- lib/puma/plugin/litestream.rb | 16 ++-------------- test/litestream/test_commands.rb | 26 ++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b61a0f..c184571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [Unreleased] +- The Puma plugin holds the pid of the litestream process itself, so stopping Puma stops replication instead of leaving the daemon running. + ## [0.14.0] - 2025-06-14 - Change async behaviour of replicate and other commands ([@hschne](https://github.com/fractaledmind/litestream-ruby/pull/62)) diff --git a/lib/litestream/commands.rb b/lib/litestream/commands.rb index 0d86aad..b99eabc 100644 --- a/lib/litestream/commands.rb +++ b/lib/litestream/commands.rb @@ -94,9 +94,9 @@ def executable(exe_path: DEFAULT_DIR) exe_file end - # Replicate can be run either as a fork or in the same process, depending on the context. - # Puma will start replication as a forked process, while running replication from a rake - # tasks won't. + # Replicate runs either as a child process or in the foreground, depending on the context. + # The Puma plugin starts it as a child and gets its pid back; the rake task runs it in the + # foreground and streams its output. def replicate(async: false, **argv) cmd = prepare("replicate", argv) run_replicate(cmd, async: async) @@ -169,9 +169,13 @@ def run(cmd, tabled_output:) rows.map { keys.zip(_1).to_h } end + # Async replication returns the pid of the litestream process itself so a + # supervisor can signal and wait on it. Forking a Ruby child that then + # execs would hand back a pid that exits (as a zombie) while the daemon + # is reparented to init and outlives its supervisor. def run_replicate(cmd, async:) if async - exec(*cmd) if fork.nil? + Process.spawn(*cmd) else # When running in-process, we capture output continuously and write to stdout. IO.popen(cmd, err: [:child, :out]) do |io| diff --git a/lib/puma/plugin/litestream.rb b/lib/puma/plugin/litestream.rb index 023497f..2d51a46 100644 --- a/lib/puma/plugin/litestream.rb +++ b/lib/puma/plugin/litestream.rb @@ -2,17 +2,13 @@ # Copied from https://github.com/rails/solid_queue/blob/15408647f1780033dad223d3198761ea2e1e983e/lib/puma/plugin/solid_queue.rb Puma::Plugin.create do - attr_reader :puma_pid, :litestream_pid, :log_writer + attr_reader :litestream_pid, :log_writer def start(launcher) @log_writer = launcher.log_writer - @puma_pid = $$ launcher.events.on_booted do - @litestream_pid = fork do - Thread.new { monitor_puma } - Litestream::Commands.replicate(async: true) - end + @litestream_pid = Litestream::Commands.replicate(async: true) in_background do monitor_litestream @@ -33,10 +29,6 @@ def stop_litestream rescue Errno::ECHILD, Errno::ESRCH end - def monitor_puma - monitor(:puma_dead?, "Detected Puma has gone away, stopping Litestream...") - end - def monitor_litestream monitor(:litestream_dead?, "Detected Litestream has gone away, stopping Puma...") end @@ -59,10 +51,6 @@ def litestream_dead? true end - def puma_dead? - Process.ppid != puma_pid - end - def log(...) log_writer.log(...) end diff --git a/test/litestream/test_commands.rb b/test/litestream/test_commands.rb index 9b96224..963f7e3 100644 --- a/test/litestream/test_commands.rb +++ b/test/litestream/test_commands.rb @@ -873,3 +873,29 @@ def test_output_formatting_generates_formatted_table end end end + +class TestReplicateProcess < ActiveSupport::TestCase + def test_async_replicate_returns_the_pid_of_the_litestream_process + Dir.mktmpdir do |dir| + executable = File.join(dir, "litestream") + pid_file = File.join(dir, "pid") + # Exits cleanly on INT like the real daemon, so Process.wait leaves a + # normal exit status behind for tests that read $? after a stubbed command. + File.write(executable, "#!/bin/sh\ntrap 'exit 0' INT TERM\necho $$ > #{pid_file}\nwhile :; do sleep 1; done\n") + File.chmod(0o755, executable) + + Litestream::Commands.stub :executable, executable do + pid = Litestream::Commands.replicate(async: true) + sleep 0.2 until File.exist?(pid_file) && !File.read(pid_file).strip.empty? + + assert_equal File.read(pid_file).to_i, pid + ensure + if pid + Process.kill(:INT, pid) + Process.wait(pid) + assert_raises(Errno::ECHILD) { Process.wait(pid, Process::WNOHANG) } + end + end + end + end +end