PAGI-Server

 view release on metacpan or  search on metacpan

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

eval { require Future::IO::Impl::IOAsync; 1 }
    or plan skip_all => 'Future::IO::Impl::IOAsync required for SSE tests';

use PAGI::Server;
use IO::Socket::INET;

plan skip_all => "Server integration tests not supported on Windows" if $^O eq 'MSWin32';

# Regression for the sse.close send-event (HTTP/1.1):
#   - sse.close is accepted (does not raise) and ends the STREAM immediately,
#     decoupled from the application returning;
#   - sending after sse.close raises (failed Future);
#   - the post-close event never reaches the wire;
#   - the stream is framed off with the chunked terminator, and the CONNECTION
#     stays alive: a clean end honors the "Connection: keep-alive" that
#     sse.start advertised (design 11.6, ratified by John 2026-08-22), so the
#     same socket serves the next request.
# Both an explicit sse.close and a plain return must end the stream identically
# on the wire (D3: keep return-to-end valid).

my $loop = IO::Async::Loop->new;

sub create_server {
    my ($app) = @_;
    my $server = PAGI::Server->new(
        app => $app, host => '127.0.0.1', port => 0, quiet => 1, shutdown_timeout => 1,
    );
    $loop->add($server);
    $server->listen->get;
    return $server;
}

# Serves the follow-up request that proves the connection survived the stream.
async sub serve_plain {
    my ($scope, $receive, $send) = @_;
    await $receive->();
    await $send->({ type => 'http.response.start', status => 200,
                    headers => [ ['content-type', 'text/plain'] ] });
    await $send->({ type => 'http.response.body', body => 'REUSED', more => 0 });
    return;
}

# Reads raw bytes until $stop matches, the server closes (EOF), or a deadline.
sub read_until {
    my ($sock, $stop, $timeout) = @_;
    my $buf = '';
    my $eof = 0;
    my $deadline = time + ($timeout // 5);
    while (time < $deadline) {
        my $chunk;
        my $n = sysread($sock, $chunk, 4096);
        if (defined $n && $n > 0) { $buf .= $chunk }
        elsif (defined $n && $n == 0) { $eof = 1; last }   # server closed the connection
        last if $buf =~ $stop;
        $loop->loop_once(0.05);
    }
    return ($buf, $eof);
}

# Open an SSE GET and read the stream up to its chunked terminator, then drain
# the loop so the app coroutine finishes. Returns (socket, wire, saw_eof); the
# socket is left open so callers can assert connection reuse on it.
sub sse_get {
    my ($port) = @_;
    my $sock = IO::Socket::INET->new(
        PeerAddr => '127.0.0.1', PeerPort => $port, Proto => 'tcp', Timeout => 5,
    ) or return (undef, '', 0);
    print $sock "GET / HTTP/1.1\r\nHost: 127.0.0.1:$port\r\nAccept: text/event-stream\r\n\r\n";
    $sock->blocking(0);

    my ($wire, $eof) = read_until($sock, qr/\r\n0\r\n\r\n/);
    $loop->loop_once(0.05) for 1 .. 20;   # let the app coroutine run to completion
    return ($sock, $wire, $eof);
}

# Issue an ordinary request on an already-used socket.
sub plain_request_on {
    my ($sock, $port) = @_;
    print $sock "GET /after HTTP/1.1\r\nHost: 127.0.0.1:$port\r\n\r\n";
    my ($wire) = read_until($sock, qr/REUSED/);
    return $wire;
}

subtest 'sse.close ends the stream; send-after-close raises' => sub {
    my ($close_ok, $post_close_raised) = (0, 0);

    my $app = async sub {
        my ($scope, $receive, $send) = @_;
        return await serve_plain(@_) if ($scope->{type} // '') eq 'http';
        die "expected sse scope" unless ($scope->{type} // '') eq 'sse';

        await $send->({ type => 'sse.start', status => 200,
                        headers => [ ['content-type', 'text/event-stream'] ] });
        await $send->({ type => 'sse.send', event => 'tick', data => '1' });

        eval { await $send->({ type => 'sse.close', reason => 'done_testing' }); $close_ok = 1; 1 };

        # After sse.close, any further send MUST raise. The transport is still
        # open at this point (design 11.6), so this is the sequence machine
        # rejecting the send, not a closed-transport no-op.
        eval { await $send->({ type => 'sse.send', event => 'late', data => 'LATE' }); 1 }
            or $post_close_raised = 1;
    };

    my $server = create_server($app);
    my $port = $server->port;
    my ($sock, $wire, $eof) = sse_get($port);

    like($wire, qr/HTTP\/1\.1 200/,                 '200 OK');
    like($wire, qr/content-type:\s*text\/event-stream/i, 'event-stream content type');
    like($wire, qr/data: 1/,                        'tick event delivered before close');
    ok($close_ok,          'sse.close was accepted (did not raise)');
    ok($post_close_raised, 'sse.send after sse.close raised');
    unlike($wire, qr/LATE/, 'post-close event did not reach the wire');
    like($wire, qr/\r\n0\r\n\r\n/, 'sse.close framed the stream off with the chunked terminator');
    ok(!$eof,              'connection stays alive after sse.close (keep-alive honored)');
    like(plain_request_on($sock, $port), qr/REUSED/,
        'same socket serves an ordinary request after sse.close');
    close $sock;

    $server->shutdown->get;
};

subtest 'return-to-end still terminates the stream (D3)' => sub {
    my $app = async sub {
        my ($scope, $receive, $send) = @_;
        return await serve_plain(@_) if ($scope->{type} // '') eq 'http';
        await $send->({ type => 'sse.start', status => 200,
                        headers => [ ['content-type', 'text/event-stream'] ] });
        await $send->({ type => 'sse.send', event => 'tick', data => 'R' });
        return;   # no sse.close -- end by returning
    };



( run in 3.220 seconds using v1.01-cache-2.11-cpan-364913b4093 )