From c45f0065e94e02f496d7f6d28732649b6d037003 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 5 Aug 2026 19:44:40 -0700 Subject: [PATCH] fix(dev): give an EACCES bind failure an actionable message The dev API server rejected an EACCES bind with Node's raw text. The CLI prints only error.message, so the operator saw no cause and no next step. Map the errno in one module-level function. The EACCES message names the address, both causes, the netsh command that lists Windows reserved ports, and the port 0 escape hatch. EADDRINUSE keeps its wording. --- dev/src/server/adk_api_server.ts | 50 +++++++++++++++++++++----- dev/test/server/adk_api_server_test.ts | 47 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/dev/src/server/adk_api_server.ts b/dev/src/server/adk_api_server.ts index a52ad63ab..2e638ad33 100644 --- a/dev/src/server/adk_api_server.ts +++ b/dev/src/server/adk_api_server.ts @@ -75,6 +75,45 @@ interface ServerOptions { registerProcessors?: (tracerProvider: TracerProvider) => void; } +/** + * A Node system error: an `Error` that carries an errno string in `code`. + * + * The `@types/node` package declares this shape only as + * `NodeJS.ErrnoException`, in an ambient namespace that the repository's + * `no-undef` lint rule rejects. + */ +interface SystemError extends Error { + code?: string; +} + +/** + * Converts a `listen()` failure into the error the caller reports. + * + * The CLI prints only `error.message`, so a cause the operator can act on has + * to be in the message itself. An errno with no better wording than the system + * error is returned unchanged. + */ +export function toListenError( + err: SystemError, + host: string, + port: number, +): Error { + switch (err.code) { + case 'EADDRINUSE': + return new Error(`Port ${port} is already in use`, {cause: err}); + case 'EACCES': + return new Error( + `Permission denied binding ${host}:${port}. Ports below 1024 need ` + + `elevated privileges. Windows also reserves blocks of ports; list ` + + `them with "netsh interface ipv4 show excludedportrange ` + + `protocol=tcp". Use a different port, or port 0 for any free port.`, + {cause: err}, + ); + default: + return err; + } +} + export class AdkApiServer { private readonly host: string; private readonly port: number; @@ -973,15 +1012,8 @@ export class AdkApiServer { } }); - this.server.on('error', (err: unknown) => { - if ((err as {code: string}).code === 'EADDRINUSE') { - const error = new Error(); - error.cause = err; - error.message = `Port ${this.port} is already in use`; - reject(error); - } else { - reject(err); - } + this.server.on('error', (err: SystemError) => { + reject(toListenError(err, this.host, this.port)); }); }); } diff --git a/dev/test/server/adk_api_server_test.ts b/dev/test/server/adk_api_server_test.ts index d6db712d2..2adad341f 100644 --- a/dev/test/server/adk_api_server_test.ts +++ b/dev/test/server/adk_api_server_test.ts @@ -28,6 +28,7 @@ import {z} from 'zod'; import { A2A_AUTH_TOKEN_ENV_VAR, AdkApiServer, + toListenError, } from '../../src/server/adk_api_server.js'; import {AgentLoader} from '../../src/utils/agent_loader.js'; @@ -1256,3 +1257,49 @@ describe('AdkWebServer', () => { }); }); }); + +describe('toListenError', () => { + it('keeps the in-use wording for EADDRINUSE', () => { + const err = Object.assign( + new Error('listen EADDRINUSE: address already in use ::1:8000'), + {code: 'EADDRINUSE'}, + ); + + const error = toListenError(err, 'localhost', 8000); + + expect(error.message).toBe('Port 8000 is already in use'); + expect(error.cause).toBe(err); + }); + + it('explains both causes of EACCES and how to work around them', () => { + const err = Object.assign( + new Error('listen EACCES: permission denied ::1:80'), + {code: 'EACCES'}, + ); + + const error = toListenError(err, 'localhost', 80); + + expect(error.message).toContain('localhost:80'); + expect(error.message).toContain('below 1024'); + expect(error.message).toContain( + 'netsh interface ipv4 show excludedportrange protocol=tcp', + ); + expect(error.message).toContain('port 0'); + expect(error.message).not.toContain('--port'); + expect(error.cause).toBe(err); + }); + + it('passes an unrelated errno through unchanged', () => { + const err = Object.assign(new Error('read ECONNRESET'), { + code: 'ECONNRESET', + }); + + expect(toListenError(err, 'localhost', 8000)).toBe(err); + }); + + it('passes an error with no code through unchanged', () => { + const err = new Error('something else went wrong'); + + expect(toListenError(err, 'localhost', 8000)).toBe(err); + }); +});