You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit b48490e
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-hono-server): end a graceful close with its last in-flight request; cut or budget seven slow Test Core cases (#31189)
<!-- claude-projects-attribution: {"github_login":"marchtian"} -->
_Requested by **田騏** · [project
thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiNjyvCdXYygFqntMNR7S2dW)_Fixes#30920
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:Hono 服务器优雅关闭时,正在处理的请求一结束就关掉它自己的连接(未发响应头的回 `Connection: close`,已发
keep-alive
头的在全部字节发完后只关自己这条连接,不碰别的连接);关闭期间的连接记账按连接存放、连接断开即清掉;另外七条慢用例里两条砍掉重复工作、四条按「CI
最慢读数 ×3」给了单独超时,一条经测量不需要改。
- 为什么改:两条 drain 用例在空闲机器上也要 3–4 秒,实测原因是关闭时还在跑的请求结束后变成空闲 keep-alive
连接,服务器要等客户端自己断开(fetch 3 秒、node 代理 4 秒);线上服务关闭同样会白等这段时间。其余七条在 CI 上已用到 5
秒默认超时的一半以上,负载一高就会把 main 和合并队列变红。
- 风险与回滚:生产代码只改 `plugin-hono-server`
的关闭路径;一条响应结束时只释放它自己的连接,别的连接上还在发送的大响应不受影响(有慢读者 32 MiB
用例钉住),流水线请求在连接断开后不再被留在内存里;drain 超时强关的兜底不变;其余都是测试文件。回滚就是 revert 本 PR。
- 先看:`packages/plugins/plugin-hono-server/src/adapter.ts` 的
`trackForDrain`、`responsesOwedOn` 与 `close()`;`adapter-drain.test.ts`
新增的慢读者、流水线断连与迟到请求头三条用例;`packages/client/src/auth-rotated-session-token.test.ts`
模块顶层新增的 `fileServer()` 预建。
- 两分钟验证:`pnpm --filter @objectstack/plugin-hono-server exec vitest run
src/adapter-drain.test.ts src/adapter-tls-listener.test.ts
--reporter=verbose`,应看到 12 条全过,两条 drain 用例约 0.2 秒(改前 3.2 秒与 4.2 秒)。
- 需要你做的:无,席位落地。
## What changes
**The drain (`packages/plugins/plugin-hono-server/src/adapter.ts`, patch
changeset).** `close()` calls `closeIdleConnections()` once, when the
drain starts. A connection whose request is still running at that moment
is skipped, and Node never runs the release again. When the response
ends, the connection becomes an idle keep-alive socket, and
`server.close()` waits until the client drops it. The client drops it
after the server's `Keep-Alive: timeout=5` hint minus its own margin: 3
s for `fetch` (undici) and 4 s for the `node:http(s)` agent. Measured
with a socket trace (see Tests): the response finishes at about 220 ms,
the server socket waits with a 6000 ms timer, and `close()` resolves
only when the client sends FIN, at 3232 ms (`fetch`) and 4216 ms
(agent).
During a drain the adapter now does two things:
- a response whose headers are not written yet goes out with
`Connection: close`, so Node ends the socket once it is sent and the
client knows not to reuse it. This covers responses in flight when
`close()` starts, and requests whose headers finish arriving on an open
connection after the drain began;
- a response whose keep-alive headers were already sent when the drain
began (a stream, for example) ends its OWN connection with
`destroySoon()` once it has finished (every byte handed to the socket)
and no other response on that connection is pending. It never calls the
server-wide `closeIdleConnections()`: Node counts a response as done
once it has called `end()`, before its bytes are flushed, so a sweep at
that moment would destroy any other connection whose response is ended
but still flushing to a slow reader.
The drain bookkeeping is kept per connection (`responsesOwedOn`) and
dropped when the connection closes. A response pipelined behind another
never gets a socket if the connection dies, so it never emits `'close'`;
keying by connection keeps it from being held until `close()`. The drain
window still force-closes whatever outlasts it.
**The seven other cases.** Each budget follows the rule PR #30919 used:
3x the slowest `Test Core` reading on the card, rounded up to the next
second, with the readings and the arithmetic in a comment at the case.
No global timeout, no skip, no retry.
| Case | Action | Local before (ms) | Local after (ms) |
|---|---|---|---|
| `metadata-protocol` two derivations of the same schemas agree exactly
| **cut**: one derivation compared with the module-load singleton, not
two plus the singleton | 743 / 844 / 775 | 353 / 372 / 410 |
| `client` login → enable → verifyTotp → disable → deleteUser | **cut**:
the file's shared server and its better-auth instance are built at
module scope, out of the first case's clock | 729 / 800 / 743 | 566 /
583 / 558 |
| `driver-sql` sql-driver.ts and the modules split out of it contain
none of them | budget 8000 (3 x 2499 = 7497; the reading is 1 ms under
the rule's over-half-the-default line, and the comment says why it takes
the budget anyway) | 469 / 442 | 433 / 313 / 432 |
| `plugin-security` posture single/group/isolated hidden-column cases |
budget 9000 (3 x 2949 = 8847) | 624-694 | 489-669 |
| `plugin-sharing` a refused upgrade write does not block the read |
budget 8000 (3 x 2605 = 7815) | 545 / 458 / 689 | 499 / 398 / 474 |
| `cli` the census reaches the live tree | budget 8000 (3 x 2629 = 7887)
| 1291 / 1243 / 1102 | 1117 / 1234 / 1217 |
| `cli` the boot this sweep reads from really did compose the deployment
| **no change**, see Acceptance notes | 10222 / 11341 / 10996 | n/a |
## Tests
`adapter-drain.test.ts` pins each part:
- the in-flight case asserts the response carried `Connection: close`;
- a streamed response whose keep-alive headers went out before `close()`
completes, and `close()` resolves less than 1000 ms after its body ends
(the defect waits about 3000 ms);
- a raw connection writes half a request's headers, `close()` starts,
the rest arrives: the answer is `Connection: close`;
- a slow reader's 32 MiB response calls `end()` during the drain and is
still flushing (asserted) when another keep-alive stream ends: every
byte arrives;
- four pipelined requests on one raw connection, the connection
destroyed: the drain bookkeeping holds none of them afterwards.
Ablation, one part at a time, through `scripts/ablation-replace.mjs` on
head `586338ffbd` (the tests import `./adapter` from source, so no build
is involved; each restore verified as blob == HEAD with an empty `git
diff HEAD`):
- the round-1 server-wide `closeIdleConnections()` in place of the
own-socket release: the slow-reader case goes red, `expected 3919730 to
be 33554432` bytes;
- without dropping a connection's entry on its `'close'`: the pipelined
case goes red, `expected 3 to be +0`;
- without the request-time `Connection: close` line: the late-headers
case goes red, `expected 'keep-alive' to be 'close'`.
The whole new test file against the round-1 `adapter.ts` (`2ff6bb0735`):
the slow-reader case fails (`expected 3919730 to be 33554432`) and the
pipelined case fails (the round-1 set holds responses, so its retained
count reads `NaN` against the expected 4); the late-headers case passes,
as the round-1 code had that line.
<details><summary>Socket trace of close() with one request in flight
(before / after)</summary>
Before (`fetch`):
```
[ 56 ms] close() called
[ 219 ms] server: response finished; shouldKeepAlive=true _last=false
[ 224 ms] client: status 200, connection=keep-alive, keep-alive=timeout=5
[ 226 ms] server socket after finish: timeout=6000
[ 3232 ms] server socket: "end" (peer, the client, sent FIN)
[ 3232 ms] close() resolved
```
Before (`node:http` agent, keepAlive): FIN from the client and `close()`
resolved at 4216 ms. With `agent: false` (no keep-alive) `close()`
resolved at 215 ms, which isolates the keep-alive wait as the cause.
After (`fetch`):
```
[ 59 ms] close() called
[ 227 ms] server: response finished; shouldKeepAlive=false _last=true
[ 228 ms] close() resolved
[ 237 ms] client: status 200, connection=close
```
</details>
<details><summary>Drain cases, local, before / after</summary>
| Case | Before | After |
|---|---|---|
| `adapter-tls-listener.test.ts` drains on close exactly as the plain
listener does | 4209 ms | 214 ms |
| `adapter-drain.test.ts` lets an in-flight request complete instead of
aborting it | 3233 ms | 268 ms |
</details>
## Acceptance notes
- **`cli` unmanaged-tables "the boot this sweep reads from really did
compose the deployment" is not changed.** Timing trace (instrumented
locally, not committed): the case body takes 1-2 ms, its `afterEach` 3
ms, and its `beforeEach` 10038-10378 ms, nearly all of it the first
`bootSchemaStack` of the worker. vitest reports hooks inside a case's
duration, but the 5 s `testTimeout` clocks only the body; this
`beforeEach` declares its own `180_000`. So the 5 s default cannot time
this case out, and a per-case timeout on a 2 ms body would change
nothing. The later cases in the file boot in 90-145 ms. The local
readings are on a box shared with other builds; the card's CI reading is
3295 ms.
- **`client` keeps no budget, by the file's own rule.** Its header says
a recurrence is answered by cutting the case's own work, ⛔ not by a
timeout, and its pin ④ refuses a numeric per-case timeout. The cut above
moves arrangement out of the clock; what is left is the probe itself (a
sign-up and five password or TOTP checks).
- **Found, not fixed here:** a response that has called `end()` but is
still flushing at the moment `close()` STARTS is destroyed mid-body.
Node's own `server.close()` sweeps idle connections (Node 19+), and the
adapter's call at drain start does the same; both treat an ended
response as done before its bytes are flushed. Reproduced on this branch
with the slow-reader case reordered so the big response ends before
`close()`: `expected 3919702 to be 33554432` bytes. That path is
unchanged from `main`; reported to the seat for a card.
- No other file changes. The drain fix is a published-package change, so
it ships a `patch` changeset; the test-only changes in the other six
packages publish nothing.
## Verification
Head `586338ffbd` (round 2, on the round-1 merge of `origin/main` at
`8ef4d30905`; the delta against `main` is these nine files).
- Typecheck, each touched package: `plugin-hono-server`, `client`,
`metadata-protocol`, `plugin-security`, `plugin-sharing`, `driver-sql`,
`cli`: all exit 0.
- Tests: `plugin-hono-server` 481/481 on round 2 (478/478 on round 1),
`client` 712/712, `metadata-protocol` 22525 passed / 26 skipped,
`plugin-security` 5820 passed / 45 skipped, `plugin-sharing` 1445/1445,
`driver-sql` 3867 passed / 218 skipped, `cli --project unit` 5603/5603.
The `cli` integration layer is left to CI.
- Gates derived by `node scripts/pm/dispatch-gates.mjs --commands` on
`586338ffbd`: 69 families, 69 run, all exit 0; `--ran` reconciliation: 0
NOT-MEASURED, 0 UNRUN. `check:tenant-audit-census` first flagged the new
set's `.delete()` receiver; the set is now a named type in the adapter,
and the census passes. `check:dts-closure` and
`check:dual-build-cjs-loads` first read a `dist/` that was being rebuilt
and went red on missing files; rerun on the finished build, both exit 0.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- signed: domain:devx#2 · marchtian ·
session_017ihL2ebbZkr2QAEG1x7UC8 -->
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-hono-server): a graceful `close()` now finishes when the last in-flight request ends, not a keep-alive window later
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
`HonoHttpServer.close()` released idle keep-alive sockets only once, at the start of the drain. A connection whose request was still running at that moment became an idle keep-alive socket after its response ended, and `close()` then waited for the client to drop it: about 3 s with `fetch` and 4 s with the `node:http` / `node:https` agent, or the server's own keep-alive timeout. During a drain, a response that has not written its headers yet now goes out with `Connection: close`, and a response that had already sent keep-alive headers closes its own connection once every byte of it has been flushed. Other connections are not touched when one response ends. The drain window still force-closes whatever outlasts it.
0 commit comments