PAGI-Tools

 view release on metacpan or  search on metacpan

t/sse/11-async-close.t  view on Meta::CPAN

# Regression for S2: PAGI::SSE::close() is a synchronous method that drives the
# async _run_close_callbacks via ->get (SSE.pm:550). An on_close callback that
# suspends on a not-yet-ready Future (real async I/O -- e.g. DB cleanup) cannot
# complete: ->get on a pending Future dies (plain Future) or re-enters the event
# loop (IO::Async, deadlock-prone when close() is called from inside the loop).
# The async dispatch paths (run/disconnect) already `await` the callbacks; only
# the public close() uses ->get. close() must await the callbacks too.

subtest 'close() awaits a suspending async on_close instead of dying' => sub {
    my $sse = PAGI::SSE->new({ type => 'sse' }, sub {}, sub { Future->done });
    $sse->start->get;

    my $gate = Future->new;          # pending: the async I/O the callback awaits
    my $cleanup_ran = 0;
    $sse->on_close(async sub { await $gate; $cleanup_ran = 1; return });

    # PRIMARY contract: close() must not blow up when a callback suspends.
    # Current sync close() does ->get on a still-pending Future and dies here.
    my $close_f = eval { $sse->close };
    my $err = $@;
    ok($close_f, 'close() did not die when an on_close callback suspended')
        or diag("close() threw: $err");

    if ($close_f) {
        $gate->done;
        Future->wrap($close_f)->get;
        ok($cleanup_ran, 'async on_close ran to completion via close()');
    }
};

# Same defect, seen through a real application: an SSE app that streams an event,
# registers an async cleanup (e.g. release a subscription / close a DB handle),
# then explicitly closes the stream -- a completely ordinary "done streaming"
# flow. The app uses the forward-looking idiom `await $sse->close`.
subtest 'an SSE app that closes after streaming, with async cleanup, does not crash' => sub {
    my @sent;
    my $send    = sub { push @sent, $_[0]; Future->done };
    my $receive = sub { Future->new };   # client never disconnects on its own

    my $gate        = Future->new;       # the async cleanup's I/O
    my $cleanup_ran = 0;

    my $app = async sub {
        my ($scope, $receive, $send) = @_;
        my $sse = PAGI::SSE->new($scope, $receive, $send);
        await $sse->start;
        $sse->on_close(async sub { await $gate; $cleanup_ran = 1; return });
        await $sse->send("hello");
        await $sse->close;               # app is done; close the stream
    };

    my $app_f = $app->({ type => 'sse' }, $receive, $send);

    # Release the cleanup's I/O so a correctly-awaiting close() can finish.
    $gate->done;

    my $lived = eval { $app_f->get; 1 };
    my $err = $@;
    ok($lived, 'SSE app that closes after streaming did not crash')
        or diag("app died: $err");
    # Guarded by $lived so a resumed orphan coroutine can't fake a green here.
    ok($lived && $cleanup_ran, 'async on_close cleanup completed as part of close()');
};

done_testing;



( run in 1.790 second using v1.01-cache-2.11-cpan-7fcb06a456a )