Repository navigation
Commit fd3ab0a
fix(plugin-hono-server): keep a graceful close from cutting off a response still flushing to a slow client (#31255)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X6PYHRe3yiUSd4YRysSoxcj)_
Fixes #31208
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:服务优雅关闭(`HonoHttpServer.close()`)时,一个已经调用
`end()`、但还在往慢客户端发送的响应,不再在关闭开始那一刻被掐断;空闲的 keep-alive 连接改为在没有响应仍在发送时才统一释放。
- 为什么改:每次 `os serve` / 运行时关停或重新部署时,正在下载大响应的客户端会收到被截断的内容(实测 32 MiB 只收到
3,919,702 字节),原因是 Node 自己的 `server.close()` 把「已 end 未发完」的连接当作空闲连接销毁了。
- 风险与回滚:只改 `@objectstack/plugin-hono-server` 一个文件,patch 级;关闭窗口的强制关闭兜底(默认
10 秒)原样保留,所以最坏情况是空闲连接晚一点释放,不会让关停卡住;回滚即还原本 PR。
- 先看:`packages/plugins/plugin-hono-server/src/adapter.ts` 的
`closeIdleConnectionsOnceFlushed`(什么时候推迟清扫),再看 `tryListen` 里把它装到 server
实例上的那三行,以及 `trackForDrain` 里响应 `'close'` 时补跑被推迟的清扫。
- 两分钟验证:`pnpm --filter @objectstack/plugin-hono-server exec vitest run
src/adapter-drain.test.ts`,预期 9 个用例全绿。
- 需要你做的:无,席位落地。
## What changes
`packages/plugins/plugin-hono-server/src/adapter.ts`, the drain start of
`HonoHttpServer.close()`:
- **What destroyed the response, measured.** On Node 22.23.4 (`.nvmrc`
is `22`, `engines` is `>=22.0.0`, and every CI job reads `.nvmrc` or
pins `'22'`), a `Socket.prototype.destroy` probe during the reordered
slow-reader case recorded two destroys of the flushing socket, in this
order: first `Server.closeIdleConnections` called from
`httpServerPreClose` from `Server.close` (`adapter.ts`
`server.close(...)`), then the adapter's own
`server.closeIdleConnections()` call on the next line. So Node's own
`server.close()` sweep destroys it first; the adapter's call would have
done the same. Both skip a connection only while
`socket._httpMessage.finished` is false, and `end()` sets `finished`
before the bytes are flushed.
- **The guard.** `tryListen` puts a `closeIdleConnections` on the server
instance that wraps Node's: `closeIdleConnectionsOnceFlushed` runs
Node's sweep only when no tracked response is `writableEnded &&
!writableFinished`. Otherwise it holds the sweep back. Node's
`httpServerPreClose` calls `server.closeIdleConnections()` on the
instance (the probe's stack shows it), so both sweeps at drain start go
through the guard, and `server.close()` still stops accepting and clears
Node's connection-check interval at once.
- **Each flushing connection closes after its own `'finish'`.** That
path is PR #31189's, unchanged: the response's `'close'`
`destroySoon()`s its own socket once it has finished and nothing else is
owed on that connection.
- **The held-back sweep then runs.** Each tracked response's `'close'`
(in `trackForDrain`) re-asks the guard when a sweep is held back, so
idle keep-alive connections are released as soon as no response is
flushing, not a keep-alive window later. Node's raw server-wide sweep is
still never run from a response's `'close'` (the PR #31189 M1 hazard):
the only sweep there is the guarded one, which runs only when nothing is
flushing.
- **The backstop is untouched.** The `closeAllConnections()` timer and
its window are byte-identical. A reader that never drains is now held
until the window ends instead of being destroyed at drain start.
**Why this route and not the other one the card names.** Stopping the
accept without the sweep means calling `net.Server.prototype.close`
instead of `server.close()`. That skips the other half of
`httpServerPreClose`,
`clearInterval(server[kConnectionsCheckingInterval])`, an interval only
reachable through a private symbol, so it would keep the closed server
alive in a timer; and calling `server.close()` later to clear it emits
`'close'` a second time when the connections have already drained.
Deferring the whole `server.close()` keeps accepting new connections
during the flush. The instance guard keeps Node's `close()` whole and
changes only when its sweep runs, on every Node the CI runs (22) and any
with the sweep (19 and later).
## Tests
`packages/plugins/plugin-hono-server/src/adapter-drain.test.ts`, three
new cases:
- **The reordered slow-reader pin.** A 32 MiB response calls `end()` to
a paused raw socket before `close()` starts; the case asserts the
premise (`writableEnded` true, `writableFinished` false), then the
client receives every byte against `Content-Length`. An idle keep-alive
connection sits beside it and must be released within 1 s of the flush
ending (the keep-alive timer would hold it 5 s). **Red on `main`**
(`df22adc3`, adapter unchanged since `714dda8f`): `every byte of the
body arrived: expected 3919702 to be 33554432`.
- **Control: an idle keep-alive connection is released at drain start.**
Nothing is flushing, so the sweep runs at once: `close()` and the
connection end within 1 s.
- **Control: the backstop still force-closes a reader that never
drains.** A paused reader of a 32 MiB response with a 300 ms window:
`close()` takes at least 250 ms (held for the window, not swept at drain
start) and under 2 s, and the reader gets fewer than 32 MiB.
<details><summary>Ablations</summary>
Through `scripts/ablation-replace.mjs` on the committed head, anchor hit
once, restore proven by blob == HEAD and an empty `git diff HEAD`. The
suite imports `./adapter` from `src/`, so no build sits on the path.
- A1 (head `ab2845c8`), the guard's flushing check disabled (`if (false
&& res.writableEnded && !res.writableFinished)`): 2 failed / 7 passed.
`a response that ended before close() but is still flushing to a slow
reader arrives whole`: `expected 3919702 to be 33554432`; `the drain
window still force-closes a reader that never drains`: `expected 1 to be
greater than or equal to 250`.
- A2 (head `1502afc5`), the held-back sweep never re-run (the `if
(sweep) this.closeIdleConnectionsOnceFlushed(sweep);` line deleted): 1
failed / 8 passed, the slow-reader case `Test timed out in 5000ms` (the
idle keep-alive connection holds `close()` open).
</details>
## Acceptance notes
- **The guard relies on Node calling the instance method.**
`httpServerPreClose` reads `server.closeIdleConnections()` dynamically
in Node 22 (measured by the probe's stack). If a future Node called the
prototype directly, the slow-reader pin goes red again; it does not fail
silently.
- **Idle connections wait while something flushes.** While an ended
response is still flushing, the sweep is held back, so an idle
keep-alive connection stays open until the flush ends (then it is
released at once) or the window ends. A request arriving on it meanwhile
is answered `Connection: close` by PR #31189's request-time line. Under
a stream of new slow flushes the backstop decides, as before.
- **A pipelined response queued behind another counts as flushing once
it has ended** (its bytes sit in `outputData`), which only holds the
sweep back until it is written. A dead connection's responses drop out
with the connection's entry, so they cannot hold the sweep.
## Verification
Gate union derived by `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` on the final head `ab2845c8`
(`origin/main` merged once, at `733efbd4`): 63 families, each exit code
captured before any pipe.
- **63 run, exit 0 each.** `--ran`: "63 derived famil(ies) accounted for
— 63 run, 0 NOT-MEASURED". Three first read exit 3 (prerequisite, not
measured) and were re-run green: `check-plugin-teardown-shape
--self-test` after `git fetch --depth=1 origin 621a487` for its
fixture, and `check:dual-build-cjs-loads` and `check:lean-entry-closure`
after a full `pnpm turbo run build --concurrency=2` under the lock
(VERDICT command-exit 0). `check:dts-closure` and
`check:published-files` were read again on the full build, exit 0.
- `pnpm --filter @objectstack/plugin-hono-server build && typecheck &&
test` under the lock on `ab2845c8`: VERDICT command-exit 0, Test Files
38 passed (38), Tests 484 passed (484). The package `tsconfig.json`
includes `src/**/*`, so `typecheck` compiles the test file.
- `pnpm lint` scoped: `eslint --no-inline-config` over the two touched
source files, exit 0.
- No dogfood battery: no `!` in the title and no `(narrowing)` arm.
- CI families are read from this PR's check-runs, not re-run here.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5puKTX6kQoJs8iKrFr)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 455229e commit fd3ab0a
3 files changed
Lines changed: 151 additions & 11 deletions
File tree
- .changeset
- packages/plugins/plugin-hono-server/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
185 | 185 | | |
186 | 186 | | |
187 | 187 | | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
188 | 270 | | |
189 | 271 | | |
190 | 272 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
595 | 595 | | |
596 | 596 | | |
597 | 597 | | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
598 | 604 | | |
599 | 605 | | |
600 | 606 | | |
| |||
1849 | 1855 | | |
1850 | 1856 | | |
1851 | 1857 | | |
| 1858 | + | |
| 1859 | + | |
| 1860 | + | |
| 1861 | + | |
| 1862 | + | |
| 1863 | + | |
1852 | 1864 | | |
1853 | 1865 | | |
1854 | 1866 | | |
| |||
1880 | 1892 | | |
1881 | 1893 | | |
1882 | 1894 | | |
1883 | | - | |
1884 | | - | |
1885 | | - | |
1886 | | - | |
| 1895 | + | |
| 1896 | + | |
| 1897 | + | |
| 1898 | + | |
| 1899 | + | |
| 1900 | + | |
| 1901 | + | |
1887 | 1902 | | |
1888 | 1903 | | |
1889 | 1904 | | |
| |||
1898 | 1913 | | |
1899 | 1914 | | |
1900 | 1915 | | |
1901 | | - | |
1902 | | - | |
1903 | | - | |
| 1916 | + | |
| 1917 | + | |
| 1918 | + | |
| 1919 | + | |
| 1920 | + | |
| 1921 | + | |
1904 | 1922 | | |
1905 | 1923 | | |
1906 | 1924 | | |
| 1925 | + | |
| 1926 | + | |
| 1927 | + | |
| 1928 | + | |
| 1929 | + | |
| 1930 | + | |
| 1931 | + | |
| 1932 | + | |
| 1933 | + | |
| 1934 | + | |
| 1935 | + | |
| 1936 | + | |
| 1937 | + | |
| 1938 | + | |
| 1939 | + | |
| 1940 | + | |
| 1941 | + | |
| 1942 | + | |
| 1943 | + | |
| 1944 | + | |
| 1945 | + | |
| 1946 | + | |
| 1947 | + | |
| 1948 | + | |
| 1949 | + | |
| 1950 | + | |
| 1951 | + | |
1907 | 1952 | | |
1908 | 1953 | | |
1909 | 1954 | | |
| |||
1931 | 1976 | | |
1932 | 1977 | | |
1933 | 1978 | | |
1934 | | - | |
1935 | | - | |
1936 | | - | |
1937 | | - | |
| 1979 | + | |
| 1980 | + | |
| 1981 | + | |
| 1982 | + | |
| 1983 | + | |
| 1984 | + | |
1938 | 1985 | | |
1939 | 1986 | | |
1940 | 1987 | | |
| |||
1963 | 2010 | | |
1964 | 2011 | | |
1965 | 2012 | | |
| 2013 | + | |
1966 | 2014 | | |
1967 | 2015 | | |
1968 | 2016 | | |
0 commit comments