App-karr
view release on metacpan or search on metacpan
t/163-foundation-sigterm-cleanup.t view on Meta::CPAN
use strict;
use warnings;
# Ticket #163. Foundation installed no SIGTERM handler. Stop it
# mid-drain -- systemd TimeoutStopSec, a deploy, an operator's kill --
# and the agent survived, reparented to init, while .karr.lock named the
# foundation's now-dead pid and the next tick started a second agent.
#
# The fix has two halves:
# - Foundation::run installs SIGTERM/INT/HUP handlers that kill the
# live agent's process group and force-release the lock.
# - the lock now records the work (an open fd, plus pid/pgid as
# evidence), so the handler can find and clean it up.
#
# This test exercises the handler end-to-end. We fork a driver child
# that imports Foundation, installs the SIGTERM handler, drives an
# agent through _run_command (so the agent is in its own process
# group, the same shape production leaves), then sends SIGTERM to
# itself. We then check from the parent:
# 1. the agent is gone (it would have been reparented to init without
# the handler, and would still be running);
# 2. .karr.lock is gone (the handler force-released it);
# 3. a fresh foundation instance reports the lock free.
use Test::More;
use POSIX qw( WNOHANG SIGTERM );
use File::Temp qw( tempdir );
use Path::Tiny qw( path );
sub reap_or_kill {
my ($pid) = @_;
# waitpid(2) with WNOHANG: if the child has exited, $w is the pid
# (or 0 if still alive, -1 on error). A handler that called
# POSIX::_exit has already produced a status for us; we just want
# to collect it without the WNOHANG-vs-blocking race that would
# otherwise be the test's own failure mode.
my $w = waitpid( $pid, WNOHANG );
return 1 if $w > 0 || $w < 0;
kill 'KILL', $pid;
waitpid( $pid, 0 );
return 1;
}
sub cmdline_of {
my ($pid) = @_;
my $cmdline = path("/proc/$pid/cmdline")->slurp_utf8 // return '';
$cmdline =~ s/\0/ /g;
return $cmdline;
}
# A script the driver child runs. We write it to a tempdir and exec it
# so that the test process itself is not exposed to the SIGTERM -- the
# handler runs in the driver only, and the test process just observes.
sub write_driver_script {
my ( $dir, $repo_str ) = @_;
my $lib = path('lib')->absolute->stringify;
my $script = path($dir)->child('driver.pl');
$script->spew_utf8(<<PERL);
use strict;
use warnings;
use lib '$lib';
use App::karr::Foundation;
use Path::Tiny qw( path );
my \$repo = path("$repo_str");
my \$f = App::karr::Foundation->new( _config_data => {} );
\$f->_install_signal_handlers;
\$f->_acquire_lock( \$repo );
# _run_command is what production uses; it sets _live_agent on the
# foundation and gives the agent its own process group, which is the
# invariant the handler relies on. We call it inline here, not from
# _drain_repo, so the only thing that can stop this driver is SIGTERM.
my \$start = time;
my ( \$code, \$out ) = \$f->_run_command( \$repo, { max_runtime => 60 },
'sleep 30' );
my \$elapsed = time - \$start;
# This line is only printed if SIGTERM did NOT fire -- if it did, the
# handler's POSIX::_exit(143) replaces the run before we get here.
print "DRAIN_FINISHED code=\$code elapsed=\$elapsed\\n";
exit 0;
PERL
return $script;
}
subtest 'SIGTERM to the foundation kills its agent and releases the lock' => sub {
my $scratch = path( tempdir( CLEANUP => 1 ) );
my $repo = path( tempdir( CLEANUP => 1 ) );
my $script = write_driver_script( $scratch, "$repo" );
# The driver reads from /tmp/sentinel so the parent can know when it
# has reached the agent-running state.
my $sentinel = $scratch->child('sentinel');
my $driver_out = $scratch->child('driver.out');
open( my $sentinel_fh, '>', "$sentinel" ) or die "open sentinel: $!";
my $driver_pid = fork // die "fork driver: $!";
if ( $driver_pid == 0 ) {
# child driver
open( STDOUT, '>', "$driver_out" ) or die "open stdout: $!";
$| = 1;
# Touch the sentinel so the parent knows we are past module
# import. The parent polls the sentinel's existence + mtime.
close $sentinel_fh;
exec { $^X } ( $^X, "$script" ) or die "exec: $!";
}
close $sentinel_fh;
# Wait for the driver to actually be running the agent.
my $deadline = time + 15;
while ( time < $deadline ) {
last if -e $sentinel && -M $sentinel < -M $script;
select undef, undef, undef, 0.05;
}
ok -e $sentinel, 'driver reached its post-import state';
# Find the agent: a child of the driver running "sleep 30".
my $agent_pid;
$deadline = time + 5;
while ( time < $deadline && !$agent_pid ) {
opendir my $d, '/proc' or die;
while ( my $entry = readdir($d) ) {
next unless $entry =~ /^\d+$/;
next if $entry == $$ || $entry == $driver_pid;
my $cmdline = path("/proc/$entry/cmdline")->slurp_utf8 // next;
$cmdline =~ s/\0/ /g;
my $status = path("/proc/$entry/status")->slurp_utf8 // next;
my ($ppid) = $status =~ /^PPid:\s*(\d+)/m;
next unless defined $ppid && $ppid == $driver_pid;
if ( $cmdline =~ /sleep 30/ ) {
$agent_pid = $entry;
last;
}
}
closedir $d;
select undef, undef, undef, 0.05 unless $agent_pid;
}
ok $agent_pid, 'found the agent process running under the driver'
or BAIL_OUT 'driver did not fork the agent -- cannot exercise handler';
ok kill( 0, $agent_pid ), 'agent is alive before SIGTERM';
# SIGTERM the driver -- the handler should run, kill the agent's
# group, force-release the lock, and exit 143.
kill 'TERM', $driver_pid;
# Wait for the driver to actually exit (handler runs synchronously
# in the signal delivery, then POSIX::_exit). Give it a moment.
my $w = 0;
$deadline = time + 5;
while ( time < $deadline ) {
$w = waitpid( $driver_pid, WNOHANG );
last if $w > 0;
select undef, undef, undef, 0.05;
}
if ( !$w ) {
diag 'driver did not exit within 5s of SIGTERM -- signal handler stalled';
kill 'KILL', $driver_pid;
waitpid( $driver_pid, 0 );
}
my $driver_status = $? >> 8;
is $driver_status, 143,
'driver exited 128+SIGTERM (the conventional signal-death code)'
or diag "driver exited with status $driver_status -- handler did not run";
# Give the kernel a moment to actually reap the agent.
my $end = time + 3;
my $still_alive = 0;
while ( time < $end ) {
$still_alive = kill( 0, $agent_pid ) ? 1 : 0;
last unless $still_alive;
select undef, undef, undef, 0.05;
}
ok !$still_alive, 'the agent was killed by the SIGTERM handler'
or diag "agent $agent_pid survived SIGTERM to the foundation";
# And .karr.lock is gone -- the handler called _force_release_lock.
ok !$repo->child('.karr.lock')->exists,
'.karr.lock is unlinked by the handler, not left for the next tick'
or diag '.karr.lock was left behind -- the next tick would skip the board';
# A fresh foundation instance reports the board free.
require App::karr::Foundation;
my $f_fresh = App::karr::Foundation->new( _config_data => {} );
ok !$f_fresh->_lock_held( $repo ),
'a fresh tick sees the lock free -- the next drain will run';
# Cleanup in case the agent is still in the process of dying.
kill 'KILL', $agent_pid if kill( 0, $agent_pid );
};
done_testing;
( run in 1.800 second using v1.01-cache-2.11-cpan-364913b4093 )