PAGI: What happens to a Send in Flight?

PAGI: What happens to a Send in Flight?

PAGI:发送中的请求会发生什么?

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. 简介 2026-08-28 · PAGI 0.002006–0.002007(核心规范 0.5 / Www 子规范 0.4)· PAGI::Server 0.002011。如果你本月一直在关注 PAGI 的更新日志,你会发现发布非常频繁——五天内发布了四个规范版本和四个服务器版本,标题中充满了“结算(settlement)”、“转换顺序(transition order)”和“RST_STREAM”等词汇。本文将解释这些变动究竟是什么,为什么其中绝大部分不需要你修改应用程序,以及为什么那小部分涉及应用改动的内容反而让程序变得更简单,而非更复杂。

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. 对于普通的请求/响应应用程序,简短的总结是:你的整个升级说明只有两句话。如果你的事件格式错误或顺序不对,正在等待的发送(Awaited sends)现在可能会失败(过去在某些路径下会被静默忽略);此外,请务必完成你的响应——不完整的响应现在会明确报错,而不是静默挂起。本文其余部分提到的所有内容,都是你无需编写任何代码即可获得的保证。

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. 一场演变成规范漏洞的 Bug 追捕:触发点在于框架工作,而非服务器工作。在基于 PAGI 构建流式响应辅助工具时,我们陷入了一个经典的异步陷阱:针对断开连接竞争条件的每一次修复,都会暴露另一个问题。该辅助工具增加了一个标志位来记录“在发送调用期间收到了断开连接”,接着又增加了一个标志位来“推迟发布断开连接直到发送结算”,并正朝着创建一个自定义 Future 子类以跟踪是否已观察到失败的方向发展。当修复工作开始需要“哲学思考”时,说明上游出了问题。

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.connection object 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. 问题出在规范上。PAGI 一直对两种断开连接的情况有明确定义:在连接关闭后发出的发送操作是一个成功的空操作(no-op)——它既不会投递也不会抛出异常。你应该通过 pagi.connection 对象或协议的断开连接事件来检测断开,而不是通过检查发送结果。无效的发送(事件格式错误、顺序错误、文件不可读)会在连接关闭前使其 Future 失败。但规范对于流式应用常见的情况只字未提:当客户端断开连接时,一个已经在等待中的发送 Future(服务器正在施加背压)会怎样?没有任何规定说明它是否会结算(由于应用程序被要求等待每一次发送,如果服务器让它永远挂起,就会导致完全合规的应用程序死锁)。也没有说明它会解析、失败还是被取消。更没有说明当你恢复时会观察到什么连接状态。框架所发明的每一种解决方案在当时都是合法的,这正是为什么没有一种方案能真正奏效的原因。

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. 生态系统教会我们的事:在编写新条款之前,我们深入研究了其他人是如何解决这个问题的,其历史经验很有启发性。ASGI(PAGI 最亲近的亲戚)选择了另一条路:其规范(2.4 版本)规定在已关闭的连接上进行发送应该抛出异常。Uvicorn 实现了这一点,结果导致已部署的 FastAPI 应用程序崩溃,随后 Uvicorn 回滚了该更改——因此,如今主流的 ASGI 服务器与其自身规范并不一致。抛出异常之所以在 ASGI 中具有吸引力,深层原因是:ASGI 没有带外(out-of-band)连接状态对象,因此从 send() 中抛出异常是它唯一可行的断开连接通知渠道。

.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. .NET 的 Kestrel 是相反的先例,也是更稳定的先例:在客户端断开连接后,对响应的写入会被静默丢弃,其文档约定的契约是“观察 RequestAborted 取消令牌”。该令牌正是 PAGI 的 pagi.connection 对象所代表的内容。TCP 本身也有发言权:无论你的 API 承诺什么,向已死亡的对端写入数据在一段时间内仍会成功进入内核缓冲区,因此“客户端离开时发送失败”的契约承诺了网络无法实现的功能。无论如何,每个正确的应用程序都需要带外信号。因此,PAGI 0.002006 完善了其现有的原则,而不是推翻它。

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 and disconnect_reason() is set. Disconnect notifications never re-enter your code: on_disconnect callbacks and disconnect_future resolution are delivered from the event loop, never synchronously inside your own call into $send or $receive. 契约(四句话总结):当连接结束时,仍在等待中的发送 Future 会通过解析(resolving)迅速结算。它绝不会因断开连接错误而失败,不会被取消,也不会被挂起——而对于因实际原因(验证失败、文件不可读)已经失败的发送,则保持失败状态。在断开连接时处于等待中的接收 Future 会通过协议的断开连接事件(http.disconnect, websocket.disconnect, sse.disconnect)进行解析。通过这种设计,“等待后检查”是无竞争条件的:当你的协程从等待的发送或接收中恢复时,连接状态已经转换——is_connected() 为 false 且 disconnect_reason() 已设置。断开连接通知绝不会重入你的代码:on_disconnect 回调和 disconnect_future 的解析由事件循环分发,绝不会在你调用 $send$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. 实现所有这些功能的服务器会进行声明:作用域(scope)现在携带 pagi => { version => '0.5', spec_version => '0.4' },并且 PAGI::Server 从 0.002010 版本开始实现端到端合规(通过新的合规性测试,在 HTTP/1.1、HTTP/2、WebSocket 和 SSE 的真实套接字上锁定每一项条款)。现在让我们看看这能为你带来什么。

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: 迷你应用 1:不在乎的应用。首先,证明“两句话升级说明”的说法。这是一个完整的 PAGI 应用程序。它不受上述任何内容的影响:

[Code block omitted for brevity] (代码块略)

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 迷你应用 2:没有安全网的流式传输。这就是结算契约发挥作用的地方。一个大型流式下载(比如生成的导出文件)希望在客户端离开的瞬间停止工作,而无需在每次发送时都包裹异常处理,也不必让回调函数与自身的写入操作竞争。在新的契约下,模式是:等待发送,然后……