Skip to content

Commit b48490e

Browse 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>
1 parent 66bf5b4 commit b48490e

9 files changed

Lines changed: 362 additions & 9 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
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.

‎packages/cli/test/released-port-production-spawn.pin.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,22 @@ describe('#24295 / #24342: no file that spawns the CLI draws a released port', (
338338
}
339339
});
340340

341+
/**
342+
* An explicit budget, sized to the measured work. The case reads and parses
343+
* every `.ts` file under `test/` (`liveTree()`). Reaching the whole live
344+
* tree is what the case claims, so the walk is not cut. The case is
345+
* synchronous, so vitest judges it after the body returns.
346+
*
347+
* Readings: the slowest `Test Core` reading is 2629 ms (`main` push and merge
348+
* group runs, 2026-11-03..06, n=6); local idle, 1102-1291 ms.
349+
*
350+
* The rule is 3x the slowest reading, rounded up to the next second (the rule
351+
* and why 3x: plugin-sharing's `share-link-password-webcontainer.test.ts`):
352+
* 3 x 2629 = 7887, so the budget is 8000. If `test/` grows much, re-measure
353+
* and re-size.
354+
*/
355+
const LIVE_TREE_BUDGET_MS = 8_000;
356+
341357
it('the census reaches the live tree: every way of spawning the CLI, and the draw, are found', () => {
342358
const tree = liveTree();
343359
const population = cliSpawners(tree);
@@ -359,7 +375,7 @@ describe('#24295 / #24342: no file that spawns the CLI draws a released port', (
359375
const instrument = tree.get('serve-port-bind-probe.test.ts');
360376
expect(instrument?.drawLines.length ?? 0, 'the draw census finds no draw in the bind-probe instrument').toBeGreaterThan(0);
361377
expect(instrument?.spawnsCli).toBe(false);
362-
});
378+
}, LIVE_TREE_BUDGET_MS);
363379

364380
it('every file that spawns the CLI draws exactly what its ledger entry allows — zero without one', () => {
365381
const offenders: string[] = [];

‎packages/client/src/auth-rotated-session-token.test.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,10 @@ const arrangeServer = async () => {
214214
};
215215

216216
/**
217-
* [#23181] The file's ONE server, built by the first case that signs in and
218-
* shared by every later one. Each case still signs a user of its own up on it,
217+
* [#23181] The file's ONE server, shared by every case. It is built at module
218+
* scope right after the warm-up (below), so no case's clock carries its
219+
* engine, schema sync and better-auth instance: before, the first case to
220+
* sign in built it inside its own window. Each case still signs a user of its own up on it,
219221
* through a client of its own, and every assertion here is about that user's
220222
* credential: a rotation revokes only the rotating user's sessions, and a
221223
* case reads its own user by address. So no case reads another case's writes,
@@ -357,6 +359,10 @@ const closeEngines = async (): Promise<void> => {
357359
// and `④` below pins that.
358360
await signedIn();
359361
await closeEngines();
362+
// The cases' shared server, built here for the same reason: it is
363+
// arrangement, not behaviour, and the first case used to pay it. Its
364+
// better-auth instance is built on first use, so that is asked for here too.
365+
await (await fileServer()).manager.getAuthInstance();
360366

361367
beforeEach(() => {
362368
vi.spyOn(console, 'warn').mockImplementation(() => {});

‎packages/drivers/driver-sql/src/logger-receiver-detach.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,12 +512,32 @@ describe('[#12792] ③ no detach-then-call shape survives in sql-driver.ts', ()
512512
expect(SCANNED.length).toBeGreaterThan(1);
513513
});
514514

515+
/**
516+
* An explicit budget, sized to the measured work. The case parses every
517+
* module in SCANNED with the TypeScript compiler and walks each tree twice
518+
* (37 files, about 1.5 MB of source, at this commit). That work is the claim,
519+
* so it is not cut. The case is synchronous, so vitest judges it after the
520+
* body returns.
521+
*
522+
* Readings: the slowest `Test Core` reading is 2499 ms (`main` push and merge
523+
* group runs, 2026-11-03..06, n=2); local idle, 442 and 469 ms.
524+
*
525+
* The rule (plugin-sharing's `share-link-password-webcontainer.test.ts`):
526+
* a case whose slowest reading is over half the default gets 3x that
527+
* reading, rounded up to the next second. 2499 ms is 1 ms under that line,
528+
* and it takes the budget anyway: the default leaves it 2.0x, barely above
529+
* the 1.88x spread between two CI runs that the rule's 3x was sized on, from
530+
* only two readings. 3 x 2499 = 7497, so the budget is 8000. If the split
531+
* adds modules, re-measure and re-size.
532+
*/
533+
const SCAN_BUDGET_MS = 8_000;
534+
515535
it('sql-driver.ts and the modules split out of it contain none of them', () => {
516536
const found = SCANNED.flatMap((name) =>
517537
scan(readFileSync(new URL(`./${name}`, import.meta.url), 'utf8'), name).map(
518538
(f) => `${name} ${f.shape} @${f.line}: ${f.text.trim()}`,
519539
),
520540
);
521541
expect(found, 'every log call must go through a property access so the receiver stays bound').toEqual([]);
522-
});
542+
}, SCAN_BUDGET_MS);
523543
});

