Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/30920-hono-drain-closes-connections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/plugin-hono-server': patch
---

fix(plugin-hono-server): a graceful `close()` now finishes when the last in-flight request ends, not a keep-alive window later

Clause-②: no
Metadata-protocol: none

`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.
18 changes: 17 additions & 1 deletion packages/cli/test/released-port-production-spawn.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,22 @@ describe('#24295 / #24342: no file that spawns the CLI draws a released port', (
}
});

/**
* An explicit budget, sized to the measured work. The case reads and parses
* every `.ts` file under `test/` (`liveTree()`). Reaching the whole live
* tree is what the case claims, so the walk is not cut. The case is
* synchronous, so vitest judges it after the body returns.
*
* Readings: the slowest `Test Core` reading is 2629 ms (`main` push and merge
* group runs, 2026-11-03..06, n=6); local idle, 1102-1291 ms.
*
* The rule is 3x the slowest reading, rounded up to the next second (the rule
* and why 3x: plugin-sharing's `share-link-password-webcontainer.test.ts`):
* 3 x 2629 = 7887, so the budget is 8000. If `test/` grows much, re-measure
* and re-size.
*/
const LIVE_TREE_BUDGET_MS = 8_000;

it('the census reaches the live tree: every way of spawning the CLI, and the draw, are found', () => {
const tree = liveTree();
const population = cliSpawners(tree);
Expand All @@ -359,7 +375,7 @@ describe('#24295 / #24342: no file that spawns the CLI draws a released port', (
const instrument = tree.get('serve-port-bind-probe.test.ts');
expect(instrument?.drawLines.length ?? 0, 'the draw census finds no draw in the bind-probe instrument').toBeGreaterThan(0);
expect(instrument?.spawnsCli).toBe(false);
});
}, LIVE_TREE_BUDGET_MS);

it('every file that spawns the CLI draws exactly what its ledger entry allows — zero without one', () => {
const offenders: string[] = [];
Expand Down
10 changes: 8 additions & 2 deletions packages/client/src/auth-rotated-session-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,10 @@ const arrangeServer = async () => {
};

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

beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
Expand Down
22 changes: 21 additions & 1 deletion packages/drivers/driver-sql/src/logger-receiver-detach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,12 +512,32 @@ describe('[#12792] ③ no detach-then-call shape survives in sql-driver.ts', ()
expect(SCANNED.length).toBeGreaterThan(1);
});

/**
* An explicit budget, sized to the measured work. The case parses every
* module in SCANNED with the TypeScript compiler and walks each tree twice
* (37 files, about 1.5 MB of source, at this commit). That work is the claim,
* so it is not cut. The case is synchronous, so vitest judges it after the
* body returns.
*
* Readings: the slowest `Test Core` reading is 2499 ms (`main` push and merge
* group runs, 2026-11-03..06, n=2); local idle, 442 and 469 ms.
*
* The rule (plugin-sharing's `share-link-password-webcontainer.test.ts`):
* a case whose slowest reading is over half the default gets 3x that
* reading, rounded up to the next second. 2499 ms is 1 ms under that line,
* and it takes the budget anyway: the default leaves it 2.0x, barely above
* the 1.88x spread between two CI runs that the rule's 3x was sized on, from
* only two readings. 3 x 2499 = 7497, so the budget is 8000. If the split
* adds modules, re-measure and re-size.
*/
const SCAN_BUDGET_MS = 8_000;

it('sql-driver.ts and the modules split out of it contain none of them', () => {
const found = SCANNED.flatMap((name) =>
scan(readFileSync(new URL(`./${name}`, import.meta.url), 'utf8'), name).map(
(f) => `${name} ${f.shape} @${f.line}: ${f.text.trim()}`,
),
);
expect(found, 'every log call must go through a property access so the receiver stays bound').toEqual([]);
});
}, SCAN_BUDGET_MS);
});
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,17 @@ describe('[#9190] derived reference sites — derivation is a pure function of t
// The module-load singleton must not be doing anything a caller cannot
// reproduce; a derived registry that depends on WHEN it ran is a
// curated one wearing a function.
//
// The two derivations are the module-load singleton and one call made
// now, at a different time and in a different state of the process.
// A third call would only repeat the second at the cost of another
// full schema walk, so the case derives once.
const a = deriveReferenceSites();
const b = deriveReferenceSites();
expect(a).not.toBe(REFERENCE_SITES); // a second derivation, not the singleton handed back
const flatten = (index: ReturnType<typeof deriveReferenceSites>) =>
[...index.byTarget.entries()]
.map(([target, sites]) => `${target}=${sites.map((s) => `${s.fromType}.${s.property}`).join(',')}`)
.sort();
expect(flatten(a)).toEqual(flatten(b));
expect(flatten(a)).toEqual(flatten(REFERENCE_SITES));
expect([...a.compositeKeyByTarget]).toEqual([...REFERENCE_SITES.compositeKeyByTarget]);
});
Expand Down
180 changes: 180 additions & 0 deletions packages/plugins/plugin-hono-server/src/adapter-drain.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,55 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { connect, type Socket } from 'node:net';
import type { ServerResponse } from 'node:http';
import { describe, it, expect } from 'vitest';
import { HonoHttpServer } from './adapter';

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

/**
* A raw HTTP/1.1 connection: what it writes is exactly what the server parses,
* so a test can split one request's headers across a `close()`, pipeline
* several requests in one write, or stop reading a response part-way.
*/
async function rawConnection(port: number) {
const socket: Socket = connect(port, '127.0.0.1');
await new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('error', reject);
});
const chunks: Buffer[] = [];
socket.on('data', (chunk: Buffer) => chunks.push(chunk));
socket.on('error', () => { /* a destroyed connection is part of some cases */ });
const ended = new Promise<void>((resolve) => socket.once('close', () => resolve()));
return {
socket,
ended,
received: () => Buffer.concat(chunks),
};
}

/** The head of the first response in `bytes`, its header map, and the body bytes after it. */
function splitResponse(bytes: Buffer) {
const at = bytes.indexOf('\r\n\r\n');
if (at < 0) return { head: '', headers: {} as Record<string, string>, body: Buffer.alloc(0) };
const head = bytes.subarray(0, at).toString('latin1');
const headers: Record<string, string> = {};
for (const line of head.split('\r\n').slice(1)) {
const colon = line.indexOf(':');
headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
}
return { head, headers, body: bytes.subarray(at + 4) };
}

/** Every response the adapter is still holding for its drain bookkeeping. */
function retainedResponses(server: HonoHttpServer): number {
const tracked = (server as unknown as { inFlightResponses: Map<unknown, Set<unknown>> }).inFlightResponses;
let n = 0;
for (const pending of tracked.values()) n += pending.size;
return n;
}

/**
* P1-3 regression: `close()` must DRAIN in-flight requests (let them finish)
* rather than force-killing them, and must force-close only after the drain
Expand All @@ -27,9 +72,144 @@ describe('HonoHttpServer — graceful close drains in-flight requests (P1-3)', (
const res = await reqP;
expect(res.status).toBe(200);
expect(await res.text()).toBe('drained-ok'); // completed, not reset
// Written after the drain began, so it closes its connection rather than
// leaving an idle keep-alive socket for `close()` to wait out.
expect(res.headers.get('connection')).toBe('close');
await closeP;
});

it('a response whose keep-alive headers went out before close() releases its connection when it ends', async () => {
// The arm `Connection: close` cannot reach: the headers are already on the
// wire. Before the fix `close()` resolved only when the client dropped the
// idle socket — about 3 s later for `fetch`, its keep-alive margin under
// the server's `timeout=5` hint.
const server = new HonoHttpServer(0, undefined, 5000);
server.getRawApp().get('/stream', (c) => c.body(new ReadableStream({
async start(controller) {
controller.enqueue(new TextEncoder().encode('head-'));
await sleep(200); // still streaming when close() is called
controller.enqueue(new TextEncoder().encode('tail'));
controller.close();
},
})));
await server.listen(0);
const port = server.getPort();

const res = await fetch(`http://127.0.0.1:${port}/stream`);
expect(res.headers.get('connection')).toBe('keep-alive'); // sent before the drain
const closeP = server.close();

