Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
12 changes: 8 additions & 4 deletions lib/litestream/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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|
Expand Down
16 changes: 2 additions & 14 deletions lib/puma/plugin/litestream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -59,10 +51,6 @@ def litestream_dead?
true
end

def puma_dead?
Process.ppid != puma_pid
end

def log(...)
log_writer.log(...)
end
Expand Down
26 changes: 26 additions & 0 deletions test/litestream/test_commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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