PAGI-Server
view release on metacpan or search on metacpan
lib/PAGI/Server/Connection.pm view on Meta::CPAN
package PAGI::Server::Connection;
use strict;
use warnings;
our $VERSION = '0.002013';
use Future;
use Future::AsyncAwait;
use Scalar::Util qw(weaken refaddr);
use Protocol::WebSocket::Handshake::Server;
use Protocol::WebSocket::Frame;
use Digest::SHA qw(sha1_base64);
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 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
sub _validate_header_value { PAGI::Server::EventValidator::check_header_value($_[0]) }
sub _validate_header_name { PAGI::Server::EventValidator::check_header_name($_[0]) }
# =============================================================================
# HTTP/2 connection-specific header stripping (RFC 9113 section 8.2.2, design
# doc section 13.3)
# =============================================================================
# HTTP/2 forbids connection-specific header fields. An app-supplied
# connection, keep-alive, proxy-connection, transfer-encoding, or upgrade
# header -- or a te header carrying anything but the token 'trailers' --
# corrupts the response at the framing layer: the client receives only
# :status, with no body. HTTP/1.1 has no such prohibition, so this strip
# applies only to the HTTP/2 response paths that call it.
my %H2_CONNECTION_SPECIFIC_HEADER = map { $_ => 1 }
qw(connection keep-alive proxy-connection transfer-encoding upgrade te);
# Returns a new arrayref with connection-specific header pairs removed,
# warning once per stripped occurrence (not deduplicated by name -- two
# 'keep-alive' headers warn twice). Does not mutate $headers.
#
# $in_trailers (optional, default false) selects the trailer-block variant
# of this rule: RFC 9110 section 6.6.2 forbids every connection-specific
# field from a trailer section outright, so unlike a response's HEADERS
# block there is no 'te: trailers' carve-out inside a trailer block itself
# -- that carve-out is what lets a response ADVERTISE trailers are coming,
# not something a trailer block may then contain. A trailer-borne 'te'
# tuple is therefore stripped regardless of its value.
sub _h2_strip_connection_headers {
my $self = shift;
my ($headers, $in_trailers) = @_;
my $context = $in_trailers ? 'trailers' : 'response';
my @kept;
for my $h (@$headers) {
my ($name, $value) = @$h;
my $lc_name = lc $name;
if ($H2_CONNECTION_SPECIFIC_HEADER{$lc_name}) {
# RFC 9113 permits a response 'te' only with the exact token
# 'trailers'; a case-insensitive VALUE compare (not a name/list
# match) is what the token grammar calls for. OWS
# (leading/trailing whitespace) around the token is trimmed
# before the compare, per RFC 9110's field-value grammar -- a
# compound value like 'trailers, gzip' is not the bare token and
# is still stripped. This carve-out does not apply in a trailer
# block (see $in_trailers above).
if (!$in_trailers && $lc_name eq 'te') {
(my $v = lc $value) =~ s/^\s+|\s+\z//g;
if ($v eq 'trailers') {
# Submit the normalized token, never the original value:
# RFC 9113 8.2.1 forbids OWS in field values, and
lib/PAGI/Server/Connection.pm view on Meta::CPAN
my $body = $ss->{body};
$ss->{body} = '';
return {
type => 'http.request',
body => $body,
more => 0,
};
}
while (1) {
# Wait for body data (or, once the request has been fully
# delivered, for the stream to end)
if (!$ss->{body_pending}) {
$ss->{body_pending} = Future->new;
}
await $ss->{body_pending};
# Re-fetch stream state (may have changed)
$ss = $weak_self->{h2_streams}{$stream_id};
return { type => 'http.disconnect' } unless $ss;
# Check queue after waking -- a queued event wins over the
# body fallthrough (a close can set body_complete AND queue
# http.disconnect on the same wake)
if (@{$ss->{receive_queue}}) {
return shift @{$ss->{receive_queue}};
}
# Terminal event already delivered: this wake brought
# nothing for the application -- park again rather than
# re-synthesize the final body event.
next if $ss->{final_request_delivered};
my $more = $ss->{body_complete} ? 0 : 1;
$ss->{final_request_delivered} = 1 unless $more;
my $body = $ss->{body};
$ss->{body} = '';
return {
type => 'http.request',
body => $body,
more => $more,
};
}
})->();
return $future;
};
}
sub _h2_create_send {
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
# mirror update and let END_STREAM land back on the DATA frame.
my $trailers_declared = 0;
# Trailers-vs-data-provider handshake (design §8.3). Confirmed
# empirically: calling submit_trailer() BEFORE the data provider has
# actually handed nghttp2 its terminal (eof=1) chunk silently abandons
# any DATA nghttp2 has not yet pulled through $data_callback (observed
# under real per-stream flow control -- the still-queued tail of a
# file/fh body never reached the wire, yet the stream closed "cleanly"
# with the trailer -- a DEFERRED data-provider item is detached from
# nghttp2's own outbound queue, so an early trailing HEADERS orphans
# it). The Net::HTTP2::nghttp2 binding's own POD says submit_trailer()
# "can be called inside" the data-provider callback OR after it
# returns -- it does not say "at any later, unrelated time" -- so the
# invariant this handshake actually enforces is narrower and stricter:
# never BEFORE the provider has delivered its terminal EOF. The
# trailers arm below submits directly ONLY once $data_eof_delivered is
# already true (no further callback invocation will occur for this
# stream); otherwise it stages the headers here and PARKS the send()
# until the callback's own terminal invocation submits them.
#
# Contract note (flag for Task 6 / Compliance.pod): this means a
# trailers send() can now block for as long as the peer withholds
# flow-control window on a still-draining body -- new, unbounded-in-
# the-app's-view blocking that a pre-Task-4 (stub) reading of the spec
# would not have anticipated. This is arguably MORE correct, not a
# regression: trailers now participate in the same backpressure body
# sends already do, rather than racing ahead of undelivered DATA.
my $data_eof_delivered = 0;
my $pending_trailer_headers;
# Called by $data_callback at the exact point it hands nghttp2 the
# terminal (eof=1) chunk. If a trailers send() is waiting on this
# (staged $pending_trailer_headers), submits it HERE -- synchronously,
# from inside the data-provider callback, per nghttp2's own sanctioned
# pattern -- then wakes the waiting send() on the next loop tick (never
# resolve an app Future from inside a native nghttp2 callback -- same
# discipline as the drain waiters above).
my $deliver_trailer_eof = sub {
$data_eof_delivered = 1;
return unless defined $pending_trailer_headers;
my $headers = $pending_trailer_headers;
$pending_trailer_headers = undef;
my $ss2 = $weak_self && $weak_self->{h2_streams}{$stream_id};
# Dead-stream invariant, kept local here rather than inferred from
# the doomed-but-still-present carve-out pattern used across this
# file (h2_closed set, entry not yet reclaimed): don't reach
# nghttp2 a second time on a stream id it may already be tearing
# down. Two close paths, two mechanisms, same outcome -- there's
# nothing left to do here either way: _h2_on_close and the 413
# early-close branch set h2_closed on the still-present entry
# (caught by the check below), while a whole-connection _close
# deletes the h2_streams entry outright (so $ss2 comes back undef
# here) and releases trailer_wait itself via its own sweep. Either
# way, whichever close path is running has already released -- or
# is about to release -- trailer_wait.
return if $ss2 && $ss2->{h2_closed};
my $ok = eval {
$weak_self->{h2_session}->submit_trailer($stream_id, headers => $headers);
1;
};
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;
# Respect max_len â XS truncates without preserving remainder
if (length($chunk) > $max_len) {
unshift @$q, substr($chunk, $max_len);
$chunk = substr($chunk, 0, $max_len);
}
$ss->{send_queue_bytes} -= length($chunk);
# Per-stream backpressure: once this stream's queue falls below the
# low watermark, release any producer blocked in
# _h2_wait_for_stream_drain. This callback runs inside nghttp2's
# extract(), so resolve on the next loop tick â completing the Future
# resumes the awaiting producer synchronously, and it must not call
# resume_stream/_h2_write_pending re-entrantly into nghttp2.
if (($ss->{send_queue_bytes} // 0) < $weak_self->{write_low_watermark}
&& $ss->{stream_drain_waiters} && @{$ss->{stream_drain_waiters}}) {
my @waiters = splice @{$ss->{stream_drain_waiters}};
$weak_self->{server}->loop->later(sub {
$_->done for grep { !$_->is_ready } @waiters;
});
}
# Fire the app's on_drain hysteresis callbacks once this stream's
# queue falls below the low watermark. Like the waiters above, this
# runs inside nghttp2's extract(), and an on_drain callback may call
# $send to resume its source â which would re-enter nghttp2. Splice
# the fires out first (so they can't double-fire), then invoke them on
# the next loop tick.
if (($ss->{send_queue_bytes} // 0) < $weak_self->{write_low_watermark}
&& $ss->{transport_drain_fires} && @{$ss->{transport_drain_fires}}) {
my @fires = splice @{$ss->{transport_drain_fires}};
$weak_self->{server}->loop->later(sub {
$_->() for @fires;
});
}
my $eof = (!@$q && $eof_pending) ? 1 : 0;
# Trailers declared: reserve END_STREAM for the trailing
# HEADERS block (design §8.3) instead of letting it land on
# this DATA frame. Has no effect unless $eof is also true
# (Net::HTTP2::nghttp2's own contract), so it is safe to key
# off $trailers_declared unconditionally here rather than the
# $ss->{seq_state} mirror, which the trailers arm may have
# already advanced past 'awaiting_trailers' by this point.
my $no_end = $trailers_declared ? 1 : 0;
$deliver_trailer_eof->() if $eof;
return ($chunk, $eof, $no_end);
}
# Queue empty but EOF pending â signal end of stream
if ($eof_pending) {
my $no_end = $trailers_declared ? 1 : 0;
$deliver_trailer_eof->();
return ('', 1, $no_end);
}
# Queue empty, more data expected â defer (NGHTTP2_ERR_DEFERRED in C layer)
return undef;
};
# Shared file/fh chunk pump: pushes produced chunks into this stream's
# send queue under the per-stream watermark, then marks EOF. The producer
# is an async sub that receives an async "emit" callback and must await it
# per chunk; emit dies with the sentinel below if the stream vanishes
# (client reset) so the pump stops reading without treating it as an error.
my $STREAM_GONE = "PAGI::h2 stream gone\n";
my $emit_chunk = async sub {
my ($chunk) = @_;
my $ss = $weak_self ? $weak_self->{h2_streams}{$stream_id} : undef;
die $STREAM_GONE unless $ss && !$ss->{h2_closed} && !$weak_self->{closed};
if (($ss->{send_queue_bytes} // 0) >= $weak_self->{write_high_watermark}) {
await $weak_self->_h2_wait_for_stream_drain($stream_id);
$ss = $weak_self ? $weak_self->{h2_streams}{$stream_id} : undef;
die $STREAM_GONE unless $ss && !$ss->{h2_closed} && !$weak_self->{closed};
}
if (length $chunk) {
push @{$ss->{send_queue}}, $chunk;
$ss->{send_queue_bytes} += length $chunk;
}
$ss->{transport_state}->_check_watermarks if $ss->{transport_state};
$weak_self->{h2_session}->resume_stream($stream_id);
$weak_self->_h2_write_pending;
return;
};
# Shared tail for the file/fh arms below: mark EOF pending and resume the
# stream once the read loop finishes without error. A no-op if the stream
# vanished (client reset) while the last chunk was in flight.
my $finish_body_stream = sub {
$eof_pending = 1;
return unless $weak_self;
return unless $weak_self->{h2_streams}{$stream_id};
$weak_self->{h2_session}->resume_stream($stream_id);
$weak_self->_h2_write_pending;
};
# 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
# stream is simply gone -- a quiet no-op), or marks the body stream's
# EOF pending on success. Mirrors h1's D1 advance-then-rollback pattern.
my $finish_or_rollback_body_send = sub {
my ($ok, $err, $ss, $seq_before) = @_;
if (!$ok) {
return if $err eq $STREAM_GONE; # client reset: quiet no-op
$seq = $seq_before; # recoverable, per contract
$ss->{seq_state} = $seq if $ss;
die $err;
}
$finish_body_stream->();
};
return async sub {
my ($event) = @_;
return unless $weak_self;
my $type = $event->{type} // '';
# Once the machine has recorded the response complete, the machine
# decides what happens next -- 'complete' has no idempotent case, so
# any further send always raises -- not the stream-gone check below.
# h2_streams entries for a finished stream are reclaimed
# asynchronously by _h2_on_close, so by the time a post-complete send
# arrives $ss may already be gone.
my $already_closed = ($seq eq 'complete');
my $ss = $weak_self->{h2_streams}{$stream_id};
# A doomed-but-still-present entry (h2_closed set but not yet
# deleted -- see the 413-overrun branch in _h2_on_body) is treated
# the same as an absent one: both are post-close sends and must
# silently no-op, not reach nghttp2 a second time on this stream id.
return if (!$ss || $ss->{h2_closed}) && !$already_closed;
return if $weak_self->{closed} && !$already_closed;
# 1. Shape validation (mandatory)
PAGI::Server::EventValidator::validate_http_send(
$event, { extensions => $weak_self->{extensions} });
# 2. HEAD: the server suppresses the body (PAGI Www.pod "HEAD Requests").
# The app responds exactly as for GET; we discard payloads, never open
# file/fh, and accept-and-discard trailers. Sequence state still
# advances so the lifecycle (completion, post-complete raises) matches GET.
if ($is_head && ($type eq 'http.response.body' || $type eq 'http.response.trailers')) {
$seq = PAGI::Server::EventValidator::advance_http($seq, $event);
$ss->{seq_state} = $seq if $ss;
if ($seq eq 'complete' && !$ss->{h2_head_finished}) {
$ss->{h2_head_finished} = 1;
$weak_self->{h2_session}->submit_response($stream_id,
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# trailers at start. advance-then-rollback (h1 D1 pattern, mirrored
# from _create_send's chunked-framing check): the sequence machine
# and its $ss mirror advance FIRST, then the native submit is
# attempted (directly here, or via $deliver_trailer_eof above --
# see the closure-top comment for why). advance_http itself may
# croak here (trailers undeclared or body not yet complete) -- that
# propagates unrolled-back, same as validate_http_send's shape
# check above, since $seq was never reassigned in that case. If
# submit_trailer throws, both are rolled back to their pre-event
# value and the error propagates: the app's return then lands in
# the existing incomplete-response arm (RST NGHTTP2_INTERNAL_ERROR)
# -- the same machinery a dropped body chunk already relies on, not
# duplicated here.
if ($type eq 'http.response.trailers') {
my $seq_before = $seq;
$seq = PAGI::Server::EventValidator::advance_http($seq, $event);
$ss->{seq_state} = $seq if $ss;
# Empty/absent headers still calls submit_trailer with [] --
# trailers were declared and must still terminate the response.
my $trailer_headers = [
map { [_validate_header_name($_->[0]), _validate_header_value($_->[1])] }
@{ $event->{headers} // [] }
];
# RFC 9110 6.6.2 / design 13.3 -- an app-supplied connection,
# transfer-encoding, etc. in a trailer block corrupts it at the
# framing layer exactly like a response header: nghttp2 rejects
# the whole trailer HEADERS block, so the peer sees zero
# trailing HEADERS (on_stream_close never fires) even though
# submit_trailer below reports success. Strip before submission,
# same as every response-header path; $in_trailers=1 drops the
# response-only 'te: trailers' carve-out (RFC 9110 6.6.2 bans
# connection-specific fields from trailers outright).
$trailer_headers = $weak_self->_h2_strip_connection_headers($trailer_headers, 1);
my $ok;
if ($data_eof_delivered) {
# The data provider already handed nghttp2 its terminal
# chunk -- no further $data_callback invocation will occur
# for this stream, so it is safe to submit directly here.
$ok = eval {
$weak_self->{h2_session}->submit_trailer(
$stream_id, headers => $trailer_headers);
1;
};
} else {
# The data provider has not yet delivered EOF to nghttp2
# (still mid-transfer, possibly blocked on real per-stream
# flow control). Stage the headers for $deliver_trailer_eof
# to submit from inside the callback's own terminal
# 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
# body arms' own post-drain-wait liveness re-check
# (:1616-1619 / :1635-1638 above). Without this, a
# connection that died while we awaited would leave
# $weak_self->{h2_session} undef and the shared tail below
# would call a method on it.
return unless $weak_self && !$weak_self->{closed} && $weak_self->{h2_session};
}
if (!$ok) {
my $err = $@;
$seq = $seq_before;
$ss->{seq_state} = $seq if $ss;
die $err;
}
# Stream-existence re-check, same discipline as every sibling
# await site's own $ss re-fetch (e.g. $emit_chunk above): the
# connection-liveness check at :1524 only covers the deferred
# branch and only the connection, not this stream specifically.
# Placed AFTER the rollback above (not folded into :1524) so a
# deferred native failure still reaches its rollback+die even if
# the stream looks gone by the time we get here -- only the
# success-path tail below, which is about to call resume_stream/
# _h2_write_pending on this stream id again, needs the guard.
# 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}) {
my $file = $event->{file};
my $offset = $event->{offset} // 0;
my $length = $event->{length};
# Snapshot+advance BEFORE the -f/-r checks (and the checks live
# inside the eval below) so a file event arriving in an illegal
# 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;
if ($weak_self->{sync_file_threshold} > 0
&& $effective_length <= $weak_self->{sync_file_threshold}) {
# Small-file fast path (parity with h1's sync_file_threshold):
# a file at or under the threshold is read synchronously,
# in-process, as ONE queue chunk -- avoiding a worker-pool
# round trip for a body small enough that it costs more
# than it saves. The no-slurp rule targets large files.
open my $fh, '<:raw', $file or die "Cannot open file $file: $!\n";
seek($fh, $offset, 0) if $offset;
my $bytes_read = read($fh, my $data, $effective_length);
die "Failed to read file $file: $!\n" unless defined $bytes_read;
close $fh;
await $emit_chunk->($data);
}
else {
my $loop = $weak_self->{server}->loop;
await PAGI::Server::AsyncFile->read_file_chunked(
$loop, $file, $emit_chunk,
offset => $offset,
(defined $length ? (length => $length) : ()),
chunk_size => FILE_CHUNK_SIZE,
);
}
1;
};
$finish_or_rollback_body_send->($ok, $@, $ss, $seq_before);
return;
}
# 5. fh body: streamed through the send queue via $emit_chunk, one
# chunk at a time, under this stream's own backpressure. The
# 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;
}
# The bare block scopes only the 'closed' warning
# suppression around read() -- a bare block is itself a
# one-iteration loop, so die/last/await must live outside
# it or 'last' would only exit the block, not this while.
my ($bytes_read, $chunk);
{ no warnings 'closed';
$bytes_read = read($fh, $chunk, $to_read);
}
die "Failed to read filehandle: $!\n" unless defined $bytes_read;
last if $bytes_read == 0;
await $emit_chunk->($chunk);
$remaining -= $bytes_read if defined $remaining;
}
1;
};
$finish_or_rollback_body_send->($ok, $@, $ss, $seq_before);
return;
}
# 6. Sequence enforcement (start / plain body / fullflush). advance_http
# is deliberately called from four places in this sub â the HEAD
# block above, the file arm, the fh arm, and here â each has
# different pre/post-state needs; do not consolidate them.
$seq = PAGI::Server::EventValidator::advance_http($seq, $event);
$ss->{seq_state} = $seq if $ss;
if ($type eq 'http.response.start') {
$ss->{response_started} = 1;
$ss->{connection_state}->_mark_response_started if $ss->{connection_state};
$trailers_declared = 1 if $event->{trailers};
$status = $event->{status} // 200;
@response_headers = map {
[_validate_header_name($_->[0]), _validate_header_value($_->[1])]
} @{$event->{headers} // []};
# RFC 9113 8.2.2 / design 13.3 â strips app-supplied connection,
# transfer-encoding, etc. before this list reaches nghttp2 (also
# covers the HEAD path below, which submits this same array).
@response_headers = @{ $weak_self->_h2_strip_connection_headers(\@response_headers) };
# 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
# streams.
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};
}
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;
} 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 {
my ($self, $stream_id, $stream_state) = @_;
my $pseudo = $stream_state->{pseudo};
my $headers = $stream_state->{headers};
my $full_path = $pseudo->{':path'} // '/';
my ($path, $query_string) = split(/\?/, $full_path, 2);
$query_string //= '';
# Match HTTP/1.1 pipeline: URI::Escape + UTF-8 decode with fallback
my $unescaped = uri_unescape($path);
my $decoded_path = eval { decode('UTF-8', $unescaped, Encode::FB_CROAK) }
// $unescaped;
# Extract subprotocols from headers
my @subprotocols;
for my $header (@$headers) {
my ($name, $value) = @$header;
if ($name eq 'sec-websocket-protocol') {
push @subprotocols, map { s/^\s+|\s+$//gr } split /,/, $value;
}
}
return {
type => 'websocket',
pagi => {
version => '0.5',
spec_version => '0.5',
},
http_version => '2',
scheme => $self->_get_ws_scheme,
path => $decoded_path,
raw_path => $path,
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}],
subprotocols => \@subprotocols,
state => keys %{$self->{state}} ? { %{$self->{state}} } : {},
extensions => do {
my %ext = (%{$self->_get_extensions_for_scope}, 'websocket.http.response' => {});
# fullflush has no validate_websocket_send arm; advertising it here
# would lie to the app (design 13.2).
delete $ext{fullflush};
\%ext;
},
# 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) = @_;
weaken(my $weak_self = $self);
# Fallback disconnect for a receive() that resolves after this stream is
# gone or the connection is closed: 1006/'' by default (RFC 6455
# abnormal closure, no reason available), but prefers the per-stream
# server_close_reason token when the stream's own state is still
# reachable in h2_streams -- a server-initiated teardown (idle timeout,
# keepalive timeout, ...) records that token there before tearing the
# stream down, so a receive() racing that teardown still reports why.
my $fallback_disconnect = sub {
my $ss = $weak_self && $weak_self->{h2_streams}{$stream_id};
return {
type => 'websocket.disconnect',
code => 1006,
reason => ($ss && $ss->{server_close_reason}) // '',
};
};
return sub {
return Future->done($fallback_disconnect->())
unless $weak_self;
return Future->done($fallback_disconnect->())
if $weak_self->{closed};
my $ss = $weak_self->{h2_streams}{$stream_id};
return Future->done($fallback_disconnect->())
unless $ss;
my $future = (async sub {
return $fallback_disconnect->()
unless $weak_self;
my $ss = $weak_self->{h2_streams}{$stream_id};
return $fallback_disconnect->()
unless $ss;
# Check queue first
if (@{$ss->{receive_queue}}) {
return shift @{$ss->{receive_queue}};
}
# First call returns websocket.connect
if (!$ss->{ws_connect_sent}) {
$ss->{ws_connect_sent} = 1;
return { type => 'websocket.connect' };
}
# Wait for events
while (1) {
if (@{$ss->{receive_queue}}) {
return shift @{$ss->{receive_queue}};
}
return $fallback_disconnect->()
if $weak_self->{closed};
if (!$ss->{body_pending}) {
$ss->{body_pending} = Future->new;
}
await $ss->{body_pending};
$ss = $weak_self->{h2_streams}{$stream_id};
return $fallback_disconnect->()
unless $ss;
}
})->();
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;
my $q = $ss->{send_queue} ||= [];
if (@$q) {
my $chunk = shift @$q;
# Respect max_len â XS truncates without preserving remainder
if (length($chunk) > $max_len) {
unshift @$q, substr($chunk, $max_len);
$chunk = substr($chunk, 0, $max_len);
}
$ss->{send_queue_bytes} -= length($chunk);
# Per-stream backpressure: once this stream's queue falls below the
# low watermark, release any producer blocked in
# _h2_wait_for_stream_drain. This runs inside nghttp2's extract(), so
# resolve on the next loop tick â completing the Future resumes the
# awaiting producer synchronously, and it must not re-enter nghttp2.
if (($ss->{send_queue_bytes} // 0) < $weak_self->{write_low_watermark}
&& $ss->{stream_drain_waiters} && @{$ss->{stream_drain_waiters}}) {
my @waiters = splice @{$ss->{stream_drain_waiters}};
$weak_self->{server}->loop->later(sub {
$_->done for grep { !$_->is_ready } @waiters;
});
}
# Fire the app's on_drain hysteresis callbacks once this stream's
# queue falls below the low watermark. Deferred for the same reason:
# an on_drain callback may call $send, which would re-enter nghttp2.
if (($ss->{send_queue_bytes} // 0) < $weak_self->{write_low_watermark}
&& $ss->{transport_drain_fires} && @{$ss->{transport_drain_fires}}) {
my @fires = splice @{$ss->{transport_drain_fires}};
$weak_self->{server}->loop->later(sub {
$_->() for @fires;
});
}
my $eof = (!@$q && $ss->{ws_eof_pending}) ? 1 : 0;
return ($chunk, $eof);
}
# Queue empty but EOF pending (a close frame already delivered as
# the terminal chunk above) â signal end of stream.
return ('', 1) if $ss->{ws_eof_pending};
# Queue empty, more data expected â defer (NGHTTP2_ERR_DEFERRED in C layer)
return undef;
};
return async sub {
my ($event) = @_;
return unless $weak_self;
my $type = $event->{type} // '';
# Once the machine has recorded the connection closed or the denial
# response complete, the machine decides what happens next -- both
# 'closed' and 'denial_complete' have no idempotent case, so any
# further send always raises -- not the stream-gone check below.
# h2_streams entries for a finished stream are reclaimed
# asynchronously by _h2_on_close (websocket.close itself triggers
# this via END_STREAM, same mechanism as the h2 HTTP/SSE closures),
# so by the time a post-close send arrives $ss may already be gone.
my $already_closed = ($seq eq 'closed' || $seq eq 'denial_complete');
my $ss = $weak_self->{h2_streams}{$stream_id};
# A doomed-but-still-present entry (h2_closed set but not yet
# deleted -- see the 413-overrun branch in _h2_on_body) is treated
# the same as an absent one: both are post-close sends and must
# silently no-op, not reach nghttp2 a second time on this stream id.
return if (!$ss || $ss->{h2_closed}) && !$already_closed;
return if $weak_self->{closed} && !$already_closed;
# websocket.http.response is always available on this path (the
# scope advertises it unconditionally; see
# _h2_create_websocket_scope), unlike connection-level extensions
# such as fullflush.
PAGI::Server::EventValidator::validate_websocket_send(
$event, { extensions => { %{$weak_self->{extensions}}, 'websocket.http.response' => {} } });
$seq = PAGI::Server::EventValidator::advance_websocket($seq, $event);
if ($type eq 'websocket.accept') {
# A duplicate accept is already rejected by advance_websocket.
# HTTP/2 WebSocket: respond with 200 (not 101)
my @headers;
if (my $subprotocol = $event->{subprotocol}) {
$subprotocol = _validate_subprotocol($subprotocol);
push @headers, ['sec-websocket-protocol', $subprotocol];
}
if (my $extra = $event->{headers}) {
push @headers, map {
[_validate_header_name($_->[0]), _validate_header_value($_->[1])]
} @$extra;
}
# RFC 9113 8.2.2 / design 13.3 â strip app-supplied connection,
# 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} = '';
$weak_self->_h2_process_ws_frames($stream_id, $ss, $buffered);
}
}
elsif ($type eq 'websocket.send') {
return unless $ss->{ws_accepted};
my $frame;
if (defined $event->{text}) {
$frame = Protocol::WebSocket::Frame->new(
buffer => $event->{text},
type => 'text',
);
}
elsif (defined $event->{bytes}) {
$frame = Protocol::WebSocket::Frame->new(
buffer => $event->{bytes},
type => 'binary',
);
}
else {
return;
}
my $bytes = $frame->to_bytes;
# Per-stream backpressure: bound on THIS stream's queue, not the
# shared TCP buffer (meaningless across multiplexed h2 streams).
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};
# Refetch (same idiom as emit_chunk, :1374-1375): a stream
# can close while this send was parked.
$ss = $weak_self->{h2_streams}{$stream_id};
return unless $ss;
return if $ss->{h2_closed};
# This send can wake AFTER this stream's close frame was
# already queued (window opened below the low watermark
# while ws_eof_pending was set) -- pushing app data now would
# land it BEHIND the close frame: Close would ship without
# END_STREAM and a Text/Binary frame would follow it,
# violating RFC 6455 5.5.1. Same post-close no-op contract
# as the top-of-closure $already_closed check.
return if $ss->{ws_eof_pending};
}
push @{$ss->{send_queue}}, $bytes;
$ss->{send_queue_bytes} = ($ss->{send_queue_bytes} // 0) + length $bytes;
# Synchronous â app send path, not nghttp2 extract â so on_high_water
# may fire here to tell the app to pause its source.
lib/PAGI/Server/Connection.pm view on Meta::CPAN
$weak_self->_h2_write_pending;
}
elsif ($type eq 'websocket.keepalive') {
return unless $ss->{ws_accepted};
my $interval = $event->{interval} // 0;
my $timeout = $event->{timeout};
if ($interval > 0) {
$weak_self->_h2_start_ws_keepalive($stream_id, $ss, $interval, $timeout);
}
else {
$weak_self->_h2_stop_ws_keepalive($ss);
}
}
return;
};
}
# =============================================================================
# HTTP/2 SSE (Server-Sent Events over HTTP/2)
# =============================================================================
sub _h2_create_sse_scope {
my ($self, $stream_id, $stream_state) = @_;
my $pseudo = $stream_state->{pseudo};
my $headers = $stream_state->{headers};
my $full_path = $pseudo->{':path'} // '/';
my ($path, $query_string) = split(/\?/, $full_path, 2);
$query_string //= '';
# Match HTTP/1.1 pipeline: URI::Escape + UTF-8 decode with fallback
my $unescaped = uri_unescape($path);
my $decoded_path = eval { decode('UTF-8', $unescaped, Encode::FB_CROAK) }
// $unescaped;
return {
type => 'sse',
pagi => {
version => '0.5',
spec_version => '0.5',
},
http_version => '2',
method => $pseudo->{':method'} // 'GET',
scheme => $pseudo->{':scheme'} // $self->_get_scheme,
path => $decoded_path,
raw_path => $path,
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);
my $sse_disconnect = sub {
return {
type => 'sse.disconnect',
reason => 'client_closed',
};
};
return sub {
return Future->done($sse_disconnect->()) unless $weak_self;
return Future->done($sse_disconnect->()) if $weak_self->{closed};
my $ss = $weak_self->{h2_streams}{$stream_id};
return Future->done($sse_disconnect->()) unless $ss;
my $future = (async sub {
return $sse_disconnect->() unless $weak_self;
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}}) {
return shift @{$ss->{receive_queue}};
}
return $sse_disconnect->() if $weak_self->{closed};
if (!$ss->{body_pending}) {
$ss->{body_pending} = Future->new;
}
await $ss->{body_pending};
$ss = $weak_self->{h2_streams}{$stream_id};
return $sse_disconnect->() unless $ss;
}
# body_complete can flip true on the very wake that also
# queued a terminal event -- e.g. _h2_on_close sets
# body_complete AND pushes sse.disconnect before waking
# body_pending. That queued event must win over delivering
# a (possibly truncated) body: the loop's own queue check
# only runs at the top of an iteration, so a queue entry
# that arrives on the wake that also satisfies the while
# condition is never seen there.
if (@{$ss->{receive_queue}}) {
return shift @{$ss->{receive_queue}};
}
$ss->{sse_request_sent} = 1;
return {
type => 'sse.request',
body => $ss->{body},
more => 0,
};
}
# Wait for disconnect
while (1) {
if (@{$ss->{receive_queue}}) {
return shift @{$ss->{receive_queue}};
}
return $sse_disconnect->()
if $weak_self->{closed};
if (!$ss->{body_pending}) {
$ss->{body_pending} = Future->new;
}
await $ss->{body_pending};
$ss = $weak_self->{h2_streams}{$stream_id};
return $sse_disconnect->() unless $ss;
}
})->();
return $future;
};
}
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) {
my $chunk = shift @$q;
# Respect max_len â XS truncates without preserving remainder
if (length($chunk) > $max_len) {
unshift @$q, substr($chunk, $max_len);
$chunk = substr($chunk, 0, $max_len);
}
$ss->{send_queue_bytes} -= length($chunk);
# Per-stream backpressure: once this stream's queue falls below the
# low watermark, release any producer blocked in
# _h2_wait_for_stream_drain. This runs inside nghttp2's extract(), so
# resolve on the next loop tick â completing the Future resumes the
# awaiting producer synchronously, and it must not re-enter nghttp2.
if (($ss->{send_queue_bytes} // 0) < $weak_self->{write_low_watermark}
&& $ss->{stream_drain_waiters} && @{$ss->{stream_drain_waiters}}) {
my @waiters = splice @{$ss->{stream_drain_waiters}};
$weak_self->{server}->loop->later(sub {
$_->done for grep { !$_->is_ready } @waiters;
});
}
# Fire the app's on_drain hysteresis callbacks once this stream's
# queue falls below the low watermark. Deferred for the same reason:
# an on_drain callback may call $send, which would re-enter nghttp2.
if (($ss->{send_queue_bytes} // 0) < $weak_self->{write_low_watermark}
&& $ss->{transport_drain_fires} && @{$ss->{transport_drain_fires}}) {
my @fires = splice @{$ss->{transport_drain_fires}};
$weak_self->{server}->loop->later(sub {
$_->() for @fires;
});
}
return ($chunk, 0); # SSE streams never EOF via data_callback
}
# Queue empty. If the application closed this stream (sse.close), emit a
# final empty DATA frame with END_STREAM to terminate it; otherwise defer.
return ('', 1) if $ss->{sse_closing};
# Queue empty â defer (NGHTTP2_ERR_DEFERRED in the C layer)
return undef;
};
return async sub {
my ($event) = @_;
return unless $weak_self;
my $type = $event->{type} // '';
# Once the machine has already recorded this stream as closed --
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# anything else (decline_complete has no idempotent case; it croaks
# unconditionally). h2_streams entries for a closed stream are
# reclaimed asynchronously by _h2_on_close, so by the time a
# post-close send arrives $ss may already be gone.
my $already_closed = ($seq eq 'closed' || $seq eq 'decline_complete');
my $ss = $weak_self->{h2_streams}{$stream_id};
# A doomed-but-still-present entry (h2_closed set but not yet
# deleted -- see the 413-overrun branch in _h2_on_body) is treated
# the same as an absent one: both are post-close sends and must
# silently no-op, not reach nghttp2 a second time on this stream id.
return if (!$ss || $ss->{h2_closed}) && !$already_closed;
return if $weak_self->{closed} && !$already_closed;
# Reset THIS stream's SSE idle timer on send activity (skip once fully closed)
$weak_self->_h2_reset_sse_idle_timer($ss) unless $already_closed;
# Mandatory event validation and sequencing (PAGI spec compliance).
PAGI::Server::EventValidator::validate_sse_send(
$event, { extensions => $weak_self->{extensions} });
$seq = PAGI::Server::EventValidator::advance_sse($seq, $event);
if ($type eq 'sse.start') {
return if $ss->{response_started};
$ss->{response_started} = 1;
my $status = $event->{status} // 200;
my $headers = $event->{headers} // [];
# Ensure Content-Type is text/event-stream
my $has_content_type = 0;
for my $h (@$headers) {
if (lc($h->[0]) eq 'content-type') {
$has_content_type = 1;
last;
}
}
my @final_headers;
for my $h (@$headers) {
push @final_headers, [_validate_header_name($h->[0]), _validate_header_value($h->[1])];
}
# RFC 9113 8.2.2 / design 13.3 â strip app-supplied connection,
# transfer-encoding, etc. before submission.
@final_headers = @{ $weak_self->_h2_strip_connection_headers(\@final_headers) };
if (!$has_content_type) {
push @final_headers, ['content-type', 'text/event-stream'];
}
# Cache-Control and Date: server-supplied only when the app didn't
# 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
# counted in the per-stream backlog so buffered_amount stays
# accurate, but they do not poke the watermark callbacks â a server
# heartbeat is not an application send.
$ss->{sse_ka_writer} = sub {
my ($text) = @_;
return unless $weak_self;
return if $weak_self->{closed};
my $ss = $weak_self->{h2_streams}{$stream_id} or return;
# PAGI Www.pod "Send SSE": encode to UTF-8 exactly once, at
# the wire boundary â all queue-length math below is on the
# resulting BYTE string.
my $bytes = eval { Encode::encode('UTF-8', $text, Encode::FB_CROAK) };
die "sse payload is not encodable as UTF-8: $@" unless defined $bytes;
push @{$ss->{send_queue} ||= []}, $bytes;
$ss->{send_queue_bytes} = ($ss->{send_queue_bytes} // 0) + length $bytes;
$weak_self->{h2_session}->resume_stream($stream_id);
$weak_self->_h2_write_pending;
};
# Start THIS stream's SSE idle timer if configured
$weak_self->_h2_start_sse_idle_timer($stream_id, $ss);
}
elsif ($type eq 'sse.send') {
return unless $ss->{response_started};
# Per-stream backpressure: bound on THIS stream's queue, not the
# shared TCP buffer (meaningless across multiplexed h2 streams).
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};
}
my $sse_data = _format_sse_event($event);
# PAGI Www.pod "Send SSE": encode to UTF-8 exactly once, at the
# wire boundary â a failed encode fails this send's Future.
my $bytes = eval { Encode::encode('UTF-8', $sse_data, Encode::FB_CROAK) };
die "sse payload is not encodable as UTF-8: $@" unless defined $bytes;
push @{$ss->{send_queue} ||= []}, $bytes;
$ss->{send_queue_bytes} = ($ss->{send_queue_bytes} // 0) + length $bytes;
# Synchronous â app send path, not nghttp2 extract â so on_high_water
# may fire here to tell the app to pause its source.
$ss->{transport_state}->_check_watermarks if $ss->{transport_state};
$weak_self->{h2_session}->resume_stream($stream_id);
$weak_self->_h2_write_pending;
}
elsif ($type eq 'sse.comment') {
return unless $ss->{response_started};
lib/PAGI/Server/Connection.pm view on Meta::CPAN
# already been reclaimed (see the already-closed comment above)
# is the idempotent no-op advance_sse just allowed; nothing left
# to do.
return unless $ss;
# This stream is ending -- stop its keepalive and idle timers so
# neither fires (or leaks) after the stream is reclaimed.
$weak_self->_h2_stop_sse_keepalive($ss);
$weak_self->_h2_stop_sse_idle_timer($ss);
# End THIS HTTP/2 stream now: flush remaining queued events, then the
# data_callback emits a final END_STREAM frame. `reason` is
# server-side only and is never written to the wire.
$ss->{sse_close_sent} = 1;
$ss->{sse_closing} = 1;
$weak_self->{sse_disconnect_reason} = $event->{reason}
if defined $event->{reason};
$weak_self->{h2_session}->resume_stream($stream_id);
$weak_self->_h2_write_pending;
}
elsif ($type eq 'sse.http.response.start') {
# A decline-after-start attempt is already rejected by advance_sse.
return if $ss->{sse_decline_started}; # idempotent
$ss->{sse_decline_started} = 1;
$ss->{sse_decline_status} = $event->{status} // 200;
$ss->{sse_decline_headers} = [
map { [_validate_header_name($_->[0]), _validate_header_value($_->[1])] }
@{$event->{headers} // []}
];
# RFC 9113 8.2.2 / design 13.3 â strip app-supplied connection,
# transfer-encoding, etc. before submission.
$ss->{sse_decline_headers} = $weak_self->_h2_strip_connection_headers($ss->{sse_decline_headers});
$ss->{sse_decline_body} = '';
}
elsif ($type eq 'sse.http.response.body') {
return unless $ss->{sse_decline_started};
return if $ss->{response_started};
$ss->{sse_decline_body} .= $event->{body} // '';
return if $event->{more}; # more chunks coming â keep buffering
# Defensive: the send-sequence state machine never permits
# sse.keepalive before sse.start, so neither timer can actually be
# armed here -- stopped anyway for the same every-closure-path
# discipline the other SSE/WS stop sites follow.
$weak_self->_h2_stop_sse_keepalive($ss);
$weak_self->_h2_stop_sse_idle_timer($ss);
$ss->{response_started} = 1;
unless (grep { lc($_->[0]) eq 'date' } @{$ss->{sse_decline_headers}}) {
push @{$ss->{sse_decline_headers}}, ['date', $weak_self->{protocol}->format_date];
}
$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) = @_;
my $frame = $stream->{ws_frame};
return unless $frame;
$frame->append($data);
while (defined(my $bytes = $frame->next_bytes)) {
my $opcode = $frame->opcode;
# RFC 6455 Section 5.2: RSV1-3 MUST be 0 unless extension defines
# meaning. PAGI doesn't support compression extensions, so RSV must
# always be 0. Same enforcement as h1's _process_websocket_frames
# (Www.pod: transport-agnostic framing enforcement, and RFC 8441's
# "identical to HTTP/1.1" claim).
my $rsv = $frame->rsv;
if ($rsv && ref($rsv) eq 'ARRAY') {
if (grep { $_ } @$rsv) {
$self->_h2_ws_close($stream_id, 1002, 'RSV bits must be 0');
# Server-initiated protocol close (Www.pod: RFC code + 'protocol_error').
$self->_h2_ws_enqueue_disconnect($stream, 1002, 'protocol_error');
return;
}
}
# RFC 6455 Section 5.2: Opcodes 3-7 and 11-15 (0xB-0xF) are reserved.
# Must fail connection with 1002 Protocol Error.
if (($opcode >= 3 && $opcode <= 7) || ($opcode >= 11 && $opcode <= 15)) {
$self->_h2_ws_close($stream_id, 1002, 'Reserved opcode');
# Server-initiated protocol close (Www.pod: RFC code + 'protocol_error').
$self->_h2_ws_enqueue_disconnect($stream, 1002, 'protocol_error');
return;
}
# RFC 6455 Section 5.5: Control frames (close/ping/pong) MUST have
# payload length <= 125 bytes.
if (($opcode == 8 || $opcode == 9 || $opcode == 10) && length($bytes) > 125) {
$self->_h2_ws_close($stream_id, 1002, 'Control frame too large');
# Server-initiated protocol close (Www.pod: RFC code + 'protocol_error').
$self->_h2_ws_enqueue_disconnect($stream, 1002, 'protocol_error');
return;
}
if ($opcode == 1) {
# Text frame
my $text = eval { Encode::decode('UTF-8', $bytes, Encode::FB_CROAK) };
unless (defined $text) {
$self->_h2_ws_close($stream_id, 1007, 'Invalid UTF-8');
# Server-initiated protocol close (Www.pod: RFC code + 'protocol_error').
$self->_h2_ws_enqueue_disconnect($stream, 1007, 'protocol_error');
return;
}
lib/PAGI/Server/Connection.pm view on Meta::CPAN
no warnings 'closed';
$bytes_read = read($fh, $chunk, $to_read);
}
die "Failed to read filehandle: $!\n" unless defined $bytes_read;
last if $bytes_read == 0; # EOF
$self->{_response_size} += $bytes_read;
if ($chunked) {
my $len = sprintf("%x", length($chunk));
$stream->write("$len\r\n$chunk\r\n");
}
else {
$stream->write($chunk);
}
if (defined $remaining) {
$remaining -= $bytes_read;
}
}
# Send final chunk if chunked encoding
if ($chunked) {
$stream->write("0\r\n\r\n");
}
}
# Diagnostics go through the server so log_level governs them and a replaced
# sink sees them. A connection can outlive its server reference during
# shutdown, so falling back to STDERR is a real path, not a formality.
sub _log {
my ($self, $level, $msg) = @_;
my $server = $self->{server};
return $server->_log($level, $msg, __PACKAGE__) if $server;
warn "$msg\n";
return;
}
1;
__END__
=head1 SSE OVER HTTP/2
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
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
(C<sse.send>, C<sse.comment>, C<sse.keepalive>, C<sse.close>). When a
stream's timer expires, only that stream ends -- the server marks the
stream closing, lets it flush any already-queued data, and then emits
the final HTTP/2 END_STREAM frame, the same path an application-initiated
C<sse.close> takes. Sibling SSE (and other) streams multiplexed on the
same HTTP/2 connection are unaffected, and the connection itself stays
open.
Over HTTP/1.1, each SSE stream already owns its own TCP connection, so
C<sse_idle_timeout> is enforced at the connection level there -- expiry
closes that connection, which only ever carries the one SSE stream.
=head2 Connection Reuse after an SSE Stream (HTTP/1.1)
C<sse.start> advertises C<Connection: keep-alive>, and the server honors it.
When an HTTP/1.1 SSE stream ends B<cleanly> -- the application returns, or it
sends C<sse.close> -- the server writes the chunked terminator, resets the
per-request state the stream accumulated, and hands the connection back to
ordinary keep-alive request handling, including serving any request already
pipelined in the read buffer. A pooled client (browser, C<Net::Async::HTTP>,
curl) can therefore reuse the same socket for its next request, which matters
for the short POST-SSE-exchange pattern used by fetch-event-source and
datastar.
Keep-alive yields to the usual overrides, each of which closes the connection
the same way it does outside SSE: a client C<Connection: close>, HTTP/1.0
semantics, server shutdown, an application exception, and any B<abnormal> end
(client disconnect, idle timeout, write error). An abnormal end is also the
only thing that delivers C<sse.disconnect> to the application; a clean end
never does.
Ending the stream is decoupled from the application returning. After
C<sse.close> the application keeps running against a live transport, and any
further send on that scope fails through the event sequence machine (C<after
sse.close>) rather than being silently swallowed by a closed transport.
=head1 SEE ALSO
L<PAGI::Server>, L<PAGI::Server::Protocol::HTTP1>
=head1 AUTHOR
John Napiorkowski E<lt>jjnapiork@cpan.orgE<gt>
=head1 LICENSE
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=cut
( run in 0.976 second using v1.01-cache-2.11-cpan-5c0b1e786e0 )