PAGI-Server

 view release on metacpan or  search on metacpan

t/http2/15-sse-keepalive.t  view on Meta::CPAN

    my $ss_a = $conn->{h2_streams}{$a_id};
    ok($ss_a, 'server holds per-stream state for A immediately after open');

    open_sse_stream_tracked($client, $client_sock, '/b', \$b_id);
    my $ss_b = $conn->{h2_streams}{$b_id};
    ok($ss_b, 'server holds per-stream state for B immediately after open');

    # Both keepalives are armed immediately after each stream's sse.start.
    # A ticks every 0.2s; expect >= 2 pingA comments by ~0.4s. Ceiling 3s is
    # a >= 7x margin over that expectation.
    my $deadline = Time::HiRes::time() + 3;
    while (Time::HiRes::time() < $deadline && comment_count($a_data, 'pingA') < 2) {
        exchange_frames($client, $client_sock, 1);
    }
    ok(comment_count($a_data, 'pingA') >= 2,
        'stream A received >= 2 of its own keepalive comments')
        or diag("a_data: $a_data");

    # Isolation: the bug this task fixes is the h2 SSE keepalive writer being
    # connection-level, so the second stream's sse.start silently replaced
    # the first stream's writer and one stream ended up carrying (some of)
    # the other's comments. Neither stream may EVER see the other's text.
    is(comment_count($a_data, 'pingB'), 0, 'stream A never received a pingB comment');
    is(comment_count($b_data, 'pingA'), 0, 'stream B never received a pingA comment');

    my $timer_a = $ss_a->{sse_ka_timer};
    ok($timer_a && $timer_a->is_running, "A's per-stream keepalive timer is armed");

    # A closes itself at ~0.65s (app-driven, see make_two_stream_keepalive_app).
    # Ceiling 3s (fresh deadline from here) is a wide margin over whatever
    # remains of that 0.65s budget.
    $deadline = Time::HiRes::time() + 3;
    while (Time::HiRes::time() < $deadline && !$closed{$a_id}) {
        exchange_frames($client, $client_sock, 1);
    }
    ok($closed{$a_id}, 'stream A closed within the bounded window');

    # White-box: A's own per-stream keepalive timer is released; B's timer
    # (a DIFFERENT $ss, on the same connection) is untouched and still
    # running. This is design section 11.3's "closing one stream removes
    # only its timers".
    ok(!$ss_a->{sse_ka_timer}, "A's sse_ka_timer field is released after A's close");
    ok($timer_a && !$timer_a->is_running, "A's armed timer object itself is stopped");
    ok($ss_b->{sse_ka_timer} && $ss_b->{sse_ka_timer}->is_running,
        "B's sse_ka_timer is still running after A closed");
    ok(!$closed{$b_id},
        'stream B has not closed yet (well before its own 2.5s close budget)');

    # B keeps ticking at its own 0.5s cadence after A is gone. Window is
    # 0.7s (>= 1 more tick expected); B's own close (2.5s total) is not due
    # for a long while yet, so this cannot race B's teardown.
    my $before_b = comment_count($b_data, 'pingB');
    my $settle_deadline = Time::HiRes::time() + 0.7;
    while (Time::HiRes::time() < $settle_deadline) {
        exchange_frames($client, $client_sock, 1);
    }
    ok(comment_count($b_data, 'pingB') > $before_b,
        "B's keepalive kept ticking after A's stream closed")
        or diag("b_data: $b_data");

    # Drain: let B's own app coroutine reach its 2.5s close and return
    # naturally before tearing down, so no suspended async sub is abandoned
    # mid-await. Ceiling 6s is a wide margin over B's own budget.
    $deadline = Time::HiRes::time() + 6;
    while (Time::HiRes::time() < $deadline && !$closed{$b_id}) {
        exchange_frames($client, $client_sock, 1);
    }
    ok($closed{$b_id}, "B's own close completed before teardown");

    $stream_io->close_now;
    $loop->remove($server);
};

# ============================================================
# Per-stream SSE idle timeout independence (design section 11.3)
# ============================================================
# Before this task, sse_idle_timer lived on $self (the connection): only
# the FIRST stream's sse.start actually armed it (a `return if
# $self->{sse_idle_timer}` guard on the second), and on expiry it called
# the connection-wide _handle_disconnect_and_close, tearing down every
# multiplexed stream -- not just the idle one. This proves one idle stream
# closes on its own while an active sibling (which keeps resetting its own
# idle timer via sse.send) is unaffected.
my $active_done = 0;   # flips true once the '/active' app coroutine returns

