Developer-Dashboard

 view release on metacpan or  search on metacpan

t/110-saved-ajax-group-coverage.t  view on Meta::CPAN

}

# ---- _terminate_saved_ajax_process: ownership-condition fallbacks ------------
{
    is( $runtime->_terminate_saved_ajax_process(0), 1, '_terminate_saved_ajax_process returns for a falsy pid' );

    my $dead = _fork_reaped_child();

    {
        local $Developer::Dashboard::Platform::OS_NAME = 'MSWin32';
        is( $runtime->_terminate_saved_ajax_process( $dead, $dead ), 1, '_terminate_saved_ajax_process ignores the recorded group on Windows' );
    }
    is( $runtime->_terminate_saved_ajax_process($dead), 1, '_terminate_saved_ajax_process falls back to direct-pid cleanup without a group' );
    is( $runtime->_terminate_saved_ajax_process( $dead, 'not-a-pid' ), 1, '_terminate_saved_ajax_process rejects a non-numeric group id' );
    is( $runtime->_terminate_saved_ajax_process( $dead, 0 ), 1, '_terminate_saved_ajax_process rejects the zero group recorded by Windows launches' );
    is( $runtime->_terminate_saved_ajax_process( $dead, $dead ), 1, '_terminate_saved_ajax_process completes for an owned group whose pid is already reaped' );
}

# ---- _terminate_saved_ajax_process: live owned group with a descendant -------
{
    my $marker = File::Spec->catfile( $home, "dd396-group-grandchild-$$.txt" );
    pipe my $ready_r, my $ready_w or die "pipe: $!";
    my $leader = fork();
    die "fork failed: $!" if !defined $leader;
    if ( !$leader ) {
        close $ready_r;
        POSIX::setpgid( 0, 0 ) or POSIX::_exit(97);
        my $grandchild = fork();
        POSIX::_exit(98) if !defined $grandchild;
        if ( !$grandchild ) {
            $SIG{TERM} = 'IGNORE';
            open my $marker_fh, '>', $marker or POSIX::_exit(91);
            print {$marker_fh} "$$\n";
            close $marker_fh or POSIX::_exit(92);
            select undef, undef, undef, 30;
            POSIX::_exit(0);
        }
        for ( 1 .. 200 ) {
            last if -s $marker;
            select undef, undef, undef, 0.02;
        }
        syswrite $ready_w, 'up';
        select undef, undef, undef, 30;
        POSIX::_exit(0);
    }
    push @cleanup_pids, $leader;
    close $ready_w;
    my $ready = '';
    sysread $ready_r, $ready, 2;
    close $ready_r;
    is( $ready, 'up', 'group fixture reports the leader ready after its grandchild started' );

    my $grandchild_pid;
    if ( open my $marker_fh, '<', $marker ) {
        my $line = <$marker_fh> // '';
        close $marker_fh;
        $line =~ s/\s+//g;
        $grandchild_pid = $line =~ /^\d+$/ ? $line + 0 : undef;
    }
    push @cleanup_pids, $grandchild_pid;
    ok( defined $grandchild_pid && kill( 0, $grandchild_pid ), 'group fixture grandchild is alive before termination' );

    is( $runtime->_terminate_saved_ajax_process( $leader, $leader ), 1, '_terminate_saved_ajax_process terminates a live owned process group' );
    waitpid( $leader, 0 );
    ok( !kill( 0, $leader ), '_terminate_saved_ajax_process leaves the group leader dead' );

    my $grandchild_gone = 0;
    for ( 1 .. 100 ) {
        if ( !kill 0, $grandchild_pid ) { $grandchild_gone = 1; last; }
        select undef, undef, undef, 0.05;
    }
    ok( $grandchild_gone, '_terminate_saved_ajax_process kills the TERM-ignoring descendant through the group signal' );
    unlink $marker if -e $marker;
}

# ---- DD-428: the SIGTERM escalation grants a real elapsed grace window -------

# _await_ready($read_handle)
# Blocks until a fixture process reports that its signal handlers are installed.
# Input: read end of the fixture readiness pipe.
# Output: the two-byte readiness token the fixture wrote.
sub _await_ready {
    my ($read_handle) = @_;
    my $ready = '';
    sysread $read_handle, $ready, 2;
    close $read_handle;
    return $ready;
}

# _read_marker_pid($path)
# Reads a pid a fixture descendant recorded in its marker file.
# Input: marker file path.
# Output: pid integer, or undef when the marker holds no pid.
sub _read_marker_pid {
    my ($path) = @_;
    open my $fh, '<', $path or return undef;
    my $line = <$fh> // '';
    close $fh;
    $line =~ s/\s+//g;
    return $line =~ /^\d+$/ ? $line + 0 : undef;
}