expect(await res.text()).toBe('head-tail'); // completed, not reset
const bodyEnded = Date.now();
await closeP;
// 1000 ms is a third of the defect's 3 s wait and far above the few
// milliseconds the release takes, so neither load nor a slow box decides it.
expect(Date.now() - bodyEnded).toBeLessThan(1000);
});

it('a request whose headers finish arriving after close() began is answered Connection: close', async () => {
// A connection mid-headers when the drain starts is active, so
// `closeIdleConnections()` leaves it alone, and its response is created
// only after `close()` has run its loop over the in-flight ones. The
// request-time branch is what keeps that response from going out
// keep-alive.
const server = new HonoHttpServer(0, undefined, 5000);
server.getRawApp().get('/probe', (c) => c.text('late-arrival'));
await server.listen(0);
const conn = await rawConnection(server.getPort());

conn.socket.write('GET /probe HTTP/1.1\r\nHost: 127.0.0.1\r\n');
await sleep(50); // the first half has been read: the connection is active
const closeP = server.close();
await sleep(20);
conn.socket.write('Accept: */*\r\n\r\n');

await conn.ended; // the server ended the connection after the response
const { head, headers, body } = splitResponse(conn.received());
expect(head).toMatch(/^HTTP\/1\.1 200/);
expect(body.toString('utf8')).toBe('late-arrival');
expect(headers.connection).toBe('close');
await closeP;
});