‎packages/metadata-protocol/src/reference-sites.derivation.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,13 +226,17 @@ describe('[#9190] derived reference sites — derivation is a pure function of t
226226
// The module-load singleton must not be doing anything a caller cannot
227227
// reproduce; a derived registry that depends on WHEN it ran is a
228228
// curated one wearing a function.
229+
//
230+
// The two derivations are the module-load singleton and one call made
231+
// now, at a different time and in a different state of the process.
232+
// A third call would only repeat the second at the cost of another
233+
// full schema walk, so the case derives once.
229234
const a = deriveReferenceSites();
230-
const b = deriveReferenceSites();
235+
expect(a).not.toBe(REFERENCE_SITES); // a second derivation, not the singleton handed back
231236
const flatten = (index: ReturnType<typeof deriveReferenceSites>) =>
232237
[...index.byTarget.entries()]
233238
.map(([target, sites]) => `${target}=${sites.map((s) => `${s.fromType}.${s.property}`).join(',')}`)
234239
.sort();
235-
expect(flatten(a)).toEqual(flatten(b));
236240
expect(flatten(a)).toEqual(flatten(REFERENCE_SITES));
237241
expect([...a.compositeKeyByTarget]).toEqual([...REFERENCE_SITES.compositeKeyByTarget]);
238242
});

‎packages/plugins/plugin-hono-server/src/adapter-drain.test.ts‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,55 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import { connect, type Socket } from 'node:net';
4+
import type { ServerResponse } from 'node:http';
35
import { describe, it, expect } from 'vitest';
46
import { HonoHttpServer } from './adapter';
57

68
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
79

