PAGI-Server
view release on metacpan or search on metacpan
lib/PAGI/Server/Compliance.pod view on Meta::CPAN
=encoding utf8
=head1 NAME
PAGI::Server::Compliance - HTTP/1.1, HTTP/2, WebSocket, SSE, and Security Compliance Documentation
=head1 DESCRIPTION
This document details the compliance testing results for PAGI::Server against
HTTP/1.1 (RFC 7230/7231), HTTP/2 (RFC 9113), WebSocket (RFC 6455), Server-Sent
Events, and common security attack vectors.
PAGI::Server demonstrates full HTTP/1.1 compliance, 93.8% HTTP/2 conformance
(matching the nghttp2 library ceiling), strong security posture, stable resource
management, and 71% WebSocket RFC 6455 compliance.
=head1 TEST ENVIRONMENT
=over 4
=item * B<Server>: PAGI::Server with IO::Async and EV backend
=item * B<Platform>: macOS Darwin / Linux
=item * B<Event Loop>: EV with kqueue (macOS) or epoll (Linux)
=item * B<Test Date>: February 2026
=back
=head1 RESULTS SUMMARY
+---------------------------+-------+--------+--------+
| Category | Tests | Passed | Failed |
+---------------------------+-------+--------+--------+
| HTTP/1.1 Compliance | 10 | 10 | 0 |
| HTTP/2 (h2spec) | 146 | 137 | 9 |
| Slow HTTP Attacks | 4 | 4 | 0 |
| Concurrent Attack+Traffic | 1 | 1 | 0 |
| Request Smuggling | 6 | 6 | 0 |
| nikto Scanner | 4 | 4 | 0 |
| Protocol Fuzzing | 49 | 49 | 0 |
| Memory/Resource Leaks | 4 | 4 | 0 |
| WebSocket (Autobahn) | 301 | 215 | 86 |
+---------------------------+-------+--------+--------+
=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
both HTTP/1.1 and HTTP/2.
=item * B<WebSocket over HTTP/2> (RFC 8441 Extended CONNECT), including
per-stream C<websocket.keepalive> (see L</"WebSocket over HTTP/2 (RFC
8441)">).
=item * B<TLS introspection> (the C<tls> extension) -- certificates, negotiated
version, and cipher suite. The server negotiates TLS 1.3 by default
(C<min_version> is a floor, not a pin).
=item * B<Server-Sent Events> over all HTTP methods, with keepalive (see
L</"SERVER-SENT EVENTS (SSE)">).
=item * B<Lifespan> -- the per-process startup/shutdown protocol, in C<auto>
(decline-tolerant, default) and C<on> (strict) modes (see L</"Lifespan
Modes">).
=back
The server validates outbound events against the spec and rejects malformed
ones unconditionally, on every send path, in every environment.
=head2 Lifespan Modes
C<lifespan_mode> controls how the server treats the PAGI lifespan protocol --
the C<lifespan> scope an application may use to run startup/shutdown logic
once per process.
=over 4
=item * B<auto> (default) -- decline-tolerant. If the application does not
implement lifespan, or declines it (raises on the C<lifespan> scope, or
returns without signalling C<lifespan.startup.complete>/
C<lifespan.startup.failed>), the server continues startup without it. An
explicit C<lifespan.startup.failed> still aborts startup in this mode too.
=item * B<on> -- strict. A decline at startup is a fatal startup failure: the
server refuses to start rather than serve traffic without the initialization
the application depended on.
=back
There is no C<off> mode. The PAGI Lifespan spec forbids a server from
skipping the protocol entirely: "A server must not offer an 'off' switch for
this protocol." Passing C<lifespan_mode =E<gt> 'off'> to the constructor or
to the C<configure> setter dies immediately, naming the rejected value.
In multi-worker mode (C<workers =E<gt> N>), each forked worker inherits
C<lifespan_mode> and C<lifespan_startup_timeout> from the master's
configuration and runs its own lifespan handshake against its own copy of
lib/PAGI/Server/Compliance.pod view on Meta::CPAN
(same file, request-direction subtest) -- in neither case does the
C library layer, nor PAGI, silently rewrite the value. HTTP/1.1 performs no
such rejection.
=head2 Load Testing (h2load)
Tool: h2load (nghttp2)
Protocol: h2 over TLS (ALPN negotiated)
+--------------+------+----------+-----------+---------+---------+
| Requests | Conn | Streams | Req/sec | Errors | Timeout |
+--------------+------+----------+-----------+---------+---------+
| 10,000 | 10 | 100 | 8,294 | 0 | 0 |
| 50,000 | 50 | 100 | 6,823 | 0 | 0 |
+--------------+------+----------+-----------+---------+---------+
Zero failures across 60,000 total requests under concurrent load.
=head2 nghttp Client Validation
Tool: nghttp (nghttp2 CLI client)
=over 4
=item * B<GET request>: Clean h2 exchange with ALPN, SETTINGS, HPACK compression
=item * B<POST with body>: DATA frame + END_STREAM handled correctly
=item * B<16 concurrent streams>: All responded 200 on a single connection in 3ms
=back
=head2 HTTP/2 Features
=over 4
=item * ALPN negotiation (h2, http/1.1)
=item * Stream multiplexing (100 concurrent streams default)
=item * HPACK header compression (93.7% space savings observed)
=item * Flow control (per-stream and connection-level)
=item * GOAWAY handling (graceful session shutdown)
=item * Stream state validation (END_STREAM tracking, RST_STREAM on violations)
=item * Extended CONNECT for WebSocket over HTTP/2 (RFC 8441)
=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
by the server, which use the async worker pool (L<PAGI::Server::AsyncFile>)
for files larger than C<sync_file_threshold>; a C<fh> body bypasses that
pool regardless of size.
=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
(a C<$send> awaiting the buffer to drain) still resumes on teardown, so no
coroutine is left hanging; only the app-facing hysteresis callback is
withheld.
=head2 WebSocket over HTTP/2 (RFC 8441)
PAGI::Server accepts a WebSocket upgrade over HTTP/2 through RFC 8441
Extended CONNECT (C<:protocol =E<gt> 'websocket'>). Each accepted stream carries
its own C<websocket.receive>/C<websocket.disconnect> event stream,
multiplexed alongside every other stream on the connection.
=head3 Framing Enforcement
RFC 8441 requires WebSocket-over-HTTP/2 framing to be "identical to
HTTP/1.1" (section 4), so the same three RFC 6455 framing checks the
HTTP/1.1 WebSocket compliance section above performs also apply here, per
stream: nonzero RSV1-3 bits close with
C<1002> (section 5.2; C<Protocol::WebSocket::Frame> exposes C<rsv> but
does not itself validate it), reserved/unknown opcodes C<3-7> and
C<11-15> close with C<1002> (section 5.2), and a control frame
(close/ping/pong) payload over 125 bytes closes with C<1002> (section
5.5). Each violation follows the same server-initiated-protocol-close path
described below: one Close frame on the wire, one C<websocket.disconnect>
(code C<1002>, reason C<protocol_error>) delivered to the application.
=head3 Keepalive
C<websocket.keepalive> is supported per stream. An HTTP/2 connection
multiplexes many WebSocket streams, so keepalive state and its timers live
on the individual stream rather than the connection -- unlike HTTP/1.1,
where one WebSocket occupies the whole connection and keepalive is
connection-wide. A ping is delivered as an RFC 6455 ping frame carried in
an HTTP/2 C<DATA> frame on that stream; the peer's pong (opcode 10) clears
the stream's wait flag. Each C<websocket.keepalive> event for a stream
supersedes any keepalive already running on it, and C<interval =E<gt> 0>
stops it. When C<timeout> is given and no pong arrives
within it, only that stream closes -- code C<1006>, reason
C<keepalive_timeout> -- other streams multiplexed on the same connection
are unaffected. That C<1006> is the code the application sees; it never
reaches the wire, because RFC 6455 section 7.4.1 forbids C<1006> as the
status code of a Close frame. A keepalive timeout sends no Close frame at
all: it resets the stream (C<RST_STREAM>, error code C<CANCEL>), the
HTTP/2 analogue of HTTP/1.1 dropping the transport. An omitted C<timeout>
means the server never checks for a missing pong.
=head3 Disconnect
Each HTTP/2 WebSocket stream enqueues exactly one C<websocket.disconnect>
event onto its scope's receive queue, whichever of the conditions below
closes it first: the closure paths all funnel through one deduplicating
lib/PAGI/Server/Compliance.pod view on Meta::CPAN
same encodability, but at arm time -- when the C<sse.keepalive> event
itself is sent, not deferred to the keepalive timer's first tick.
C<EventValidator> requires a present C<comment> to be a defined,
non-reference string that round-trips
C<Encode::encode('UTF-8', $comment, Encode::FB_CROAK)>, croaking
C<"sse.keepalive 'comment' must be a UTF-8-encodable string"> otherwise. An
unencodable comment therefore fails the send Future that armed the
keepalive, rather than escaping as an uncaught die inside the timer tick.
=head2 Request Bodies
C<sse.request> (the event an application receives when it reads the body of
a POST/PUT SSE request) shares its body-reading semantics with ordinary HTTP
requests rather than a separate partial parser:
=over 4
=item * HTTP/1.1 supports both C<Content-Length> and chunked
C<Transfer-Encoding> bodies, through the same body reader C<http.request>
uses;
=item * C<Expect: 100-continue> is handled transparently -- the server sends
C<100 Continue> before waiting for body data;
=item * HTTP/2 waits for the stream's C<DATA> frames and C<END_STREAM>
before delivering the (one-shot) C<sse.request> event, so a body still
arriving across frames is never reported early behind a truthful-looking
C<more =E<gt> 0>;
=item * an empty-body request receives one empty terminal event, and
disconnect stays observable once the body is complete.
=back
=head2 Per-Stream HTTP/2 Keepalive and Idle Timeout
As with HTTP/2 WebSocket (see L</"Keepalive">), an HTTP/2 connection
multiplexes many SSE streams, so C<sse.keepalive> and C<sse_idle_timeout>
state -- timer, interval, comment, writer, and last-activity reset -- live
on the individual stream, not the connection. Starting or updating
keepalive on one stream cannot stop, replace, or redirect another stream's
timer, and an idle-timeout expiry ends only that stream (the server marks
it closing, flushes any already-queued data, then sends the final
C<END_STREAM> frame -- the same path an application-initiated C<sse.close>
takes). Sibling streams and the connection itself are unaffected.
Over HTTP/1.1, one connection carries only one SSE stream at a time, so
C<sse_idle_timeout> is enforced at the connection level there: expiry closes
the connection outright.
=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
=item * B<Normal application responses> -- C<http.response.start> on
HTTP/1.1 and HTTP/2 (including the HTTP/2 HEAD-request path, which reuses
the same header list), and C<sse.start> on both transports (above).
=item * B<Application declines> -- the C<websocket.http.response>
extension's custom denial response and the C<sse.http.response.*> decline
response, on both transports.
=item * B<Server-generated responses, which carry no application header
list to check> -- the HTTP/1.1 synthesized error response (e.g. the
unanswered-request C<500> backstop and the bare WebSocket-handshake
rejection, which both share that same path); and, HTTP/2 only, the bare
WebSocket-handshake-rejection C<403>, the C<413> request-precheck
rejection, the C<413> body-size-overrun rejection, the synthesized
C<500>, and the plain (non-Extended-CONNECT) C<CONNECT> method's C<501>
rejection.
=back
=head2 HTTP/2 Connection-Specific Header Stripping
RFC 9113 section 8.2.2 forbids connection-specific header fields on
HTTP/2: an application-supplied C<Connection>, C<Keep-Alive>,
C<Proxy-Connection>, C<Transfer-Encoding>, or C<Upgrade> header -- or a
C<TE> header carrying anything but the exact token C<trailers> -- corrupts
the response at the framing layer (nghttp2 rejects the header list; the
client receives only the C<:status> pseudo-header, with no body).
PAGI::Server strips these six header names, case-insensitively, from
application-supplied response headers before they reach nghttp2, on every
HTTP/2 response path that maps application headers: C<http.response.start>
(including the HEAD-request path, which reuses the same header list),
C<sse.start>, the C<websocket.http.response> denial, the
C<sse.http.response.*> decline, C<websocket.accept>, and the trailing
C<HEADERS> block submitted by C<http.response.trailers> (see "HTTP/2
Trailers" above).
C<TE> is the one carve-out, and it applies only to a response's own
C<HEADERS> block, where it advertises that trailers are coming: a C<TE>
header whose value -- after trimming leading/trailing optional whitespace
(OWS) -- is exactly the token C<trailers> is kept there; any other C<TE>
value, including a compound value like C<trailers, gzip>, is stripped. That
carve-out does not extend inside a trailer block itself -- RFC 9110 section
6.6.2 forbids every connection-specific field, C<TE> included, from a
trailer section outright, so C<http.response.trailers> strips a
trailer-borne C<TE> unconditionally, regardless of its value. Each stripped
occurrence, not deduplicated by name, logs C<PAGI: connection-specific
header '$name' stripped from HTTP/2 response (RFC 9113)> for a response
header, or C<PAGI: connection-specific header '$name' stripped from HTTP/2
trailers (RFC 9113)> for a trailer field.
( run in 0.685 second using v1.01-cache-2.11-cpan-5c0b1e786e0 )