Repository navigation
fix(plugin-hono-server): keep a graceful close from cutting off a response still flushing to a slow client - #31255
Conversation
…onse still flushing Node's server.close() sweeps idle connections first thing and counts a response as done once it has called end(), before its bytes are flushed, so a large response still going to a slow reader at drain start was destroyed mid-body. The sweep (Node's and the adapter's own call) now waits until no ended response is flushing; each such connection closes after its own finish, and idle connections are released right after. The drain window's force-close is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5puKTX6kQoJs8iKrFr
…e instance guard Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5puKTX6kQoJs8iKrFr
…may run Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5puKTX6kQoJs8iKrFr
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 714f5e0f6d7e577dd70b00857bcc240ee2765fe1 && git checkout 714f5e0f6d7e577dd70b00857bcc240ee2765fe1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 169ff7e53889d275c7db1b1bd0e22e37791c58df ab2845c82053c7e6d35286e2c7cd7713145b5dee && git checkout -B drift-repro 169ff7e53889d275c7db1b1bd0e22e37791c58df && git merge --no-ff ab2845c82053c7e6d35286e2c7cd7713145b5dee
node scripts/docs-audit/affected-docs.mjs --json 169ff7e53889d275c7db1b1bd0e22e37791c58df
|
|
domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ 维护者速读
席位意见:接受。优雅关闭时,已结束但还在往慢客户端发送的响应不再被 Node 的空闲连接清理截断;清理改为等所有在发送的响应发完再跑,排空窗口的强制关闭兜底不变。 席位核对 — ACCEPT ·
|
Requested by Leehom · project thread
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 ofHonoHttpServer.close():.nvmrcis22,enginesis>=22.0.0, and every CI job reads.nvmrcor pins'22'), aSocket.prototype.destroyprobe during the reordered slow-reader case recorded two destroys of the flushing socket, in this order: firstServer.closeIdleConnectionscalled fromhttpServerPreClosefromServer.close(adapter.tsserver.close(...)), then the adapter's ownserver.closeIdleConnections()call on the next line. So Node's ownserver.close()sweep destroys it first; the adapter's call would have done the same. Both skip a connection only whilesocket._httpMessage.finishedis false, andend()setsfinishedbefore the bytes are flushed.tryListenputs acloseIdleConnectionson the server instance that wraps Node's:closeIdleConnectionsOnceFlushedruns Node's sweep only when no tracked response iswritableEnded && !writableFinished. Otherwise it holds the sweep back. Node'shttpServerPreClosecallsserver.closeIdleConnections()on the instance (the probe's stack shows it), so both sweeps at drain start go through the guard, andserver.close()still stops accepting and clears Node's connection-check interval at once.'finish'. That path is PR fix(plugin-hono-server): end a graceful close with its last in-flight request; cut or budget seven slow Test Core cases #31189's, unchanged: the response's'close'destroySoon()s its own socket once it has finished and nothing else is owed on that connection.'close'(intrackForDrain) 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 fix(plugin-hono-server): end a graceful close with its last in-flight request; cut or budget seven slow Test Core cases #31189 M1 hazard): the only sweep there is the guarded one, which runs only when nothing is flushing.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.closeinstead ofserver.close(). That skips the other half ofhttpServerPreClose,clearInterval(server[kConnectionsCheckingInterval]), an interval only reachable through a private symbol, so it would keep the closed server alive in a timer; and callingserver.close()later to clear it emits'close'a second time when the connections have already drained. Deferring the wholeserver.close()keeps accepting new connections during the flush. The instance guard keeps Node'sclose()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:end()to a paused raw socket beforeclose()starts; the case asserts the premise (writableEndedtrue,writableFinishedfalse), then the client receives every byte againstContent-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 onmain(df22adc3, adapter unchanged since714dda8f):every byte of the body arrived: expected 3919702 to be 33554432.close()and the connection end within 1 s.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.Ablations
Through
scripts/ablation-replace.mjson the committed head, anchor hit once, restore proven by blob == HEAD and an emptygit diff HEAD. The suite imports./adapterfromsrc/, so no build sits on the path.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.1502afc5), the held-back sweep never re-run (theif (sweep) this.closeIdleConnectionsOnceFlushed(sweep);line deleted): 1 failed / 8 passed, the slow-reader caseTest timed out in 5000ms(the idle keep-alive connection holdsclose()open).Acceptance notes
httpServerPreClosereadsserver.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.Connection: closeby PR fix(plugin-hono-server): end a graceful close with its last in-flight request; cut or budget seven slow Test Core cases #31189's request-time line. Under a stream of new slow flushes the backstop decides, as before.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/objectstackon the final headab2845c8(origin/mainmerged once, at733efbd4): 63 families, each exit code captured before any pipe.--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-testaftergit fetch --depth=1 origin 621a4876for its fixture, andcheck:dual-build-cjs-loadsandcheck:lean-entry-closureafter a fullpnpm turbo run build --concurrency=2under the lock (VERDICT command-exit 0).check:dts-closureandcheck:published-fileswere read again on the full build, exit 0.pnpm --filter @objectstack/plugin-hono-server build && typecheck && testunder the lock onab2845c8: VERDICT command-exit 0, Test Files 38 passed (38), Tests 484 passed (484). The packagetsconfig.jsonincludessrc/**/*, sotypecheckcompiles the test file.pnpm lintscoped:eslint --no-inline-configover the two touched source files, exit 0.!in the title and no(narrowing)arm.Generated by Claude Code