PAGI-Server

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

  - An application that returns, or throws, after http.response.start
    without ever sending the response's terminal event (the final body
    chunk with more => 0, a file/fh body, or declared trailers) now
    triggers the PAGI spec's forced abnormal closure on both transports,
    instead of a silent keep-alive or an open stream: HTTP/1.1 closes the
    connection with no chunked terminator and no keep-alive; HTTP/2 sends
    RST_STREAM (INTERNAL_ERROR). Either way on_disconnect fires with reason
    server_error (never on_complete), and the server logs a warning unless
    the client had already disconnected. On HTTP/2, a request that resolves
    without ever starting a response also now marks the stream's connection
    state server_error before the 500 backstop is synthesized.
  - HTTP/2 now implements http.response.trailers: a response that declares
    trailers => 1 holds its final DATA frame without END_STREAM, and the
    trailers event submits a trailing HEADERS block carrying END_STREAM
    (an empty or absent headers list still submits and still terminates the
    stream); on HEAD the event is validated and discarded rather than
    transmitted. A trailers send() can block for as long as the peer
    withholds flow-control window on a still-draining body, since trailers
    now participate in the same per-stream backpressure as ordinary body
    sends. Declaring trailers and never sending them is an incomplete
    response, reported the same way an unsent terminal body chunk is
    (RST_STREAM INTERNAL_ERROR). Requires Net::HTTP2::nghttp2 0.009 or
    later.
  - websocket.keepalive is now implemented over HTTP/2: per-stream ping/pong
    timers (the h2 multiplexing analogue of the HTTP/1.1 connection-wide
    keepalive), with pings delivered as h2 DATA-framed RFC 6455 ping frames.
    An interval with no pong within its optional timeout closes only that
    stream (code 1006, reason keepalive_timeout); other streams on the
    same connection are unaffected. interval => 0 stops keepalive; an
    omitted timeout means no dead-connection detection.
  - SSE over HTTP/1.1 now honors the Connection: keep-alive it advertises on
    sse.start. When a stream ends cleanly (application return, or
    sse.close), the server writes the chunked terminator, resets the
    per-request SSE state, and hands the connection back for ordinary
    keep-alive request handling, including any request already pipelined in
    the read buffer -- matching the reuse promise pooled clients
    (Net::Async::HTTP, browsers, curl) expect from Connection: keep-alive,
    and avoiding a TCP+TLS handshake per exchange for the short
    POST-SSE-exchange pattern (fetch-event-source, datastar). The usual
    overrides still close the connection as before: a client Connection:
    close, HTTP/1.0 semantics, server shutdown, an application exception,
    and any abnormal end (client disconnect, idle timeout, write error) --
    an abnormal end remains the only thing that delivers sse.disconnect. An
    application that returns without ever starting a response and without a
    completed decline never reaches the keep-alive path: the server treats
    it as the same protocol error the plain HTTP path reports, warning,
    synthesizing a 500 if the client is still connected, and closing the
    connection, instead of handing back a socket with zero bytes written.
  - fix(h2): a concurrent or backlogged WebSocket-over-HTTP/2 send could
    silently drop or truncate a frame. The previous send path wrote every
    frame (app messages, pong, the close-frame echo, keepalive ping)
    directly to nghttp2's submit_data, which registers only a single
    pending-send slot per stream; submitting a second frame before nghttp2
    had fully drained the first destroyed that slot's still-queued
    remainder instead of queuing behind it. Any two h2 WebSocket frames
    that landed close enough together for the first to still be in flight
    -- a real, reachable condition, not just a theoretical one -- could
    silently lose or truncate data with no error raised anywhere.
  - feat(h2): as part of the fix above, the h2 WebSocket send path now
    frames every send through the same per-stream send queue and
    pull-based data-provider model the h2 http-streaming and SSE send paths
    already use (FIFO, so wire ordering is unaffected), and now provides
    pagi.transport (buffered_amount, the high/low watermarks, and
    on_high_water/on_drain), matching HTTP/1.1 WebSocket and every other h2
    streaming scope type: from the application's perspective the transport
    is invisible, the same pagi.transport surface and semantics apply
    whether a WebSocket app runs over HTTP/1.1 or HTTP/2. Close-frame +
    END_STREAM wire behavior is unchanged: the close frame still carries
    END_STREAM on its own final DATA frame, and a send() that wakes from
    backpressure after the close frame is already queued now correctly
    no-ops instead of risking a frame after Close (RFC 6455 5.5.1).
  - HTTP/2 requests whose HEADERS block exceeds max_header_list_size now
    receive a real 431 (Request Header Fields Too Large) response instead
    of a bare RST_STREAM, matching HTTP/1.1 and RFC 9113 section 10.5.1.
    HPACK decoding of the oversized block still runs to completion (fields
    are simply discarded once the limit is crossed) rather than aborting
    mid-frame, so nghttp2's dynamic table stays consistent; the request is
    never dispatched to the application. Trailer-block overflow (a later
    HEADERS block on an already-dispatched stream) is unaffected and keeps
    resetting the stream as before.

  Breaking Changes
  - lifespan_mode 'off' is removed: the PAGI Lifespan spec forbids skipping
    the lifespan protocol entirely, so the constructor and setter now reject
    it. auto remains the default; on remains strict mode.
  - The Net::HTTP2::nghttp2 floor is raised from 0.008 to 0.009 (needed for
    trailers support: submit_trailer and the three-value data-callback
    return). The floor is enforced at load time regardless of the cpanfile's
    "recommends" phrasing: constructing a PAGI::Server with http2 => 1 (or
    --http2 on the command line) against an installed Net::HTTP2::nghttp2
    below 0.009 now dies immediately with an explicit
    "HTTP/2 support requested but Net::HTTP2::nghttp2 is not installed, or
    is older than 0.009" error and install instructions, instead of
    starting. A deployment on
    0.008 requesting HTTP/2 must upgrade Net::HTTP2::nghttp2 (or drop
    http2 => 1) before the server will start at all. HTTP/1.1-only
    deployments are unaffected.
  - disconnect_future called for the first time after the request has
    already completed cleanly now returns a Future that stays pending
    forever, instead of one already resolved with undef -- a clean
    completion is not a disconnect, so there is nothing for the Future to
    resolve with. A Future requested after an abnormal disconnect is
    unaffected: still already resolved with the disconnect reason. An
    application awaiting this Future only after completion (e.g. via
    Future->wait_any alongside other work that has already finished) now
    hangs instead of resuming with undef; use on_complete to observe a
    clean completion instead.
  - SSE connection detection (HTTP/1.1 and HTTP/2) tightens from a raw
    substring scan of the Accept header to PAGI's media-range
    client-signal check: the sse scope is assigned only when the combined
    Accept header values contain the exact range text/event-stream,
    case-insensitively, with an effective quality value greater than zero;
    q=0 and wildcard ranges (*/*, text/*) never signal SSE (nor did they
    under the old substring scan). The old scan misclassified an explicit
    refusal (Accept: text/event-stream;q=0) as SSE and false-positived on
    any Accept token merely containing the substring "text/event-stream";
    a request that reached sse only via one of those quirks now correctly
    receives an http scope instead. Real SSE clients (EventSource,
    fetch-event-source) send the exact media type and are unaffected.

  Bug Fixes
  - HTTP/1.1's completed SSE decline (sse.http.response.start +
    sse.http.response.body) no longer delivers a synthesized
    sse.disconnect to an application that calls receive() again before
    returning. The decline's own teardown closed the connection via the

Changes  view on Meta::CPAN

    and HTTP/2 streams were counted, so a worker serving only SSE or
    WebSocket traffic on HTTP/1.1 never hit its recycle point.
  - The server now supplies a Date header exactly when the application
    didn't already supply one, consistently across ten response paths
    that previously disagreed: three HTTP/1.1 paths (normal
    http.response.start, SSE decline, WebSocket denial) stopped
    duplicating an application-supplied Date, and seven HTTP/2 paths (the
    413 Content-Length precheck, the 413 body-size overrun, the
    synthesized 500, WebSocket denial, the bare WebSocket 403, SSE
    decline, and the plain-CONNECT 501) now supply Date where they
    previously supplied none.
  - HTTP/2 now strips six connection-specific header names (connection,
    keep-alive, proxy-connection, transfer-encoding, upgrade, and te
    unless its value is exactly 'trailers') from application-supplied
    response headers on all five paths that map them
    (http.response.start, including the HEAD-reuse path; sse.start;
    WebSocket denial; SSE decline; and websocket.accept), warning once
    per stripped occurrence. Previously an application-supplied
    connection or transfer-encoding header on any of these paths
    destroyed the response at the framing layer (the client saw only
    :status, no body), per RFC 9113 section 8.2.2. (HTTP/1.1's own,
    narrower strip is described below.)
  - HTTP/1.1 now strips two application-supplied response header names --
    transfer-encoding and connection -- on all six paths that map them
    (http.response.start; sse.start; SSE decline; WebSocket denial;
    websocket.accept extra headers; and http.response.trailers trailer
    blocks, where RFC 9110 6.5.1 forbids such fields on any HTTP version),
    warning once per stripped occurrence, per the PAGI spec's "the server
    must ignore or strip application-supplied Transfer-Encoding and
    Connection -- it supplies its own" clause. Previously an
    application-supplied transfer-encoding header could duplicate or
    conflict with the server's own framing header (two Transfer-Encoding
    lines, or Transfer-Encoding alongside Content-Length), and an
    application-supplied connection header could duplicate the server's
    own forced 'Connection: keep-alive' on sse.start or contradict the
    guaranteed close on SSE decline. Unlike HTTP/2, HTTP/1.1 does not
    strip keep-alive, proxy-connection, upgrade, or te -- those remain
    ordinary, legal application response headers on HTTP/1.1.
  - sse.keepalive's optional comment field is now validated at arm time
    (must be a UTF-8-encodable string), so an unencodable comment fails
    the send Future that armed the keepalive instead of dying uncaught
    inside the keepalive timer's first tick.
  - WebSocket scopes no longer advertise the fullflush extension, since
    validate_websocket_send has no http.fullflush arm. A WebSocket
    application that previously trusted the advertisement got
    "Unrecognized event type" instead of a working extension; http and
    sse scopes are unaffected.
  - A client sending legal HTTP/2 request trailers (a HEADERS block after
    the request's initial headers and any DATA frames, RFC 9113 section
    8.1) no longer corrupts the in-flight request. Every HEADERS block on a
    stream used to reinitialize the request accumulator, so the trailer
    block wiped the already-committed request state and triggered a second,
    spurious application dispatch with an empty pseudo-header set; the
    original request's body also never reached completion, since its
    END_STREAM arrived on the trailer HEADERS rather than a DATA frame.
    HEADERS blocks are now classified by nghttp2's own headers_category: a
    later block on an established stream accumulates separately, is
    validated and discarded (PAGI defines no request-trailer receive event
    yet), and its END_STREAM still completes the original request's body.
  - HTTP/1.1 http.response.body{file} sends now pre-check the file with -f
    and -r before streaming, matching HTTP/2's existing behavior: a missing
    file fails the send Future with "File not found: $file" and an unreadable
    file fails with "Cannot read file: $file", instead of failing later
    inside the file-send helper with a less specific stat/open error. The
    response's own headers, if already flushed, are unaffected; a
    conforming app may still recover by sending a normal body afterward.
  - HTTP/1.1 websocket.accept's extra application-supplied headers now go
    through the same Transfer-Encoding/Connection strip as every other h1
    response-header path. Previously an app-supplied ['Connection', 'close']
    (or 'Transfer-Encoding') reached the 101 Switching Protocols response
    verbatim, alongside the server's own literal "Connection: Upgrade" line
    -- two Connection lines (or a stray Transfer-Encoding) on one handshake
    response. The server's own Connection: Upgrade line is untouched (RFC
    6455 requires it).
  - HTTP/1.1 WebSocket connections now deliver exactly one
    websocket.disconnect event per scope after a clean peer-initiated close.
    Processing the peer's Close frame previously queued the app-facing
    disconnect event without marking the connection's disconnect-handled
    guard, so the TCP close that follows (or the application's own
    session-complete teardown, if it returns without calling receive()
    again) queued a second, ghost event -- 1006/client_closed -- that the
    application never asked for and, since it had already stopped calling
    receive() after the first (correct) event, never drained. The peer's
    own code and reason are still passed through unchanged, as before; only
    the spurious second delivery is gone. This mirrors the equivalent HTTP/2
    fix described above.
  - HTTP/2 WebSocket streams now enforce the same RFC 6455 framing rules
    HTTP/1.1 already did: a nonzero RSV1-3 bit, a reserved/unknown opcode
    (3-7, 11-15), or an oversized control-frame payload (>125 bytes) now
    closes the stream with code 1002 and delivers a single
    websocket.disconnect (reason protocol_error), the same
    server-initiated-protocol-close pairing the existing invalid-UTF-8
    (1007) path already used. Previously none of these three checks
    existed on HTTP/2 -- Protocol::WebSocket::Frame exposes rsv/opcode but
    doesn't validate them itself -- so a client could send any of them over
    an h2 WebSocket stream with no error raised anywhere, contrary to RFC
    8441's requirement that h2 WebSocket framing be identical to HTTP/1.1.
  - HTTP/2 WebSocket streams now enforce max_receive_queue on inbound
    text/binary messages, the same per-stream cap and 1008/queue_overflow
    close HTTP/1.1 already enforces: once a stream's own receive queue
    already holds max_receive_queue undelivered events, the next message
    closes the stream (code 1008) and delivers a single
    websocket.disconnect (reason queue_overflow) instead of queueing
    without bound. Previously _h2_process_ws_frames had no such check, so a
    client sending messages faster than the application drained receive()
    could grow a stream's receive_queue without limit -- an unbounded
    per-connection memory DoS on a transport that otherwise mirrors
    HTTP/1.1's WebSocket support exactly. Fixing this also closed a related
    gap in the h2 WebSocket close funnel (_h2_ws_close): it queued one
    Close frame per call with no idempotency guard, so a burst of several
    already-buffered frames that each independently warranted closure (as
    this overflow scenario naturally produces) could queue several Close
    frames for one stream instead of the one the wire is supposed to see;
    it now no-ops once a Close frame is already queued for that stream,
    matching the single-delivery guarantee _h2_ws_enqueue_disconnect
    already provided for the app-facing event.
  - The between-requests idle timer's internal disconnect reason is now
    keepalive_timeout, not idle_timeout, once a request has already
    completed on the connection -- matching the reason already documented
    in PAGI::Server::ConnectionState's disconnect_reason list. A connection
    that idles out before ever completing a request still reports

Changes  view on Meta::CPAN

    guards keyed off each body module's own $VERSION with an
    `!defined($v) || $v >= 0.002000` test; the undef branch (meant to allow an
    unversioned in-tree checkout) also admitted the pre-split modules, which are
    likewise unversioned. The guards now gate on PAGI::Tools, a module that
    exists only in the split-era distribution, so a pre-split install has no
    PAGI::Tools and the test skips. PAGI::Tools hardcodes its $VERSION (the body
    modules get theirs injected only at build), so the VERSION(0.002000) check
    is reliable for both installed releases and an in-tree checkout.

0.002002 - 2026-06-27

  Bug Fixes
  - The PAGI-Tools-dependent integration tests now require PAGI::Response (and
    the other toolkit modules they exercise) at version 0.002000 or newer, the
    release that introduced the detached PAGI::Response value API
    (new($scope) + respond($send)). A pre-split PAGI install (<= 0.001023) still
    provides those module names, so the guards previously let the tests run
    against the old `new($scope, $send)` constructor and
    t/integration/response-integration.t died with "send is required" on a
    smoker that had the old distribution installed. The guards now skip on an
    older version while still running against an unversioned in-tree checkout.

  Documentation
  - cpanfile: PAGI-Tools is now on CPAN; note updated accordingly.

0.002001 - 2026-06-26

  Bug Fixes
  - t/lifespan-post-startup-failure.t now skips when the optional
    Future::IO::Impl::IOAsync backend is unavailable, instead of dying at
    compile time. The test use'd Future::IO unconditionally while Future::IO
    is only a `recommends`, so 0.002000 failed to install on a clean smoker
    without it. Now guarded the same way as t/05-sse.t.

0.002000 - 2026-06-26

  Distribution
  - PAGI::Server and bin/pagi-server split out of the PAGI distribution
    into their own distribution. Git history preserved from the original
    repository (https://github.com/jjn1056/pagi).
  - The application runner now ships here as PAGI::Server::Runner (relocated
    from PAGI-Tools). pagi-server stays server-agnostic and threads its
    PAGI::Server-specific options through to the configured server class.

  PAGI 0.3 spec conformance
  - feat: declare PAGI 0.3 conformance — scopes emit version and
    spec_version 0.3.
  - feat(ws/h2): WebSocket Denial Response — an application may send an HTTP
    response instead of accepting the handshake, over both HTTP/1.1 and HTTP/2.
  - feat(h2): HTTP/2 responses now carry a server-supplied Date header.
  - 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).
  - fix(lifespan): treat a clean lifespan decline as unsupported rather than an
    error, and log the exception text when a startup raise is treated as
    unsupported.
  - fix(lifespan): surface post-startup lifespan-app failures. A long-lived
    lifespan or background task that died after startup completed was caught by
    a bare eval and silently discarded — no log, server kept running. Such
    failures are now logged at error level; the pre-startup "lifespan not
    supported" auto-detection is unchanged.

  Security / robustness
  - feat(h2): h2_rst_rate_limit — explicit, tunable HTTP/2 Rapid Reset
    (CVE-2023-44487) defense; corrected the max_concurrent_streams POD claim.
  - fix(tls): negotiate TLS 1.3 and compute cipher_suite; drop non-spec TLS
    extension keys.
  - fix(http): return 500 when an application returns without starting a
    response.
  - fix(worker): the master exits non-zero when every worker fails lifespan
    startup, instead of holding the listening socket with nothing serving
    (no zombie master).

  Performance
  - perf: coalesce response writes, add ASCII fast paths, and debounce the
    idle timer.

  Maintenance
  - chore: remove the dead on_error option.
  - docs: documentation-accuracy pass — corrected POD/README claims that
    contradicted the code (Server.pm options, Compliance.pod CL+TE handling,
    the HTTP1 protocol surface, and example outputs) and documented
    previously-undocumented options.

  - For changes prior to 0.002000, see the Changes file of the PAGI
    distribution (versions up to 0.001023).



( run in 0.879 second using v1.01-cache-2.11-cpan-5c0b1e786e0 )