EV-Future

 view release on metacpan or  search on metacpan

t/repro_reentrant_cancel.t  view on Meta::CPAN

use strict;
use warnings;
use Test::More;
use EV;
use EV::Future;

# A cancel issued from inside a DESTROY that fires *during* cleanup itself is
# a different hazard from an ordinary user cancel. Cleanup drops
# ctx->tasks/final_cb/worker, and any of those can be the last reference
# keeping a blessed object alive. If that object's DESTROY calls $h->cancel
# while the handle had not been detached yet, the handle still looked live
# and cancel would re-enter cleanup on a context that is already half torn
# down: it double-decs ctx->tasks and ctx->final_cb, walks whatever CV field
# the first pass freed without NULLing, then double-frees the handle cell and
# the context itself. Detaching the handle first, before any SvREFCNT_dec that
# can run Perl, makes the reentrant call see h->ctx == NULL and bail out
# immediately.
#
# All four primitives have their own cleanup with its own copy of that
# ordering, so all four are exercised here: covering only parallel let the
# other three regress silently (the detach block moved back to the end of
# series_cleanup, plimit_cleanup or race_cleanup still passed the whole suite).
#
# Both shapes crash the process outright against the buggy ordering (not only
# under valgrind), so a plain run of this file is the detector; valgrind on top
# confirms no leak either.

package EVF::Repro::Guard;

sub new {
    my ($class, $cb) = @_;
    return bless { cb => $cb }, $class;
}

sub DESTROY {
    my $self = shift;
    my $cb = delete $self->{cb};
    $cb->() if $cb;
}

package main;

my @kinds = (
    {
        name  => 'parallel',
        start => sub { parallel($_[0], $_[1]) },
        # A plain object element is a no-op task that completes instantly, so
        # the tasks array is its sole owner and nothing else has to co-operate.
        guard_element => sub { $_[0] },
    },
    {
        name  => 'series',
        start => sub { series($_[0], $_[1]) },
        guard_element => sub { $_[0] },
    },
    {
        name  => 'parallel_limit',
        # limit 2 so both elements are handled in the opening burst and the
        # operation is finished by the async task's completion, as with
        # parallel; the guard is then freed from plimit_cleanup's final path.
        start => sub { parallel_limit($_[0], 2, $_[1]) },
        guard_element => sub { $_[0] },
    },
    {
        name  => 'race',
        start => sub { race($_[0], $_[1]) },
        # race treats a non-coderef element as an instant winner, which would
        # settle it inside the XSUB before the handle reaches Perl - too early
        # for the DESTROY to have a handle to cancel. So race's guard travels
        # inside a task CV that never completes; the tasks array is still the
        # only owner, and race_cleanup still frees it.
        guard_element => sub {
            my $guard = shift;
            return sub { my $keep_alive = $guard; return };
        },
    },
);

for my $kind (@kinds) {
    my $name = $kind->{name};

    subtest "reentrant cancel from a guard freed via the final_cb closure ($name)" => sub {
        our @w;
        my ($final, $reentries, $err) = (0, 0, '');
        my @warnings;
        local $SIG{__WARN__} = sub { push @warnings, @_ };
        my $h;

        {
            my $guard = EVF::Repro::Guard->new(sub {
                $reentries++;
                eval { $h->cancel };
                $err = $@ if $@;
            });
            # $guard's only remaining owner once this block ends is the
            # closure's own captured copy. The closure itself is passed
            # straight through as an argument and never bound to a variable
            # that outlives this statement, so ctx->final_cb ends up as its
            # sole reference; freeing it during cleanup is what frees $guard
            # in turn.
            $h = $kind->{start}->(
                [ sub { my $d = shift; push @w, EV::timer 0.01, 0, sub { $d->() } } ],
                sub { my $keep_alive = $guard; $final++ },
            );
        }

        $h->cancel;

        is($reentries, 1, 'guard DESTROY ran its re-entrant cancel exactly once');
        is($final, 0, 'final_cb was freed, not called (plain cancel does not fire it)');
        ok(!$err, 'the re-entrant cancel call did not die') or diag $err;
        is_deeply(\@warnings, [], 'no "Attempt to free unreferenced scalar" or similar')
            or diag explain \@warnings;
        pass('process survived a DESTROY-triggered cancel from inside cleanup');
        @w = ();
    };

    subtest "reentrant cancel from a guard freed via the tasks array ($name)" => sub {
        our @w;
        my ($final, $reentries, $err) = (0, 0, '');
        my @warnings;
        local $SIG{__WARN__} = sub { push @warnings, @_ };
        my $h;

        # The guard reaches the operation only through the anonymous tasks
        # array, whose sole owner is ctx->tasks, so it is freed when ctx->tasks
        # is freed during ordinary completion cleanup - no explicit cancel()
        # call from outside is involved here. The first task is asynchronous so
        # that $h is assigned by the time the DESTROY runs.
        $h = $kind->{start}->(
            [
                sub { my $d = shift; push @w, EV::timer 0.01, 0, sub { $d->() } },
                $kind->{guard_element}->(EVF::Repro::Guard->new(sub {
                    $reentries++;
                    eval { $h->cancel(1) };
                    $err = $@ if $@;
                })),
            ],
            sub { $final++; EV::break },
        );

        my $bail = EV::timer 0.2, 0, sub { EV::break };
        EV::run;

        is($reentries, 1, 'guard DESTROY ran its re-entrant cancel exactly once');
        is($final, 1, 'final_cb still ran exactly once after the reentrant cancel');
        ok(!$err, 'the re-entrant cancel call did not die') or diag $err;
        is_deeply(\@warnings, [], 'no "Attempt to free unreferenced scalar" or similar')
            or diag explain \@warnings;
        pass('process survived a DESTROY-triggered cancel during normal completion');
        @w = ();
    };
}

done_testing;



( run in 2.338 seconds using v1.01-cache-2.11-cpan-14f38c9f855 )