Files
civitai__civitai/scripts/__tests__/dev-server-port-probe.test.ts
Justin Maier e1a9859b3c fix(dev-server): port picker missed every occupied state but one (#3889)
* fix(dev-server): port picker missed every occupied state but one

isPortAvailable bound 127.0.0.1 and read a successful bind as "free".
libuv sets SO_REUSEADDR unconditionally, and Windows then allows a
specific-address bind underneath a wildcard listener, so the probe only
ever detected a listener bound to exactly 127.0.0.1. Measured against a
live `next dev` on 3001 (which binds `::` dual-stack): probe said free.
Held on `::` v6-only, 0.0.0.0 or ::1: also free. Combined with the
picker not reserving the port of a session it has marked `crashed`, a
bare start hands out an occupied port and both sessions die.

Replace it with a probe that connects to both loopback addresses first
-- the only thing that distinguishes "nobody is listening" from "I am
allowed to bind too" -- then exclusive-binds each stack. Verified end to
end: with a dual-stack listener held, the daemon now returns 400 for an
explicit request and skips the port when picking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(dev-server): stop the probe waiting on close(), and cover the binds

Adversarial review of the previous commit, all four confirmed on this
host:

- close() withholds its callback until every accepted connection is
  gone, and the probe's transient listener accepts whatever arrives in
  its bind window. Resolving from that callback made one stray
  connection enough to wedge isPortFree with no timeout above it — and
  the daemon awaits it inline in POST /sessions. Resolve on `listening`
  instead, as the old code did.
- The connect half alone catches all five occupied shapes on Windows, so
  the three binds had no test at all. Cover the state only they see: a
  port the OS refuses to bind (EACCES).
- `exclusive: true` is not SO_EXCLUSIVEADDRUSE — it only diverts
  listenInCluster away from the cluster handle, so in the daemon it was
  a no-op. Dropped, and the comment now says what the binds are for.
- A silent `return` on an unavailable address family printed a green
  tick for a case that never ran. ctx.skip() says so instead.

SKILL.md no longer claims the picker sees "anything outside the daemon
entirely": on Windows a listener bound only to a non-loopback address is
still invisible.

Both new tests fail on revert: the bind test with `expected true to be
false`, the wedge test with `isPortFree never resolved` in 5.2s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(dev-server): make both probe tests run, and pass, off Windows

Second review pass, both confirmed:

- The wedge test's mock left `close` a no-op, so the probe's transient
  listener stayed bound and its three sequential binds collided with
  each other. Green on Windows (a 0.0.0.0 holder does not block a later
  dual-`::` bind there), red on Linux, which is where CI runs it. Close
  for real, withhold only the acknowledgement — same defect, no leak.
- The EACCES case scanned the host for a port the OS refuses to bind.
  That port exists on this Windows box and in no container: root and
  non-root both bind port 1 under Docker's default
  net.ipv4.ip_unprivileged_port_start, so the test skipped itself in CI
  and the bind half stayed uncovered exactly where it was claimed
  covered. Force EACCES through the mock instead, and drop the scan --
  it could also have picked 80, where connect succeeds and the verdict
  never reaches the binds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(dev-server): pin the escape that keeps an IPv6-less host usable

Collapsing tryBind's classifier so every bind error reads 'busy' passed
the whole suite. On a host without IPv6 the `::` bind returns
EAFNOSUPPORT, so that regression would make every port read busy and
every dev server start fail with "No available ports found within
range". Now fails as `expected false to be true`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 13:31:29 -06:00

172 lines
6.9 KiB
TypeScript

import type { Server, ListenOptions } from 'net';
import { createServer } from 'net';
import { afterEach, describe, expect, it, vi } from 'vitest';
// The probe under test ships with the dev-server skill (plain .mjs, run by the daemon under
// node, never bundled). It is imported by path rather than moved into src/ so the daemon can
// keep loading it without a build step.
import { isPortFree } from '../../.claude/skills/dev-server/scripts/port-probe.mjs';
const held: Server[] = [];
function close(server: Server) {
return new Promise<void>((resolve) => server.close(() => resolve()));
}
afterEach(async () => {
while (held.length) await close(held.pop()!);
});
// Load the probe against a patched `net.createServer`. Real hosts differ too much to drive
// the bind half honestly — an OS-reserved port exists on this Windows box and on no CI
// container, and a privileged port is bindable as root and connectable when something is
// already serving on it, either of which turns the assertion vacuous or absent.
async function loadProbeWith(
patch: (server: Server) => Server
): Promise<(port: number) => Promise<boolean>> {
vi.resetModules();
vi.doMock('net', async (importOriginal) => {
const actual = await importOriginal<typeof import('net')>();
return { ...actual, default: actual, createServer: () => patch(actual.createServer()) };
});
const mod = await import('../../.claude/skills/dev-server/scripts/port-probe.mjs');
return mod.isPortFree;
}
// Bind port 0 to have the OS hand out a port nothing else is using, then rebind that same
// port in the shape under test. A fixed port number would collide with whatever else is on
// the machine and fail for reasons that have nothing to do with the probe.
async function reserveEphemeralPort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen({ port: 0, host: '127.0.0.1' }, () => resolve());
});
const port = (server.address() as { port: number }).port;
await close(server);
return port;
}
async function hold(options: ListenOptions): Promise<Server | null> {
const server = createServer();
try {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(options, () => resolve());
});
} catch {
return null; // e.g. no IPv6 on this host — the case does not apply here.
}
held.push(server);
return server;
}
describe('dev-server port probe', () => {
// The negative control. Without it, a probe hardwired to `return false` would pass every
// case below.
it('reports a port with no listener as free', async () => {
const port = await reserveEphemeralPort();
expect(await isPortFree(port)).toBe(true);
});
// Each of these was reported FREE by the previous 127.0.0.1-bind-only probe on Windows,
// including `::` dual-stack — which is what `next dev` actually binds.
const occupied: Array<[string, ListenOptions]> = [
['`::` dual-stack (what next dev binds)', { host: '::', ipv6Only: false }],
['`::` IPv6-only', { host: '::', ipv6Only: true }],
['0.0.0.0 wildcard', { host: '0.0.0.0' }],
['127.0.0.1 loopback', { host: '127.0.0.1' }],
['::1 loopback', { host: '::1' }],
];
for (const [label, options] of occupied) {
it(`reports a port held on ${label} as busy`, async (ctx) => {
const port = await reserveEphemeralPort();
const server = await hold({ ...options, port });
// A silent `return` here would print a green tick for a case that never ran — and on a
// host without IPv6 that is three of these five, including the one the fix is about.
if (!server) return ctx.skip();
expect(await isPortFree(port)).toBe(false);
});
}
// Every case above is caught by the connect half alone, so without this one the three
// binds could be deleted and the suite would stay green. A port the OS refuses to hand out
// is the state only the binds see: nothing is listening, so connect gets refused, but the
// bind is refused too and the port is unusable.
it('reports a port the OS refuses to hand out as busy', async () => {
const probe = await loadProbeWith((server) => {
server.listen = ((...args: unknown[]) => {
const err: NodeJS.ErrnoException = new Error('listen EACCES');
err.code = 'EACCES';
void args;
setImmediate(() => server.emit('error', err));
return server;
}) as typeof server.listen;
return server;
});
try {
// Free by every other measure: nothing is listening, so both connects are refused.
expect(await probe(await reserveEphemeralPort())).toBe(false);
} finally {
vi.doUnmock('net');
vi.resetModules();
}
});
// The other side of that classifier. A host with no IPv6 answers the `::` bind with
// EAFNOSUPPORT, and reading that as a conflict would make every port busy and every start
// fail with "No available ports found within range".
it('reports a port free when a bind fails for want of an address family', async () => {
const probe = await loadProbeWith((server) => {
server.listen = ((...args: unknown[]) => {
const err: NodeJS.ErrnoException = new Error('listen EAFNOSUPPORT');
err.code = 'EAFNOSUPPORT';
void args;
setImmediate(() => server.emit('error', err));
return server;
}) as typeof server.listen;
return server;
});
try {
expect(await probe(await reserveEphemeralPort())).toBe(true);
} finally {
vi.doUnmock('net');
vi.resetModules();
}
});
// The probe's transient listener accepts anything that arrives in its bind window, and
// close() withholds its callback until every accepted connection is gone. Resolving from
// that callback made one stray connection enough to wedge the picker, which the daemon
// awaits inline in its POST /sessions handler — a request that never answers.
//
// Driving that with a real connection does not work: the bind window is sub-millisecond,
// so a racing client lands on the probe listener too rarely to assert on. Withholding the
// acknowledgement directly is the same defect, deterministically. The socket must still
// really close, or the probe's three sequential binds collide with each other on Linux.
it('does not wait for close() to acknowledge before reporting a port free', async () => {
const probe = await loadProbeWith((server) => {
const realClose = server.close.bind(server);
server.close = (() => {
realClose();
return server;
}) as typeof server.close;
return server;
});
try {
const port = await reserveEphemeralPort();
const verdict = await Promise.race([
probe(port),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('isPortFree never resolved')), 5000)
),
]);
expect(verdict).toBe(true);
} finally {
vi.doUnmock('net');
vi.resetModules();
}
});
});