# The shipped window is what production actually grants a signalled worker, so
# pin it here: the defect this block guards was a loop that believed it waited
# one second and returned in about a millisecond.
{
    is( $Developer::Dashboard::PageRuntime::SAVED_AJAX_TERM_GRACE_SECONDS, 1, 'the shipped saved-Ajax SIGTERM grace window is one second' );
    cmp_ok( $Developer::Dashboard::PageRuntime::SAVED_AJAX_TERM_POLL_SECONDS, '>', 0, 'the grace-window poll interval is positive' );
    cmp_ok(
        $Developer::Dashboard::PageRuntime::SAVED_AJAX_TERM_POLL_SECONDS, '<',
        $Developer::Dashboard::PageRuntime::SAVED_AJAX_TERM_GRACE_SECONDS,
        'the grace-window poll interval is shorter than the window it polls',
    );
}

# The status reference threaded into the grace-window wait is documented as
# optional, and both production callers do pass one. The omitted form is still a
# supported call that the process-group scenarios above rely on, so pin it here
# directly instead of leaving the contract to be inferred from them: a future
# refactor that reads the guard as dead defensive code has to fail this first.
{

t/110-saved-ajax-group-coverage.t  view on Meta::CPAN

        POSIX::_exit(0);
    }
    push @cleanup_pids, $worker;
    close $ready_w;
    is( _await_ready($ready_r), 'up', 'cooperative worker fixture installed its SIGTERM cleanup handler' );

    my $reaped_status;
    my $started = Time::HiRes::time();
    is( $runtime->_terminate_saved_ajax_process( $worker, $worker, \$reaped_status ), 1, '_terminate_saved_ajax_process reports success for a cooperative owned group' );
    my $elapsed = Time::HiRes::time() - $started;

    ok( -e $marker, '_terminate_saved_ajax_process lets the worker SIGTERM handler finish its cleanup before escalating' );
    is( $reaped_status, 0, '_terminate_saved_ajax_process reports the cooperative exit status instead of a SIGKILL status' );
    cmp_ok( $elapsed, '>=', 0.3, '_terminate_saved_ajax_process waits for the handler rather than returning instantly' );
    cmp_ok( $elapsed, '<', 4, '_terminate_saved_ajax_process returns as soon as the owned group is gone instead of burning the window' );
    is( waitpid( $worker, WNOHANG ), -1, '_terminate_saved_ajax_process reaped the worker it waited out' );
    unlink $marker if -e $marker;
}

# Scenario: a descendant, not the worker itself, is the one still cleaning up.
{
    my $descendant_marker = File::Spec->catfile( $home, "dd428-descendant-pid-$$.txt" );
    my $cleanup_marker    = File::Spec->catfile( $home, "dd428-descendant-cleanup-$$.txt" );
    pipe my $ready_r, my $ready_w or die "pipe: $!";
    my $leader = fork();
    die "fork failed: $!" if !defined $leader;
    if ( !$leader ) {
        close $ready_r;
        POSIX::setpgid( 0, 0 ) or POSIX::_exit(97);
        my $descendant = fork();
        POSIX::_exit(98) if !defined $descendant;
        if ( !$descendant ) {
            $SIG{TERM} = sub {
                select undef, undef, undef, 0.3;
                open my $cleanup_fh, '>', $cleanup_marker or POSIX::_exit(91);
                print {$cleanup_fh} "cleaned up\n";
                close $cleanup_fh or POSIX::_exit(92);
                POSIX::_exit(0);
            };
            open my $marker_fh, '>', $descendant_marker or POSIX::_exit(93);
            print {$marker_fh} "$$\n";
            close $marker_fh or POSIX::_exit(94);
            select undef, undef, undef, 30;
            POSIX::_exit(0);
        }
        $SIG{TERM} = sub { POSIX::_exit(0) };
        for ( 1 .. 200 ) {
            last if -s $descendant_marker;
            select undef, undef, undef, 0.02;
        }
        syswrite $ready_w, 'up';
        select undef, undef, undef, 30;
        POSIX::_exit(0);
    }
    push @cleanup_pids, $leader;
    close $ready_w;
    is( _await_ready($ready_r), 'up', 'descendant fixture reports the leader ready after its descendant started' );

    my $descendant_pid = _read_marker_pid($descendant_marker);
    push @cleanup_pids, $descendant_pid;
    ok( defined $descendant_pid && kill( 0, $descendant_pid ), 'descendant fixture process is alive before termination' );

    is( $runtime->_terminate_saved_ajax_process( $leader, $leader ), 1, '_terminate_saved_ajax_process completes when only a descendant is still shutting down' );
    ok( -e $cleanup_marker, '_terminate_saved_ajax_process holds the SIGKILL escalation while an owned descendant is still cleaning up' );

    my $descendant_gone = 0;
    for ( 1 .. 100 ) {
        if ( !kill 0, $descendant_pid ) { $descendant_gone = 1; last; }
        select undef, undef, undef, 0.05;
    }
    ok( $descendant_gone, '_terminate_saved_ajax_process leaves no descendant behind once the group drains' );
    waitpid( $leader, WNOHANG );
    unlink grep { -e $_ } $descendant_marker, $cleanup_marker;
}

