DEV Community

John Napiorkowski
John Napiorkowski

Posted on

PAGI Edge Cases: When to Spec and When is Silence OK?

I spent two days this week arguing with myself (and with my pair) about one sentence. The question was what a PAGI application should get when it calls receive() after the client has already gone away. It sounds small. It turned out to be a good test of when a protocol spec should say something and when it should remain silent.

The gap

PAGI's HTTP scope inherited a line from ASGI: http.disconnect is "sent to the application if receive is called after a response has been sent or after the connection has been closed." Any call, any number of times. The WebSocket and SSE scopes said something different in shape: the disconnect event is "sent when the scope ends." Once. Nothing said what a second call gets. The more I tested this the more problematic it became, especially for SSE which is really just a special case of HTTP streaming.

Here is the loop every WebSocket app has somewhere:

my $app = async sub {
    my ($scope, $receive, $send) = @_;
    await $receive->();                              # websocket.connect
    await $send->({ type => 'websocket.accept' });
    while (1) {
        my $ev = await $receive->();
        last if $ev->{type} eq 'websocket.disconnect';
        await $send->({ type => 'websocket.send', text => "echo: $ev->{text}" });
    }
    # cleanup here
};
Enter fullscreen mode Exit fullscreen mode

That one is fine everywhere: it stops at the first disconnect. The trouble starts one layer out. A framework that wraps the app and, after the app returns, wants to see the scope end for its own teardown:

# Middleware that wraps $app
async sub wrap {
    my ($app, $scope, $receive, $send) = @_;
    await $app->($scope, $receive, $send);
    # the app already consumed the disconnect event; what does THIS get?
    my $ev = await $receive->();
    teardown($scope);
};
Enter fullscreen mode Exit fullscreen mode

Under the spec as written, three servers could each be compliant and answer that second receive() differently. Hand the event out again. Park the call forever. Fail it. My reference server did the first. The framework author gets a working app, a silent leak, or an error log full of ordinary disconnects, and can't tell which from their own code.

The first temptation

The obvious move was to specify it. My first instinct was "fail the call." The spec already says a send after the application's own websocket.close is a programming error and must fail, and a receive after you've been told the scope is over has the same shape. The idea of creating symetry here was appealing.

Then we looked for code that would break. It took ten minutes to find. Here is a per-message deadline, a pattern that predates PAGI's connection object and is still all over the place:

my $older = $receive->();                    # pending #1
await Future->wait_any($older->without_cancel, $loop->delay_future(after => 30));
# timer won; $older is still pending, kept alive so a message isn't lost
my $newer = $receive->();                    # pending #2
my $ev = await $newer;
Enter fullscreen mode Exit fullscreen mode

Client drops. The disconnect resolves into $older, the Future nobody is looking at any more. The app calls receive() again and, under the fail rule, dies for a disconnect it never saw. We ran that app against the real server and both pending receives resolved, which is what you want, and which is also why the fail rule can't work: "delivered" from the server's side is not "observed" from the app's side.

A cleanup block that drains after an error has the same problem, one level worse: it throws inside the error handler and masks the original exception. And every one of these becomes a 500-class log line for what was a client walking away. That is exactly the noise the previous spec revision worked to eliminate on the send side. I was about to reintroduce it on the receive side.

The measurement

So was re-delivery fine? We wrote the sloppiest possible app, a loop that never checks the event type:

while (1) {
    my $ev = await $receive->();     # never looks at $ev->{type}
    $n++;
}
Enter fullscreen mode Exit fullscreen mode

Dropped its client, then sent a plain GET /health from a second client to the same process.

after 3.0s: sloppy loop iterations=665582; client B response: NONE (server starved)
Enter fullscreen mode Exit fullscreen mode

Each re-delivered event came from an already-resolved Future, so await never yielded and the event loop never turned. One broken handler took the whole worker down.

My usual answer to "what if the app does something dumb" is that the app author finds out. I'm fine with that when the person who finds out is the person who messed up. But in this case the result (a totally locked server) caused by a very easy to write mistake (forgetting to check for disconnect) was too problematic to ignore.

Where it landed

Two things, in two different places.

The spec now says the smallest true thing about the application contract. For the WebSocket scope (SSE gets the same sentence):

Once this event has been delivered the scope is over, and a further receive() resolves with the same websocket.disconnect again. The event reports the scope's terminal state rather than delivering a message, so it is never consumed by one reader and lost to another.

Plus one word in the core spec, "every" instead of "a", so that all pending receives resolve at disconnect, not just the first. That makes the three protocols (HTTP, Websocket and SSE) converge, the wrapper above works everywhere, and nothing raises on a disconnect the app didn't cause. I think this is the best option because I don't want to break middleware that calls $recieve->(). It does however leave open the problem of 'what happens when the application author makes a coding mistake and has calls to $recieve->() in a loop'.

This is a place where I decided to allow the spec as written to remain and the possible issue to remain. The PAGI specification cannot and should not try to account for every single possible corner case. Doing that would make the spec way too hard for a prospective server author to follow. And this is a place where novice programmers would do better to use a higher order framework over PAGI (like PAGI-FastAPI, WebDyne or Thunderhorse) or even the core PAGI-Tools, than to try and code an application as a raw PAGI application.

However, where the spec is silent, the server author is permitted to clarify. In this case I decided that the reference server, separately, caps how many times it will re-deliver:

my $server = PAGI::Server->new(
    app                     => $app,
    max_disconnect_receives => 100,    # the default; 0 = strict spec behaviour
);
Enter fullscreen mode Exit fullscreen mode

After a hundred synthesized answers on one scope it fails the call and logs once:

websocket scope on HTTP/1.1: receive() called 101 times after the scope's
disconnect event; the application is not checking for it
(PAGI::Server max_disconnect_receives=100)
Enter fullscreen mode Exit fullscreen mode

A hundred is two orders of magnitude above any legitimate shape we could find and well under a millisecond of spinning. Receives that were already pending don't count; only calls the server answers from the scope's terminal state do. Setting it to zero restores strict spec behaviour. And you can increase it if you have an application that somehow has more than 100 levels of $receive->(). The compliance notes call it what it is: a deliberate deviation, for a safety reason, with the switch that turns it off. This is the type of thing I'm totally fine with a server author making a personal call on. For the reference server I'm focused on reliability over performance; another server author might prefer to focus on raw speed and remove a lot of the guards and validations the reference server does.

The rule I took away

Spec what the application can observe and can't defend itself against. The application can't see which server it's on, so the spec has to make the servers agree on what a receive returns. That's the point of having a PAGI specification; the idea is that applications, servers and middleware can grow horizontally as teams scratch their own itch. As long as the specification provides enough structure, all those disparate pieces can work together.

Don't spec what the transport RFCs already decide. I nearly added a sentence about ending an HTTP/2 stream with END_STREAM rather than a reset after a 1011 Close frame. But RFC 8441 section 5 already says an orderly WebSocket close is END_STREAM. The right fix was a citation, not a rule. A spec that restates HTTP/2 will have to re-argue every line for HTTP/3.

Don't spec to protect people from their own bugs. That's the server's job, or a higher order frameworks job. Generally if an author miswrites an application we can't validate every single possible type of breakage. Putting that into the PAGI spec would cause it to collapse under is own weight.

Over the last year I've struggled with where to draw the line between a properly detailed PAGI specification and a specification that is so detailed and filled with edge case handling that no one can be sure how to write a compliant server or application. A protocol that nobody can implement correctly is worse than one with a documented gap. Most of the time the answer to "when to spec?" is "less than you think, and say exactly where you stopped."

Top comments (0)