it('a stream ending during the drain does not cut short another connection\'s response that is still flushing', async () => {
// Connection B's response calls `end()` during the drain with a body larger
// than the socket buffers, to a client that is not reading, so it is ended
// but still flushing. Connection A's stream, whose keep-alive headers went
// out before `close()`, then ends. Releasing A's connection must not touch
// B's: Node counts a response as done once it has called `end()`, before
// its bytes are flushed, so a server-wide `closeIdleConnections()` at that
// moment destroys B mid-body.
const BIG = new Uint8Array(32 * 1024 * 1024).fill(0x61);
let bigResponse: ServerResponse | undefined;
let releaseBig: () => void = () => {};
const bigReleased = new Promise<void>((resolve) => { releaseBig = resolve; });
const server = new HonoHttpServer(0, undefined, 10_000);
server.getRawApp().get('/big', async (c) => {
bigResponse = (c.env as { outgoing: ServerResponse }).outgoing;
await bigReleased; // in flight, not ended, when the drain starts
return c.body(BIG);
});
server.getRawApp().get('/stream', (c) => c.body(new ReadableStream({
async start(controller) {
controller.enqueue(new TextEncoder().encode('head-'));
await sleep(200);
controller.enqueue(new TextEncoder().encode('tail'));
controller.close();
},
})));
await server.listen(0);
const port = server.getPort();

const slowReader = await rawConnection(port);
slowReader.socket.pause(); // reads nothing until resumed below
slowReader.socket.write('GET /big HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n');
const stream = await fetch(`http://127.0.0.1:${port}/stream`);
expect(stream.headers.get('connection')).toBe('keep-alive'); // sent before the drain

const closeP = server.close();
releaseBig();
await sleep(50);
// The premise: B's response has called end() and is still flushing.
expect(bigResponse?.writableEnded, 'the big response has called end()').toBe(true);
expect(bigResponse?.writableFinished, 'the big response is still flushing').toBe(false);

expect(await stream.text()).toBe('head-tail'); // A ends during the drain
await sleep(50);
slowReader.socket.resume();
await slowReader.ended;
const { head, headers, body } = splitResponse(slowReader.received());
expect(head).toMatch(/^HTTP\/1\.1 200/);
expect(Number(headers['content-length'])).toBe(BIG.length);
expect(body.length, 'every byte of the body arrived').toBe(BIG.length);
await closeP;
});

it('forgets a pipelined response whose connection died before it was written', async () => {
// Pipelined requests queue behind the first one's response; the queued
// responses have no socket of their own, so if the connection dies they
// never emit 'close'. The drain bookkeeping must still let them go, or
// any client can grow the server's memory by pipelining and hanging up.
const server = new HonoHttpServer(0, undefined, 5000);
server.getRawApp().get('/slow', async (c) => {
await sleep(150);
return c.text('first');
});
server.getRawApp().get('/probe', (c) => c.text('queued'));
await server.listen(0);
const conn = await rawConnection(server.getPort());

const get = (path: string) => `GET ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n`;
conn.socket.write(get('/slow') + get('/probe') + get('/probe') + get('/probe'));
await sleep(60);
expect(retainedResponses(server), 'all four responses are tracked while their connection lives').toBe(4);

conn.socket.destroy();
await sleep(300); // past the first response's 150 ms, and the server has seen the connection close
expect(retainedResponses(server)).toBe(0);
await server.close();
});

it('force-closes after the drain window so shutdown cannot hang', async () => {
const server = new HonoHttpServer(0, undefined, 100); // tiny drain window
server.getRawApp().get('/hang', async (c) => {
Expand Down
Loading
Loading