10+
/**
11+
* A raw HTTP/1.1 connection: what it writes is exactly what the server parses,
12+
* so a test can split one request's headers across a `close()`, pipeline
13+
* several requests in one write, or stop reading a response part-way.
14+
*/
15+
async function rawConnection(port: number) {
16+
const socket: Socket = connect(port, '127.0.0.1');
17+
await new Promise<void>((resolve, reject) => {
18+
socket.once('connect', resolve);
19+
socket.once('error', reject);
20+
});
21+
const chunks: Buffer[] = [];
22+
socket.on('data', (chunk: Buffer) => chunks.push(chunk));
23+
socket.on('error', () => { /* a destroyed connection is part of some cases */ });
24+
const ended = new Promise<void>((resolve) => socket.once('close', () => resolve()));
25+
return {
26+
socket,
27+
ended,
28+
received: () => Buffer.concat(chunks),
29+
};
30+
}
31+
32+
/** The head of the first response in `bytes`, its header map, and the body bytes after it. */
33+
function splitResponse(bytes: Buffer) {
34+
const at = bytes.indexOf('\r\n\r\n');
35+
if (at < 0) return { head: '', headers: {} as Record<string, string>, body: Buffer.alloc(0) };
36+
const head = bytes.subarray(0, at).toString('latin1');
37+
const headers: Record<string, string> = {};
38+
for (const line of head.split('\r\n').slice(1)) {
39+
const colon = line.indexOf(':');
40+
headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
41+
}
42+
return { head, headers, body: bytes.subarray(at + 4) };
43+
}
44+
45+
/** Every response the adapter is still holding for its drain bookkeeping. */
46+
function retainedResponses(server: HonoHttpServer): number {
47+
const tracked = (server as unknown as { inFlightResponses: Map<unknown, Set<unknown>> }).inFlightResponses;
48+
let n = 0;
49+
for (const pending of tracked.values()) n += pending.size;
50+
return n;
51+
}
52+
853
/**
954
* P1-3 regression: `close()` must DRAIN in-flight requests (let them finish)
1055
* rather than force-killing them, and must force-close only after the drain
@@ -27,9 +72,144 @@ describe('HonoHttpServer — graceful close drains in-flight requests (P1-3)', (
2772
const res = await reqP;
2873
expect(res.status).toBe(200);
2974
expect(await res.text()).toBe('drained-ok'); // completed, not reset
75+
// Written after the drain began, so it closes its connection rather than
76+
// leaving an idle keep-alive socket for `close()` to wait out.
77+
expect(res.headers.get('connection')).toBe('close');
78+
await closeP;
79+
});
80+
81+
it('a response whose keep-alive headers went out before close() releases its connection when it ends', async () => {
82+
// The arm `Connection: close` cannot reach: the headers are already on the
83+
// wire. Before the fix `close()` resolved only when the client dropped the
84+
// idle socket — about 3 s later for `fetch`, its keep-alive margin under
85+
// the server's `timeout=5` hint.
86+
const server = new HonoHttpServer(0, undefined, 5000);
87+
server.getRawApp().get('/stream', (c) => c.body(new ReadableStream({
88+
async start(controller) {
89+
controller.enqueue(new TextEncoder().encode('head-'));
90+
await sleep(200); // still streaming when close() is called
91+
controller.enqueue(new TextEncoder().encode('tail'));
92+
controller.close();
93+
},
94+
})));
95+
await server.listen(0);
96+
const port = server.getPort();
97+
98+
const res = await fetch(`http://127.0.0.1:${port}/stream`);
99+
expect(res.headers.get('connection')).toBe('keep-alive'); // sent before the drain
100+
const closeP = server.close();
101+
102+
expect(await res.text()).toBe('head-tail'); // completed, not reset
103+
const bodyEnded = Date.now();
104+
await closeP;
105+
// 1000 ms is a third of the defect's 3 s wait and far above the few
106+
// milliseconds the release takes, so neither load nor a slow box decides it.
107+
expect(Date.now() - bodyEnded).toBeLessThan(1000);
108+
});
109+
110+
it('a request whose headers finish arriving after close() began is answered Connection: close', async () => {
111+
// A connection mid-headers when the drain starts is active, so
112+
// `closeIdleConnections()` leaves it alone, and its response is created
113+
// only after `close()` has run its loop over the in-flight ones. The
114+
// request-time branch is what keeps that response from going out
115+
// keep-alive.
116+
const server = new HonoHttpServer(0, undefined, 5000);
117+
server.getRawApp().get('/probe', (c) => c.text('late-arrival'));
118+
await server.listen(0);
119+
const conn = await rawConnection(server.getPort());
120+
121+
conn.socket.write('GET /probe HTTP/1.1\r\nHost: 127.0.0.1\r\n');
122+
await sleep(50); // the first half has been read: the connection is active
123+
const closeP = server.close();
124+
await sleep(20);
125+
conn.socket.write('Accept: */*\r\n\r\n');
126+
127+
await conn.ended; // the server ended the connection after the response
128+
const { head, headers, body } = splitResponse(conn.received());
129+
expect(head).toMatch(/^HTTP\/1\.1 200/);
130+
expect(body.toString('utf8')).toBe('late-arrival');
131+
expect(headers.connection).toBe('close');
30132
await closeP;
31133
});
32134

