From c18c1ffb587ad134a1027a0545eefaec6de57b0b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 21:24:07 +0000 Subject: [PATCH 1/2] test(plugin-hono-server): pin kernel:listening's busy-port warning to a real busy port A request for port 0 that binds port N must print no busy-port line at the CLI's default level or at info; a held non-zero port must still warn, naming the requested and the bound port. Red on this commit: the fix follows. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- .../src/kernel-listening-port-notice.test.ts | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 packages/plugins/plugin-hono-server/src/kernel-listening-port-notice.test.ts diff --git a/packages/plugins/plugin-hono-server/src/kernel-listening-port-notice.test.ts b/packages/plugins/plugin-hono-server/src/kernel-listening-port-notice.test.ts new file mode 100644 index 00000000000..42050889ed3 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/kernel-listening-port-notice.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `kernel:listening` warns about a busy port only when a port was busy. + * + * Port `0` is a legal request: `listen(0)` binds whatever port the kernel hands + * out, so the bound port can never equal the request and nothing was in use. + * The hook used to compare the two numbers and warn whenever they differed, so + * every kernel-assigned boot (`os serve --port 0`, and every CLI test that boots + * that way) printed a busy-port warning under the banner's Boot diagnostics, + * at the CLI's default kernel log level (`warn`) and at `info`. + * + * Both arms run the plugin's real `init()` / `start()`, the REAL adapter (a + * real socket is bound) and the REAL core logger at the levels the CLI runs it + * at. They read the records the hook emits by KIND and COUNT, never by wording: + * + * - a request for `0` that binds port N: no record at the default level, and + * at `info` exactly one record, the one that names N; + * - a non-zero request whose port is HELD: one `warn` record that names both + * the requested port and the port actually bound. + * + * The neighbour holding the port in the second arm is a plain TCP listener on + * the same wildcard address the adapter binds, so the adapter's own + * EADDRINUSE walk is what moves the port. Nothing here simulates the shift. + */ + +import { createServer, type Server } from 'node:net'; +import { describe, expect, it, vi } from 'vitest'; +import { createLogger } from '@objectstack/core'; +import { HonoServerPlugin } from './hono-plugin'; + +/** The CLI's default kernel log level (`os serve --log-level`'s documented default). */ +const CLI_DEFAULT_LEVEL = 'warn'; + +const LEVEL_RANK: Record = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }; + +interface LogRecord { + level: string; + msg: string; + [key: string]: unknown; +} + +interface Booted { + /** The records the `kernel:listening` hook wrote, as the real logger serialised them. */ + records: LogRecord[]; + /** The port the adapter really bound, read from the transport itself. */ + bound: number; +} + +/** + * Boot the plugin through `init()` / `start()` under a silent context, then + * fire its `kernel:listening` hooks under a real core logger at `level`, + * capturing exactly what that logger writes while the hooks run. + */ +async function listenAt(port: number, level: 'warn' | 'info'): Promise { + const plugin = new HonoServerPlugin({ port, cors: false }); + const services = new Map(); + const listeningHooks: Array<() => unknown> = []; + const silent = { debug() {}, info() {}, warn() {}, error() {}, fatal() {} }; + const ctx: any = { + logger: silent, + getKernel: () => ({ hasPlugin: () => false, getService: () => undefined }), + registerService: (name: string, service: unknown) => services.set(name, service), + hook: (event: string, fn: () => unknown) => { + if (event === 'kernel:listening') listeningHooks.push(fn); + }, + getService: () => undefined, + }; + + await plugin.init(ctx); + await plugin.start(ctx); + expect(listeningHooks, 'the plugin registered no kernel:listening hook').toHaveLength(1); + + const server = services.get('http.server') as { getPort(): number; close(): Promise }; + ctx.logger = createLogger({ level, format: 'json' }); + + const chunks: string[] = []; + const capture = ((chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }) as never; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(capture); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(capture); + try { + for (const fn of listeningHooks) await fn(); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + + const bound = server.getPort(); + await server.close(); + + const records = chunks + .join('') + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => JSON.parse(line) as LogRecord); + return { records, bound }; +} + +/** Records at `warn` or above: what the default level shows. */ +const warnings = (records: LogRecord[]) => + records.filter((record) => (LEVEL_RANK[record.level] ?? 0) >= LEVEL_RANK.warn); + +/** A whole-number mention of `port` in a record's message. */ +const names = (record: LogRecord, port: number) => new RegExp(`\\b${port}\\b`).test(record.msg); + +function holdPort(): Promise<{ port: number; release: () => Promise }> { + return new Promise((resolveHold, rejectHold) => { + const holder: Server = createServer(); + holder.on('error', rejectHold); + // No host: the same wildcard address the adapter's `serve()` binds. + holder.listen(0, () => { + const address = holder.address(); + if (address === null || typeof address === 'string') { + rejectHold(new Error(`listen(0) produced no numeric address: ${String(address)}`)); + return; + } + resolveHold({ + port: address.port, + release: () => new Promise((done) => holder.close(() => done())), + }); + }); + }); +} + +describe('kernel:listening names a busy port only when one was busy', () => { + it('a request for port 0 says nothing at the default level', async () => { + const { records, bound } = await listenAt(0, CLI_DEFAULT_LEVEL); + + // The control: the kernel really assigned a port. + expect(bound, 'listen(0) bound no port').toBeGreaterThan(0); + expect(records, JSON.stringify(records, null, 2)).toEqual([]); + }); + + it('a request for port 0 prints one line at info, the one naming the bound port', async () => { + const { records, bound } = await listenAt(0, 'info'); + const seen = JSON.stringify(records, null, 2); + + expect(bound, 'listen(0) bound no port').toBeGreaterThan(0); + expect(warnings(records), seen).toEqual([]); + // The existing started line already names the bound port, so no second + // line is owed, and none may say anything about a port in use. + expect(records, seen).toHaveLength(1); + expect(records[0]!.level, seen).toBe('info'); + expect(records[0]!.port, seen).toBe(bound); + }); + + it('a held non-zero port still warns, naming the requested and the bound port', async () => { + const neighbour = await holdPort(); + try { + const { records, bound } = await listenAt(neighbour.port, CLI_DEFAULT_LEVEL); + const seen = JSON.stringify(records, null, 2); + + // The control: the port really moved, or there is nothing to announce. + expect(bound, `the adapter bound the held port ${neighbour.port}${seen}`).not.toBe(neighbour.port); + + const warned = warnings(records); + expect(warned, seen).toHaveLength(1); + expect(warned[0]!.level, seen).toBe('warn'); + expect(names(warned[0]!, neighbour.port), `the warning does not name the requested port${seen}`).toBe(true); + expect(names(warned[0]!, bound), `the warning does not name the bound port${seen}`).toBe(true); + } finally { + await neighbour.release(); + } + }); +}); From b2509f8b08be60892eeaaa7a8e8f7b9094a84067 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 21:27:27 +0000 Subject: [PATCH 2/2] fix(plugin-hono-server): a request for port 0 no longer warns that port 0 is in use kernel:listening compared the requested port with the bound one and warned whenever they differed. Port 0 asks for a kernel-assigned port, so the two can never match and nothing was in use. The warning now fires only for a non-zero request the adapter's EADDRINUSE walk moved; the started line already names the bound port. The released-port ledger's reason for serve-port-drift-notice.e2e.test.ts quoted that warning as why its negative arm cannot use --port 0. It now states only the arm's subject: a non-zero request, which --port 0 cannot ask. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- .changeset/24387-port-zero-busy-warning.md | 10 ++++++++++ .../test/released-port-production-spawn.pin.test.ts | 3 +-- packages/plugins/plugin-hono-server/src/hono-plugin.ts | 7 ++++++- 3 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 .changeset/24387-port-zero-busy-warning.md diff --git a/.changeset/24387-port-zero-busy-warning.md b/.changeset/24387-port-zero-busy-warning.md new file mode 100644 index 00000000000..24e84855300 --- /dev/null +++ b/.changeset/24387-port-zero-busy-warning.md @@ -0,0 +1,10 @@ +--- +'@objectstack/plugin-hono-server': patch +--- + +fix(plugin-hono-server): a request for port 0 no longer warns that port 0 is in use + +Clause-②: no (no export, option or type moves: a log line stops firing for a legal request) + +- **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. +- **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. diff --git a/packages/cli/test/released-port-production-spawn.pin.test.ts b/packages/cli/test/released-port-production-spawn.pin.test.ts index a807d4f80d1..f5a67e3bdfd 100644 --- a/packages/cli/test/released-port-production-spawn.pin.test.ts +++ b/packages/cli/test/released-port-production-spawn.pin.test.ts @@ -86,8 +86,7 @@ const RELEASED_DRAW_LEDGER: Readonly