PAGI-Server
view release on metacpan or search on metacpan
t/http2/12-error-handling.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: HTTP/2 Error Handling and Edge Cases
# ============================================================
# Tests error paths, cleanup, and edge cases in the HTTP/2
# implementation.
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 (same pattern as 11-streaming.t)
# ============================================================
sub create_test_server {
my (%args) = @_;
my $server = PAGI::Server->new(
app => $args{app} // sub { },
host => '127.0.0.1',
port => 0,
quiet => 1,
http2 => 1,
%args,
);
$loop->add($server);
return $server;
}
sub create_h2c_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, %overrides);
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,
server => $server,
h2_protocol => $server->{http2_protocol},
h2c_enabled => $server->{h2c_enabled},
max_body_size => $server->{max_body_size},
);
$server->add_child($stream);
$conn->start;
return ($conn, $stream, $sock_b, $server);
}
sub create_client {
my (%overrides) = @_;
require Net::HTTP2::nghttp2::Session;
return Net::HTTP2::nghttp2::Session->new_client(
callbacks => {
on_begin_headers => $overrides{on_begin_headers} // sub { 0 },
on_header => $overrides{on_header} // sub { 0 },
on_frame_recv => $overrides{on_frame_recv} // sub { 0 },
on_data_chunk_recv => $overrides{on_data_chunk_recv} // sub { 0 },
on_stream_close => $overrides{on_stream_close} // sub { 0 },
},
t/http2/12-error-handling.t view on Meta::CPAN
exchange_frames($client, $client_sock, 20);
is($response_headers{':status'}, '413', 'Server responded with 413');
ok($stream_closed, 'Stream was closed');
ok(!$app_saw_body, 'App never saw the request body');
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# Content-Length early rejection over HTTP/2
# ============================================================
subtest 'content-length exceeding max_body_size rejected early with 413' => sub {
my $app_called = 0;
my $app = async sub {
my ($scope, $receive, $send) = @_;
$app_called = 1;
await $receive->();
await $send->({
type => 'http.response.start',
status => 200,
headers => [],
});
await $send->({
type => 'http.response.body',
body => 'ok',
more => 0,
});
};
my ($conn, $stream_io, $client_sock, $server) = create_h2c_connection(
app => $app,
max_body_size => 100,
);
my %response_headers;
my $response_body = '';
my $stream_closed = 0;
my $client = create_client(
on_header => sub {
my ($sid, $name, $value) = @_;
$response_headers{$name} = $value;
return 0;
},
on_data_chunk_recv => sub {
my ($sid, $data) = @_;
$response_body .= $data;
return 0;
},
on_stream_close => sub {
$stream_closed = 1;
return 0;
},
);
h2c_handshake($client, $client_sock);
# Send POST with content-length > max_body_size using a streaming body
# The server should reject based on content-length header alone,
# before any body data arrives
$client->submit_request(
method => 'POST',
path => '/upload-cl',
scheme => 'http',
authority => 'localhost',
headers => [
['content-type', 'application/octet-stream'],
['content-length', '50000'],
],
body => sub { return undef }, # streaming: keep open
);
$client_sock->syswrite($client->mem_send);
exchange_frames($client, $client_sock, 20);
is($response_headers{':status'}, '413', 'Server responded with 413 based on content-length');
ok(!$app_called, 'App was never called');
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# RST_STREAM from client during streaming response
# ============================================================
subtest 'RST_STREAM from client does not crash server' => sub {
my $send_started = 0;
my $app = async sub {
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 => 'chunk1',
more => 1,
});
$send_started = 1;
# Keep streaming â client will RST_STREAM
for my $i (2..10) {
eval {
await $send->({
type => 'http.response.body',
body => "chunk$i",
more => ($i < 10) ? 1 : 0,
});
};
last if $@; # Stream may be reset
}
};
my ($conn, $stream_io, $client_sock, $server) = create_h2c_connection(app => $app);
my $stream_id;
my $client = create_client(
on_frame_recv => sub {
my ($f) = @_;
# Capture the stream ID from the first HEADERS response
if ($f->{type} == 1 && $f->{stream_id} > 0) {
$stream_id = $f->{stream_id};
}
return 0;
},
);
h2c_handshake($client, $client_sock);
$client->submit_request(
method => 'GET',
path => '/streaming-rst',
scheme => 'http',
authority => 'localhost',
);
$client_sock->syswrite($client->mem_send);
# Wait for streaming to start
for (1..15) {
$loop->loop_once(0.1);
my $buf = '';
$client_sock->sysread($buf, 16384);
$client->mem_recv($buf) if length($buf);
my $out = $client->mem_send;
$client_sock->syswrite($out) if length($out);
last if $send_started;
}
if ($stream_id) {
# Build RST_STREAM frame manually:
# Length=4, Type=3, Flags=0, Stream ID, Error Code=8 (CANCEL)
my $rst_frame = pack('nCCCNN',
0, 4, # length high bytes (we need 3-byte length)
3, # type = RST_STREAM
0, # flags
$stream_id,
8, # error code = CANCEL
);
# Actually, HTTP/2 frame format is:
# 3-byte length + 1-byte type + 1-byte flags + 4-byte stream_id + payload
$rst_frame = pack('CnCCN',
0, # length byte 1 (high)
4, # length bytes 2-3
3, # type = RST_STREAM
0, # flags
$stream_id,
) . pack('N', 8); # error code
$client_sock->syswrite($rst_frame);
}
# Process the RST_STREAM
exchange_frames($client, $client_sock, 15);
# If we got here without crashing, the test passes
pass('Server survived RST_STREAM from client');
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# Empty POST body (END_STREAM on HEADERS, no DATA frames)
# ============================================================
subtest 'POST with empty body (END_STREAM on HEADERS)' => sub {
my $received_has_body;
my $received_event;
my $app = async sub {
my ($scope, $receive, $send) = @_;
$received_has_body = $scope->{_has_body};
$received_event = await $receive->();
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({
type => 'http.response.body',
t/http2/12-error-handling.t view on Meta::CPAN
require Protocol::WebSocket::Frame;
my $ws_accepted = 0;
my $close_received = 0;
my $close_code;
my $app = async sub {
my ($scope, $receive, $send) = @_;
if ($scope->{type} eq 'websocket') {
await $send->({ type => 'websocket.accept' });
$ws_accepted = 1;
# Receive connect event
my $event = await $receive->();
# Wait for messages/disconnect
while ($event->{type} ne 'websocket.disconnect') {
$event = await $receive->();
}
$close_code = $event->{code};
$close_received = 1;
}
};
my ($conn, $stream_io, $client_sock, $server) = create_h2c_connection(app => $app);
my %stream_status;
my %stream_data;
my %stream_closed;
my $client = create_client(
on_header => sub {
my ($sid, $name, $value) = @_;
$stream_status{$sid} = $value if $name eq ':status';
return 0;
},
on_data_chunk_recv => sub {
my ($sid, $data) = @_;
$stream_data{$sid} //= '';
$stream_data{$sid} .= $data;
return 0;
},
on_stream_close => sub {
my ($sid, $ec) = @_;
$stream_closed{$sid} = 1;
return 0;
},
);
h2c_handshake($client, $client_sock);
# Send Extended CONNECT for WebSocket
my $ws_stream_id = $client->submit_request(
method => 'CONNECT',
path => '/ws/test',
scheme => 'http',
authority => 'localhost',
headers => [
[':protocol', 'websocket'],
['sec-websocket-version', '13'],
],
body => sub { return undef }, # streaming: keep open
);
$client_sock->syswrite($client->mem_send);
# Exchange until WebSocket is accepted
for (1..15) {
$loop->loop_once(0.1);
my $buf = '';
$client_sock->sysread($buf, 16384);
$client->mem_recv($buf) if length($buf);
my $out = $client->mem_send;
$client_sock->syswrite($out) if length($out);
last if $ws_accepted;
}
ok($ws_accepted, 'WebSocket was accepted');
if ($ws_accepted && $ws_stream_id) {
# Note: h2c path may not always deliver the 200 status to the client
# callback (nghttp2 protocol-level issue with CONNECT). The important
# assertion is the 1007 close frame below.
# Send a WebSocket text frame with invalid UTF-8
my $frame = Protocol::WebSocket::Frame->new(
type => 'text',
buffer => "\xFF\xFE", # Invalid UTF-8
);
my $frame_bytes = $frame->to_bytes;
$client->submit_data($ws_stream_id, $frame_bytes, 0);
$client_sock->syswrite($client->mem_send);
# Exchange frames to process the invalid frame
exchange_frames($client, $client_sock, 20);
# The response data should contain a WebSocket close frame with code 1007
my $response_data = $stream_data{$ws_stream_id} // '';
if (length($response_data) > 0) {
my $parse_frame = Protocol::WebSocket::Frame->new;
$parse_frame->append($response_data);
my $close_bytes = $parse_frame->next_bytes;
if (defined $close_bytes && length($close_bytes) >= 2) {
my $code = unpack('n', substr($close_bytes, 0, 2));
is($code, 1007, 'Server sent close frame with code 1007');
} else {
pass('Server sent close response');
}
} else {
fail('No response data received for close frame');
}
}
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# WebSocket close frame validation over HTTP/2
# ============================================================
subtest 'invalid WS close frame (1-byte payload) triggers 1002' => sub {
require Protocol::WebSocket::Frame;
( run in 0.788 second using v1.01-cache-2.11-cpan-995e09ba956 )