PAGI-Server
view release on metacpan or search on metacpan
t/23-connection-cleanup.t view on Meta::CPAN
my $port = $server->port;
my @sockets;
# Send 10 requests (5 will throw exceptions)
for my $i (1..10) {
my $sock = IO::Socket::INET->new(
PeerAddr => '127.0.0.1',
PeerPort => $port,
Proto => 'tcp',
Timeout => 2,
) or die "Cannot connect: $!";
# Use keep-alive to verify exception connections are closed, not due to Connection: close
print $sock "GET /$i HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n";
my $response = '';
$sock->blocking(0);
my $deadline = time + 2;
while (time < $deadline) {
$loop->loop_once(0.1);
my $data;
my $bytes = sysread($sock, $data, 4096);
if (defined $bytes && $bytes > 0) {
$response .= $data;
}
elsif (defined $bytes && $bytes == 0) {
last; # Server closed connection (exception case)
}
# For keep-alive success case, response ends with body
last if $response =~ /OK$/;
}
push @sockets, $sock; # Keep socket open to simulate keep-alive
}
# Let server process
$loop->loop_once(0.2);
is($request_count, 10, "All 10 requests processed");
is($exception_count, 5, "5 exceptions thrown");
# Exception connections (5) should be closed immediately
# Keep-alive successful connections (5) should still be tracked (waiting for more requests)
my $conn_count = keys %{$server->{connections}};
is($conn_count, 5, "Exception connections closed, keep-alive connections still tracked");
# Clean up: close client sockets
close($_) for @sockets;
$server->shutdown->get;
eval { $loop->remove($server) };
};
# =============================================================================
# Test: h1 teardown with the outbound buffer above the high mark must NOT
# fire on_drain (the connection is going away, not draining -- h2 already
# gets this right via its separate transport_drain_fires list; h1's arm_drain
# used to piggyback directly on the same _drain_waiters Futures that
# blocking producers await, so tearing down resolved both indiscriminately
# and fired on_drain with the buffer nowhere near the low mark). A producer
# genuinely parked on a blocking backpressure await must still resume (no
# coroutine leak) -- only the app-facing on_drain callback must be dropped.
#
# Exercises the real Connection-level machinery (_h1_transport_state's
# arm_drain, _wait_for_drain, _cancel_drain_waiters) directly against a
# fake stream that reports a controlled, non-draining buffer size -- a
# unit-level handler invocation, not a real socket, so the buffer-stays-full
# precondition is exact rather than timing-dependent.
# =============================================================================
package Local::FakeWriter {
sub new { my ($c, $d) = @_; return bless { data => $d }, $c }
sub data { return $_[0]->{data} }
}
package Local::FakeStream {
sub new { return bless { writequeue => [] }, shift }
sub configure { my $self = shift; my %args = @_; %$self = (%$self, %args); return }
}
package Local::FakeServer {
sub new { my ($c, $loop) = @_; return bless { loop => $loop }, $c }
sub loop { return $_[0]->{loop} }
}
subtest 'h1 teardown with buffer above the high mark drops on_drain but resumes a parked wait' => sub {
my $fake_loop = IO::Async::Loop->new;
my $fake_server = Local::FakeServer->new($fake_loop);
my $fake_stream = Local::FakeStream->new;
my $conn = PAGI::Server::Connection->new(
stream => $fake_stream, server => $fake_server, app => sub { },
write_high_watermark => 10, write_low_watermark => 2,
);
# Buffer is (and stays, for this test) well above the high mark.
$fake_stream->{writequeue} = [ Local::FakeWriter->new('x' x 100) ];
my $transport = $conn->_h1_transport_state;
my $drain_fired = 0;
$transport->on_drain(sub { $drain_fired++ });
# Crossing the high mark arms drain detection -- h1's arm_drain fires
# into $conn->{_drain_fires} (post-fix) rather than reusing the blocking
# producer queue.
$transport->_check_watermarks;
# A genuinely parked producer, awaiting the buffer to drain before its
# next write -- must still resume when the connection tears down.
my $parked = $conn->_wait_for_drain;
ok(!$parked->is_ready, 'parked wait is pending before teardown (buffer still above low mark)');
# Teardown: the buffer is still full (nothing has actually drained).
$conn->_cancel_drain_waiters('connection closing');
is($drain_fired, 0, 'on_drain did NOT fire on teardown (connection going away, not draining)');
ok($parked->is_ready, 'the parked blocking wait resumed anyway (no coroutine leak)');
};
subtest 'h1 on_drain still fires normally when the buffer genuinely drains (not torn down)' => sub {
my $fake_loop = IO::Async::Loop->new;
my $fake_server = Local::FakeServer->new($fake_loop);
my $fake_stream = Local::FakeStream->new;
my $conn = PAGI::Server::Connection->new(
stream => $fake_stream, server => $fake_server, app => sub { },
write_high_watermark => 10, write_low_watermark => 2,
);
$fake_stream->{writequeue} = [ Local::FakeWriter->new('x' x 100) ];
my $transport = $conn->_h1_transport_state;
my $drain_fired = 0;
$transport->on_drain(sub { $drain_fired++ });
$transport->_check_watermarks;
my $parked = $conn->_wait_for_drain;
# The buffer actually falls back below the low mark -- a real drain, the
# same event on_outgoing_empty reports on a live stream.
$fake_stream->{writequeue} = [];
$conn->_check_drain_waiters;
is($drain_fired, 1, 'on_drain fired on a genuine drain');
ok($parked->is_ready, 'the parked blocking wait also resolved');
};
# =============================================================================
# Test: socket-error handlers (spec tokens read_error/write_error).
#
# IO::Async::Stream's own contract (perldoc IO::Async::Stream, "on_read_error"
# / "on_write_error"): "If an error occurs when the corresponding error
# callback is not supplied, ... the close method is called instead" --
# confirmed in source (_do_read / _do_write):
# $self->maybe_invoke_event(on_read_error => $errno) or $self->close_now;
# maybe_invoke_event always returns a (truthy) arrayref once ANY handler is
# registered, regardless of what that handler itself returns, so simply
# registering on_read_error/on_write_error is sufficient by itself to
# suppress IO::Async's own close_now -- there is no risk of double-teardown
# from IO::Async's side; our handler becomes solely responsible for tearing
# the connection down, via the same _handle_disconnect_and_close every other
# reason already uses (whose _disconnect_handled guard is independently
# idempotent against any other path -- e.g. on_closed -- that might also
# fire afterward).
#
# A live EPIPE/ECONNRESET provocation (RST-closing the client socket via
# SO_LINGER=>0, then forcing a server write) was attempted first, per the
# audit brief, using several variants (immediate release, delayed release,
# large single write, many small writes). On this platform/sandbox the
# server's on_read handler's EOF/close detection reliably wins the race
# against any write-side failure -- every variant tried settled on
# client_closed (or a same-tick 200, meaning the write itself succeeded
# despite the peer RST) rather than a genuine write-side error, so this pins
# the fix with a unit-level handler invocation instead, per the brief's own
# fallback allowance: a real Connection, with start() actually called (so
# on_read_error/on_write_error are registered by the real production code,
# not hand-rolled by the test), against a real IO::Async::Stream backed by a
( run in 1.605 second using v1.01-cache-2.11-cpan-364913b4093 )