DBIx-QuickDB
view release on metacpan or search on metacpan
t/server_exit_status.t view on Meta::CPAN
use strict;
use warnings;
use Test2::V0;
use POSIX qw/:sys_wait_h/;
use File::Temp qw/tempdir tempfile/;
# Without this "sleep 0.05" is CORE::sleep(0), collapsing the poll budgets below
# from 5 seconds to 5 milliseconds and flaking under -j16.
use Time::HiRes qw/sleep time/;
use File::Path qw/remove_tree/;
# POSIX process semantics throughout: zombies, ECHILD, and a signal in the low 7
# bits of the wait status. MSWin32 has none of it, and never builds a watcher.
skip_all "POSIX wait-status and zombie semantics do not exist on $^O"
if $^O eq 'MSWin32';
use FindBin qw/$Bin/;
use lib "$Bin/lib";
use DBIx::QuickDB::Watcher;
use QDB::FakeDriver;
# The watcher's server is its own child and nothing else reaped it, so a crashed
# server lingered as a zombie -- and kill(0) succeeds on a zombie, so start()
# called it alive and waited out the whole timeout. The watcher now reaps it and
# records the wait status where the owner, which cannot waitpid, can read it.
my $dir = tempdir("QDB-TEST-$$-XXXXXX", TMPDIR => 1, CLEANUP => 1);
# Returns -1 rather than dying, so an exhausted poll budget fails one assertion
# instead of aborting the file.
sub read_status {
my ($file) = @_;
open(my $fh, '<', $file) or do {
diag("Could not open '$file': $!");
return -1;
};
chomp(my $status = <$fh> // '');
close($fh);
return length($status) ? $status : -1;
}
subtest zombie_defeats_kill_0 => sub {
my $pid = fork();
die "Could not fork: $!" unless defined $pid;
if (!$pid) { POSIX::_exit(0) }
# Give it a moment to exit without reaping it.
sleep 1;
ok(kill(0, $pid), "kill(0) still succeeds on the unreaped (zombie) child")
or diag("This premise is what made the old 'alive' probe wrong");
waitpid($pid, 0);
};
subtest reap_records_status => sub {
my $sdir = tempdir("QDB-TEST-$$-XXXXXX", TMPDIR => 1, CLEANUP => 1);
my $file = DBIx::QuickDB::Watcher->server_exit_status_file($sdir);
my $pid = fork();
die "Could not fork: $!" unless defined $pid;
if (!$pid) { POSIX::_exit(42) }
ok(!-f $file, "No status file before the reap");
# Poll exactly as the watch loop does.
my $reaped = 0;
for (1 .. 100) {
$reaped = DBIx::QuickDB::Watcher->_reap_server($pid, $sdir);
last if $reaped;
sleep 0.05;
}
ok($reaped, "Reaped the exited child");
ok(-f $file, "Status file was written");
my $status = read_status($file);
is($status >> 8, 42, "Recorded the child's exit value");
is($status & 127, 0, "No terminating signal recorded");
# A second call cannot reap again, so it reports not-collected. Harmless:
# the watch loop latches the first true result and stops calling.
ok(!DBIx::QuickDB::Watcher->_reap_server($pid, $sdir), "Re-reap reports not-collected and does not die");
ok(-f $file, "Re-reap left the recorded status in place");
};
subtest reap_records_signal => sub {
my $sdir = tempdir("QDB-TEST-$$-XXXXXX", TMPDIR => 1, CLEANUP => 1);
my $file = DBIx::QuickDB::Watcher->server_exit_status_file($sdir);
my $pid = fork();
die "Could not fork: $!" unless defined $pid;
if (!$pid) { sleep 30; POSIX::_exit(0) }
kill('KILL', $pid);
my $reaped = 0;
for (1 .. 100) {
$reaped = DBIx::QuickDB::Watcher->_reap_server($pid, $sdir);
last if $reaped;
sleep 0.05;
}
ok($reaped, "Reaped the killed child");
is(read_status($file) & 127, 9, "Recorded SIGKILL as the terminating signal");
};
subtest running_server_is_not_reaped => sub {
my $sdir = tempdir("QDB-TEST-$$-XXXXXX", TMPDIR => 1, CLEANUP => 1);
t/server_exit_status.t view on Meta::CPAN
# Removing a disposable data dir is a goal in its own right, not a reward for a
# successful kill: a dir that outlives the process that asked for it is the
# failure the watcher exists to prevent. A server that cannot be signalled at
# all is already pathological, and its dir must still go.
#
# The eliminate case pins a leak the TERM path really had. The fast_eliminate
# case pins policy rather than a past leak -- master already removed the dir
# there on a kill failure -- so it guards against the gate being reintroduced.
for my $case (
{name => 'eliminate', method => 'eliminate', args => {stop_signal => 999}},
{name => 'fast_eliminate', method => 'fast_eliminate', args => {fast_stop_signal => 999, fast_destroy => 1}},
) {
subtest "unstoppable_server_still_loses_its_data_dir_via_$case->{name}" => sub {
my $dir = tempdir("QDB-TEST-$$-XXXXXX", TMPDIR => 1, CLEANUP => 1);
local $ENV{QDB_START_TIMEOUT} = 30;
local $ENV{QDB_STOP_GRACE} = 1;
my $db = QDB::FakeDriver->new(
dir => $dir,
serve => 1,
run_seconds => 30,
cleanup => 1,
autostart => 0,
%{$case->{args}},
);
# The watcher warns about the undeliverable signal from its own process,
# where warnings{} cannot reach it. Its STDERR is whatever fd 2 was when
# start() forked, so redirect across the fork and restore straight after
# -- the watcher keeps the file, this process keeps the harness.
my ($errfh, $errfile) = tempfile("QDB-TEST-$$-XXXXXX", TMPDIR => 1, UNLINK => 1);
open(my $saved_stderr, '>&', \*STDERR) or die "Could not save STDERR: $!";
open(STDERR, '>&', $errfh) or die "Could not redirect STDERR: $!";
my $started = eval { $db->start; 1 };
my $start_err = $@;
open(STDERR, '>&', $saved_stderr) or die "Could not restore STDERR: $!";
close($saved_stderr);
die $start_err unless $started;
my $watcher = $db->watcher;
my $spid = $watcher ? $watcher->server_pid : 0;
ok($spid, "Got a server pid") or return;
# Checked at the WATCHER, before the driver is destroyed: the driver
# calls cleanup() as its own fallback, so asserting after DESTROY
# passes even with the watcher's removal gated off. When an owner
# exits without running destructors the watcher is the only cleanup.
my $method = $case->{method};
$watcher->$method;
# Captured rather than left to leak into the harness, and asserted: a
# server this pathological is exactly what the probe exists to report,
# so losing the warning is a regression.
my $warnings = warnings { $watcher->wait };
like(
join("\n", @$warnings),
qr/still appears to be alive/,
"wait() reported the server that outlived its watcher",
);
my $watcher_stderr = do {
open(my $fh, '<', $errfile) or die "Could not read '$errfile': $!";
local $/;
<$fh> // '';
};
like(
$watcher_stderr,
qr/Could not signal server pid \Q$spid\E with SIG999: /,
"The watcher reported the signal it could not deliver",
);
ok(kill(0, $spid), "Server survived, as an invalid signal guarantees");
ok(!-d $dir, "The watcher removed its disposable data dir anyway");
kill('KILL', $spid);
waitpid($spid, 0);
};
}
# Both signals that lead to the removal. eliminate() alone left the latch on
# fast_eliminate() -- the path destroy_quietly takes -- free to be deleted with
# the whole suite still green.
for my $method (qw/eliminate fast_eliminate/) {
subtest "reap_proof_survives_the_dir_it_was_written_in_via_$method" => sub {
# The watch loop records its reap inside the data dir, and disposable
# teardown then deletes that dir. The result has to be latched before
# the signal that triggers that teardown, or wait() loses the proof and
# probes the stored pid instead -- which, for a server reaped long
# before its owner was destroyed, may name an unrelated live process by
# then. That costs the full stop timeout and warns about a server
# reaped ages ago.
#
# Driven through the public signal, not the latch helper: calling the
# helper would pass with nothing wired to it.
local $ENV{QDB_STOP_GRACE} = 1;
my $sdir = tempdir("QDB-TEST-$$-XXXXXX", TMPDIR => 1, CLEANUP => 1);
open(my $sfh, '>', DBIx::QuickDB::Watcher->server_exit_status_file($sdir))
or die "Could not write status: $!";
print $sfh "0\n";
close($sfh);
# Stands in for a watcher that has not exited yet, and for a server pid
# since recycled to something unrelated and alive.
my $fake_watcher = fork();
die "Could not fork: $!" unless defined $fake_watcher;
if (!$fake_watcher) { $SIG{TERM} = 'IGNORE'; $SIG{USR1} = 'IGNORE'; sleep 60; POSIX::_exit(0) }
my $decoy_server = fork();
die "Could not fork: $!" unless defined $decoy_server;
if (!$decoy_server) { sleep 60; POSIX::_exit(0) }
my $watcher = bless {
DBIx::QuickDB::Watcher::DATA_DIR() => $sdir,
DBIx::QuickDB::Watcher::MASTER_PID() => $$,
DBIx::QuickDB::Watcher::SERVER_PID() => $decoy_server,
DBIx::QuickDB::Watcher::WATCHER_PID() => $fake_watcher,
}, 'DBIx::QuickDB::Watcher';
$watcher->$method;
ok($watcher->server_reaped, "$method() latched the reap before the dir could be removed");
remove_tree($sdir); # disposable cleanup takes the proof with it
kill('KILL', $fake_watcher);
waitpid($fake_watcher, 0);
my $start = time;
$watcher->wait();
my $took = time - $start;
ok($took < 1, "wait() returned at once rather than probing a recycled pid");
kill('KILL', $decoy_server);
waitpid($decoy_server, 0);
};
}
subtest reusable_data_dir_is_never_removed => sub {
# The other side of the boundary. A cleanup => 0 dir is somebody's template
# -- Pool builds its cache this way -- and clones are made from it long after
# this process is gone. "Remove it anyway" applies only to disposable dirs.
my $dir = tempdir("QDB-TEST-$$-XXXXXX", TMPDIR => 1, CLEANUP => 1);
local $ENV{QDB_START_TIMEOUT} = 30;
my $db = QDB::FakeDriver->new(dir => $dir, serve => 1, run_seconds => 30, cleanup => 0, autostart => 0);
$db->start;
undef $db;
ok(-d $dir, "A reusable data dir survives teardown");
};
subtest teardown_still_cleans_up_normally => sub {
# The guard above must not cost the ordinary case its cleanup.
my $dir = tempdir("QDB-TEST-$$-XXXXXX", TMPDIR => 1, CLEANUP => 1);
local $ENV{QDB_START_TIMEOUT} = 30;
my $db = QDB::FakeDriver->new(dir => $dir, serve => 1, run_seconds => 30, cleanup => 1, autostart => 0);
$db->start;
( run in 0.602 second using v1.01-cache-2.11-cpan-14f38c9f855 )