Skip to content
Open
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
30 changes: 29 additions & 1 deletion src/webhook-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
* route byte-identical. Conventions follow PR #2617: real HTTP server on a
* fixed WEBHOOK_PORT, real fetch.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import http from 'http';

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

import type { Chat } from 'chat';

import { log } from './log.js';
import { registerWebhookAdapter, stopWebhookServer } from './webhook-server.js';

const PORT = 3917;
Expand Down Expand Up @@ -104,3 +107,28 @@ describe('registerWebhookAdapter — route/handler split', () => {
expect(res.status).toBe(404);
});
});

describe('registerWebhookAdapter — bind failure', () => {
it('EADDRINUSE is logged, not thrown as an uncaught exception that kills the host', async () => {
// Occupy the port the webhook server will try to bind.
const blocker = http.createServer();
await new Promise<void>((resolve) => blocker.listen(PORT, '0.0.0.0', resolve));
const errSpy = vi.spyOn(log, 'error').mockImplementation(() => {});

try {
registerWebhookAdapter(stubChat('doomed').chat, 'slack');

// The 'error' event fires asynchronously after listen(); wait for it.
for (let i = 0; i < 40 && errSpy.mock.calls.length === 0; i++) {
await new Promise((r) => setTimeout(r, 25));
}

expect(errSpy).toHaveBeenCalled();
const [, ctx] = errSpy.mock.calls[0] as [string, { code?: string }];
expect(ctx.code).toBe('EADDRINUSE');
} finally {
errSpy.mockRestore();
await new Promise<void>((resolve) => blocker.close(() => resolve()));
}
});
});
17 changes: 17 additions & 0 deletions src/webhook-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,23 @@ function ensureServer(): void {
}
});

// Without an 'error' listener a bind failure (e.g. EADDRINUSE) becomes an
// uncaught exception that takes the whole host down with it — polling,
// delivery, sweep and all. Better to log the port and keep running; only the
// webhook-backed adapters go quiet until the conflict is cleared.
server.on('error', (err: NodeJS.ErrnoException) => {
server = null;
log.error('Webhook server failed to start', {
port,
code: err.code,
err,
hint:
err.code === 'EADDRINUSE'
? `Port ${port} is already in use (another process or nanoclaw copy). Set WEBHOOK_PORT to a free port.`
: undefined,
});
});

server.listen(port, '0.0.0.0', () => {
log.info('Webhook server started', { port, adapters: [...routes.keys()] });
});
Expand Down
Loading