Introduction
2026-08-28 · PAGI 0.002006–0.002007 (core spec 0.5 / Www sub-spec 0.4) · PAGI::Server 0.002011
If you've been watching the PAGI changelogs this month you've seen a burst of
releases — four spec releases and four server releases in five days, with
titles full of words like settlement, transition order, and
RST_STREAM. This post explains what that churn actually was, why almost
none of it requires you to change your application, and why the small part
that does touch applications makes them simpler, not more complicated.
The short version for a normal request/response app: your entire upgrade
note is two sentences. Awaited sends can now fail if your events are
malformed or out of sequence (they used to be silently ignored on some
paths); and always finish your responses — an incomplete response is now
loudly abnormal instead of a silent hang. Everything else in this post is
guarantees you inherit without writing a line.
The bug hunt that turned out to be a spec hole
The trigger was framework work, not server work. While building a streaming
response helper on top of PAGI, we hit a classic async rabbit hole: every
fix for a disconnect race exposed another one. The helper grew a flag for
"a disconnect arrived while I was inside the send call," then a flag for
"defer publishing the disconnect until the send settles," and was heading
toward a custom Future subclass that tracked whether a failure had been
observed yet. When the fixes start needing philosophy, something upstream
is wrong.
The something was the spec. PAGI has always been precise about two
disconnect cases:
- A send issued after the connection closed is a successful no-op — it
neither delivers nor raises. You detect disconnection through the
pagi.connectionobject or the protocol's disconnect event, never by inspecting a send's result. - A send that's invalid — bad event shape, wrong sequence, unreadable file — fails its Future before the connection closes.
But it said nothing about the case streaming apps live in: a send Future
already pending — the server applying backpressure — when the client
disconnects. Nothing said it ever settles (and since applications are
required to await every send, a server that left it pending forever would
deadlock a perfectly conforming app). Nothing said whether it resolves,
fails, or gets cancelled. Nothing said what connection state you'd observe
when you resumed. Every answer a framework invented was legal, which is
exactly why no answer worked.
What the ecosystem taught us
Before writing new clauses we looked hard at how everyone else answers
this, and the history is instructive.
ASGI — PAGI's closest relative — went the other way: its spec (version 2.4)
says a send on a closed connection should raise. Uvicorn implemented
that, it broke deployed FastAPI applications, and uvicorn
reverted it — so today the dominant ASGI server disagrees with its own
spec. The deeper reason raising was attractive there: ASGI has no
out-of-band connection-state object, so raising from send() was the only
practical disconnect channel it had.
.NET's Kestrel is the opposite precedent, and the stable one: after a
client disconnect, writes to the response are silently discarded
and the documented contract is "observe the RequestAborted cancellation
token." That token is exactly what PAGI's pagi.connection object is.
And TCP itself gets a vote: writes to a dead peer succeed into kernel
buffers for a while regardless of what your API promises, so a
"sends fail when the client is gone" contract over-promises something the
network cannot deliver. Every correct application needs the out-of-band
signal anyway.
So PAGI 0.002006 completes its existing doctrine instead of reversing it.
The contract, in four sentences
- A send Future still pending when the connection ends settles promptly, by resolving. Never failed with a disconnect error, never cancelled, never left hanging — and a send that already failed for a real reason (validation, unreadable file) stays failed.
-
A receive Future pending at disconnect resolves with the protocol's
disconnect event (
http.disconnect,websocket.disconnect,sse.disconnect). -
Await-then-check is race-free by construction: by the time your
coroutine resumes from an awaited send or receive, the connection state
has already transitioned —
is_connected()is false anddisconnect_reason()is set. -
Disconnect notifications never re-enter your code:
on_disconnectcallbacks anddisconnect_futureresolution are delivered from the event loop, never synchronously inside your own call into$sendor$receive.
Servers that implement all of this advertise it: the scope now carries
pagi => { version => '0.5', spec_version => '0.4' }, and PAGI::Server
conforms end-to-end as of 0.002010 (with new conformance tests pinning
every clause over real sockets, on HTTP/1.1, HTTP/2, WebSocket, and SSE).
Now let's see what it buys you.
Mini app 1: the app that doesn't care
First, proof of the "two-sentence upgrade note" claim. Here is a complete
PAGI application. It is unaffected by everything above:
# hello.pl — run with: pagi-server --app hello.pl --port 5000
use strict;
use warnings;
use Future::AsyncAwait;
my $app = async sub {
my ($scope, $receive, $send) = @_;
die "Unsupported scope type: $scope->{type}" if $scope->{type} ne 'http';
await $send->({
type => 'http.response.start',
status => 200,
headers => [ [ 'content-type', 'application/json' ] ],
});
await $send->({
type => 'http.response.body',
body => '{"hello":"pagi"}',
more => 0,
});
};
$app;
It awaits its sends and finishes its response. That was already the rule;
it's just enforced everywhere now. Done.
Mini app 2: streaming without a safety net
Here's where the settlement contract earns its keep. A large streaming
download — say a generated export — wants to stop working the moment the
client goes away, without wrapping every send in exception handling or
racing callbacks against its own writes.
Under the new contract the pattern is: await the send, then look at the
connection. No try/catch, no flags, no race:
# bigdownload.pl — run with: pagi-server --app bigdownload.pl --port 5000
use strict;
use warnings;
use Future::AsyncAwait;
my $CHUNK = 'x' x 65536; # stand-in for real generated data
my $CHUNKS = 1600; # ~100MB total
my $app = async sub {
my ($scope, $receive, $send) = @_;
die "Unsupported scope type: $scope->{type}" if $scope->{type} ne 'http';
my $conn = $scope->{'pagi.connection'};
await $send->({
type => 'http.response.start',
status => 200,
headers => [ [ 'content-type', 'application/octet-stream' ] ],
});
my $sent = 0;
for my $n (1 .. $CHUNKS) {
# Backpressure: this Future stays pending while the client is slow.
# If the client disconnects mid-flight, it RESOLVES (never fails,
# never hangs) -- and by the time we resume, the connection state
# below is guaranteed to already reflect the disconnect.
await $send->({
type => 'http.response.body',
body => $CHUNK,
more => $n < $CHUNKS ? 1 : 0, # last chunk IS the terminal event
});
# After the terminal send, is_connected is false on a CLEAN finish
# too (completed is a terminal state) -- so the abnormal-vs-clean
# discriminator is disconnect_reason: undef means all is well.
if (defined(my $reason = $conn->disconnect_reason)) {
warn sprintf "client gone after %d bytes (%s); stopping\n",
$sent, $reason;
return; # cleanup runs; no terminal event needed -- the
# request already ended abnormally on the client side
}
$sent += length $CHUNK;
}
};
$app;
Three things to notice:
-
The awaited send is also your disconnect detector's wake-up call. A
producer parked on backpressure used to be the nightmare case — you were
suspended with no way to learn the client left. Now the disconnect is
precisely what un-parks you, and clause 3 guarantees the
connection-state check you do next tells the truth — here
disconnect_reason, since after the merged terminal send a clean finish also flipsis_connected, and reason defined is the spec's own abnormal-vs-clean discriminator. This exact behavior is pinned by the server's conformance suite (a send parked on a full buffer, client killed, app resumes and observes the disconnect — on h1 and on an HTTP/2 stream reset). - No exception handling for disconnects, anywhere. A failed send Future still means what it always meant — you sent something invalid — and now that's all it can mean.
- Returning early after a disconnect is clean. The spec's incomplete-response rules explicitly carve out the client-already-gone case: no synthesized 500, no error log, no scolding.
Where this used to go wrong: every line of that loop sat on
undefined behavior. The parked send could legally hang forever (the
spec never said it settled — and an app is required to await it, so
that's a deadlock), or fail, or resolve whileis_connectedstill said
true — meaning the check after the await could lie, and the loop would
keep streaming megabytes into the void. Frameworks papered over this
with competing receive loops, timeouts, and callback arbitration; that
machinery is exactly what the contract made deletable.
Aside: why does http get a special object at all?
WS and SSE learn about disconnects through an event on $receive, so it's
fair to ask why http needs pagi.connection instead of just doing the
same. Two structural reasons, and one scar from a neighboring ecosystem.
First, on http, $receive is the request body channel. A disconnect
check that works by pulling the next event is destructive: if the client
wasn't disconnected, you just consumed a body chunk your handler needed.
WS technically shares its receive channel too, but a WS app lives in a
receive loop by nature — the disconnect arrives in a stream you were
already reading. An http handler often never calls $receive again after
the body, or at all for a GET.
Second, the http workload that most needs disconnect awareness — a
streaming response — is send-driven. The producer in mini app 2 has no
reason to be anywhere near $receive; an event-only design puts the
signal in a channel nobody's listening to. ASGI is the proof by
experiment: it shipped event-only http disconnect, applications had to run
a competing receive() task just to notice a vanished client, the
ecosystem reached for "make send() raise instead" as an escape hatch,
and that's the change uvicorn had to revert. pagi.connection — a
synchronous, non-destructive, out-of-band handle, the moral equivalent of
.NET's RequestAborted token — is the designed answer to that history.
The http.disconnect event still exists; the object is additive.
Whether WS and SSE should eventually grow the same object is a live
question — you'll feel the asymmetry yourself in mini app 5 — and the spec
deliberately leaves the door open ("an SSE-specific equivalent may be
defined later if a need arises"). For now the toolkit layer papers over
it, which is the cheaper place to find out if the pattern is right.
Mini app 3: long-poll with exactly-once cleanup
The pagi.connection object also gives you the push-style tools:
disconnect_future for racing, and the on_disconnect / on_complete
pair, of which exactly one fires per request. That exactly-once
property is what makes resource cleanup a one-liner instead of a
reference-counting exercise:
# longpoll.pl — run with: pagi-server --app longpoll.pl --port 5000
use strict;
use warnings;
use Future::AsyncAwait;
use Future;
my $app = async sub {
my ($scope, $receive, $send) = @_;
die "Unsupported scope type: $scope->{type}" if $scope->{type} ne 'http';
my $conn = $scope->{'pagi.connection'};
my $work = claim_work_slot(); # imagine: db handle, queue lease...
# Exactly one of these two runs, ever. New in 0.4: neither can fire
# re-entrantly inside one of your own $send/$receive calls, so helpers
# need no guards against being called back mid-write.
$conn->on_disconnect(sub {
my ($reason) = @_;
$work->release;
warn "long-poll abandoned: $reason\n";
});
$conn->on_complete(sub { $work->release });
# Race the interesting event against the client leaving. One bit of
# lore: disconnect_future is SHOULD-level, so guard for undef. Every
# returned Future is cancellation-isolated (spec 0.002007) -- losing
# this race cannot disarm the signal -- so race it bare; just take a
# fresh one per race, since an observer that lost once cannot win later.
my $ready = $work->next_event_future; # your event source
my $gone = $conn->disconnect_future;
await Future->wait_any($ready, $gone) if $gone;
return unless $conn->is_connected;
await $send->({
type => 'http.response.start',
status => 200,
headers => [ [ 'content-type', 'application/json' ] ],
});
await $send->({
type => 'http.response.body',
body => $ready->get,
more => 0,
});
};
$app;
(claim_work_slot is your business logic; the shape is what matters.)
Where this used to go wrong: the exactly-once callback pair already
existed, but two things around it didn't. Nothing stopped
on_disconnectfrom firing synchronously inside one of your own
$sendcalls — so a callback touching the same state as the code that
was mid-send needed re-entrancy guards you'd only discover you needed
in production. And nothing ordereddisconnect_futureresolution
against the state flags, so the line after thewait_anycould observe
is_connectedstill true for a client that was already gone. Both are
now guaranteed: callbacks arrive from the event loop, and
await-then-check is race-free by construction.
The footgun this post caught in the act
That bare wait_any above deserves a confession, because an earlier
draft of this post couldn't write it. Future->wait_any cancels its
losing components, and disconnect_future used to return the one shared
cached future — so a race your work side won silently cancelled the
connection's disconnect future for the rest of the request. The later
real disconnect resolved nothing; every other consumer held a dead
handle; and the "obvious" refactor of hoisting the future out of a loop
made it worse. The draft taught a ->without_cancel shield as required
lore.
A reader-level style complaint — "this feels like it should have a nicer
encapsulation" — turned into a probe, the probe turned into a four-way
adversarial design review (one defender, three explorers across
ecosystem precedent, Future idioms, and call-site ergonomics), and all
of them converged on the same verdict: the race is the right primitive,
and the shield belonged in the accessor, not at every call site. So
that's where it went. PAGI 0.002007 states that every Future
returned by disconnect_future is cancellation-isolated — framed as a
clarification, since the accessor always promised each returned future
"resolves, with the reason, on abnormal disconnect," a promise a
poisonable future can't keep — and PAGI::Server 0.002011 implements
it, pinned by its own conformance subtest. The naive call site became
the correct one, which is the best outcome a design review can have.
One rule of thumb survives, and it's worth internalizing: the server can
only pre-isolate futures it hands you. A shared future you own
still needs the shield when you race it — which is exactly the situation
in the next-but-one mini app.
Mini app 4: WebSocket echo and the who-closed rule
WebSocket has no pagi.connection — its disconnect channel is the
websocket.disconnect event — and its post-close send rule has a nuance
worth knowing, now stated crisply in the spec: who closed decides.
After the transport closed (peer vanished, timeout), your sends are
harmless no-ops, because you may race a disconnect through no fault of your
own. After you sent websocket.close, further sends fail — you can't
race yourself.
# echo.pl — run with: pagi-server --app echo.pl --port 5000
use strict;
use warnings;
use Future::AsyncAwait;
my $app = async sub {
my ($scope, $receive, $send) = @_;
die "Unsupported scope type: $scope->{type}"
if $scope->{type} ne 'websocket';
while (1) {
my $ev = await $receive->(); # pending here? a disconnect
# resolves it -- guaranteed
if ($ev->{type} eq 'websocket.connect') {
await $send->({ type => 'websocket.accept' });
}
elsif ($ev->{type} eq 'websocket.receive') {
await $send->({
type => 'websocket.send',
defined $ev->{text}
? (text => "echo: $ev->{text}")
: (bytes => $ev->{bytes}),
});
}
elsif ($ev->{type} eq 'websocket.disconnect') {
warn "ws closed: code=$ev->{code} reason=$ev->{reason}\n";
last;
}
}
};
$app;
The same settlement rules apply on this scope: a send parked on a slow
client resolves when the peer disconnects, and the pending $receive above
is exactly the "resolves with the disconnect event" clause in action.
Where this used to go wrong: nothing required that pending
$receiveto ever resolve on an abrupt peer death — the reference
server did the right thing, but the spec didn't demand it, so this loop
could legally hang forever on a different conforming server. And a send
racing the peer's disconnect had no defined settlement at all. Both are
now single sentences in the contract.
Mini app 5: an SSE progress stream that stops when the tab closes
Server-Sent Events is where PAGI gets interesting for the htmx /
datastar / fetch-event-source crowd, and it's also the scope with the
most instructive disconnect story. SSE has no pagi.connection — its
disconnect channel is the sse.disconnect event delivered through
$receive. So the idiomatic long-running SSE app races its event source
against a receive, and the contract's "a pending receive resolves with the
disconnect event" clause is precisely what makes that race trustworthy.
One subtlety worth learning once, from someone who got it wrong first
(twice, actually — the second time in an early draft of this very post):
you keep one pending receive Future across race iterations, and you
race it behind a ->without_cancel shield. wait_any cancels its
losing components — so an unshielded receive future dies the first time a
job step beats it, and re-racing it then blows up. Shielded, the real
future survives losing rounds and you re-race it; you never call
$receive->() fresh each time around. (Yes, this is the same hazard the
server now absorbs for disconnect_future — but this receive future is
yours, not the server's, so here the shield remains your job.)
# progress.pl — run with: pagi-server --app progress.pl --port 5000
use strict;
use warnings;
use Future::AsyncAwait;
use Future;
my $app = async sub {
my ($scope, $receive, $send) = @_;
die "Unsupported scope type: $scope->{type}" if $scope->{type} ne 'sse';
# Drain the request first. A GET arrives as one empty-body event;
# a POST body (fetch-event-source style) streams in sse.request chunks.
my $req = await $receive->();
while ($req->{type} eq 'sse.request' && $req->{more}) {
$req = await $receive->();
}
await $send->({ type => 'sse.start' });
my $job = start_job(); # your business logic: a future-per-step
my $pending_receive; # ONE receive future, re-raced each loop
while (!$job->done) {
$pending_receive //= $receive->();
my $step = $job->next_progress_future;
# The shield: wait_any cancels losers, and this receive future
# must survive losing a round to be re-raced next iteration.
await Future->wait_any($pending_receive->without_cancel, $step);
if ($pending_receive->is_ready) {
# After the request is drained, the only event an sse scope
# can deliver is sse.disconnect: the client is gone.
my $ev = $pending_receive->get;
warn "client gone ($ev->{reason}); cancelling job\n";
$job->cancel; # your own producer future -- yours to cancel
return;
}
await $send->({
type => 'sse.send',
event => 'progress',
data => $step->get,
});
}
await $send->({ type => 'sse.send', event => 'done', data => 'complete' });
await $send->({ type => 'sse.close' });
};
$app;
The payoff is in the warn line: the server stops computing the moment
the browser tab closes, not at the next write failure, not never.
Where this used to go wrong: two ways. The race itself was
unreliable — with no guarantee the parked receive resolved on
disconnect, thewait_anycould simply never wake for a vanished
client, and the job ran to completion streaming into the void. And if
you tried to compensate by watching the send side instead, you were
back to mini app 2's problem: a send's behavior at disconnect was
undefined, so there was nothing dependable to watch.
If this looks hard to get right — that's what PAGI::Tools is for
It should be said plainly: the mini apps above are written against the
raw PAGI protocol, which is the assembly language of this ecosystem.
The race-one-receive pattern, the await-then-check discipline, the
drain-the-request loop — these are things you should understand once, and
then mostly not write by hand.
Take the disconnect race as the worked case. Getting it right by hand
used to take three pieces of lore: the undef-guard (because
disconnect_future is SHOULD-level), a ->without_cancel shield
(because wait_any cancels losers and the future was shared), and the
await-then-check afterward. The middle one has since been deleted at the
source — that's the story two sections back — which is the ideal fate
for lore. Two pieces remain, and two pieces of lore for one intention —
"do this, unless the client leaves" — is still a helper method begging
to exist, and the toolkit's request and stream objects are its natural
home.
That's the job of PAGI-Tools: PAGI::Request and PAGI::Response,
PAGI::WebSocket and PAGI::SSE handler objects, an endpoint framework, a
router, and thirty-odd middleware — all of which encode exactly these
patterns under the hood, against the now-pinned contract, so application
code reads like application code. Use them directly, or subclass them as
the foundation of your own framework — the relationship between PAGI and
PAGI-Tools is deliberately the one between PSGI and Plack: a small, stable
wire contract underneath, and an opinionated toolkit on top that gets the
sharp edges right so you don't have to. (The streaming helpers are being
rebuilt on this contract as I write — the settlement rules let them
delete their disconnect-arbitration machinery, which is the best
evidence the contract landed where it should.)
The rest of the churn, honestly
The disconnect contract was the headline, but the same five days of
releases carried a pile of work in three other buckets:
- HTTP/1.1 / HTTP/2 parity. HEAD suppression, trailers, file and filehandle streaming under per-stream backpressure, per-stream connection state, WebSocket keepalive over h2, real 431 responses for oversized header blocks. Your app doesn't change; things that silently differed between transports now don't. Buried in here was a genuine data-loss fix: concurrent WebSocket-over-HTTP/2 sends could silently drop or truncate frames. If you needed evidence this precision work isn't pedantry, that's it.
- Loud failures over silent ones. Event validation and send sequencing are now enforced on every send path. Sloppy sends that some paths used to ignore now fail the awaited Future; responses left unfinished force a visible abnormal closure instead of a hung client or a poisoned keep-alive. Correct apps feel nothing; latent bugs surface with a stack trace instead of a mystery.
-
Wire and ops minutiae. The
Connection: upgradecompanion on426responses, canonicalte: trailers, hop-by-hop header ownership consolidated to "the RFCs' exceptions and no others," SSE streams honoring their advertised keep-alive, multi-server-per-process fixes. You will never think about any of these — which is the point.
Why so much churn at once?
Because this is what finishing a specification looks like. The happy path —
parse a request, send a response — was never ambiguous, so nobody ever
tripped on it. Ambiguity concentrates at the edges: disconnects, errors,
timing, and the seams between transports, because that's where independent
implementations silently diverge. Every clause in this wave came from a
real divergence found by adversarially reconciling the spec against two
implementations — and each one was resolved once, in the spec, so that
every future framework and server doesn't have to solve it again. The
framework helper that started all this gets to delete its entire
arbitration layer: the race it was arbitrating can no longer exist on a
conforming server.
Doing this now, while PAGI is young and the version numbers are cheap, is
the whole strategy. Ask the ASGI ecosystem what it costs to change a
disconnect contract after deployment.
PAGI 0.002007 and PAGI::Server 0.002011 are on CPAN. The disconnect
contract lives in PAGI::Spec ("Send Completion Contract") and
PAGI::Spec::Www ("Connection State" / "State Transition Order"), with
the cancellation-isolation clause in the same Connection State section;
the conformance tests are t/61-pending-io-at-disconnect.t,
t/http2/40-pending-io-at-disconnect.t, and the isolation subtest in
t/37-connection-state.t, all in the PAGI-Server distribution.
Top comments (0)