subtest 'per-stream SSE idle timeout: an idle stream closes without killing an active sibling' => sub {
    $active_done = 0;
    my $app = async sub {
        my ($scope, $receive, $send) = @_;
        return unless $scope->{type} eq 'sse';

        await $receive->();
        await $send->({ type => 'sse.start', status => 200 });

        if ($scope->{path} eq '/idle') {
            # Sends nothing else -- only the per-stream idle timer (armed at
            # sse.start) can end this stream. Wait on receive() for the
            # eventual sse.disconnect it delivers, rather than a fixed
            # background delay that would outlive the test's own teardown
            # and get abandoned.
            await $receive->();
        }
        elsif ($scope->{path} eq '/active') {
            # First send immediately (no delay), so this stream already has
            # data well before the idle stream's independently-timed 0.3s
            # timeout can fire -- the two streams are dispatched moments
            # apart, so a delayed first send here would race that closure
            # instead of reliably preceding it. Then resets its own idle
            # timer on every further send (8 * 0.1s = 0.8s), comfortably
            # outliving the 0.3s idle timeout, but still short enough that
            # the coroutine returns (rather than being abandoned mid-await)
            # before this subtest's own teardown -- see $active_done below.
            await $send->({ type => 'sse.send', data => 'tick0' });
            for my $i (1 .. 8) {
                await $loop->delay_future(after => 0.1);
                await $send->({ type => 'sse.send', data => "tick$i" });
            }
            $active_done = 1;
        }
    };

    my $server = create_test_server(app => $app, sse_idle_timeout => 0.3);
    my ($conn, $stream_io, $client_sock) =
        create_h2c_connection(app => $app, server => $server);

    my ($idle_id, $active_id);
    my $active_data = '';
    my %closed;
    my $client = create_client(
        on_data_chunk_recv => sub {
            my ($sid, $data) = @_;
            $active_data .= $data if defined $active_id && $sid == $active_id;
            return 0;
        },
        on_stream_close => sub {
            my ($sid, $err) = @_;
            $closed{$sid} = 1;
            return 0;
        },
    );

    h2c_handshake($client, $client_sock);
    open_sse_stream_tracked($client, $client_sock, '/idle', \$idle_id);
    open_sse_stream_tracked($client, $client_sock, '/active', \$active_id);

    # Idle timeout is 0.3s and the idle stream sends nothing after
    # sse.start, so it should close at ~0.3s. Ceiling 5s is a >= 16x margin.
    my $deadline = Time::HiRes::time() + 5;
    while (Time::HiRes::time() < $deadline && !$closed{$idle_id}) {
        exchange_frames($client, $client_sock, 1);
    }
    ok($closed{$idle_id}, 'idle stream closed within the bounded window');

    # Independence: the active stream (resetting its OWN idle timer every
    # 0.1s) must not have been swept away by the idle stream's timeout --
    # that whole-connection blast radius is exactly the pre-fix behavior.
    ok(!$closed{$active_id}, 'active stream is still open when the idle stream closes');
    ok(length($active_data) > 0, 'active stream had already received data');

    # The active stream keeps receiving further data afterward too, proving
    # the connection (and the active stream's own scope) survived the idle
    # stream's teardown, not just the instant of closure. 0.3s is short
    # enough to land mid-loop (active's total budget is 0.8s from its own
    # dispatch, which starts only slightly after idle's).
    my $before = length($active_data);
    my $settle_deadline = Time::HiRes::time() + 0.3;
    while (Time::HiRes::time() < $settle_deadline) {
        exchange_frames($client, $client_sock, 1);
    }
    ok(length($active_data) > $before,
        'active stream continued receiving data after the sibling idle-timeout');

    # Drain: let the active app coroutine's bounded loop finish and return
    # naturally before tearing down, so no suspended async sub is abandoned
    # mid-await. Ceiling 5s is a wide margin over its ~0.8s own budget.
    my $drain_deadline = Time::HiRes::time() + 5;
    while (Time::HiRes::time() < $drain_deadline && !$active_done) {
        exchange_frames($client, $client_sock, 1);
    }
    ok($active_done, "active stream's app coroutine returned before teardown");

    $stream_io->close_now;
    $loop->remove($server);
};

done_testing;



( run in 0.663 second using v1.01-cache-2.11-cpan-364913b4093 )