view release on metacpan or search on metacpan
frame (app messages, pong, the close-frame echo, keepalive ping)
directly to nghttp2's submit_data, which registers only a single
pending-send slot per stream; submitting a second frame before nghttp2
had fully drained the first destroyed that slot's still-queued
remainder instead of queuing behind it. Any two h2 WebSocket frames
that landed close enough together for the first to still be in flight
-- a real, reachable condition, not just a theoretical one -- could
silently lose or truncate data with no error raised anywhere.
- feat(h2): as part of the fix above, the h2 WebSocket send path now
frames every send through the same per-stream send queue and
pull-based data-provider model the h2 http-streaming and SSE send paths
already use (FIFO, so wire ordering is unaffected), and now provides
pagi.transport (buffered_amount, the high/low watermarks, and
on_high_water/on_drain), matching HTTP/1.1 WebSocket and every other h2
streaming scope type: from the application's perspective the transport
is invisible, the same pagi.transport surface and semantics apply
whether a WebSocket app runs over HTTP/1.1 or HTTP/2. Close-frame +
END_STREAM wire behavior is unchanged: the close frame still carries
END_STREAM on its own final DATA frame, and a send() that wakes from
backpressure after the close frame is already queued now correctly
no-ops instead of risking a frame after Close (RFC 6455 5.5.1).
- HTTP/2 requests whose HEADERS block exceeds max_header_list_size now
receive a real 431 (Request Header Fields Too Large) response instead
of a bare RST_STREAM, matching HTTP/1.1 and RFC 9113 section 10.5.1.
HPACK decoding of the oversized block still runs to completion (fields
stream used to reinitialize the request accumulator, so the trailer
block wiped the already-committed request state and triggered a second,
spurious application dispatch with an empty pseudo-header set; the
original request's body also never reached completion, since its
END_STREAM arrived on the trailer HEADERS rather than a DATA frame.
HEADERS blocks are now classified by nghttp2's own headers_category: a
later block on an established stream accumulates separately, is
validated and discarded (PAGI defines no request-trailer receive event
yet), and its END_STREAM still completes the original request's body.
- HTTP/1.1 http.response.body{file} sends now pre-check the file with -f
and -r before streaming, matching HTTP/2's existing behavior: a missing
file fails the send Future with "File not found: $file" and an unreadable
file fails with "Cannot read file: $file", instead of failing later
inside the file-send helper with a less specific stat/open error. The
response's own headers, if already flushed, are unaffected; a
conforming app may still recover by sending a normal body afterward.
- HTTP/1.1 websocket.accept's extra application-supplied headers now go
through the same Transfer-Encoding/Connection strip as every other h1
response-header path. Previously an app-supplied ['Connection', 'close']
(or 'Transfer-Encoding') reached the 101 Switching Protocols response
verbatim, alongside the server's own literal "Connection: Upgrade" line
- fix(server): populate the WebSocket disconnect reason/code on the
disconnect event; renamed the queue_overflow close path.
- feat(server): fire on_complete on clean HTTP request completion, distinct
from the abnormal-disconnect Future; the lifespan scope state is a
documented HashRef.
- fix(server): drop non-spec scope keys â pagi.features from all scopes, and
pagi.connection from HTTP/2 WebSocket/SSE scopes.
Backpressure / flow control
- feat: pagi.transport flow-control handle on HTTP, WebSocket and SSE scopes
(HTTP/1.1, plus HTTP/2 streaming responses and SSE-over-HTTP/2), exposing
buffered_amount and edge-triggered on_high_water / on_drain watermark
callbacks.
- fix(h2): bound streaming backpressure on the per-stream send queue rather
than the shared TCP buffer, and break a transport_state reference cycle at
stream teardown.
- feat(cli): expose --write-high-watermark and --write-low-watermark on
pagi-server, threading the write backpressure watermarks through to the
server constructor (previously settable only via the constructor API).
Lifespan
- feat(lifespan): lifespan_mode (auto|on|off) and a matching --lifespan CLI
flag.
- feat(lifespan): bound startup with lifespan_startup_timeout (default 30s).
Makefile.PL
README
README.md
SECURITY.md
UPGRADING.md
bin/pagi-server
cpanfile
dist.ini
examples/01-hello-http/README.md
examples/01-hello-http/app.pl
examples/02-streaming-response/README.md
examples/02-streaming-response/app.pl
examples/03-request-body/README.md
examples/03-request-body/app.pl
examples/04-websocket-echo/README.md
examples/04-websocket-echo/app.pl
examples/05-sse-broadcaster/README.md
examples/05-sse-broadcaster/app.pl
examples/06-lifespan-state/README.md
examples/06-lifespan-state/app.pl
examples/07-extension-fullflush/README.md
examples/07-extension-fullflush/app.pl
lib/PAGI/Server/Connection.pm
lib/PAGI/Server/ConnectionState.pm
lib/PAGI/Server/EventValidator.pm
lib/PAGI/Server/Protocol/HTTP1.pm
lib/PAGI/Server/Protocol/HTTP2.pm
lib/PAGI/Server/Runner.pm
lib/PAGI/Server/TransportState.pm
nytprof.out
t/00-load.t
t/01-hello-http.t
t/02-streaming.t
t/03-request-body.t
t/04-websocket.t
t/05-sse.t
t/06-lifespan.t
t/07-extensions.t
t/08-tls.t
t/10-http-compliance.t
t/11-multiworker.t
t/12-fork-loop-isolation.t
t/13-multiworker-signal.t
t/http2/02-server-config.t
t/http2/03-detection.t
t/http2/04-read-handler.t
t/http2/05-request-lifecycle.t
t/http2/06-integration.t
t/http2/07-websocket.t
t/http2/08-websocket-edge.t
t/http2/09-cli.t
t/http2/09-multiworker.t
t/http2/10-h2c.t
t/http2/11-streaming.t
t/http2/12-error-handling.t
t/http2/13-sse-detection.t
t/http2/14-sse-events.t
t/http2/15-sse-keepalive.t
t/http2/16-sse-cleanup.t
t/http2/17-h2-ws-sse-no-connection-state.t
t/http2/18-transport-leak.t
t/http2/19-transport-callbacks.t
t/http2/20-sse-transport.t
t/http2/21-http-date-header.t
# HTTP/2 over TLS (experimental)
pagi-server --http2 --ssl-cert cert.pem --ssl-key key.pem ./app.pl
```
Run `perldoc pagi-server` for the full list of options (workers, timeouts,
limits, watermarks, TLS, listeners, and more).
## Examples
The [`examples/`](examples/) directory contains progressively more advanced,
runnable applications â minimal HTTP, streaming with disconnect handling,
request-body draining, a WebSocket echo server, an SSE broadcaster,
lifespan/shared-state, extension-aware streaming, TLS introspection, a job
runner, UTF-8 handling, and a backpressure test harness. Each has its own
`README.md`. Start with [`examples/01-hello-http`](examples/01-hello-http/).
## Documentation
- [`PAGI::Server`](lib/PAGI/Server.pm) â the server class, constructor options,
and operational notes (`perldoc PAGI::Server`).
- [`pagi-server`](bin/pagi-server) â the command-line launcher
(`perldoc pagi-server`).
- [`PAGI::Server::Runner`](lib/PAGI/Server/Runner.pm) â application loading and
examples/02-streaming-response/README.md view on Meta::CPAN
# 02 â Streaming Response with Disconnect Handling
Shows how to:
- Drain the incoming `http.request` body (if any) before replying.
- Send multiple `http.response.body` chunks with `more => 1`.
- Emit `http.response.trailers` when `trailers => 1` was advertised.
- Watch for `{ type => 'http.disconnect' }` while streaming and stop if the client drops.
## Quick Start
**1. Start the server:**
```bash
pagi-server --app examples/02-streaming-response/app.pl --port 5000
```
**2. Demo with curl:**
```bash
# Watch chunks stream in (one per second)
curl -N http://localhost:5000/
# => Chunk 1
# => Chunk 2
# => Chunk 3
# Test disconnect handling - press Ctrl+C during streaming
curl -N http://localhost:5000/
# (press Ctrl+C to see server handle disconnect)
```
## Spec References
Covered by the PAGI specification in the upstream PAGI distribution
(`PAGI::Spec` POD and protocol documents, https://github.com/jjn1056/pagi):
- HTTP events, trailers, disconnect
examples/07-extension-fullflush/README.md view on Meta::CPAN
# 07 â Extension-Aware Streaming with FullFlush
Demonstrates how to:
- Check for extension support via `scope->{extensions}{fullflush}`
- Use `http.fullflush` event during streaming to force immediate TCP buffer flush
- Only send extension events when the server advertises support
The fullflush extension is useful for real-time streaming scenarios where you want each chunk delivered to the client immediately rather than waiting for TCP buffer fill or Nagle's algorithm.
## Quick Start
**1. Start the server:**
```bash
pagi-server --app examples/07-extension-fullflush/app.pl --port 5000
```
**2. Demo with curl:**
```bash
# Watch real-time streaming with immediate flush
curl -N http://localhost:5000/
# => Line 1 (flushed immediately)
# => Line 2 (flushed immediately)
# => Line 3 (flushed immediately)
# Each chunk appears instantly rather than being buffered
```
**Note:** The difference from regular streaming is most noticeable with small chunks that would normally be buffered by TCP.
## Spec References
Covered by the PAGI specification in the upstream PAGI distribution
(`PAGI::Spec` POD and protocol documents, https://github.com/jjn1056/pagi):
- Extensions section
- Fullflush extension
examples/07-extension-fullflush/app.pl view on Meta::CPAN
use strict;
use warnings;
use Future::AsyncAwait;
# Demonstrates fullflush extension during streaming response.
# The fullflush event forces immediate TCP buffer flush, useful for
# Server-Sent Events or real-time streaming where latency matters.
async sub app {
my ($scope, $receive, $send) = @_;
die "Unsupported scope type: $scope->{type}" if $scope->{type} ne 'http';
# Drain request body if present
while (1) {
my $event = await $receive->();
last if $event->{type} ne 'http.request';
examples/11-job-runner/README.md view on Meta::CPAN
```bash
perl -Ilib -Iexamples/11-job-runner/lib bin/pagi-server \
--app examples/11-job-runner/app.pl --port 5001
```
Then open http://localhost:5001 in your browser.
## Features
- **Real-time job queue** - Create countdown jobs and watch them execute
- **Live progress streaming** - SSE updates show second-by-second progress
- **WebSocket dashboard** - Queue-wide updates pushed to all connected clients
- **Concurrent execution** - Worker processes up to 3 jobs simultaneously
## Architecture
```
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Browser (app.js) â
â ââââââââââââââââ ââââââââââââââââ ââââââââââââââââââââ â
â â WebSocket â â SSE â â HTTP (REST) â â
examples/11-job-runner/lib/JobRunner/SSE.pm view on Meta::CPAN
data => $JSON->encode($data),
});
}
1;
__END__
# NAME
JobRunner::SSE - Server-Sent Events for job progress streaming
# DESCRIPTION
Provides real-time progress streaming for individual jobs via SSE.
## Endpoint
GET /api/jobs/:id/progress
## Events
- **status** - Initial job status on connection
- **progress** - Progress update { percent, message }
- **complete** - Job completed successfully { status, result, duration }
examples/README.md view on Meta::CPAN
middleware if the PAGI-Tools distribution happens to be installed, and
silently skips it otherwise.)
Note: Some advanced examples (job-runner, chat) use `IO::Async` directly for
timer and subprocess features. These are PAGI::Server-specific patterns.
Examples assume you understand the core PAGI specification (see the `PAGI::Spec` POD from the `PAGI` distribution on CPAN, https://github.com/jjn1056/pagi) plus the relevant protocol documents.
## Example List
1. `01-hello-http` - minimal HTTP response
2. `02-streaming-response` - chunked body, trailers, disconnect handling
3. `03-request-body` - reads multi-event request bodies
4. `04-websocket-echo` - handshake and echo loop
5. `05-sse-broadcaster` - server-sent events
6. `06-lifespan-state` - lifespan protocol with shared state
7. `07-extension-fullflush` - middleware using the `fullflush` extension
8. `08-tls-introspection` - prints TLS metadata when present
9. `11-job-runner` - background job processing example
10. `12-utf8` - UTF-8 handling demonstration
11. `13-custom-logging` - sending the server's diagnostics somewhere else (needs `Log::Dispatch`)
lib/PAGI/Server.pm view on Meta::CPAN
=head2 effective_max_connections
my $max = $server->effective_max_connections;
Returns the effective maximum connections limit. If C<max_connections>
was set explicitly, returns that value. Otherwise returns the default
of 1000.
=head1 FILE RESPONSE STREAMING
PAGI::Server supports efficient file streaming via the C<file> and C<fh>
keys in C<http.response.body> events:
# Stream entire file
await $send->({
type => 'http.response.body',
file => '/path/to/file.mp4',
more => 0,
});
# Stream partial file (for Range requests)
lib/PAGI/Server/AsyncFile.pm view on Meta::CPAN
use PAGI::Server::AsyncFile;
use IO::Async::Loop;
# Create or obtain an IO::Async::Loop
my $loop = IO::Async::Loop->new;
# Read entire file
my $content = await PAGI::Server::AsyncFile->read_file($loop, '/path/to/file');
# Read file in chunks (streaming)
await PAGI::Server::AsyncFile->read_file_chunked($loop, '/path/to/file', async sub {
my ($chunk) = @_;
# Process each chunk
}, chunk_size => 65536);
# Write file
await PAGI::Server::AsyncFile->write_file($loop, '/path/to/file', $content);
# Append to file
await PAGI::Server::AsyncFile->append_file($loop, '/path/to/file', $log_line);
=head1 DESCRIPTION
This module provides non-blocking file I/O operations using L<IO::Async::Function>
worker processes. It is used internally by L<PAGI::Server> for efficient file
streaming.
B<Note:> This is a PAGI::Server internal module. PAGI applications are
loop-agnostic and should use synchronous file I/O (which is simple and fast
for typical file sizes) or bring their own async file library if needed.
It uses L<IO::Async::Function> to offload blocking file operations to worker
processes, preventing the main event loop from being blocked during disk I/O.
Regular file I/O in POSIX is always blocking at the kernel level - even
C<select()>/C<poll()>/C<epoll()> report regular files as always "ready".
This module works around this limitation by running file operations in
lib/PAGI/Server/AsyncFile.pm view on Meta::CPAN
# Process chunk
}, chunk_size => 65536);
# For Range requests (partial file):
await PAGI::Server::AsyncFile->read_file_chunked($loop, $path, $callback,
offset => 1000, # Start at byte 1000
length => 5000, # Read 5000 bytes total
);
Read a file in chunks, calling a callback for each chunk. This is suitable
for streaming large files without loading the entire file into memory.
Parameters:
=over 4
=item * C<$loop> - IO::Async::Loop instance
=item * C<$path> - Path to the file to read
=item * C<$callback> - Async callback called with each chunk. Receives the chunk data.
lib/PAGI/Server/Compliance.pod view on Meta::CPAN
=head1 PAGI SPECIFICATION SUPPORT
Beyond protocol conformance, PAGI::Server implements the optional capabilities
defined in L<PAGI::Spec::Www>, across both HTTP/1.1 and HTTP/2:
=over 4
=item * B<Transport flow control> (C<pagi.transport>) -- C<buffered_amount>,
high/low watermarks, and C<on_high_water>/C<on_drain> backpressure callbacks, on
both HTTP/1.1 and HTTP/2, for every streaming scope type PAGI offers it on:
http, sse, and websocket (see L</"Transport Flow Control (pagi.transport)">).
=item * B<Connection state> (C<pagi.connection>) for HTTP scopes --
C<is_connected>, C<disconnect_reason>, C<on_disconnect> (abnormal only),
C<on_complete> (success only), and C<disconnect_future>. Tracked
independently per stream on HTTP/2 (see L</"Connection State
(pagi.connection)">).
=item * B<WebSocket Denial Response> (the C<websocket.http.response> extension)
-- reject a handshake with a custom HTTP response instead of a bare C<403>, on
lib/PAGI/Server/Compliance.pod view on Meta::CPAN
=item * Server SETTINGS: configurable max_concurrent_streams, initial_window_size,
max_frame_size, max_header_list_size
=item * A request HEADERS block exceeding max_header_list_size gets a real
431 response (RFC 9113 section 10.5.1) instead of a bare RST_STREAM,
matching HTTP/1.1
=item * HEAD request body suppression, matching HTTP/1.1: DATA frames are
withheld and file/fh bodies are never opened
=item * File and filehandle body streaming through the per-stream send queue,
under the same per-stream backpressure watermark as chunked bodies
=item * C<http.fullflush> on HTTP and SSE streams
=back
Filehandle (C<fh>) response bodies are read B<synchronously>, in fixed-size
chunks inside the connection's own send loop, on both HTTP/1.1 and HTTP/2 --
the handle is application-owned and cannot be handed to the async worker
pool across a fork boundary. This differs from C<file> (path) bodies opened
lib/PAGI/Server/Compliance.pod view on Meta::CPAN
=head2 Transport Flow Control (pagi.transport)
PAGI exposes outbound flow-control introspection to applications through the
C<pagi.transport> scope key (see L<PAGI::Spec::Www/"Transport Flow Control">):
C<buffered_amount>, the high/low watermarks, and the
C<on_high_water>/C<on_drain> backpressure callbacks. Over HTTP/2 the handle
measures the B<per-stream> send backlog, so each multiplexed stream is bounded
independently.
PAGI::Server provides C<pagi.transport> for every streaming scope type on both
transports: C<http> (streaming responses), C<sse>, and C<websocket>, on both
HTTP/1.1 and HTTP/2. The HTTP/2 WebSocket send path frames application
messages, protocol replies (pong, the close-frame echo), and its own keepalive
ping through the same per-stream send queue and pull-based data-provider model
that HTTP/2 streaming and SSE use. From the application's perspective the
transport is invisible: a WebSocket app sees the same C<pagi.transport>
surface and semantics -- C<buffered_amount>, the high/low watermarks,
C<on_high_water>/C<on_drain> -- whether the connection is HTTP/1.1 or
HTTP/2.
C<on_drain> fires only for a genuine drain -- the buffer actually falling
back below the low mark -- on both transports. Tearing a connection down
while the buffer is still above the high mark (client disconnect, timeout,
server shutdown, ...) does not fire C<on_drain>: the connection is going
away, not draining. A producer parked on the blocking backpressure path
lib/PAGI/Server/Compliance.pod view on Meta::CPAN
=head2 Response Headers
On C<sse.start> the server supplies C<Content-Type>, C<Cache-Control>, and
C<Date> only when the application did not already set them; an
application-supplied value for any of these is sent as-is, not duplicated
alongside a server default. C<Connection: keep-alive> on HTTP/1.1 is the
exception: it is a framing header the protocol requires the server to
control, so it is always advertised regardless of what the application
sent. HTTP/2 never emits an HTTP/1-only C<Connection> header or a
C<Transfer-Encoding> (chunked-framing) header -- an HTTP/2 SSE stream is
framed as DATA frames via C<submit_response_streaming>, which carries no
such headers.
The C<Date>-only-when-absent rule above holds server-wide, not only for
C<sse.start>: on every response path, the server supplies a plain C<Date>
header only when a C<Date> is not already present; where the application
supplies its own header list, an application-supplied C<Date> is always
honored as-is instead of being duplicated. This spans both transports and
every response shape the server can emit:
=over 4
lib/PAGI/Server/Connection.pm view on Meta::CPAN
use URI::Escape qw(uri_unescape);
use IO::Async::Timer::Countdown;
use IO::Async::Timer::Periodic;
use Time::HiRes qw(gettimeofday tv_interval);
use PAGI::Server::AsyncFile;
use PAGI::Server::ConnectionState;
use PAGI::Server::TransportState;
use PAGI::Server::EventValidator;
use constant FILE_CHUNK_SIZE => 65536; # 64KB chunks for file streaming
# Per-second cache for CLF timestamp in access log (same pattern as HTTP1::format_date)
my $_cached_log_timestamp;
my $_cached_log_time = 0;
# =============================================================================
# Header Validation (CRLF Injection Prevention)
# =============================================================================
# RFC 7230 Section 3.2.6: Field values MUST NOT contain CR or LF
lib/PAGI/Server/Connection.pm view on Meta::CPAN
my ($self, $stream_id, $stream_state) = @_;
weaken(my $weak_self = $self);
my $status;
my @response_headers;
# Streaming state for deferred data provider pattern.
# The send queue lives on per-stream state ($ss->{send_queue} /
# $ss->{send_queue_bytes}) so the h2 transport handle can measure it;
# $eof_pending / $streaming_started stay closure-local.
my $eof_pending = 0;
my $streaming_started = 0;
# Mirrors the stream state's own starting point (see the seq_state =>
# 'initial' initializer in _h2_on_request); the two must stay in step.
my $seq = 'initial';
my $is_head = (($stream_state->{pseudo}{':method'} // '') eq 'HEAD');
# Set once, from http.response.start's own 'trailers' flag (section 6
# below), and never changed again. $data_callback's no_end computation
# MUST key off this rather than $ss->{seq_state}: the trailers arm
# advances that mirror to 'complete' as soon as the app's send() call
# is made, which can be BEFORE the data provider has actually drained
# (deferred submit, see below) -- keying off seq_state there raced the
lib/PAGI/Server/Connection.pm view on Meta::CPAN
};
my $err = $@;
my $f = $ss2 && delete $ss2->{trailer_wait};
return unless $f;
$weak_self->{server}->loop->later(sub {
return if $f->is_ready;
if ($ok) { $f->done(1) } else { $f->fail($err) }
});
};
# Data callback for nghttp2's streaming response.
# Returns ($data, $eof) when data is available, or undef to defer.
my $data_callback = sub {
my ($cb_stream_id, $max_len) = @_;
my $ss = $weak_self && $weak_self->{h2_streams}{$stream_id};
return undef unless $ss;
my $q = $ss->{send_queue} ||= [];
if (@$q) {
my $chunk = shift @$q;
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# Shared preamble for the file/fh body arms below (extraction, not a
# redesign -- see the arms themselves for why each piece sits where it
# does). Split in two because the two pieces cross the arms' own
# eval boundary: the file arm must report an illegal-sequence error
# (from advance_http) ahead of a misleading "File not found" from its
# own -f/-r checks, but must NOT have submitted response headers to the
# client before those checks pass -- so the sequence advance runs
# BEFORE the arm's eval (a comment-preserving-only move: advance_http
# is a pure function, and a caught vs. uncaught throw here already
# behaved identically before this extraction, since $seq is simply
# never reassigned on a throw either way), while the streaming-start
# call must stay INSIDE the eval, at each arm's own correct position
# relative to its own pre-checks, so a failure there still rolls back
# via the shared tail below instead of leaking a submitted-but-broken
# response.
# Outside-eval half: snapshot+advance+mirror the sequence state ahead
# of either arm's own validity checks. Returns the pre-advance $seq so
# the tail helper below can roll back to it on failure.
my $advance_seq_for_body = sub {
my ($ss, $event) = @_;
my $seq_before = $seq;
$seq = PAGI::Server::EventValidator::advance_http($seq, $event);
$ss->{seq_state} = $seq if $ss;
return $seq_before;
};
# Inside-eval half: submit the streaming response exactly once, the
# first time either arm actually has a chunk ready to send. Each arm
# calls this at its own correct point (after its own pre-checks, so a
# failed check never causes headers to reach the client for a response
# that goes on to fail).
my $ensure_h2_streaming_started = sub {
my ($ss) = @_;
return if $streaming_started;
$streaming_started = 1;
$ss->{send_queue} //= []; $ss->{send_queue_bytes} //= 0;
$weak_self->{h2_session}->submit_response_streaming(
$stream_id,
status => $status, headers => \@response_headers,
data_callback => $data_callback,
);
$weak_self->_h2_write_pending;
};
# Shared failure-rollback-or-finish tail for the file/fh body arms:
# given the arm's own eval result, either rolls $seq back to its
# pre-event value and re-raises (recoverable per contract, unless the
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# invocation, and await that -- resolving this send() any
# earlier would let submit_trailer race ahead of not-yet-
# extracted DATA (see the closure-top comment). This PARKS
# the send() until the body actually finishes draining --
# e.g. until the peer grants enough flow-control window --
# which can be a real, unbounded wait from the app's point
# of view (closure-top comment has the full contract note).
$pending_trailer_headers = $trailer_headers;
my $f = $weak_self->{server}->loop->new_future;
$ss->{trailer_wait} = $f;
$weak_self->{h2_session}->resume_stream($stream_id) if $streaming_started;
$weak_self->_h2_write_pending;
# eval wraps the await (not just a plain assignment) so a
# failed $f (native rejection, via $deliver_trailer_eof's
# own $f->fail) is caught into $@ and folds into the same
# $ok/rollback handling the direct branch above uses.
$ok = eval { await $f; 1 };
# $f resolves DONE (not fail) on a whole-connection teardown
# too (_close's own sweep releases trailer_wait -- see
# _h2_resolve_stream_trailer_wait) -- the h2_closed carve-
# out's "successful no-op" contract, same discipline as the
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# A gone stream here is the h2_closed carve-out's ordinary
# "successful no-op" case: the trailers already landed (ok=1)
# before the stream went away, so returning quietly is correct,
# not a swallowed error.
$ss = $weak_self ? $weak_self->{h2_streams}{$stream_id} : undef;
return if !$ss || $ss->{h2_closed};
# Flush any still-queued DATA before the trailing HEADERS --
# nghttp2 orders trailers after queued data on its own, but the
# stream must be resumed if the data callback had deferred.
$weak_self->{h2_session}->resume_stream($stream_id) if $streaming_started;
$weak_self->_h2_write_pending;
return;
}
# 4. file body: streamed through the send queue via $emit_chunk, one
# chunk at a time, under this stream's own backpressure. The type
# guard (not just "defined $event->{file}") keeps a nonconforming
# http.response.start carrying a stray 'file' key from being
# misrouted into this arm.
if ($type eq 'http.response.body' && defined $event->{file}) {
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# sequence state reports the SEQUENCE error, not a misleading
# "File not found" -- h1 parity. A failed file send must NOT
# mark the response complete, so on failure $seq rolls back to
# $seq_before exactly as the h1 twin in _create_send does.
my $seq_before = $advance_seq_for_body->($ss, $event);
my $ok = eval {
die "File not found: $file\n" unless -f $file;
die "Cannot read file: $file\n" unless -r $file;
$ensure_h2_streaming_started->($ss);
# Mirror h1's effective-length computation (_send_file_response)
# so the sync-vs-async choice below sees the same number h1
# would, including the Www.pod rule that an offset past EOF
# clamps to zero bytes rather than failing.
my $file_size = -s $file;
die "Cannot stat file $file: $!\n" unless defined $file_size;
my $effective_length = $length // ($file_size - $offset);
$effective_length = 0 if $effective_length < 0;
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# application owns $fh (opened before the send, closed or not by the
# app afterward) -- the server never closes it. Mirrors the file arm
# above; the read loop is adapted from h1's _send_fh_response. The
# type guard mirrors the file arm's M1 fix above.
if ($type eq 'http.response.body' && defined $event->{fh}) {
my $fh = $event->{fh};
my $seq_before = $advance_seq_for_body->($ss, $event);
my $ok = eval {
$ensure_h2_streaming_started->($ss);
if (my $off = $event->{offset}) {
seek($fh, $off, 0) or die "Cannot seek: $!\n";
}
my $remaining = $event->{length};
while (1) {
my $to_read = FILE_CHUNK_SIZE;
if (defined $remaining) {
$to_read = $remaining if $remaining < $to_read;
last if $to_read <= 0;
}
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# Server-supplied Date header (HTTP/1.1 parity) â add if the app didn't.
unless (grep { lc($_->[0]) eq 'date' } @response_headers) {
push @response_headers, ['date', $weak_self->{protocol}->format_date];
}
}
elsif ($type eq 'http.response.body') {
my $body = $event->{body} // '';
my $more = $event->{more} // 0;
if ($more) {
if (!$streaming_started) {
# First streaming chunk â submit with data callback
$streaming_started = 1;
$ss->{send_queue} //= [];
$ss->{send_queue_bytes} //= 0;
if (length $body) {
push @{$ss->{send_queue}}, $body;
$ss->{send_queue_bytes} += length $body;
}
# Synchronous: we're in the app's send path (not nghttp2's
# extract), so on_high_water can fire here to tell the app to
# pause its source.
$ss->{transport_state}->_check_watermarks if $ss->{transport_state};
$weak_self->{h2_session}->submit_response_streaming(
$stream_id,
status => $status,
headers => \@response_headers,
data_callback => $data_callback,
);
$weak_self->_h2_write_pending;
} else {
# Subsequent chunk â backpressure check then push and resume.
# Bound on THIS stream's send queue (per-stream), not the
# shared TCP buffer which is meaningless across multiplexed
lib/PAGI/Server/Connection.pm view on Meta::CPAN
if (length $body) {
push @{$ss->{send_queue}}, $body;
$ss->{send_queue_bytes} += length $body;
}
# Synchronous â app send path, not nghttp2 extract.
$ss->{transport_state}->_check_watermarks if $ss->{transport_state};
$weak_self->{h2_session}->resume_stream($stream_id);
$weak_self->_h2_write_pending;
}
} else {
if ($streaming_started) {
# Final chunk on an already-streaming response. Bound on THIS
# stream's send queue (per-stream), not the shared TCP buffer.
if (($ss->{send_queue_bytes} // 0) >= $weak_self->{write_high_watermark}) {
await $weak_self->_h2_wait_for_stream_drain($stream_id);
return unless $weak_self;
return if $weak_self->{closed};
return unless $weak_self->{h2_streams}{$stream_id};
}
$eof_pending = 1;
if (length $body) {
push @{$ss->{send_queue}}, $body;
lib/PAGI/Server/Connection.pm view on Meta::CPAN
}
# Synchronous â app send path, not nghttp2 extract.
$ss->{transport_state}->_check_watermarks if $ss->{transport_state};
$weak_self->{h2_session}->resume_stream($stream_id);
$weak_self->_h2_write_pending;
} elsif ($seq eq 'awaiting_trailers') {
# Trailers were declared and this is the (single-shot)
# terminal body event: a plain submit_response would set
# END_STREAM immediately, ending the stream before the
# trailers arrive -- design §8.3 forbids that. Route
# through the streaming path instead, mirroring the
# file/fh arms' $streaming_started idiom; the data
# callback above already reserves END_STREAM for the
# trailing HEADERS block once it observes
# 'awaiting_trailers'.
$streaming_started = 1;
$ss->{send_queue} //= [];
$ss->{send_queue_bytes} //= 0;
$eof_pending = 1;
if (length $body) {
push @{$ss->{send_queue}}, $body;
$ss->{send_queue_bytes} += length $body;
}
$ss->{transport_state}->_check_watermarks if $ss->{transport_state};
$weak_self->{h2_session}->submit_response_streaming(
$stream_id,
status => $status,
headers => \@response_headers,
data_callback => $data_callback,
);
$weak_self->_h2_write_pending;
} else {
# Non-streaming: single response (unchanged one-shot path)
$weak_self->{h2_session}->submit_response($stream_id,
status => $status,
headers => \@response_headers,
body => $body,
);
$weak_self->_h2_write_pending;
}
}
}
elsif ($type eq 'http.fullflush') {
# Hand any pending frames to the session's write path (design §8.4).
$weak_self->{h2_session}->resume_stream($stream_id) if $streaming_started;
$weak_self->_h2_write_pending;
}
};
}
# =============================================================================
# HTTP/2 WebSocket over HTTP/2 (RFC 8441)
# =============================================================================
sub _h2_create_websocket_scope {
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# max_frame_size: omitted when unenforced (max_ws_frame_size 0/undef
# means unlimited, per Protocol::WebSocket::Frame's max_payload_size
# semantics -- a server that does not enforce a cap must not
# advertise one). max_receive_queue has no unlimited mode (a hard,
# always-enforced cap), so it is always present.
($self->{max_ws_frame_size}
? (max_frame_size => $self->{max_ws_frame_size})
: ()
),
max_receive_queue => $self->{max_receive_queue},
# Per-stream outbound flow-control handle. Like the h2 sse/streaming
# scopes, it measures THIS stream's send queue (h2 multiplexes many
# streams over one connection, so the shared TCP buffer is
# meaningless per stream). Gives WebSocket-over-h2 the same
# pagi.transport surface HTTP/1.1 WebSocket already provides.
'pagi.transport' => ($stream_state->{transport_state} = $self->_h2_transport_state($stream_state)),
};
}
sub _h2_create_websocket_receive {
my ($self, $stream_id, $stream_state) = @_;
lib/PAGI/Server/Connection.pm view on Meta::CPAN
return $future;
};
}
sub _h2_create_websocket_send {
my ($self, $stream_id, $stream_state) = @_;
weaken(my $weak_self = $self);
my $seq = 'connecting';
# Data callback for nghttp2's streaming response (the same pull-based
# data-provider model _h2_create_send/_h2_create_sse_send already use).
# Pulls raw WS frame bytes from the per-stream queue -- app messages,
# protocol replies (pong, close-echo), and this stream's own keepalive
# ping are all pushed there in call order, so FIFO ordering on the wire
# is preserved exactly as it was under direct submit_data calls.
#
# $ss->{ws_eof_pending} lives on the stream state (not a closure-local,
# unlike the http streaming callback's $eof_pending) because it is set
# from other subs entirely -- _h2_ws_close and the close-frame arm of
# _h2_process_ws_frames -- not just from this closure. When set, it
# merges END_STREAM onto the LAST queued chunk (the close frame itself)
# rather than emitting a separate empty terminal frame, matching the
# previous submit_data($id, $close_frame_bytes, 1) behavior exactly.
my $data_callback = sub {
my ($cb_stream_id, $max_len) = @_;
my $ss = $weak_self && $weak_self->{h2_streams}{$stream_id};
return undef unless $ss;
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# transfer-encoding, etc. before submission.
@headers = @{ $weak_self->_h2_strip_connection_headers(\@headers) };
$ss->{ws_accepted} = 1;
$ss->{response_started} = 1;
$ss->{ws_frame} = Protocol::WebSocket::Frame->new(
max_payload_size => $weak_self->{max_ws_frame_size},
);
# Submit 200 response with a pull-based data provider (same
# model as h2 streaming/SSE): frames are pushed onto
# $ss->{send_queue} and pulled by $data_callback as nghttp2's
# per-stream flow-control window allows.
$ss->{send_queue} //= [];
$ss->{send_queue_bytes} //= 0;
$weak_self->{h2_session}->submit_response_streaming($stream_id,
status => 200,
headers => \@headers,
data_callback => $data_callback,
);
$weak_self->_h2_write_pending;
# Process any data that arrived before accept
if (length($ss->{body}) > 0) {
my $buffered = $ss->{body};
$ss->{body} = '';
lib/PAGI/Server/Connection.pm view on Meta::CPAN
query_string => $query_string,
root_path => '',
headers => $headers,
(defined $self->{client_host}
? (client => [$self->{client_host}, $self->{client_port}])
: ()
),
server => [$self->{server_host}, $self->{server_port}],
state => keys %{$self->{state}} ? { %{$self->{state}} } : {},
extensions => $self->_get_extensions_for_scope,
# Per-stream outbound flow-control handle. Like the h2 streaming scope,
# it measures THIS stream's send queue (h2 multiplexes many streams over
# one connection, so the shared TCP buffer is meaningless per stream).
'pagi.transport' => ($stream_state->{transport_state} = $self->_h2_transport_state($stream_state)),
};
}
sub _h2_create_sse_receive {
my ($self, $stream_id, $stream_state) = @_;
weaken(my $weak_self = $self);
lib/PAGI/Server/Connection.pm view on Meta::CPAN
my $ss = $weak_self->{h2_streams}{$stream_id};
return $sse_disconnect->() unless $ss;
# Check queue first
if (@{$ss->{receive_queue}}) {
return shift @{$ss->{receive_queue}};
}
# First call returns sse.request once the full body has arrived.
# A POST body still streaming across DATA frames must not be
# truncated behind a truthful more=>0 -- wait for body_complete
# (set on END_STREAM or stream close, see _h2_on_body/_h2_on_close)
# before delivering it as a single terminal event. This is the
# smaller change relative to reworking sse.request into a
# truthful multi-chunk stream: the wire shape here was already
# one-shot, so completing that contract fixes the dispatch-timing
# bug (design section 11.1) without touching the event shape.
if (!$ss->{sse_request_sent}) {
while (!$ss->{body_complete}) {
if (@{$ss->{receive_queue}}) {
lib/PAGI/Server/Connection.pm view on Meta::CPAN
}
sub _h2_create_sse_send {
my ($self, $stream_id, $stream_state) = @_;
weaken(my $weak_self = $self);
my $seq = 'initial';
# Streaming state for the data-provider pull pattern. The send queue lives on
# per-stream state ($ss->{send_queue} / $ss->{send_queue_bytes}) so the
# pagi.transport handle can measure THIS stream's backlog. $streaming_started
# stays closure-local.
my $streaming_started = 0;
# Data callback for nghttp2's streaming response. Pulls from the per-stream
# queue; SSE responses stay open, so this never signals EOF (returns eof=0),
# or undef to defer when the queue is empty.
my $data_callback = sub {
my ($cb_stream_id, $max_len) = @_;
my $ss = $weak_self && $weak_self->{h2_streams}{$stream_id};
return undef unless $ss;
my $q = $ss->{send_queue} ||= [];
if (@$q) {
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# supply them (design doc section 11.4).
unless (grep { lc($_->[0]) eq 'cache-control' } @final_headers) {
push @final_headers, ['cache-control', 'no-cache'];
}
# Server-supplied Date header (HTTP/1.1 parity) â the h1 SSE path adds
# this too; add it unless the app supplied one.
unless (grep { lc($_->[0]) eq 'date' } @final_headers) {
push @final_headers, ['date', $weak_self->{protocol}->format_date];
}
$streaming_started = 1;
$ss->{send_queue} //= [];
$ss->{send_queue_bytes} //= 0;
$weak_self->{h2_session}->submit_response_streaming(
$stream_id,
status => $status,
headers => \@final_headers,
data_callback => $data_callback,
);
$weak_self->_h2_write_pending;
# Protocol-specific keepalive writer (HTTP/2 DATA frames), scoped to
# THIS stream (design section 11.3): a second multiplexed SSE stream
# must not steal or replace this one's writer. Keepalive bytes are
lib/PAGI/Server/Connection.pm view on Meta::CPAN
}
$weak_self->{h2_session}->submit_response($stream_id,
status => $ss->{sse_decline_status},
headers => $ss->{sse_decline_headers},
body => $ss->{sse_decline_body},
);
$weak_self->_h2_write_pending;
}
elsif ($type eq 'http.fullflush') {
# Hand any pending frames to the session's write path (design §8.4).
$weak_self->{h2_session}->resume_stream($stream_id) if $streaming_started;
$weak_self->_h2_write_pending;
}
return;
};
}
sub _h2_process_ws_frames {
my ($self, $stream_id, $stream, $data) = @_;
lib/PAGI/Server/Connection.pm view on Meta::CPAN
=head2 How It Works
A request is detected as SSE when its combined C<Accept> header values
contain the exact media range C<text/event-stream>, case-insensitively,
with an effective quality value greater than zero (see L<PAGI::Spec::Www/
"SSE Connection Detection">); a C<q=0> refusal or a wildcard range such as
C<*/*> never signals SSE. Detection works identically regardless of HTTP
version. Over HTTP/1.1, SSE data is sent using chunked Transfer-Encoding.
Over HTTP/2, SSE data is sent as DATA frames via the
C<submit_response_streaming>/C<data_callback> mechanism. This difference is
transparent to the application.
The C<http_version> field in the scope hash will be C<'2'> for HTTP/2
connections, allowing applications to distinguish if needed.
=head2 SSE Idle Timeout over HTTP/2
The C<sse_idle_timeout> setting is enforced B<per stream> on HTTP/2: each
SSE stream owns its own idle timer, armed when that stream's
C<sse.start> is sent and reset by that stream's own send activity
lib/PAGI/Server/ConnectionState.pm view on Meta::CPAN
sub response_started { return $_[0]->{_response_started} ? 1 : 0 }
# Server-internal: called from the send path when http.response.start is emitted.
sub _mark_response_started { $_[0]->{_response_started} = 1; return }
=head2 response_complete
my $done = $conn->response_complete; # undef = unsupported, else 0 or 1
Returns true once this request's response body has been fully sent, false
while a response is still streaming or has not started, and C<undef> if the
server does not track completion. This server does not currently track
response-body completion, so this accessor always returns C<undef>. SHOULD-level
per L<PAGI::Spec::Www/"Connection State">; test C<defined> before relying on it.
=cut
sub response_complete { return undef }
=head2 disconnect_reason
lib/PAGI/Server/EventValidator.pm view on Meta::CPAN
my $type = $event->{type} // '';
if ($state eq 'closed') {
return 'closed' if $type eq 'sse.close';
croak "cannot send '$type' after sse.close";
}
croak "cannot send '$type': decline response already complete"
if $state eq 'decline_complete';
if ($state eq 'initial') {
return 'streaming' if $type eq 'sse.start';
return 'declining' if $type eq 'sse.http.response.start';
croak "cannot send '$type' before sse.start";
}
if ($state eq 'streaming') {
return 'streaming' if $type eq 'sse.send' || $type eq 'sse.comment' || $type eq 'sse.keepalive' || $type eq 'http.fullflush';
return 'closed' if $type eq 'sse.close';
croak "cannot decline with sse.http.response.start after sse.start"
if $type eq 'sse.http.response.start';
croak "cannot send duplicate sse.start"
if $type eq 'sse.start';
croak "cannot send '$type' after sse.start";
}
if ($state eq 'declining') {
if ($type eq 'sse.http.response.body') {
lib/PAGI/Server/EventValidator.pm view on Meta::CPAN
(C<"cannot send '<type>' after websocket.http.response.start">); any event
once C<closed> (C<"cannot send '<type>' after websocket.close">, including a
second C<websocket.close> - unlike SSE, WebSocket close is not idempotent);
any event once C<denial_complete>
(C<"cannot send '<type>': denial response already complete">).
=head2 advance_sse($state, $event)
Pure send-sequence transition function for the SSE family. Does not
validate event shape; call C<validate_sse_send> separately first. States:
C<initial>, C<streaming>, C<declining>, C<decline_complete>, C<closed>.
Starting state is C<initial>.
From C<initial>: C<sse.start> advances to C<streaming>;
C<sse.http.response.start> (a decline) advances to C<declining>. From
C<streaming>: C<sse.send>, C<sse.comment>, C<sse.keepalive>, and
C<http.fullflush> keep C<streaming>; C<sse.close> advances to C<closed>.
From C<declining>:
C<sse.http.response.body> with a true C<more> field keeps C<declining>; a
terminal body chunk advances to C<decline_complete>. From C<closed>,
C<sse.close> is idempotent and stays C<closed>.
Croaks: any event in C<initial> other than C<sse.start>/decline start
(C<"cannot send '<type>' before sse.start">); a decline start after
C<sse.start> (C<"cannot decline with sse.http.response.start after sse.start">);
a duplicate C<sse.start> (C<"cannot send duplicate sse.start">); any other
event once C<streaming> (C<"cannot send '<type>' after sse.start">); any
non-body event once declining has started
(C<"cannot send '<type>' after sse.http.response.start">); any event other
than C<sse.close> once C<closed> (C<"cannot send '<type>' after sse.close">);
any event once C<decline_complete>
(C<"cannot send '<type>': decline response already complete">).
=head2 advance_lifespan($state, $event)
Pure send-sequence transition function for the lifespan family. Does not
validate event shape; call C<validate_lifespan_send> separately first.
lib/PAGI/Server/Protocol/HTTP2.pm view on Meta::CPAN
=head2 submit_response
$session->submit_response($stream_id,
status => 200,
headers => [['content-type', 'text/html']],
body => $body,
);
Submit a response on a stream. C<body> can be a string (sent as single
response) or a coderef for streaming.
=cut
sub submit_response {
my ($self, $stream_id, %args) = @_;
return $self->{nghttp2}->submit_response($stream_id, %args);
}
=head2 submit_response_streaming
$session->submit_response_streaming($stream_id,
status => 200,
headers => [['content-type', 'text/event-stream']],
data_callback => sub {
my ($stream_id, $max_len) = @_;
return ($chunk, $is_eof);
},
);
Submit a streaming response with a data provider callback.
=cut
sub submit_response_streaming {
my ($self, $stream_id, %args) = @_;
return $self->{nghttp2}->submit_response($stream_id,
status => $args{status},
headers => $args{headers},
data_callback => $args{data_callback},
callback_data => $args{callback_data},
);
}
=head2 resume_stream
lib/PAGI/Server/Protocol/HTTP2.pm view on Meta::CPAN
Key differences that affect PAGI integration:
=over 4
=item * Multiplexing - Multiple concurrent requests on one TCP connection
=item * Binary Framing - nghttp2 handles all framing; PAGI feeds/extracts bytes
=item * Header Compression - HPACK is built into nghttp2
=item * Flow Control - Per-stream and connection-level, via streaming callbacks
=back
=head1 SEE ALSO
L<Net::HTTP2::nghttp2>, L<PAGI::Server::Protocol::HTTP1>
=cut
t/02-streaming.t view on Meta::CPAN
use Test2::V0;
use IO::Async::Loop;
use Net::Async::HTTP;
use Future::AsyncAwait;
use PAGI::Server;
plan skip_all => "Server integration tests not supported on Windows" if $^O eq 'MSWin32';
# Step 2: Streaming Responses and Disconnect Handling
# Tests for examples/02-streaming-response/app.pl
my $loop = IO::Async::Loop->new;
# Test 1: Streaming response with multiple chunks
subtest 'Streaming response uses chunked Transfer-Encoding' => sub {
my $simple_streaming_app = async sub {
my ($scope, $receive, $send) = @_;
die "Unsupported: $scope->{type}" if $scope->{type} ne 'http';
while (1) {
my $event = await $receive->();
last if $event->{type} ne 'http.request';
last unless $event->{more};
}
await $send->({
t/02-streaming.t view on Meta::CPAN
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({ type => 'http.response.body', body => "Chunk 1\n", more => 1 });
await $send->({ type => 'http.response.body', body => "Chunk 2\n", more => 1 });
await $send->({ type => 'http.response.body', body => "Chunk 3\n", more => 0 });
};
my $server = PAGI::Server->new(
app => $simple_streaming_app,
host => '127.0.0.1',
port => 0,
quiet => 1,
);
$loop->add($server);
$server->listen->get;
my $port = $server->port;
t/02-streaming.t view on Meta::CPAN
my $body = $response->decoded_content;
like($body, qr/Chunk 1.*Chunk 2.*Chunk 3/s, 'Response body contains all chunks in order');
$server->shutdown->get;
$loop->remove($server);
};
# Test 2: Multiple body chunks arrive in order
subtest 'Multiple http.response.body events work correctly' => sub {
my $streaming_app = async sub {
my ($scope, $receive, $send) = @_;
die "Unsupported: $scope->{type}" if $scope->{type} ne 'http';
while (1) {
my $event = await $receive->();
last if $event->{type} ne 'http.request';
last unless $event->{more};
}
await $send->({
t/02-streaming.t view on Meta::CPAN
status => 200,
headers => [['content-type', 'text/plain']],
});
await $send->({ type => 'http.response.body', body => "First\n", more => 1 });
await $send->({ type => 'http.response.body', body => "Second\n", more => 1 });
await $send->({ type => 'http.response.body', body => "Third\n", more => 0 });
};
my $server = PAGI::Server->new(
app => $streaming_app,
host => '127.0.0.1',
port => 0,
quiet => 1,
);
$loop->add($server);
$server->listen->get;
my $port = $server->port;
t/02-streaming.t view on Meta::CPAN
is($response->code, 200, 'Response status is 200');
is($response->header('Transfer-Encoding'), 'chunked', 'Response uses chunked encoding');
my $body = $response->decoded_content;
like($body, qr/Body content/, 'Body content is correct');
$server->shutdown->get;
$loop->remove($server);
};
# Test 5: Client disconnect detection
subtest 'Client disconnect stops streaming app' => sub {
my $disconnect_detected = 0;
my $chunks_sent = 0;
my $slow_streaming_app = async sub {
my ($scope, $receive, $send) = @_;
die "Unsupported: $scope->{type}" if $scope->{type} ne 'http';
my $loop = IO::Async::Loop->new;
# Drain request body
while (1) {
my $event = await $receive->();
last if $event->{type} ne 'http.request';
last unless $event->{more};
t/02-streaming.t view on Meta::CPAN
await $send->({ type => 'http.response.body', body => "Chunk $i\n", more => ($i < 5) ? 1 : 0 });
# Wait between chunks (allows disconnect to be detected)
if ($i < 5 && $loop) {
await $loop->delay_future(after => 0.2);
}
}
};
my $server = PAGI::Server->new(
app => $slow_streaming_app,
host => '127.0.0.1',
port => 0,
quiet => 1,
);
$loop->add($server);
$server->listen->get;
my $port = $server->port;
t/02-streaming.t view on Meta::CPAN
ok($sock, 'Connected to server');
# Send HTTP request
print $sock "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
# Read just the headers and first chunk
my $data = '';
$sock->blocking(0);
# Give server time to start streaming
my $timeout = time + 2;
while (time < $timeout) {
$loop->loop_once(0.1);
my $buf;
my $n = $sock->sysread($buf, 4096);
if (defined $n && $n > 0) {
$data .= $buf;
# Once we have some body data, disconnect
if ($data =~ /Chunk 1/) {
last;
t/10-http-compliance.t view on Meta::CPAN
})->()->get;
};
# Test 18: send() after the response is complete raises, per mandatory
# sequencing (PAGI spec compliance) -- not the genuine post-disconnect no-op,
# which lives in t/52-mandatory-validation.t's /post-close case.
subtest 'send() after response completion raises "response already complete"' => sub {
# Exercise the _create_send implementation directly by verifying that a
# send after the response has completed fails the returned Future.
# First verify by examining the send() return behavior in a streaming test
# where the client disconnects mid-stream
my $app_completed = 0;
my $error_during_send = 0;
my $test_app = async sub {
my ($scope, $receive, $send) = @_;
# Handle lifespan scope
if ($scope->{type} eq 'lifespan') {
while (1) {
my $event = await $receive->();
t/40-event-validation.t view on Meta::CPAN
};
# =============================================================================
# Sequence State Machines
# =============================================================================
subtest 'advance_http transition matrix' => sub {
my $adv = \&PAGI::Server::EventValidator::advance_http;
is( $adv->('initial', { type => 'http.response.start', status => 200 }), 'started', 'start -> started');
is( $adv->('initial', { type => 'http.response.start', status => 200, trailers => 1 }), 'started_t', 'start+trailers -> started_t');
is( $adv->('started', { type => 'http.response.body', body => 'x', more => 1 }), 'started_i', 'streaming chunk marks inline bytes delivered');
is( $adv->('started', { type => 'http.response.body', body => 'x' }), 'complete', 'terminal body -> complete');
is( $adv->('started', { type => 'http.response.body', file => '/tmp/f' }), 'complete', 'file body -> complete');
is( $adv->('started', { type => 'http.response.body', fh => \*STDOUT, more => 1 }), 'complete', 'fh body is always terminal regardless of more');
is( $adv->('started_t', { type => 'http.response.body', body => 'x', more => 1 }), 'started_t_i', 'streaming chunk marks inline bytes delivered, trailers still declared');
is( $adv->('started_t', { type => 'http.response.body', body => 'x', more => 0 }), 'awaiting_trailers', 'terminal body with declared trailers -> awaiting_trailers');
is( $adv->('awaiting_trailers', { type => 'http.response.trailers', headers => [] }), 'complete', 'trailers -> complete');
is( $adv->('started', { type => 'http.fullflush' }), 'started', 'fullflush leaves started unchanged');
is( $adv->('started_t', { type => 'http.fullflush' }), 'started_t', 'fullflush leaves started_t unchanged');
is( $adv->('awaiting_trailers', { type => 'http.fullflush' }), 'awaiting_trailers', 'fullflush leaves awaiting_trailers unchanged');
like( dies { $adv->('initial', { type => 'http.response.body', body => 'x' }) }, qr/before http\.response\.start/, 'body before start');
like( dies { $adv->('initial', { type => 'http.response.trailers' }) }, qr/before http\.response\.start/, 'trailers before start');
like( dies { $adv->('started', { type => 'http.response.start', status => 200 }) }, qr/duplicate http\.response\.start/, 'duplicate start');
like( dies { $adv->('started_t', { type => 'http.response.start', status => 200 }) }, qr/duplicate http\.response\.start/, 'duplicate start after trailers declared');
like( dies { $adv->('started', { type => 'http.response.trailers' }) }, qr/not declared/, 'undeclared trailers');
t/40-event-validation.t view on Meta::CPAN
is( $adv->('started_i', { type => 'http.response.body', body => 'x', more => 0 }),
'complete', 'and terminate normally');
is( $adv->('started_t_i', { type => 'http.response.body', body => 'x', more => 0 }),
'awaiting_trailers', 'reaching the trailers phase as before');
is( $adv->('started_i', { type => 'http.fullflush' }), 'started_i',
'fullflush leaves the inline marker alone');
};
subtest 'advance_sse close is idempotent, streams stay exclusive' => sub {
my $adv = \&PAGI::Server::EventValidator::advance_sse;
is( $adv->('initial', { type => 'sse.start' }), 'streaming', 'start -> streaming');
is( $adv->('streaming', { type => 'sse.send', data => 'x' }), 'streaming', 'send keeps streaming');
is( $adv->('streaming', { type => 'sse.comment', comment => 'x' }), 'streaming', 'comment keeps streaming');
is( $adv->('streaming', { type => 'sse.keepalive', interval => 15 }), 'streaming', 'keepalive keeps streaming');
is( $adv->('streaming', { type => 'http.fullflush' }), 'streaming', 'fullflush leaves streaming unchanged');
like( dies { $adv->('initial', { type => 'http.fullflush' }) }, qr/before sse\.start/, 'fullflush before start');
is( $adv->('streaming', { type => 'sse.close' }), 'closed', 'close -> closed');
is( $adv->('closed', { type => 'sse.close' }), 'closed', 'second close idempotent');
is( $adv->('declining', { type => 'sse.http.response.body', more => 1 }), 'declining', 'decline body chunk keeps declining');
is( $adv->('declining', { type => 'sse.http.response.body' }), 'decline_complete', 'terminal decline body -> decline_complete');
like( dies { $adv->('closed', { type => 'sse.send', data => 'x' }) }, qr/after sse\.close/, 'send after close');
is( $adv->('initial', { type => 'sse.http.response.start', status => 404 }), 'declining', 'decline start');
like( dies { $adv->('initial', { type => 'sse.send', data => 'x' }) }, qr/before sse\.start/, 'send before start');
like( dies { $adv->('streaming', { type => 'sse.http.response.start', status => 404 }) }, qr/after sse\.start/, 'decline after start');
like( dies { $adv->('streaming', { type => 'sse.start' }) }, qr/duplicate sse\.start/, 'duplicate start');
like( dies { $adv->('streaming', { type => 'sse.http.response.body', body => 'x' }) }, qr/after sse\.start/, 'decline body while streaming croaks');
like( dies { $adv->('declining', { type => 'sse.send', data => 'x' }) }, qr/after sse\.http\.response\.start/, 'stream event while declining');
like( dies { $adv->('decline_complete', { type => 'sse.close' }) }, qr/decline response already complete/, 'anything after decline complete');
};
subtest 'advance_websocket denial and accept are exclusive' => sub {
my $adv = \&PAGI::Server::EventValidator::advance_websocket;
is( $adv->('connecting', { type => 'websocket.accept' }), 'accepted', 'accept');
is( $adv->('connecting', { type => 'websocket.http.response.start', status => 401 }), 'denial', 'denial start');
is( $adv->('connecting', { type => 'websocket.close' }), 'closed', 'close while connecting');
is( $adv->('accepted', { type => 'websocket.send', text => 'x' }), 'accepted', 'send keeps accepted');
t/53-trailers-framing.t view on Meta::CPAN
is( $T::CL_DISC_REASON, 'server_error',
'/cl-trailers: app-return after the failed trailers send reports on_disconnect(server_error)' );
ok(
(scalar grep { /returned with an incomplete response/i } @cl_warnings),
'/cl-trailers: incomplete-response warning logged'
);
# --- /chunked-trailers: control -- raw socket, trailers actually on the wire
# Net::Async::HTTP doesn't expose trailers (see t/02-streaming.t), so read
# the raw chunked framing off the wire ourselves. Socket-pump idiom lifted
# from t/52-mandatory-validation.t's $ws_handshake_and_drain; the chunk
# grammar being asserted against (hex chunk-size line, CRLF-terminated
# chunks, "0\r\n" + trailer headers + CRLF as the terminator) is the same
# grammar t/16-chunked-validation.t exercises against the parser.
my $raw_request = sub {
my ($port, $method, $path) = @_;
my $sock = IO::Socket::INET->new(
PeerAddr => '127.0.0.1',
PeerPort => $port,
t/http2/07-websocket.t view on Meta::CPAN
method => 'CONNECT',
path => '/ws/chat',
scheme => 'https',
authority => 'localhost',
headers => [
[':protocol', 'websocket'],
['sec-websocket-version', '13'],
['sec-websocket-protocol', 'chat, superchat'],
['origin', 'https://localhost'],
],
body => sub { return undef }, # streaming: keep open
);
$client_sock->syswrite($client->mem_send);
exchange_frames($client, $client_sock);
ok(scalar @scopes >= 1, 'App was called');
if (@scopes) {
my $scope = $scopes[0];
is($scope->{type}, 'websocket', 'scope type is websocket');
t/http2/07-websocket.t view on Meta::CPAN
# Send Extended CONNECT
my $ws_stream_id = $client->submit_request(
method => 'CONNECT',
path => '/ws/echo',
scheme => 'https',
authority => 'localhost',
headers => [
[':protocol', 'websocket'],
['sec-websocket-version', '13'],
],
body => sub { return undef }, # streaming: keep open
);
$client_sock->syswrite($client->mem_send);
# Wait for 200 response
exchange_frames($client, $client_sock);
is($response_headers{':status'}, '200', 'Got 200 for WebSocket accept');
# Send a WebSocket text frame via HTTP/2 DATA
my $ws_frame = Protocol::WebSocket::Frame->new(
buffer => 'Hello WebSocket',
t/http2/07-websocket.t view on Meta::CPAN
# Open WebSocket
my $ws_stream_id = $client->submit_request(
method => 'CONNECT',
path => '/ws/close-test',
scheme => 'https',
authority => 'localhost',
headers => [
[':protocol', 'websocket'],
['sec-websocket-version', '13'],
],
body => sub { return undef }, # streaming: keep open
);
$client_sock->syswrite($client->mem_send);
exchange_frames($client, $client_sock);
is($response_headers{':status'}, '200', 'WebSocket accepted');
# Send close frame (code=1000, reason="normal closure")
my $close_frame = Protocol::WebSocket::Frame->new(
type => 'close',
buffer => pack('n', 1000) . 'normal closure',
t/http2/11-streaming.t view on Meta::CPAN
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 Streaming Responses
# ============================================================
# Verifies that HTTP/2 streaming responses (more => 1) send
# DATA frames incrementally rather than accumulating in memory.
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;
t/http2/11-streaming.t view on Meta::CPAN
$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);
}
}
# ============================================================
# Basic streaming: 3 chunks + final
# ============================================================
subtest 'basic streaming response delivers all data' => sub {
my $app = async sub {
my ($scope, $receive, $send) = @_;
await $receive->();
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
for my $i (1..3) {
await $send->({
t/http2/11-streaming.t view on Meta::CPAN
on_stream_close => sub {
$stream_closed = 1;
return 0;
},
);
h2c_handshake($client, $client_sock);
$client->submit_request(
method => 'GET',
path => '/streaming',
scheme => 'http',
authority => 'localhost',
);
$client_sock->syswrite($client->mem_send);
exchange_frames($client, $client_sock, 20);
is($response_body, 'chunk1chunk2chunk3final', 'All streaming chunks received');
ok($stream_closed, 'Stream was closed');
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# Incremental delivery: data arrives before more => 0
# ============================================================
# This is the KEY test that proves the bug. With the old code,
# data only arrives after more => 0 because everything is
# accumulated in $body_chunks. With the fix, data arrives
# incrementally.
subtest 'streaming data arrives incrementally (not buffered until EOF)' => sub {
# Use futures to coordinate: app waits for client to confirm
# receipt of each chunk before sending the next one.
my @chunk_received_at; # Track when each data callback fires
my $app = async sub {
my ($scope, $receive, $send) = @_;
await $receive->();
await $send->({
type => 'http.response.start',
status => 200,
t/http2/11-streaming.t view on Meta::CPAN
ok(scalar @data_events > 1,
'Data arrived in multiple chunks (not accumulated)')
or diag "Got " . scalar(@data_events) . " data events: " .
join(', ', map { "'$_'" } @data_events);
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# Empty final chunk: streaming + empty more => 0
# ============================================================
subtest 'streaming with empty final chunk' => sub {
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',
t/http2/11-streaming.t view on Meta::CPAN
$client->submit_request(
method => 'GET',
path => '/empty-final',
scheme => 'http',
authority => 'localhost',
);
$client_sock->syswrite($client->mem_send);
exchange_frames($client, $client_sock, 20);
is($response_body, 'only-chunk', 'Body is just the streaming chunk');
ok($stream_closed, 'Stream was closed');
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# Backpressure: many large chunks don't cause unbounded growth
# ============================================================
subtest 'streaming with many chunks completes without accumulation' => sub {
my $chunk_count = 50;
my $chunk_size = 8192;
my $chunk_data = 'X' x $chunk_size;
my $app = async sub {
my ($scope, $receive, $send) = @_;
await $receive->();
await $send->({
type => 'http.response.start',
status => 200,
t/http2/11-streaming.t view on Meta::CPAN
on_stream_close => sub {
$stream_closed = 1;
return 0;
},
);
h2c_handshake($client, $client_sock);
$client->submit_request(
method => 'GET',
path => '/large-streaming',
scheme => 'http',
authority => 'localhost',
);
$client_sock->syswrite($client->mem_send);
# Many rounds needed: 50 x 8KB = 400KB, flow control window is 65535
exchange_frames($client, $client_sock, 100);
is($received_bytes, $chunk_count * $chunk_size,
'All streaming data received');
ok($stream_closed, 'Stream was closed');
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# Single-shot (non-streaming): more => 0 as first body event
# ============================================================
subtest 'non-streaming response (more => 0 only) still works' => sub {
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',
t/http2/11-streaming.t view on Meta::CPAN
is($response_headers{':status'}, '200', 'Got 200 status');
is($response_body, 'single-shot', 'Body received correctly');
ok($stream_closed, 'Stream was closed');
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# Connection close during streaming: no crashes
# ============================================================
subtest 'connection close during streaming does not crash' => sub {
my $send_started = 0;
my $send_error = 0;
my $app = async sub {
my ($scope, $receive, $send) = @_;
await $receive->();
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
t/http2/11-streaming.t view on Meta::CPAN
# Close the client side to simulate disconnect
close($client_sock);
# Let the event loop process the disconnection
for (1..10) {
$loop->loop_once(0.1);
}
# If we got here without crashing, the test passes
pass('No crash on connection close during streaming');
$stream_io->close_now;
$loop->remove($server);
};
# ============================================================
# EOF race: empty final body with eof_pending
# ============================================================
# Regression: if data_callback is called when @data_queue is
# empty but $eof_pending is true, it should return ('', 1)
# to signal EOF, not undef (defer).
subtest 'EOF signaling when data_callback invoked after queue drained' => sub {
# App sends streaming chunks, then final empty body (more => 0).
# The key scenario: the sentinel empty string in @data_queue
# gets consumed, then data_callback is called again.
my $app = async sub {
my ($scope, $receive, $send) = @_;
await $receive->();
await $send->({
type => 'http.response.start',
status => 200,
headers => [['content-type', 'text/plain']],
});
t/http2/12-error-handling.t view on Meta::CPAN
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,
t/http2/12-error-handling.t view on Meta::CPAN
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);
t/http2/12-error-handling.t view on Meta::CPAN
is($submit_response_calls, 1,
'only ONE submit_response reached nghttp2 for this stream -- the 413, not a second (200) on top of it')
or diag('submitted statuses: ' . join(', ', @submitted_statuses));
is($submitted_statuses[0], 413, 'the one submit_response that landed was the 413');
$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
}
t/http2/12-error-handling.t view on Meta::CPAN
$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;
}
t/http2/12-error-handling.t view on Meta::CPAN
# 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;
t/http2/13-sse-detection.t view on Meta::CPAN
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 t/http2/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,
t/http2/14-sse-events.t view on Meta::CPAN
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 t/http2/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,
t/http2/14-sse-events.t view on Meta::CPAN
exchange_frames($client, $client_sock, 20);
# Verify headers
is($response_headers{':status'}, '200', 'Status 200');
like($response_headers{'content-type'}, qr{text/event-stream}, 'Content-Type correct');
is($response_headers{'cache-control'}, 'no-cache', 'Cache-Control set');
ok(defined $response_headers{'date'} && length $response_headers{'date'},
'Date header present by default (design 11.4)');
# design doc section 11.4: HTTP/2 never emits HTTP/1-only Connection or
# chunked-framing headers (it uses DATA frames via submit_response_streaming).
ok(!exists $response_headers{'connection'}, 'no Connection header on HTTP/2 SSE responses');
ok(!exists $response_headers{'transfer-encoding'}, 'no Transfer-Encoding header on HTTP/2 SSE responses');
# Verify SSE event format
like($response_body, qr/event: update\n/, 'Named event field present');
like($response_body, qr/data: payload1\n/, 'Data field for event 1');
like($response_body, qr/id: 1\n/, 'ID field for event 1');
like($response_body, qr/data: payload2\n/, 'Data field for event 2');
like($response_body, qr/retry: 5000\n/, 'Retry field present');
t/http2/16-sse-cleanup.t view on Meta::CPAN
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;
t/http2/16-sse-cleanup.t view on Meta::CPAN
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);
t/http2/18-transport-leak.t view on Meta::CPAN
my $stream_closed = 0;
my $client = create_client(on_stream_close => sub { $stream_closed = 1; return 0 });
# h2c handshake.
$client->send_connection_preface;
$client_sock->syswrite($client->mem_send);
pump($client, $client_sock);
$client->submit_request(
method => 'GET',
path => '/streaming',
scheme => 'http',
authority => 'localhost',
);
$client_sock->syswrite($client->mem_send);
# Pump until the stream completes and closes (request fully handled).
pump($client, $client_sock, sub { $stream_closed });
ok($stream_closed, 'stream completed and closed');
ok($saw_handle, 'transport handle was attached to the h2 scope');
t/http2/19-transport-callbacks.t view on Meta::CPAN
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: pagi.transport on_high_water / on_drain fire on a real HTTP/2 stream
# ============================================================
# A single streaming chunk larger than the high-water mark fires on_high_water
# at the synchronous post-push poke (before nghttp2 pulls from the per-stream
# queue). nghttp2 then pulls the queue down past the low-water mark (the client
# uses the default 64KB window and consumes), firing on_drain (deferred via
# loop->later). Both must be observed by the time the stream closes.
use PAGI::Server::Connection;
use PAGI::Server;
use PAGI::Server::Protocol::HTTP1;
use PAGI::Server::Protocol::HTTP2;
t/http2/19-transport-callbacks.t view on Meta::CPAN
$loop->loop_once(0.02);
my $buf = '';
$client_sock->sysread($buf, 65536);
$client->mem_recv($buf) if length($buf);
my $out = $client->mem_send;
$client_sock->syswrite($out) if length($out);
last if $cond && $cond->();
}
}
subtest 'on_high_water and on_drain fire on an h2 streaming response' => sub {
my ($hit_high, $hit_drain) = (0, 0);
my $app = async sub {
my ($scope, $receive, $send) = @_;
await $receive->();
my $t = $scope->{'pagi.transport'};
$t->on_high_water(sub { $hit_high++ });
$t->on_drain(sub { $hit_drain++ });
t/http2/19-transport-callbacks.t view on Meta::CPAN
my ($conn, $stream_io, $client_sock, $server) = create_h2c_connection(app => $app);
my $stream_closed = 0;
my $client = create_client(on_stream_close => sub { $stream_closed = 1; return 0 });
$client->send_connection_preface;
$client_sock->syswrite($client->mem_send);
pump($client, $client_sock);
$client->submit_request(
method => 'GET', path => '/streaming', scheme => 'http', authority => 'localhost',
);
$client_sock->syswrite($client->mem_send);
# Pump until the stream closes (and at least until both callbacks fire).
pump($client, $client_sock, sub { $stream_closed && $hit_high && $hit_drain });
ok($stream_closed, 'stream completed and closed');
ok($hit_high, 'on_high_water fired when the queue exceeded the high mark');
ok($hit_drain, 'on_drain fired once nghttp2 drained the queue below the low mark');
t/http2/20-sse-transport.t view on Meta::CPAN
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: pagi.transport on a real SSE-over-HTTP/2 stream
# ============================================================
# SSE-over-h2 must provide the same pagi.transport handle as HTTP/2 streaming
# and HTTP/1.1: the app cannot tell which transport carries its events.
# Subtest 1 exercises on_high_water / on_drain on a real stream; subtest 2
# proves the handle (and its $ss reference cycle) is collected at teardown.
use PAGI::Server::Connection;
use PAGI::Server;
use PAGI::Server::Protocol::HTTP1;
use PAGI::Server::Protocol::HTTP2;
my $loop = IO::Async::Loop->new;
t/http2/21-http-date-header.t view on Meta::CPAN
on_stream_close => sub { $stream_closed = 1; return 0 },
);
$client->send_connection_preface;
$client_sock->syswrite($client->mem_send);
pump($client, $client_sock);
$client->submit_request(
method => 'POST', path => '/upload', 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);
pump($client, $client_sock, sub { $stream_closed });
is($headers{':status'}, '413', 'content-length precheck rejected with 413');
ok(!$app_called, 'app was never called');
ok(defined $headers{date}, 'HTTP/2 413 (precheck) response carries a Date header');
$stream_io->close_now;
$loop->remove($server);
t/http2/21-http-date-header.t view on Meta::CPAN
on_header => sub { my ($sid, $n, $v) = @_; $headers{lc $n} = $v; return 0 },
);
$client->send_connection_preface;
$client_sock->syswrite($client->mem_send);
pump($client, $client_sock);
$client->submit_request(
method => 'CONNECT', path => '/ws/test', scheme => 'https', authority => 'localhost',
headers => [[':protocol', 'websocket'], ['sec-websocket-version', '13']],
body => sub { return undef }, # streaming: keep open
);
$client_sock->syswrite($client->mem_send);
pump($client, $client_sock, sub { defined $headers{':status'} && $headers{':status'} eq '401' });
is($headers{':status'}, '401', 'custom denial status used');
ok(defined $headers{date}, 'HTTP/2 WebSocket denial response carries a Date header');
$stream_io->close_now;
$loop->remove($server);
};