135+
it('a stream ending during the drain does not cut short another connection\'s response that is still flushing', async () => {
136+
// Connection B's response calls `end()` during the drain with a body larger
137+
// than the socket buffers, to a client that is not reading, so it is ended
138+
// but still flushing. Connection A's stream, whose keep-alive headers went
139+
// out before `close()`, then ends. Releasing A's connection must not touch
140+
// B's: Node counts a response as done once it has called `end()`, before
141+
// its bytes are flushed, so a server-wide `closeIdleConnections()` at that
142+
// moment destroys B mid-body.
143+
const BIG = new Uint8Array(32 * 1024 * 1024).fill(0x61);
144+
let bigResponse: ServerResponse | undefined;
145+
let releaseBig: () => void = () => {};
146+
const bigReleased = new Promise<void>((resolve) => { releaseBig = resolve; });
147+
const server = new HonoHttpServer(0, undefined, 10_000);
148+
server.getRawApp().get('/big', async (c) => {
149+
bigResponse = (c.env as { outgoing: ServerResponse }).outgoing;
150+
await bigReleased; // in flight, not ended, when the drain starts
151+
return c.body(BIG);
152+
});
153+
server.getRawApp().get('/stream', (c) => c.body(new ReadableStream({
154+
async start(controller) {
155+
controller.enqueue(new TextEncoder().encode('head-'));
156+
await sleep(200);
157+
controller.enqueue(new TextEncoder().encode('tail'));
158+
controller.close();
159+
},
160+
})));
161+
await server.listen(0);
162+
const port = server.getPort();
163+
164+
const slowReader = await rawConnection(port);
165+
slowReader.socket.pause(); // reads nothing until resumed below
166+
slowReader.socket.write('GET /big HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n');
167+
const stream = await fetch(`http://127.0.0.1:${port}/stream`);
168+
expect(stream.headers.get('connection')).toBe('keep-alive'); // sent before the drain
169+
170+
const closeP = server.close();
171+
releaseBig();
172+
await sleep(50);
173+
// The premise: B's response has called end() and is still flushing.
174+
expect(bigResponse?.writableEnded, 'the big response has called end()').toBe(true);
175+
expect(bigResponse?.writableFinished, 'the big response is still flushing').toBe(false);
176+
177+
expect(await stream.text()).toBe('head-tail'); // A ends during the drain
178+
await sleep(50);
179+
slowReader.socket.resume();
180+
await slowReader.ended;
181+
const { head, headers, body } = splitResponse(slowReader.received());
182+
expect(head).toMatch(/^HTTP\/1\.1 200/);
183+
expect(Number(headers['content-length'])).toBe(BIG.length);
184+
expect(body.length, 'every byte of the body arrived').toBe(BIG.length);
185+
await closeP;
186+
});
187+
188+
it('forgets a pipelined response whose connection died before it was written', async () => {
189+
// Pipelined requests queue behind the first one's response; the queued
190+
// responses have no socket of their own, so if the connection dies they
191+
// never emit 'close'. The drain bookkeeping must still let them go, or
192+
// any client can grow the server's memory by pipelining and hanging up.
193+
const server = new HonoHttpServer(0, undefined, 5000);
194+
server.getRawApp().get('/slow', async (c) => {
195+
await sleep(150);
196+
return c.text('first');
197+
});
198+
server.getRawApp().get('/probe', (c) => c.text('queued'));
199+
await server.listen(0);
200+
const conn = await rawConnection(server.getPort());
201+
202+
const get = (path: string) => `GET ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n`;
203+
conn.socket.write(get('/slow') + get('/probe') + get('/probe') + get('/probe'));
204+
await sleep(60);
205+
expect(retainedResponses(server), 'all four responses are tracked while their connection lives').toBe(4);
206+
207+
conn.socket.destroy();
208+
await sleep(300); // past the first response's 150 ms, and the server has seen the connection close
209+
expect(retainedResponses(server)).toBe(0);
210+
await server.close();
211+
});
212+
33213
it('force-closes after the drain window so shutdown cannot hang', async () => {
34214
const server = new HonoHttpServer(0, undefined, 100); // tiny drain window
35215
server.getRawApp().get('/hang', async (c) => {

0 commit comments

Comments
 (0)