view release on metacpan or search on metacpan
- 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).
META.yml
Makefile.PL
README
README.md
SECURITY.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/Compliance.pod
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
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
Also included: `backpressure-test` - demonstrates backpressure handling (unnumbered utility example)
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
HTTP/1.1 for http/websocket/sse; on HTTP/2 for http streaming and sse. B<Not yet>
on WebSocket-over-HTTP/2 (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>.
=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
both HTTP/1.1 and HTTP/2.
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 HTTP/2 C<http> (streaming responses)
and C<sse> scopes, matching its HTTP/1.1 coverage of C<http>, C<sse>, and
C<websocket>.
B<Known gap:> WebSocket over HTTP/2 (RFC 8441) does B<not> yet provide
C<pagi.transport>. The HTTP/2 WebSocket send path hands each frame to nghttp2
through its push-style C<submit_data> entry point, which keeps no per-stream
send queue for the handle to measure, and the binding exposes no per-stream
buffered-byte API. Closing the gap means converting the HTTP/2 WebSocket send
path to the same pull-based data-provider model that HTTP/2 streaming and SSE
use. Until then a WebSocket application running over HTTP/2 sees
C<pagi.transport> as absent: the L<PAGI::WebSocket> helpers degrade quietly
(C<buffered_amount> reports C<0>, C<is_writable> stays true, and the callbacks
are no-ops). WebSocket over HTTP/1.1 is unaffected and provides the handle
normally.
The long-term goal is parity: C<pagi.transport> for every streaming scope type
over every transport, so an application need not know whether it runs over
HTTP/1.1 or HTTP/2.
=head2 Enabling HTTP/2
# TLS mode (h2 via ALPN)
pagi-server --http2 --ssl-cert cert.pem --ssl-key key.pem --app myapp.pl
# Cleartext mode (h2c)
pagi-server --http2 --app myapp.pl
lib/PAGI/Server/Connection.pm view on Meta::CPAN
use Encode;
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 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;
# =============================================================================
# Unrecognized Event Type Handler (PAGI spec compliance)
# =============================================================================
# Per main.mkdn: "Servers must raise exceptions if... The type field is unrecognized"
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;
# 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
}
}
elsif ($type eq 'http.response.body') {
my $ss = $weak_self->{h2_streams}{$stream_id} or return;
return unless $ss->{response_started};
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;
$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 {
# 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;
}
}
}
else {
lib/PAGI/Server/Connection.pm view on Meta::CPAN
[_validate_header_name($_->[0]), _validate_header_value($_->[1])]
} @$extra;
}
$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 streaming body that defers
$weak_self->{h2_session}->submit_response($stream_id,
status => 200,
headers => \@headers,
body => sub { return undef }, # defer until submit_data
);
$weak_self->_h2_write_pending;
# Process any data that arrived before accept
if (length($ss->{body}) > 0) {
my $buffered = $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
};
}
sub _h2_create_sse_send {
my ($self, $stream_id, $stream_state) = @_;
weaken(my $weak_self = $self);
# 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
if (!$has_content_type) {
push @final_headers, ['content-type', 'text/event-stream'];
}
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). Keepalive
# bytes are counted in the per-stream backlog so buffered_amount stays
# accurate, but they do not poke the watermark callbacks â a server
lib/PAGI/Server/Connection.pm view on Meta::CPAN
SSE events (C<sse.start>, C<sse.send>, C<sse.comment>, C<sse.keepalive>)
work transparently over both HTTP/1.1 and HTTP/2. Applications do not need
to change their SSE handling code based on protocol version.
=head2 How It Works
When a request arrives with C<Accept: text/event-stream>, the connection
detects it as SSE 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 applies at the B<connection level>,
not per-stream. Over HTTP/1.1, this is a non-issue since each SSE
stream occupies its own TCP connection. Over HTTP/2, where multiple
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
$loop->remove($server);
})->()->get;
};
# Test 18: send() after disconnect is a no-op per spec
# This tests the unit behavior of the send function when connection is closed
subtest 'send() after disconnect returns completed Future' => sub {
# Test the _create_send implementation directly by verifying
# that it returns immediately when closed flag is set
# 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/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);
};
# ============================================================
# 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/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/22-denial-response.t view on Meta::CPAN
# Extended CONNECT (RFC 8441) â matches t/http2/07-websocket.t submit_request
$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);
exchange_frames($client, $client_sock, 20);
ok(defined $captured_scope, 'app was called with a websocket scope');
ok(
$captured_scope && $captured_scope->{extensions}{'websocket.http.response'},
'extension websocket.http.response is advertised on h2 ws scope',
);
is($headers{':status'}, '401', 'h2 denial uses custom 401, not 200 or 403');
t/integration/app-file.t view on Meta::CPAN
my $response = $http->GET("http://127.0.0.1:$port$path")->get;
ok($response->code >= 400, "URL-encoded traversal blocked for $path (status: " . $response->code . ")");
}
$loop->remove($http);
$server->shutdown->get;
$loop->remove($server);
};
# =============================================================================
# Test: Large file streaming (verifies file response integration)
# =============================================================================
subtest 'Large file streaming' => sub {
# Create a temp directory with a large file
# Size is 65KB - just over the 64KB sync_file_threshold to trigger async path
# Kept small to minimize buffer pressure on constrained CI environments (e.g., FreeBSD)
my $test_dir = File::Temp::tempdir(CLEANUP => 1);
my $large_content = "X" x (65 * 1024); # 65KB file (exceeds 64KB threshold)
open my $fh, '>', "$test_dir/large.bin" or die;
print $fh $large_content;
close $fh;
my $server = create_server(root => $test_dir);
t/integration/app-file.t view on Meta::CPAN
is($response->code, 200, 'GET /large.bin returns 200');
is(length($response->content), length($large_content), 'Large file length matches');
is($response->content, $large_content, 'Large file content matches');
$loop->remove($http);
$server->shutdown->get;
$loop->remove($server);
};
# =============================================================================
# Test: Large file Range request streaming
# =============================================================================
subtest 'Large file Range request' => sub {
# Size is 65KB - just over the 64KB sync_file_threshold to trigger async path
my $test_dir = File::Temp::tempdir(CLEANUP => 1);
my $large_content = "X" x (65 * 1024); # 65KB file (exceeds 64KB threshold)
open my $fh, '>', "$test_dir/large.bin" or die;
print $fh $large_content;
close $fh;
my $server = create_server(root => $test_dir);
t/integration/psgi-bridge.t view on Meta::CPAN
my $response = $http->GET("http://127.0.0.1:$port/")->get;
is($response->code, 200, 'Response status is 200 OK');
is($response->decoded_content, 'Hello from filehandle', 'Filehandle body is read correctly');
$server->shutdown->get;
$loop->remove($server);
$loop->remove($http);
};
# Test 6: PSGI streaming response (coderef body) works
subtest 'PSGI streaming response (coderef body) works' => sub {
my $streaming_psgi = sub {
my ($env) = @_;
return sub {
my ($responder) = @_;
# Immediate response with streaming body
my $writer = $responder->([200, ['Content-Type' => 'text/plain']]);
$writer->write("Streaming ");
$writer->write("response");
$writer->close;
};
};
my $wrapper = PAGI::App::WrapPSGI->new(psgi_app => $streaming_psgi);
my $pagi_app = $wrapper->to_app;
# Wrap with lifespan handler
my $wrapped_app = async sub {
my ($scope, $receive, $send) = @_;
if ($scope->{type} eq 'lifespan') {
while (1) {
my $event = await $receive->();
if ($event->{type} eq 'lifespan.startup') {
await $send->({ type => 'lifespan.startup.complete' });
t/integration/response-integration.t view on Meta::CPAN
my $response = await $http->GET("http://127.0.0.1:$port/");
is $response->code, 200, 'status 200';
my $cookie = $response->header('Set-Cookie');
like $cookie, qr/session=abc123/, 'cookie value';
like $cookie, qr/Path=\//, 'cookie path';
like $cookie, qr/HttpOnly/, 'httponly flag';
})->get;
};
subtest 'streaming response' => sub {
my $app = async sub {
my ($scope, $receive, $send) = @_;
my $res = PAGI::Response->new($scope);
$res->content_type('text/plain')
->stream(async sub {
my ($writer) = @_;
await $writer->write("chunk1");
await $writer->write("chunk2");
await $writer->write("chunk3");
});