mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
- rename packages/server-v2 to packages/kap-server and update every @moonshot-ai/server-v2 reference across apps, packages, flake.nix, and build scripts - remove the experimental `kimi v2` CLI prefix and its tests from the apps/kimi-code main entry - add legacy BackgroundTask protocol types and the `blocked` turn interrupt reason for cross-engine compatibility
249 lines
7.9 KiB
TypeScript
249 lines
7.9 KiB
TypeScript
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import { createServer, type Server } from 'node:net';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { pino } from 'pino';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import type { LockContents } from '../src/lock';
|
|
import { listenWithPortRetry, type RunningServer, startServer } from '../src/start';
|
|
import { authedFetch } from './helpers/auth';
|
|
|
|
describe('server-v2 boot', () => {
|
|
let server: RunningServer | undefined;
|
|
let home: string | undefined;
|
|
|
|
afterEach(async () => {
|
|
if (server !== undefined) {
|
|
await server.close();
|
|
server = undefined;
|
|
}
|
|
if (home !== undefined) {
|
|
await rm(home, { recursive: true, force: true });
|
|
home = undefined;
|
|
}
|
|
});
|
|
|
|
it('boots agent-core-v2 and serves the basic /api/v1 routes', async () => {
|
|
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-'));
|
|
server = await startServer({
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
homeDir: home,
|
|
logLevel: 'silent',
|
|
});
|
|
|
|
const base = `http://127.0.0.1:${server.port}`;
|
|
|
|
const healthz = await fetch(`${base}/api/v1/healthz`);
|
|
expect(healthz.status).toBe(200);
|
|
const healthBody = await healthz.json() as {
|
|
code: number;
|
|
data: { ok: boolean };
|
|
request_id: string;
|
|
};
|
|
expect(healthBody.code).toBe(0);
|
|
expect(healthBody.data.ok).toBe(true);
|
|
expect(typeof healthBody.request_id).toBe('string');
|
|
|
|
const meta = await authedFetch(server, base, '/api/v1/meta');
|
|
expect(meta.status).toBe(200);
|
|
const metaBody = await meta.json() as {
|
|
code: number;
|
|
data: { server_id: string; server_version: string; capabilities: Record<string, boolean> };
|
|
};
|
|
expect(metaBody.code).toBe(0);
|
|
expect(typeof metaBody.data.server_id).toBe('string');
|
|
expect(typeof metaBody.data.server_version).toBe('string');
|
|
expect(metaBody.data.capabilities).toBeDefined();
|
|
|
|
const auth = await authedFetch(server, base, '/api/v1/auth');
|
|
expect(auth.status).toBe(200);
|
|
const authBody = await auth.json() as {
|
|
code: number;
|
|
data: { ready: boolean; providers_count: number; default_model: string | null };
|
|
};
|
|
expect(authBody.code).toBe(0);
|
|
expect(typeof authBody.data.ready).toBe('boolean');
|
|
expect(authBody.data.providers_count).toBeGreaterThanOrEqual(0);
|
|
|
|
// Poll with no flow in flight → null payload; exercises the v2 IOAuthService
|
|
// wiring without starting a real (networked) device-code flow.
|
|
const oauthPoll = await authedFetch(server, base, '/api/v1/oauth/login');
|
|
expect(oauthPoll.status).toBe(200);
|
|
const oauthBody = await oauthPoll.json() as { code: number; data: null };
|
|
expect(oauthBody.code).toBe(0);
|
|
expect(oauthBody.data).toBeNull();
|
|
});
|
|
});
|
|
|
|
function silentLogger() {
|
|
return pino({ level: 'silent' });
|
|
}
|
|
|
|
function addrInUse(): NodeJS.ErrnoException {
|
|
const err = new Error('listen EADDRINUSE') as NodeJS.ErrnoException;
|
|
err.code = 'EADDRINUSE';
|
|
return err;
|
|
}
|
|
|
|
function listenOnPort(host: string, port: number): Promise<Server> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = createServer();
|
|
server.once('error', reject);
|
|
server.listen({ host, port }, () => resolve(server));
|
|
});
|
|
}
|
|
|
|
function closeNetServer(server: Server): Promise<void> {
|
|
return new Promise((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
/** Find `port` such that both `port` and `port + 1` are free to bind. */
|
|
async function allocateAdjacentFreePair(
|
|
host = '127.0.0.1',
|
|
): Promise<{ port: number; next: number }> {
|
|
for (let i = 0; i < 30; i++) {
|
|
const a = await listenOnPort(host, 0);
|
|
const address = a.address();
|
|
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
await closeNetServer(a);
|
|
if (port <= 0 || port >= 65535) continue;
|
|
const probe = await listenOnPort(host, port + 1).catch(() => null);
|
|
if (probe === null) continue;
|
|
await closeNetServer(probe);
|
|
return { port, next: port + 1 };
|
|
}
|
|
throw new Error('could not allocate an adjacent free port pair');
|
|
}
|
|
|
|
describe('listenWithPortRetry', () => {
|
|
it('returns the requested port when the first listen succeeds', async () => {
|
|
const attempts: number[] = [];
|
|
const result = await listenWithPortRetry({
|
|
listen: async (_host, port) => {
|
|
attempts.push(port);
|
|
return `http://127.0.0.1:${String(port)}`;
|
|
},
|
|
host: '127.0.0.1',
|
|
port: 5000,
|
|
logger: silentLogger(),
|
|
});
|
|
|
|
expect(result.port).toBe(5000);
|
|
expect(attempts).toEqual([5000]);
|
|
});
|
|
|
|
it('retries with port+1 on EADDRINUSE until a bind succeeds', async () => {
|
|
const attempts: number[] = [];
|
|
const result = await listenWithPortRetry({
|
|
listen: async (_host, port) => {
|
|
attempts.push(port);
|
|
if (port < 5002) throw addrInUse();
|
|
return `http://127.0.0.1:${String(port)}`;
|
|
},
|
|
host: '127.0.0.1',
|
|
port: 5000,
|
|
logger: silentLogger(),
|
|
});
|
|
|
|
expect(result.port).toBe(5002);
|
|
expect(result.address).toBe('http://127.0.0.1:5002');
|
|
expect(attempts).toEqual([5000, 5001, 5002]);
|
|
});
|
|
|
|
it('does not retry on non-EADDRINUSE errors', async () => {
|
|
const attempts: number[] = [];
|
|
const boom = Object.assign(new Error('listen EACCES'), { code: 'EACCES' });
|
|
await expect(
|
|
listenWithPortRetry({
|
|
listen: async (_host, port) => {
|
|
attempts.push(port);
|
|
throw boom;
|
|
},
|
|
host: '127.0.0.1',
|
|
port: 5000,
|
|
logger: silentLogger(),
|
|
}),
|
|
).rejects.toBe(boom);
|
|
expect(attempts).toEqual([5000]);
|
|
});
|
|
|
|
it('throws after exhausting maxRetries', async () => {
|
|
const attempts: number[] = [];
|
|
await expect(
|
|
listenWithPortRetry({
|
|
listen: async (_host, port) => {
|
|
attempts.push(port);
|
|
throw addrInUse();
|
|
},
|
|
host: '127.0.0.1',
|
|
port: 5000,
|
|
logger: silentLogger(),
|
|
maxRetries: 3,
|
|
}),
|
|
).rejects.toMatchObject({ code: 'EADDRINUSE' });
|
|
// initial attempt + 3 retries, then the cap throws.
|
|
expect(attempts).toEqual([5000, 5001, 5002, 5003]);
|
|
});
|
|
|
|
it('does not walk ports when the requested port is 0 (ephemeral)', async () => {
|
|
const attempts: number[] = [];
|
|
const result = await listenWithPortRetry({
|
|
listen: async (_host, port) => {
|
|
attempts.push(port);
|
|
return 'http://127.0.0.1:54321';
|
|
},
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
logger: silentLogger(),
|
|
});
|
|
|
|
expect(result.port).toBe(0);
|
|
expect(attempts).toEqual([0]);
|
|
});
|
|
});
|
|
|
|
describe('server-v2 boot — port retry', () => {
|
|
let server: RunningServer | undefined;
|
|
let home: string | undefined;
|
|
|
|
afterEach(async () => {
|
|
if (server !== undefined) {
|
|
await server.close();
|
|
server = undefined;
|
|
}
|
|
if (home !== undefined) {
|
|
await rm(home, { recursive: true, force: true });
|
|
home = undefined;
|
|
}
|
|
});
|
|
|
|
it('retries on port+1 and advertises the bound port in the lock', async () => {
|
|
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-port-retry-'));
|
|
const { port, next } = await allocateAdjacentFreePair();
|
|
// Occupy the requested port with a raw TCP server (a "third-party" process
|
|
// from the server's point of view — it does NOT hold the lock).
|
|
const occupant = await listenOnPort('127.0.0.1', port);
|
|
try {
|
|
server = await startServer({
|
|
host: '127.0.0.1',
|
|
port,
|
|
homeDir: home,
|
|
logLevel: 'silent',
|
|
});
|
|
|
|
// Bound to the next available port (>= next); the lock advertises it so
|
|
// status/kill/ps work. On Windows a recently-closed probe port can linger
|
|
// in TIME_WAIT, so the retry may land on port+2 instead of port+1.
|
|
expect(server.port).toBeGreaterThanOrEqual(next);
|
|
const stored = JSON.parse(
|
|
await readFile(join(home, 'server', 'lock'), 'utf8'),
|
|
) as LockContents;
|
|
expect(stored.port).toBe(server.port);
|
|
} finally {
|
|
await closeNetServer(occupant);
|
|
}
|
|
});
|
|
});
|