mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
e1a9859b3c
* 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>
172 lines
6.9 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|