DBIx-QuickDB
view release on metacpan or search on metacpan
t/watcher_owner_death.t view on Meta::CPAN
use strict;
use warnings;
use Test2::V0;
use Test2::Tools::QuickDB;
use File::Path qw/remove_tree/;
use IO::Select;
use POSIX ();
use Time::HiRes qw/sleep time/;
# Owner-death recovery depends on the Unix watcher and POSIX signals.
skip_all "watcher owner-death handling is not supported on $^O"
if $^O eq 'MSWin32';
my $db = get_db_or_skipall({driver => 'PostgreSQL'});
$db->stop if $db->started; # clone() requires a stopped source
sub pid_alive {
my ($pid) = @_;
return $pid && kill(0, $pid) ? 1 : 0;
}
# Start a live disposable/reusable clone in a child owner, stop its server so
# graceful-vs-fast teardown is observable, then make the owner disappear via
# _exit(). The independent watcher inherits $err_w, allowing the supervising
# test process to collect its diagnostics through EOF.
sub owner_death_case {
my (%params) = @_;
pipe(my $meta_r, my $meta_w) or die "metadata pipe failed: $!";
pipe(my $err_r, my $err_w) or die "stderr pipe failed: $!";
my $owner_pid = fork();
die "owner fork failed: $!" unless defined $owner_pid;
if (!$owner_pid) {
close($meta_r);
close($err_r);
close(STDERR);
open(STDERR, '>&', $err_w) or POSIX::_exit(2);
close($err_w);
my $clone = eval {
$db->clone(
autostart => 1,
cleanup => $params{cleanup},
fast_destroy => 1,
);
};
unless ($clone) {
my $error = $@ || 'clone returned no database';
$error =~ s/\s+/ /g;
print {$meta_w} "ERROR\t$error\n";
close($meta_w);
POSIX::_exit(2);
}
my $watcher = $clone->watcher;
print {$meta_w} join("\t",
$watcher->server_pid,
$watcher->watcher_pid,
$clone->dir,
), "\n";
close($meta_w);
kill('STOP', $watcher->server_pid) or do {
print STDERR "Could not stop server: $!\n";
POSIX::_exit(3);
};
# Deliberately bypass Perl destructors and END blocks. The watcher
# must detect that this owner has disappeared and enforce its policy.
POSIX::_exit(0);
}
close($meta_w);
close($err_w);
my $metadata = <$meta_r>;
close($meta_r);
waitpid($owner_pid, 0);
my $owner_status = $?;
unless (defined $metadata && $metadata !~ /^ERROR\t/) {
my $error = defined($metadata) ? $metadata : 'owner produced no metadata';
Test2::Tools::QuickDB::skipall_on_resource_error($error);
return {error => $error, owner_status => $owner_status};
}
chomp($metadata);
my ($server_pid, $watcher_pid, $dir) = split(/\t/, $metadata, 3);
my $started = time;
my $stderr = '';
my $select = IO::Select->new($err_r);
my $stderr_closed = 0;
while (time - $started < $params{timeout}) {
for my $ready ($select->can_read(0.1)) {
my $bytes = sysread($ready, my $chunk, 8192);
if (defined($bytes) && $bytes > 0) {
$stderr .= $chunk;
}
elsif (defined($bytes)) {
$stderr_closed = 1;
last;
}
}
last if $stderr_closed;
}
close($err_r);
my $elapsed = time - $started;
my $settle = time;
while ((pid_alive($server_pid) || pid_alive($watcher_pid))
&& time - $settle < 5) {
sleep 0.02;
}
# Failure-path containment: do not let a stopped database escape this
# test. These are exact pids announced by the just-exited owner.
if (!$stderr_closed && pid_alive($server_pid)) {
kill('CONT', $server_pid);
kill('KILL', $server_pid);
}
if (!$stderr_closed && pid_alive($watcher_pid)) {
my $reap_start = time;
while (pid_alive($watcher_pid) && time - $reap_start < 5) {
sleep 0.02;
}
kill('KILL', $watcher_pid) if pid_alive($watcher_pid);
}
return {
dir => $dir,
elapsed => $elapsed,
owner_status => $owner_status,
server_pid => $server_pid,
stderr => $stderr,
stderr_closed => $stderr_closed,
watcher_pid => $watcher_pid,
};
}
subtest disposable_fast_owner_death => sub {
local $ENV{QDB_STOP_GRACE} = 8;
my $case = owner_death_case(cleanup => 1, timeout => 15);
if ($case->{error}) {
fail('owner created the disposable clone');
diag($case->{error});
return;
}
is($case->{owner_status}, 0, 'owner exited via _exit without an error');
ok($case->{stderr_closed}, 'watcher exited and closed stderr');
ok($case->{elapsed} < 6,
"owner-death teardown skipped the 8s graceful wait ($case->{elapsed}s)");
is($case->{stderr}, '', 'fast owner-death teardown emitted no diagnostics');
ok(!pid_alive($case->{server_pid}), 'server is gone');
ok(!pid_alive($case->{watcher_pid}), 'watcher is gone');
ok(!-d $case->{dir}, 'disposable data directory was removed');
remove_tree($case->{dir}, {safe => 1})
if !pid_alive($case->{server_pid})
&& !pid_alive($case->{watcher_pid})
&& -d $case->{dir};
};
subtest reusable_owner_death_stays_graceful => sub {
local $ENV{QDB_STOP_GRACE} = 3;
my $case = owner_death_case(cleanup => 0, timeout => 12);
if ($case->{error}) {
fail('owner created the reusable clone');
diag($case->{error});
return;
}
is($case->{owner_status}, 0, 'owner exited via _exit without an error');
ok($case->{stderr_closed}, 'watcher exited and closed stderr');
ok($case->{elapsed} >= 2.5,
"reusable database retained graceful owner-death policy ($case->{elapsed}s)");
like($case->{stderr}, qr/Server taking too long to shut down, sending SIGQUIT/,
'captured the expected graceful-path fast-signal escalation');
like($case->{stderr}, qr/Server still running, sending SIGKILL/,
'captured the expected final escalation for the stopped server');
ok(!pid_alive($case->{server_pid}), 'server is gone');
ok(!pid_alive($case->{watcher_pid}), 'watcher is gone');
ok(-d $case->{dir}, 'reusable data directory was preserved');
remove_tree($case->{dir}, {safe => 1})
if !pid_alive($case->{server_pid})
&& !pid_alive($case->{watcher_pid})
&& -d $case->{dir};
};
done_testing;
( run in 1.277 second using v1.01-cache-2.11-cpan-14f38c9f855 )