PAGI-Server
view release on metacpan or search on metacpan
t/http2/40-pending-io-at-disconnect.t view on Meta::CPAN
use strict;
use warnings;
use Test2::V0;
use IO::Async::Loop;
use IO::Async::Stream;
use Future::AsyncAwait;
use FindBin;
use lib "$FindBin::Bin/../../lib";
use Socket qw(AF_UNIX SOCK_STREAM);
plan skip_all => "Server integration tests not supported on Windows" if $^O eq 'MSWin32';
BEGIN {
require PAGI::Server::Protocol::HTTP2;
PAGI::Server::Protocol::HTTP2->available
or plan(skip_all => 'HTTP/2 not available (Net::HTTP2::nghttp2 0.008+ required)');
}
# ============================================================
# Test: RST_STREAM settles pending I/O (spec 0.5 / Www 0.4)
# ============================================================
# The spec maps RST_STREAM to the standard abnormal-disconnect transition
# for that stream's scope. Pinned here, end-to-end over a real nghttp2
# client session:
# 1. A send Future parked on the stream (flow-control window exhausted, no
# client WINDOW_UPDATEs) settles by RESOLVING when the client resets the
# stream -- never fails, never hangs.
# 2. The resumed coroutine observes is_connected() false with the reason
# set, and on_disconnect fired outside the application send call frame.
# 3. The application Future is NOT cancelled: post-disconnect cleanup that
# must be resumed by the event loop still runs to completion.
# 4. A receive Future pending at RST_STREAM resolves with http.disconnect.
#
# h1/WebSocket/SSE settlement coverage lives in
# t/61-pending-io-at-disconnect.t.
use PAGI::Server::Connection;
use PAGI::Server;
use PAGI::Server::Protocol::HTTP1;
use PAGI::Server::Protocol::HTTP2;
my $loop = IO::Async::Loop->new;
my $protocol = PAGI::Server::Protocol::HTTP1->new;
# ============================================================
# Helpers (lifted verbatim from t/http2/05-request-lifecycle.t, plus
# access_log silencing and small write watermarks so a flooding producer
# parks quickly).
# ============================================================
sub create_test_server {
my (%args) = @_;
my $server = PAGI::Server->new(
app => $args{app} // sub { },
host => '127.0.0.1',
port => 0,
quiet => 1,
access_log => undef,
http2 => 1,
write_high_watermark => 8192,
write_low_watermark => 2048,
%args,
);
$loop->add($server);
return $server;
}
sub create_h2_connection {
my (%overrides) = @_;
socketpair(my $sock_a, my $sock_b, AF_UNIX, SOCK_STREAM, 0)
or die "socketpair: $!";
$sock_a->blocking(0);
$sock_b->blocking(0);
my $app = $overrides{app} // sub { };
my $server = $overrides{server} // create_test_server(app => $app);
my $stream = IO::Async::Stream->new(
read_handle => $sock_a,
write_handle => $sock_a,
on_read => sub { 0 },
);
my $conn = PAGI::Server::Connection->new(
stream => $stream,
app => $app,
protocol => $protocol,
t/http2/40-pending-io-at-disconnect.t view on Meta::CPAN
# Drive the loop until $cond returns true or $timeout expires.
sub pump_until {
my ($cond, $timeout) = @_;
$timeout //= 10;
my $deadline = time + $timeout;
while (time < $deadline) {
return 1 if $cond->();
$loop->loop_once(0.05);
}
return $cond->() ? 1 : 0;
}
# ============================================================
# RST_STREAM settles a parked send; app future is not cancelled
# ============================================================
subtest 'RST_STREAM settles a parked send; cleanup still runs' => sub {
my %obs = (started => 0, completed => 0, cb_count => 0);
my $chunk = 'h' x 32768;
my $app = async sub {
my ($scope, $receive, $send) = @_;
die "Unsupported scope type: $scope->{type}" unless $scope->{type} eq 'http';
my $conn = $scope->{'pagi.connection'};
my $in_send_frame = 0;
$conn->on_disconnect(sub {
my ($reason) = @_;
$obs{cb_count}++;
$obs{cb_reason} = $reason;
$obs{cb_in_send_frame} = $in_send_frame;
});
await $send->({
type => 'http.response.start',
status => 200,
headers => [ [ 'content-type', 'application/octet-stream' ] ],
});
for my $n (1 .. 64) {
$obs{started}++;
$in_send_frame = 1;
my $f = $send->({ type => 'http.response.body', body => $chunk, more => 1 });
$in_send_frame = 0;
my $ok = eval { await $f; 1 };
unless ($ok) {
$obs{send_failed} = "$@";
last;
}
$obs{completed}++;
if (!$conn->is_connected && !$obs{resumed}) {
$obs{resumed} = {
connected => $conn->is_connected ? 1 : 0,
reason => $conn->disconnect_reason,
};
last;
}
}
# Post-disconnect cleanup that requires the event loop to resume
# this coroutine again: only reachable if the application Future
# was settled-with, not cancelled out from under, the app.
await $loop->delay_future(after => 0.05);
$obs{cleanup_ran} = 1;
$obs{app_completed} = 1;
return;
};
my ($conn, $stream, $client_sock, $server) = create_h2_connection(app => $app);
my $client = create_client();
complete_h2_handshake($client, $client_sock);
my $stream_id = $client->submit_request(
method => 'GET',
path => '/stream',
scheme => 'https',
authority => 'localhost',
);
ok($stream_id, 'client opened a stream');
$client_sock->syswrite($client->mem_send);
# Do not read the socket and send no WINDOW_UPDATEs: the stream's
# flow-control window exhausts and the producer parks.
my $parked = 0;
pump_until(sub {
return 0 unless $obs{started} == $obs{completed} + 1;
my ($s, $c) = ($obs{started}, $obs{completed});
$loop->loop_once(0.2);
$parked = ($obs{started} == $s && $obs{completed} == $c);
return $parked;
}, 8);
ok($parked, 'a send is parked on the exhausted stream window')
or diag("started=$obs{started} completed=$obs{completed}");
# Client resets the stream (CANCEL).
$client->submit_rst_stream($stream_id, 8);
$client_sock->syswrite($client->mem_send);
ok(pump_until(sub { $obs{app_completed} }, 10),
'application completed after RST_STREAM (parked send did not hang)');
ok(!$obs{send_failed}, 'the parked send resolved successfully, not failed')
or diag("send failed with: $obs{send_failed}");
ok($obs{resumed}, 'application resumed from the parked send and observed disconnect');
is($obs{resumed}{connected}, 0, 'resumed coroutine sees is_connected false');
is($obs{resumed}{reason}, 'client_closed', 'RST_STREAM reports client_closed');
is($obs{cb_count}, 1, 'on_disconnect fired exactly once');
is($obs{cb_in_send_frame}, 0,
'on_disconnect was not invoked inside the application send call frame');
is($obs{cleanup_ran}, 1,
'post-disconnect cleanup ran (application Future was not cancelled)');
$server->shutdown->get;
eval { $loop->remove($server) };
};
# ============================================================
# RST_STREAM resolves a pending receive with http.disconnect
# ============================================================
subtest 'RST_STREAM resolves a pending receive with http.disconnect' => sub {
my %obs;
my $app = async sub {
my ($scope, $receive, $send) = @_;
die "Unsupported scope type: $scope->{type}" unless $scope->{type} eq 'http';
# Drain the request fully first: h2 delivers a bodyless request as
# separate http.request events (headers, then end-of-stream).
my $ev = await $receive->();
$obs{first_type} = $ev->{type};
while ($ev->{type} eq 'http.request' && $ev->{more}) {
$ev = await $receive->();
}
$obs{request_drained} = 1;
my $next = await $receive->();
$obs{next_type} = $next->{type};
$obs{app_completed} = 1;
return;
};
my ($conn, $stream, $client_sock, $server) = create_h2_connection(app => $app);
my $client = create_client();
complete_h2_handshake($client, $client_sock);
my $stream_id = $client->submit_request(
method => 'GET',
path => '/',
scheme => 'https',
authority => 'localhost',
);
ok($stream_id, 'client opened a stream');
$client_sock->syswrite($client->mem_send);
ok(pump_until(sub { $obs{request_drained} }, 5), 'app drained the request events');
is($obs{first_type}, 'http.request', 'first event is http.request');
$client->submit_rst_stream($stream_id, 8);
$client_sock->syswrite($client->mem_send);
ok(pump_until(sub { $obs{app_completed} }, 10),
'application completed after RST_STREAM (pending receive did not hang)');
is($obs{next_type}, 'http.disconnect', 'pending receive resolved with http.disconnect');
( run in 0.703 second using v1.01-cache-2.11-cpan-800906f7e73 )