PAGI-Server
view release on metacpan or search on metacpan
t/http2/16-sse-cleanup.t view on Meta::CPAN
}
# If we got here without a crash, timers were cleaned up properly
pass('No crash after disconnect with active keepalive timer');
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# max_body_size 413 during active SSE: per-stream timers must not leak
# ============================================================
# Regression test: the h2 max_body_size (413) path in _h2_on_body deletes
# $self->{h2_streams}{$stream_id} without first stopping the stream's SSE
# keepalive (or idle) timers. Those timers are add_child'ed to the SERVER,
# not to the per-stream state, so once the hash entry is gone nothing else
# reclaims them: _h2_on_close never runs for this path (no h2-level stream
# close event fires here), and the connection's own close-time sweep
# iterates h2_streams, which by then no longer lists this stream. Left
# unfixed, the timer keeps firing for the life of the process.
subtest 'max_body_size 413 during active SSE stops the stream keepalive timer' => sub {
my $sse_started = 0;
my $app = async sub {
my ($scope, $receive, $send) = @_;
# Deliberately does NOT call receive() first: the h2 SSE receive()
# contract only resolves its first call once body_complete is true
# (see _h2_create_sse_receive), and this test's client body stays
# open (streamed via submit_data below, never EOF) so that the
# server is still accumulating body bytes when the overrun hits.
# Calling receive() first would block the app forever and the
# keepalive would never get armed.
await $send->({ type => 'sse.start', status => 200 });
await $send->({
type => 'sse.keepalive',
interval => 0.05,
comment => 'ka',
});
$sse_started = 1;
# Nothing else to do: the client will overrun max_body_size and the
# server tears the stream down without this app ever calling
# receive() at all.
};
my ($conn, $stream_io, $client_sock, $server) = create_h2c_connection(
app => $app,
max_body_size => 40,
);
my $client = create_client();
h2c_handshake($client, $client_sock);
my $stream_id = $client->submit_request(
method => 'GET',
path => '/events',
scheme => 'http',
authority => 'localhost',
headers => [['accept', 'text/event-stream']],
body => sub { return undef }, # streaming: keep open, fed manually below
);
$client_sock->syswrite($client->mem_send);
# Wait for SSE to start with keepalive armed.
for (1..20) {
$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 $sse_started;
}
ok($sse_started, 'SSE started with keepalive armed before body overruns max_body_size');
my $ss = $conn->{h2_streams}{$stream_id};
ok($ss, 'server holds per-stream state for the SSE stream');
my $timer = $ss->{sse_ka_timer};
ok($timer && $timer->is_running, 'per-stream keepalive timer is armed before the overrun');
# Push body data past max_body_size (40 bytes) while the SSE response is
# already active.
$client->submit_data($stream_id, ('X' x 100), 0);
$client_sock->syswrite($client->mem_send);
for (1..10) {
$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);
}
ok(!exists $conn->{h2_streams}{$stream_id},
'stream entry reclaimed after max_body_size 413');
ok(!$timer->is_running,
'the stream keepalive timer was stopped, not leaked, by the 413 teardown');
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# max_body_size 413 with an SSE app parked on receive() BEFORE any send:
# no spurious synthesized-500 warning
# ============================================================
# SSE (like WebSocket) never attaches a connection_state, so the dispatch
# wrapper's client-already-gone carve-out for these scopes keys off a
# liveness fact (entry-exists-AND-not-h2_closed), not $cs->disconnect_reason.
# Regression: the 413 overrun branch in _h2_on_body used to wake a parked
# receive() (and, transitively, let the app's async sub resume synchronously
# and return) before marking the stream h2_closed -- so by the time the
# dispatch wrapper's liveness check ran (nested inside that very wake), it
# still saw a "live" entry and, since this app never called send() (so
# response_started is false), warned "returned without starting a response"
# and tried to synthesize a 500 over a stream the client already lost via
# 413.
subtest 'max_body_size 413 with SSE app parked on receive() before any send: no spurious 500 warn' => sub {
my ($receive_resolved, $receive_event);
my @app_warnings;
my $app = async sub {
my ($scope, $receive, $send) = @_;
# Calls receive() first, with no prior send() -- response_started
# stays false, which is what exercises the dispatch wrapper's
# synthesize-a-500 branch if client_gone is computed wrong.
$receive_event = await $receive->();
$receive_resolved = 1;
# Returns without ever calling send().
};
my ($conn, $stream_io, $client_sock, $server) = create_h2c_connection(
app => $app,
max_body_size => 40,
);
my $client = create_client();
h2c_handshake($client, $client_sock);
my $stream_id = $client->submit_request(
method => 'GET',
path => '/events-early-receive',
scheme => 'http',
authority => 'localhost',
headers => [['accept', 'text/event-stream']],
body => sub { return undef }, # streaming: keep open, fed manually below
);
$client_sock->syswrite($client->mem_send);
local $SIG{__WARN__} = sub { push @app_warnings, $_[0] };
# Poll (bounded) until the app is dispatched and parked on receive()
# (body_pending armed but not yet resolved).
my $dispatched = 0;
for (1..20) {
$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);
my $ss = $conn->{h2_streams}{$stream_id};
if ($ss && $ss->{body_pending} && !$ss->{body_pending}->is_ready) {
$dispatched = 1;
last;
}
}
ok($dispatched, 'app dispatched and parked on receive() before the overrun')
or diag('app never reached the parked-receive state -- cannot exercise wake-on-overrun');
# Push body data past max_body_size (40 bytes).
$client->submit_data($stream_id, ('X' x 100), 0);
$client_sock->syswrite($client->mem_send);
# Bounded wait for the parked receive() to resolve.
my $settled = 0;
for (1..20) {
$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);
if ($receive_resolved) {
$settled = 1;
last;
}
}
ok($settled, 'pending receive() resolved within the bounded wait')
or diag('receive_resolved=' . ($receive_resolved // 0));
is($receive_event->{type}, 'sse.disconnect', 'receive resolved to sse.disconnect');
ok(!(grep { /returned without starting a response/ } @app_warnings),
'dispatch wrapper did not synthesize a spurious 500 warning for this client-gone (413) SSE stream')
or diag(join('', @app_warnings));
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# Server-initiated SSE idle timeout -> sse.disconnect reason=idle_timeout
# ============================================================
# Distinguishes a server-initiated teardown from a client one: the client
# never sends or closes anything here, so the ONLY thing that can end the
# stream is the server's own per-stream idle timer. The stream ends via a
( run in 1.008 second using v1.01-cache-2.11-cpan-5c0b1e786e0 )