PAGI-Server

 view release on metacpan or  search on metacpan

t/59-ws-disconnect-exactly-once.t  view on Meta::CPAN

# masked per RFC 6455). Mirrors t/14-websocket-invalid-utf8.t's helper.
sub make_websocket_frame {
    my ($opcode, $payload) = @_;

    my $frame = chr(0x80 | $opcode);

    my $len = length($payload);
    if ($len < 126) {
        $frame .= chr(0x80 | $len);
    }
    else {
        $frame .= chr(0x80 | 126) . pack('n', $len);
    }

    my $mask = pack('N', int(rand(0xFFFFFFFF)));
    $frame .= $mask;

    my $masked_payload = '';
    for my $i (0 .. length($payload) - 1) {
        $masked_payload .= chr(ord(substr($payload, $i, 1)) ^ ord(substr($mask, $i % 4, 1)));
    }
    $frame .= $masked_payload;

    return $frame;
}

subtest 'clean peer close delivers exactly one websocket.disconnect' => sub {
    my @seen;

    my $test_app = async sub {
        my ($scope, $receive, $send) = @_;

        if ($scope->{type} eq 'lifespan') {
            while (1) {
                my $event = await $receive->();
                if ($event->{type} eq 'lifespan.startup') {
                    await $send->({ type => 'lifespan.startup.complete' });
                }
                elsif ($event->{type} eq 'lifespan.shutdown') {
                    await $send->({ type => 'lifespan.shutdown.complete' });
                    last;
                }
            }
            return;
        }

        return unless $scope->{type} eq 'websocket';

        my $event = await $receive->();  # websocket.connect
        await $send->({ type => 'websocket.accept' });

        # Normal, well-behaved app: stop calling receive() the moment the
        # first websocket.disconnect arrives -- exactly one call, ever.
        my $ev = await $receive->();
        push @seen, $ev;

        # Stay alive well past the TCP close that follows the peer's Close
        # frame (~50ms later, per the reproduction) without calling receive()
        # again. This isolates the on_closed path as the only thing that can
        # still act after the Close frame was handled -- the app's own
        # session-complete teardown (which fires the moment this coroutine
        # returns) must not be what triggers a second delivery.
        await $loop->delay_future(after => 0.5);
    };

    my $server = PAGI::Server->new(
        app   => $test_app,
        host  => '127.0.0.1',
        port  => 0,
        quiet => 1,
    );

    $loop->add($server);
    $server->listen->get;
    my $port = $server->port;

    my $sock = IO::Socket::INET->new(
        PeerAddr => '127.0.0.1',
        PeerPort => $port,
        Proto    => 'tcp',
        Timeout  => 5,
    );

    SKIP: {
        skip "Cannot connect to server", 5 unless $sock;

        my $key = 'dGhlIHNhbXBsZSBub25jZQ==';
        print $sock "GET / HTTP/1.1\r\n";
        print $sock "Host: 127.0.0.1:$port\r\n";
        print $sock "Upgrade: websocket\r\n";
        print $sock "Connection: Upgrade\r\n";
        print $sock "Sec-WebSocket-Key: $key\r\n";
        print $sock "Sec-WebSocket-Version: 13\r\n";
        print $sock "\r\n";

        $sock->blocking(0);
        my $response = '';
        my $deadline = time + 3;
        while (time < $deadline) {
            my $buf;
            my $n = sysread($sock, $buf, 4096);
            if (defined $n && $n > 0) {
                $response .= $buf;
                last if $response =~ /\r\n\r\n/;
            }
            $loop->loop_once(0.1);
        }
        like($response, qr/HTTP\/1\.1 101/, 'WebSocket upgrade successful');

        # White-box handle on the live Connection object -- captured now,
        # before any close activity, so it survives the connection's later
        # removal from $server->{connections}.
        my ($conn) = values %{$server->{connections}};
        ok($conn, 'captured Connection object for white-box inspection');

        # Step 1: peer sends a clean Close frame (code 1000, reason 'bye').
        my $close_frame = make_websocket_frame(8, pack('n', 1000) . 'bye');
        $sock->blocking(1);
        print $sock $close_frame;
        $sock->flush;



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