# Scenario: nothing in the owned group cooperates, so the escalation must fire
# only after the window really elapsed, and must clear the whole subtree.
{
    # A short window keeps the timing assertion cheap; the point is that the
    # measured wait tracks the configured window instead of collapsing to zero.
    local $Developer::Dashboard::PageRuntime::SAVED_AJAX_TERM_GRACE_SECONDS = 0.4;

    my $marker = File::Spec->catfile( $home, "dd428-stubborn-pid-$$.txt" );
    pipe my $ready_r, my $ready_w or die "pipe: $!";
    my $leader = fork();
    die "fork failed: $!" if !defined $leader;
    if ( !$leader ) {
        close $ready_r;
        POSIX::setpgid( 0, 0 ) or POSIX::_exit(97);
        $SIG{TERM} = 'IGNORE';
        my $descendant = fork();
        POSIX::_exit(98) if !defined $descendant;
        if ( !$descendant ) {
            $SIG{TERM} = 'IGNORE';
            open my $marker_fh, '>', $marker or POSIX::_exit(91);
            print {$marker_fh} "$$\n";
            close $marker_fh or POSIX::_exit(92);
            select undef, undef, undef, 30;
            POSIX::_exit(0);
        }
        for ( 1 .. 200 ) {
            last if -s $marker;
            select undef, undef, undef, 0.02;
        }
        syswrite $ready_w, 'up';
        select undef, undef, undef, 30;
        POSIX::_exit(0);
    }
    push @cleanup_pids, $leader;
    close $ready_w;
    is( _await_ready($ready_r), 'up', 'TERM-ignoring fixture reports the leader ready after its descendant started' );

    my $descendant_pid = _read_marker_pid($marker);
    push @cleanup_pids, $descendant_pid;
    ok( defined $descendant_pid && kill( 0, $descendant_pid ), 'TERM-ignoring descendant is alive before termination' );

    my $reaped_status = 'untouched';
    my $started       = Time::HiRes::time();
    is( $runtime->_terminate_saved_ajax_process( $leader, $leader, \$reaped_status ), 1, '_terminate_saved_ajax_process escalates for a TERM-ignoring owned group' );
    my $elapsed = Time::HiRes::time() - $started;

    cmp_ok( $elapsed, '>=', 0.4, '_terminate_saved_ajax_process waits the whole configured window before the SIGKILL escalation' );
    is( $reaped_status, 'untouched', '_terminate_saved_ajax_process leaves the status untouched when the worker had to be SIGKILLed' );
    waitpid( $leader, 0 );
    ok( !kill( 0, $leader ), '_terminate_saved_ajax_process leaves the TERM-ignoring group leader dead' );

    my $descendant_gone = 0;
    for ( 1 .. 100 ) {
        if ( !kill 0, $descendant_pid ) { $descendant_gone = 1; last; }
        select undef, undef, undef, 0.05;
    }
    ok( $descendant_gone, '_terminate_saved_ajax_process still kills the TERM-ignoring descendant after the real window' );
    unlink $marker if -e $marker;
}

done_testing;

__END__

=head1 NAME

110-saved-ajax-group-coverage.t - saved-Ajax POSIX process-group unit coverage

=head1 DESCRIPTION

This test drives every branch and condition of the saved-Ajax process-group
helpers in C<Developer::Dashboard::PageRuntime>: the launch-command wrapper,
the group-ownership recorder, the child-side exec launcher, and the
group-aware terminator, including the live owned-group path that must kill a
TERM-ignoring descendant. It also pins the elapsed SIGTERM grace window the
terminator grants before escalating to SIGKILL: a cooperative worker completes
its own cleanup handler, a still-draining descendant holds the escalation off,
and a group that ignores SIGTERM is killed only after the configured window has
really passed.

=for comment FULL-POD-DOC START

=head1 PURPOSE

Give the DD-396 process-group cleanup code direct unit coverage on all four
Devel::Cover metrics, complementing the end-to-end disconnect acceptance test
with deterministic per-branch checks, and hold the DD-428 grace window to a
measured elapsed wait rather than an intention expressed in code.

=head1 WHY IT EXISTS

The acceptance scenario only exercises the healthy POSIX path. The Windows
fallbacks, the argument guards, the setpgid failure path, and the
already-reaped-group path need direct calls with controlled inputs, and the
injectable C<$SETPGID> primitive needs both its stubbed and real forms driven.

=head1 WHEN TO USE

Run this test whenever the saved-Ajax launch wrapper, process-group ownership,
or termination escalation logic changes.



( run in 1.264 second using v1.01-cache-2.11-cpan-14f38c9f855 )