Skip to content

Commit 2a1e48a

Browse files
fix(plugin-hono-server): a request for port 0 no longer warns that port 0 is in use (#24402)
Fixes #24387 Clause-②: no (basis: a log line stops firing for a legal request; nothing accepted or exported moves, and no export, option or type changes) ## What changed `HonoServerPlugin`'s `kernel:listening` hook compared the requested port with the bound port and warned `Port 0 is in use, using port N instead` whenever the two differed. Port `0` is a legal request for a kernel-assigned port: `listen(0)` can never bind the number it was given, so nothing was in use. The hook now warns only for a non-zero request that the adapter's EADDRINUSE walk moved (`port !== 0 && actualPort !== port`). A kernel-assigned boot prints only the existing `HTTP server started successfully` info line, which already names the bound port. No line is added. - `packages/plugins/plugin-hono-server/src/hono-plugin.ts`: the guard, with a comment saying why. - `packages/plugins/plugin-hono-server/src/kernel-listening-port-notice.test.ts` (new): the card's pins. - `packages/cli/test/released-port-production-spawn.pin.test.ts`: the ledger reason for `serve-port-drift-notice.e2e.test.ts` quoted this warning as the reason its negative arm cannot use `--port 0`, and this change makes that clause false. The reason now states only the arm's subject: a boot that binds exactly the non-zero port it asked for, which `--port 0` cannot ask. The entry and its `draws: 1` are unchanged. - `.changeset/24387-port-zero-busy-warning.md`: `patch` on `@objectstack/plugin-hono-server`. Text that quotes or relies on the warning (`git grep` for `in use, using port` and for `is in use` over the tree): the pin ledger reason above, and nothing else. The helper docblock in `packages/cli/test/helpers/serve-process.ts` names the same subject ("a boot that must bind exactly the port it asked for, which `--port 0` cannot ask") without the warning, and stays true. The boot-noise budget test (`serve-boot-diagnostics-noise-budget.e2e.test.ts`) pins no informational count, so it needs no change. No doc page quotes the line. The CLI's own drift notice (`Port N is in use — serving on M instead.`) is a different line in `packages/cli/src` and is not touched. ## The pins, and why they sit at the plugin Three cases run the plugin's real `init()`/`start()`, the REAL adapter (a real socket is bound) and the REAL core logger (`createLogger`, json format) at the levels the CLI runs it at. They read the records the hook writes by kind and count, not by wording: 1. a request for `0` at the CLI's default kernel level (`warn`): no record at all; 2. a request for `0` at `info`: exactly one record, at `info`, whose `port` is the bound port; 3. a non-zero request whose port a TCP listener holds, at `warn`: the adapter really moved (bound differs from held), and exactly one `warn` record names both the requested and the bound port. Why here and not in the CLI's boot-diagnostics tests: this hook is the line's only producer, and the CLI banner only replays what the kernel logger wrote. The CLI test that reads the Boot diagnostics block is a nightly e2e cold start of a scaffolded project, and it deliberately pins no informational count. The plugin test drives the producer through a real socket in milliseconds of test time and runs in every PR's Test Core. No file changed tier: the new file is a plain plugin unit test, and the CLI pin file's edit is one string literal (it ran under `--project unit`). ## Measurement, fix, ablation - **Premise, measured by the pin on unchanged source** (commit c18c1ff is the pin alone): cases 1 and 2 red, case 3 green. The red records read `"level": "warn", "msg": "Port 0 is in use, using port 36051 instead"` at `warn`, and the same line beside the started line at `info`. - **With the fix** (b2509f8): 3 of 3 pass. - **Ablation** (fix committed first; `scripts/ablation-replace.mjs` in wrap mode, plus a `trap` restore on EXIT INT TERM against the absolute path): anchor `if (port !== 0 && actualPort !== port) {` hit 1 time, 1 -> 0; replacement `if (actualPort !== port) {` 0 -> 1; blob 70d6494 -> c5a5b2f078ce. Independent on-disk count during the mutation: guard 0, reverted 1. Pin result: cases 1 and 2 red (`Port 0 is in use, using port 44497 instead`), case 3 green. Restore: blob after restore 70d6494 == HEAD blob 70d6494, `git diff HEAD` empty, `git status --porcelain` empty. The pin imports `./hono-plugin` by relative path from `src/`, so no `dist/` is on its path and the dist preflight does not apply. - **Public door after the fix** (one-shot reading, not a test; tsx `os serve objectstack.config.ts --port 0` against `export default {};`, plugin `dist/` rebuilt from b2509f8): default level, banner reached, 0 busy-port lines, no Boot diagnostics block, `API: http://localhost:42961/`; `--log-level info`, banner reached, 0 busy-port lines, one `HTTP server started successfully {"port":38147,...}` line matching `API: http://localhost:38147/`. Both servers stopped by recorded PID. ## Local verification (all at b2509f8) - `pnpm --filter '@objectstack/plugin-hono-server^...' build`: exit 0. - `pnpm --filter @objectstack/plugin-hono-server test`: 32 files, 393 tests passed. - `pnpm --filter @objectstack/plugin-hono-server typecheck`: exit 0 (`tsc --listFiles -p tsconfig.test.json` lists the new test file). - `pnpm --filter @objectstack/cli exec vitest run --project unit test/released-port-production-spawn.pin.test.ts test/vitest-tiers-partition.test.ts`: 2 files, 26 tests passed. - `pnpm --filter @objectstack/cli typecheck`: exit 0. - `eslint --no-inline-config` over the three changed TypeScript files: exit 0, no output. - `node scripts/pm/dispatch-gates.mjs --commands`: 66 derived commands, each run with its exit code captured before any pipe, plus the 11 declared-wide families: all exit 0. `check:dual-build-cjs-loads` and `check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET, no workspace `dist/`); after `check:type-check-debt`'s own full build they were re-run and exited 0. `--ran`: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. The four roster gates whose rosters sit beside these paths (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`): exit 0. - Declared narrowing: the CLI's full unit and integration tiers and `test:repo` are left to CI's Test Core. The CLI diff is one string literal in a unit-tier pin, and `packages/cli/src` is untouched. ## Acceptance notes - `hono-plugin.test.ts`'s `should start without errors` case comments "Plugin should register kernel:ready hook to start listening", but the socket opens on `kernel:listening`. A stale comment, noted and not filed. - `HonoServerPlugin.destroy()` calls the adapter's async `close()` without awaiting it. Noted, not filed: nothing measured here depends on it. --- _Generated by [Claude Code](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f573976 commit 2a1e48a

4 files changed

Lines changed: 185 additions & 3 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 request for port 0 no longer warns that port 0 is in use
6+
7+
Clause-②: no (no export, option or type moves: a log line stops firing for a legal request)
8+
9+
- **What changed.** `HonoServerPlugin`'s `kernel:listening` hook compared the requested port with the bound one and warned `Port 0 is in use, using port N instead` whenever they differed. Port `0` asks for a kernel-assigned port, so the two can never match and nothing was in use. Every `os serve --port 0` boot printed that warning under the banner's Boot diagnostics, at the default log level and at `info`. The warning now fires only for a non-zero request that the adapter moved to another port.
10+
- **Unchanged.** A kernel-assigned boot prints the existing `HTTP server started successfully` line, which already names the bound port, and nothing else. A non-zero request whose port is held still warns, naming the requested and the bound port.

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,7 @@ const RELEASED_DRAW_LEDGER: Readonly<Record<string, { draws: number; why: string
8686
'serve-port-drift-notice.e2e.test.ts': {
8787
draws: 1,
8888
why: 'the NEGATIVE arm is a boot that binds exactly the non-zero port it asked for and says '
89-
+ 'nothing about ports; `--port 0` asks for no port, and that boot prints plugin-hono-server\'s '
90-
+ '"Port 0 is in use, using port N instead" (#24387)',
89+
+ 'nothing about ports; a non-zero request is its subject, which `--port 0` cannot ask',
9190
},
9291
'serve-publishes-bound-port.e2e.test.ts': {
9392
draws: 2,

‎packages/plugins/plugin-hono-server/src/hono-plugin.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,12 @@ export class HonoServerPlugin implements Plugin {
709709
await this.server.listen(port);
710710

711711
const actualPort = this.server.getPort();
712-
if (actualPort !== port) {
712+
// Port 0 is a request for a kernel-assigned port, not a busy one:
713+
// `listen(0)` can never bind the number it was given, so a mismatch
714+
// there says nothing about a port in use. The started line below
715+
// already names the bound port. Only a non-zero request that the
716+
// adapter's EADDRINUSE walk moved is a drift worth a warning.
717+
if (port !== 0 && actualPort !== port) {
713718
ctx.logger.warn(`Port ${port} is in use, using port ${actualPort} instead`);
714719
}
715720
ctx.logger.info('HTTP server started successfully', {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `kernel:listening` warns about a busy port only when a port was busy.
5+
*
6+
* Port `0` is a legal request: `listen(0)` binds whatever port the kernel hands
7+
* out, so the bound port can never equal the request and nothing was in use.
8+
* The hook used to compare the two numbers and warn whenever they differed, so
9+
* every kernel-assigned boot (`os serve --port 0`, and every CLI test that boots
10+
* that way) printed a busy-port warning under the banner's Boot diagnostics,
11+
* at the CLI's default kernel log level (`warn`) and at `info`.
12+
*
13+
* Both arms run the plugin's real `init()` / `start()`, the REAL adapter (a
14+
* real socket is bound) and the REAL core logger at the levels the CLI runs it
15+
* at. They read the records the hook emits by KIND and COUNT, never by wording:
16+
*
17+
* - a request for `0` that binds port N: no record at the default level, and
18+
* at `info` exactly one record, the one that names N;
19+
* - a non-zero request whose port is HELD: one `warn` record that names both
20+
* the requested port and the port actually bound.
21+
*
22+
* The neighbour holding the port in the second arm is a plain TCP listener on
23+
* the same wildcard address the adapter binds, so the adapter's own
24+
* EADDRINUSE walk is what moves the port. Nothing here simulates the shift.
25+
*/
26+
27+
import { createServer, type Server } from 'node:net';
28+
import { describe, expect, it, vi } from 'vitest';
29+
import { createLogger } from '@objectstack/core';
30+
import { HonoServerPlugin } from './hono-plugin';
31+
32+
/** The CLI's default kernel log level (`os serve --log-level`'s documented default). */
33+
const CLI_DEFAULT_LEVEL = 'warn';
34+
35+
const LEVEL_RANK: Record<string, number> = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 };
36+
37+
interface LogRecord {
38+
level: string;
39+
msg: string;
40+
[key: string]: unknown;
41+
}
42+
43+
interface Booted {
44+
/** The records the `kernel:listening` hook wrote, as the real logger serialised them. */
45+
records: LogRecord[];
46+
/** The port the adapter really bound, read from the transport itself. */
47+
bound: number;
48+
}
49+
50+
/**
51+
* Boot the plugin through `init()` / `start()` under a silent context, then
52+
* fire its `kernel:listening` hooks under a real core logger at `level`,
53+
* capturing exactly what that logger writes while the hooks run.
54+
*/
55+
async function listenAt(port: number, level: 'warn' | 'info'): Promise<Booted> {
56+
const plugin = new HonoServerPlugin({ port, cors: false });
57+
const services = new Map<string, unknown>();
58+
const listeningHooks: Array<() => unknown> = [];
59+
const silent = { debug() {}, info() {}, warn() {}, error() {}, fatal() {} };
60+
const ctx: any = {
61+
logger: silent,
62+
getKernel: () => ({ hasPlugin: () => false, getService: () => undefined }),
63+
registerService: (name: string, service: unknown) => services.set(name, service),
64+
hook: (event: string, fn: () => unknown) => {
65+
if (event === 'kernel:listening') listeningHooks.push(fn);
66+
},
67+
getService: () => undefined,
68+
};
69+
70+
await plugin.init(ctx);
71+
await plugin.start(ctx);
72+
expect(listeningHooks, 'the plugin registered no kernel:listening hook').toHaveLength(1);
73+
74+
const server = services.get('http.server') as { getPort(): number; close(): Promise<void> };
75+
ctx.logger = createLogger({ level, format: 'json' });
76+
77+
const chunks: string[] = [];
78+
const capture = ((chunk: unknown) => {
79+
chunks.push(String(chunk));
80+
return true;
81+
}) as never;
82+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(capture);
83+
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(capture);
84+
try {
85+
for (const fn of listeningHooks) await fn();
86+
} finally {
87+
stdout.mockRestore();
88+
stderr.mockRestore();
89+
}
90+
91+
const bound = server.getPort();
92+
await server.close();
93+
94+
const records = chunks
95+
.join('')
96+
.split('\n')
97+
.filter((line) => line.trim() !== '')
98+
.map((line) => JSON.parse(line) as LogRecord);
99+
return { records, bound };
100+
}
101+
102+
/** Records at `warn` or above: what the default level shows. */
103+
const warnings = (records: LogRecord[]) =>
104+
records.filter((record) => (LEVEL_RANK[record.level] ?? 0) >= LEVEL_RANK.warn);
105+
106+
/** A whole-number mention of `port` in a record's message. */
107+
const names = (record: LogRecord, port: number) => new RegExp(`\\b${port}\\b`).test(record.msg);
108+
109+
function holdPort(): Promise<{ port: number; release: () => Promise<void> }> {
110+
return new Promise((resolveHold, rejectHold) => {
111+
const holder: Server = createServer();
112+
holder.on('error', rejectHold);
113+
// No host: the same wildcard address the adapter's `serve()` binds.
114+
holder.listen(0, () => {
115+
const address = holder.address();
116+
if (address === null || typeof address === 'string') {
117+
rejectHold(new Error(`listen(0) produced no numeric address: ${String(address)}`));
118+
return;
119+
}
120+
resolveHold({
121+
port: address.port,
122+
release: () => new Promise<void>((done) => holder.close(() => done())),
123+
});
124+
});
125+
});
126+
}
127+
128+
describe('kernel:listening names a busy port only when one was busy', () => {
129+
it('a request for port 0 says nothing at the default level', async () => {
130+
const { records, bound } = await listenAt(0, CLI_DEFAULT_LEVEL);
131+
132+
// The control: the kernel really assigned a port.
133+
expect(bound, 'listen(0) bound no port').toBeGreaterThan(0);
134+
expect(records, JSON.stringify(records, null, 2)).toEqual([]);
135+
});
136+
137+
it('a request for port 0 prints one line at info, the one naming the bound port', async () => {
138+
const { records, bound } = await listenAt(0, 'info');
139+
const seen = JSON.stringify(records, null, 2);
140+
141+
expect(bound, 'listen(0) bound no port').toBeGreaterThan(0);
142+
expect(warnings(records), seen).toEqual([]);
143+
// The existing started line already names the bound port, so no second
144+
// line is owed, and none may say anything about a port in use.
145+
expect(records, seen).toHaveLength(1);
146+
expect(records[0]!.level, seen).toBe('info');
147+
expect(records[0]!.port, seen).toBe(bound);
148+
});
149+
150+
it('a held non-zero port still warns, naming the requested and the bound port', async () => {
151+
const neighbour = await holdPort();
152+
try {
153+
const { records, bound } = await listenAt(neighbour.port, CLI_DEFAULT_LEVEL);
154+
const seen = JSON.stringify(records, null, 2);
155+
156+
// The control: the port really moved, or there is nothing to announce.
157+
expect(bound, `the adapter bound the held port ${neighbour.port}${seen}`).not.toBe(neighbour.port);
158+
159+
const warned = warnings(records);
160+
expect(warned, seen).toHaveLength(1);
161+
expect(warned[0]!.level, seen).toBe('warn');
162+
expect(names(warned[0]!, neighbour.port), `the warning does not name the requested port${seen}`).toBe(true);
163+
expect(names(warned[0]!, bound), `the warning does not name the bound port${seen}`).toBe(true);
164+
} finally {
165+
await neighbour.release();
166+
}
167+
});
168+
});

0 commit comments

Comments
 (0)