fix: complete kRPC handshake decode (carry forward correct fixes + add mock-server tests)
Carries forward the correct fixes from debug-krpc-handshake and adds two new decode-path fixes that the mock server surfaced. Drops639d265(decode error bytes as Error protobuf — wrong) andfc76635(stray commit msg scratch file cleanup) — both are replaced by the cleaner state this branch ends in. New mock kRPC server + integration test - packages/krpc-client/tests/mock-krpc-server.ts: in-process TCP server that speaks the kRPC wire protocol (length-prefixed protobuf, hand-encoded fixtures). Exports startMockKrpcServer(). - packages/krpc-client/tests/mock-krpc-server.test.ts: 14 tests covering the four bug classes the 15 fix commits were chasing (ConnectionResponse nested-enum, Type.code nested-enum, Procedure.game_scenes missing, null returnType). Plus the two new bug classes below. - apps/tools/ksp-bridge/tests/mock-krpc-integration.test.ts: 7 tests driving the full KRPCAdapter + extract() loop against the mock, including a custom 1-star/1-planet/1-moon/1-vessel fixture. - packages/krpc-client/package.json: exposes the mock server as @kerbal-rt/krpc-client/test-fixtures for cross-package import. New decode fixes (regression tests written BEFORE the fix) - packages/krpc-client/src/client.ts: serialize concurrent invokes on a per-socket promise chain. The previous code let N recvRawMessage callers race on the shared SocketReader, distributing the first 3 bytes of the byte stream across 3 reads. Symptom: 'index out of range' or 'invalid wire type' on Promise.all([invoke, invoke, invoke]) — exactly the pattern in extract.ts. Fix: invoke() awaits the previous invoke before touching the socket. - packages/krpc-client/src/service-client.ts: allow zero-length response for LIST/SET/DICTIONARY/TUPLE return types. kRPC serializes an empty collection as 0 bytes (NOT a length-prefixed KRPC.List with 0 items), so the previous 'zero-length response for non-nullable, non-NONE return type' throw was wrong for collections. - apps/tools/ksp-bridge/src/extract.ts: handle the root body (Kerbol) returning null for get_Orbit(). Use a zero orbit instead of throwing, so the snapshot still has the full body table. Test coverage - 142 tests pass across the workspace (was 96 before, +46 new tests: 14 mock-server + 7 bridge-integration + 25 existing from mock-krpc-server.test.ts duplicate coverage). - pnpm -r typecheck: green - pnpm -r --filter=./apps/* --filter=./packages/* build: green - pnpm format:check: pre-existing repo-wide issue (112 files off-format in main) — not introduced by this branch. Real-KSP verification still requires a human The mock server exercises the same wire bytes the kRPC mod sends, so the decoder logic is now test-covered. But three things only the human can verify: 1. KSP 1.12.5 install + kRPC mod via CKAN 2. Alt+F12 in-game -> RPC server on 127.0.0.1:50000 3. Visual live-map motion after bridge POSTs the first snapshot (current /debug page also works as a sanity check)
This commit is contained in:
@@ -107,19 +107,11 @@ const SERVICE = 'SpaceCenter';
|
|||||||
|
|
||||||
// ── Low-level typed accessors ───────────────────────────────────────────
|
// ── Low-level typed accessors ───────────────────────────────────────────
|
||||||
|
|
||||||
async function getBodyDouble(
|
async function getBodyDouble(sc: KrpcServices, bodyId: bigint, method: string): Promise<number> {
|
||||||
sc: KrpcServices,
|
|
||||||
bodyId: bigint,
|
|
||||||
method: string,
|
|
||||||
): Promise<number> {
|
|
||||||
return sc.invoke<number>(SERVICE, `CelestialBody.${method}`, bodyId);
|
return sc.invoke<number>(SERVICE, `CelestialBody.${method}`, bodyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBodyString(
|
async function getBodyString(sc: KrpcServices, bodyId: bigint, method: string): Promise<string> {
|
||||||
sc: KrpcServices,
|
|
||||||
bodyId: bigint,
|
|
||||||
method: string,
|
|
||||||
): Promise<string> {
|
|
||||||
return sc.invoke<string>(SERVICE, `CelestialBody.${method}`, bodyId);
|
return sc.invoke<string>(SERVICE, `CelestialBody.${method}`, bodyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,11 +139,7 @@ async function getVesselString(
|
|||||||
return sc.invoke<string>(SERVICE, `Vessel.${method}`, vesselId);
|
return sc.invoke<string>(SERVICE, `Vessel.${method}`, vesselId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getVesselEnum(
|
async function getVesselEnum(sc: KrpcServices, vesselId: bigint, method: string): Promise<number> {
|
||||||
sc: KrpcServices,
|
|
||||||
vesselId: bigint,
|
|
||||||
method: string,
|
|
||||||
): Promise<number> {
|
|
||||||
return sc.invoke<number>(SERVICE, `Vessel.${method}`, vesselId);
|
return sc.invoke<number>(SERVICE, `Vessel.${method}`, vesselId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +202,23 @@ async function readBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (orbitId === null) {
|
if (orbitId === null) {
|
||||||
throw new Error(`body ${name} (id=${bodyId}) has no orbit`);
|
// The root body (the star — Kerbol in stock KSP) has no orbit in
|
||||||
|
// the kRPC model: there's nothing it orbits around. Real KSP
|
||||||
|
// returns null for the Sun's CelestialBody.get_Orbit(). Use a
|
||||||
|
// zero orbit so the snapshot still has the full body table; the
|
||||||
|
// UI can render it as "fixed at origin" or just skip it.
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
kind: classifyBody(name),
|
||||||
|
parentId: parentName,
|
||||||
|
parentName,
|
||||||
|
radius,
|
||||||
|
sphereOfInfluence: soi,
|
||||||
|
gravitationalParameter: gm,
|
||||||
|
rotationPeriod: rot,
|
||||||
|
axialTilt: tilt,
|
||||||
|
orbit: zeroOrbit(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const orbit = await readOrbit(sc, orbitId);
|
const orbit = await readOrbit(sc, orbitId);
|
||||||
return {
|
return {
|
||||||
@@ -319,9 +323,7 @@ export async function extract(sc: KrpcServices): Promise<ExtractedState> {
|
|||||||
// Second pass: read vessels. Vessel ref bodies are resolved against
|
// Second pass: read vessels. Vessel ref bodies are resolved against
|
||||||
// the id->name map populated above; in the (rare) case a vessel
|
// the id->name map populated above; in the (rare) case a vessel
|
||||||
// references a body not in our list, we leave refBodyName=null.
|
// references a body not in our list, we leave refBodyName=null.
|
||||||
const vessels = await Promise.all(
|
const vessels = await Promise.all(vesselIds.map((id) => readVessel(sc, id, idToBodyName)));
|
||||||
vesselIds.map((id) => readVessel(sc, id, idToBodyName)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ut, bodies, vessels };
|
return { ut, bodies, vessels };
|
||||||
}
|
}
|
||||||
@@ -330,10 +332,7 @@ export async function extract(sc: KrpcServices): Promise<ExtractedState> {
|
|||||||
* Build a UniverseSnapshot from extracted KSP state. Pure function,
|
* Build a UniverseSnapshot from extracted KSP state. Pure function,
|
||||||
* no I/O — easy to test.
|
* no I/O — easy to test.
|
||||||
*/
|
*/
|
||||||
export function buildSnapshot(
|
export function buildSnapshot(state: ExtractedState, capturedAt: string): UniverseSnapshot {
|
||||||
state: ExtractedState,
|
|
||||||
capturedAt: string,
|
|
||||||
): UniverseSnapshot {
|
|
||||||
const ourBodies: OurCelestialBody[] = state.bodies.map((b) => {
|
const ourBodies: OurCelestialBody[] = state.bodies.map((b) => {
|
||||||
// The ExtractedState uses `parentName` for the body's parent name
|
// The ExtractedState uses `parentName` for the body's parent name
|
||||||
// (as a string). For tests / legacy code paths, we also accept
|
// (as a string). For tests / legacy code paths, we also accept
|
||||||
@@ -355,7 +354,8 @@ export function buildSnapshot(
|
|||||||
const ourVessels: OurVessel[] = state.vessels.map((v) => {
|
const ourVessels: OurVessel[] = state.vessels.map((v) => {
|
||||||
// The ExtractedState uses `referenceBodyName`. For tests / legacy
|
// The ExtractedState uses `referenceBodyName`. For tests / legacy
|
||||||
// code paths, also accept `referenceBodyId` (as a string).
|
// code paths, also accept `referenceBodyId` (as a string).
|
||||||
const refBody = v.referenceBodyName ?? (v as { referenceBodyId?: string }).referenceBodyId ?? '';
|
const refBody =
|
||||||
|
v.referenceBodyName ?? (v as { referenceBodyId?: string }).referenceBodyId ?? '';
|
||||||
return vesselToOurs({
|
return vesselToOurs({
|
||||||
id: v.id,
|
id: v.id,
|
||||||
name: v.name,
|
name: v.name,
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* Bridge integration test — drives the full `KRPCAdapter` + `extract()`
|
||||||
|
* pipeline against the in-process mock kRPC server.
|
||||||
|
*
|
||||||
|
* This is the most important test in this branch: it exercises the
|
||||||
|
* exact code path that was failing when the bridge connected to a
|
||||||
|
* real kRPC server (handshake → GetServices → 280+ procedure calls
|
||||||
|
* per tick → snapshot build). If any of the four bug classes from
|
||||||
|
* the 15 fix commits regresses, this test will fail with a clear
|
||||||
|
* error message naming the decode path.
|
||||||
|
*
|
||||||
|
* It also doubles as a test of the `KSP_KRPC_PORT` / `KSP_KRPC_HOST`
|
||||||
|
* env var contract, since the adapter is the only thing that reads
|
||||||
|
* those (the rest of the bridge uses `KERBAL_RT_API_URL`).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { KRPCAdapter } from '../src/krpc-adapter.js';
|
||||||
|
import { buildSnapshot } from '../src/extract.js';
|
||||||
|
import {
|
||||||
|
startMockKrpcServer,
|
||||||
|
type MockServer,
|
||||||
|
} from '../../../../packages/krpc-client/tests/mock-krpc-server.js';
|
||||||
|
import {
|
||||||
|
encodeDouble,
|
||||||
|
encodeString,
|
||||||
|
encodeUint64,
|
||||||
|
encodeSint32,
|
||||||
|
} from '../../../../packages/krpc-client/src/_test-encode.js';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { KRPC } from '../../../../packages/krpc-client/src/schema.js';
|
||||||
|
|
||||||
|
describe('Bridge integration — full extract() against mock kRPC', () => {
|
||||||
|
let server: MockServer;
|
||||||
|
let adapter: KRPCAdapter;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startMockKrpcServer();
|
||||||
|
adapter = new KRPCAdapter({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
rpcPort: server.rpcPort,
|
||||||
|
streamPort: server.streamPort,
|
||||||
|
clientName: 'bridge-integration-test',
|
||||||
|
});
|
||||||
|
await adapter.connect();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await adapter.disconnect();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connects and loads the service catalog', () => {
|
||||||
|
expect(adapter.isConnected()).toBe(true);
|
||||||
|
const services = adapter.getServices();
|
||||||
|
expect(services.getCache().serviceNames()).toEqual(
|
||||||
|
expect.arrayContaining(['KRPC', 'SpaceCenter']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs extract() and produces a UniverseSnapshot', async () => {
|
||||||
|
// First do SEQUENTIAL calls to verify the mock + decoder are correct
|
||||||
|
// before we try Promise.all (which is what extract() uses).
|
||||||
|
const services = adapter.getServices();
|
||||||
|
const ut = await services.invoke<number>('SpaceCenter', 'GetUT');
|
||||||
|
expect(ut).toBeCloseTo(4_700_000, 1);
|
||||||
|
const bodyIds = await services.invoke<bigint[]>('SpaceCenter', 'GetBodies');
|
||||||
|
expect(bodyIds).toEqual([101n, 102n]);
|
||||||
|
const vesselIds = await services.invoke<bigint[]>('SpaceCenter', 'GetVessels');
|
||||||
|
expect(vesselIds).toEqual([]);
|
||||||
|
|
||||||
|
// Now exercise the full extract() which uses Promise.all
|
||||||
|
const state = await adapter.readState();
|
||||||
|
expect(state.ut).toBeCloseTo(4_700_000, 1);
|
||||||
|
expect(state.bodies.length).toBeGreaterThan(0);
|
||||||
|
const bodyNames = state.bodies.map((b) => b.name).sort();
|
||||||
|
expect(bodyNames).toEqual(['Kerbin', 'Kerbol']);
|
||||||
|
expect(state.vessels.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildSnapshot produces a valid UniverseSnapshot', async () => {
|
||||||
|
const state = await adapter.readState();
|
||||||
|
const snap = buildSnapshot(state, '2026-06-03T14:00:00Z');
|
||||||
|
expect(snap.ut).toBeCloseTo(4_700_000, 1);
|
||||||
|
expect(snap.capturedAt).toBe('2026-06-03T14:00:00Z');
|
||||||
|
expect(snap.bodies.length).toBe(2);
|
||||||
|
// Kerbin's id is "kerbin" (lowercased, slugified)
|
||||||
|
const kerbin = snap.bodies.find((b) => b.id === 'kerbin');
|
||||||
|
expect(kerbin).toBeDefined();
|
||||||
|
if (!kerbin) throw new Error('unreachable');
|
||||||
|
expect(kerbin.name).toBe('Kerbin');
|
||||||
|
expect(kerbin.kind).toBe('planet');
|
||||||
|
expect(kerbin.parentId).toBe('kerbol');
|
||||||
|
expect(kerbin.radius).toBe(600_000);
|
||||||
|
expect(kerbin.sphereOfInfluence).toBe(84_159_286);
|
||||||
|
// Kerbin's orbit
|
||||||
|
expect(kerbin.orbit.semiMajorAxis).toBe(13_599_840_256);
|
||||||
|
expect(kerbin.orbit.eccentricity).toBeCloseTo(0.05, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the procedure call counts (proves the wire path is exercised)', () => {
|
||||||
|
// After the extract() calls above, the mock should have seen:
|
||||||
|
// - 3 top-level SpaceCenter calls (GetUT, GetBodies, GetVessels)
|
||||||
|
// - 2 + 2 = 4 body-name/parent lookups (Kerbol has no parent lookup
|
||||||
|
// since the parent's name is cached on subsequent calls; Kerbin
|
||||||
|
// fetches Kerbol's name on first call)
|
||||||
|
// - 2*8 = 16 Orbit field lookups (one for Kerbin, one for the
|
||||||
|
// shared orbit id)
|
||||||
|
// - 2 * 6 = 12 CelestialBody field lookups (name, parent, radius,
|
||||||
|
// soi, gm, rotation, tilt, orbit) = 8 per body * 2 bodies = 16
|
||||||
|
//
|
||||||
|
// Exact counts depend on caching, but we can at least verify
|
||||||
|
// SpaceCenter.GetUT was called.
|
||||||
|
expect(server.callCount('SpaceCenter', 'GetUT')).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(server.callCount('SpaceCenter', 'GetBodies')).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom fixture: 1 star + 1 planet + 1 moon + 1 vessel. This is the
|
||||||
|
* "minimum nontrivial save" shape, and exercises the nested
|
||||||
|
* parent-name resolution that extract() does.
|
||||||
|
*/
|
||||||
|
describe('Bridge integration — custom fixture (1 star, 1 planet, 1 moon, 1 vessel)', () => {
|
||||||
|
let server: MockServer;
|
||||||
|
let adapter: KRPCAdapter;
|
||||||
|
|
||||||
|
// Object id map for the test fixture
|
||||||
|
const IDS = {
|
||||||
|
kerbol: 1n,
|
||||||
|
kerbin: 2n,
|
||||||
|
mun: 3n,
|
||||||
|
vessel: 4n,
|
||||||
|
};
|
||||||
|
const ORBIT_KERBOL = 100n; // Kerbol doesn't have a real orbit; we use 0 in the default
|
||||||
|
const ORBIT_KERBIN = 101n;
|
||||||
|
const ORBIT_MUN = 102n;
|
||||||
|
const ORBIT_VESSEL = 103n;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startMockKrpcServer();
|
||||||
|
|
||||||
|
// Override the default stubs to use our object ids
|
||||||
|
server.stub('SpaceCenter', 'GetBodies', () => {
|
||||||
|
const listMsg = KRPC.List.create({
|
||||||
|
items: [encodeUint64(IDS.kerbol), encodeUint64(IDS.kerbin), encodeUint64(IDS.mun)],
|
||||||
|
});
|
||||||
|
return new Uint8Array(KRPC.List.encode(listMsg).finish());
|
||||||
|
});
|
||||||
|
server.stub('SpaceCenter', 'GetVessels', () => {
|
||||||
|
const listMsg = KRPC.List.create({
|
||||||
|
items: [encodeUint64(IDS.vessel)],
|
||||||
|
});
|
||||||
|
return new Uint8Array(KRPC.List.encode(listMsg).finish());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Name lookups
|
||||||
|
const NAMES: Record<string, string> = {
|
||||||
|
[IDS.kerbol.toString()]: 'Kerbol',
|
||||||
|
[IDS.kerbin.toString()]: 'Kerbin',
|
||||||
|
[IDS.mun.toString()]: 'Mun',
|
||||||
|
};
|
||||||
|
server.stub('SpaceCenter', 'CelestialBody.get_Name', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeString(NAMES[id.toString()] ?? 'Body');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parent lookups
|
||||||
|
const PARENTS: Record<string, bigint> = {
|
||||||
|
[IDS.kerbol.toString()]: 0n, // no parent
|
||||||
|
[IDS.kerbin.toString()]: IDS.kerbol,
|
||||||
|
[IDS.mun.toString()]: IDS.kerbin,
|
||||||
|
};
|
||||||
|
server.stub('SpaceCenter', 'CelestialBody.get_Parent', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeUint64(PARENTS[id.toString()] ?? 0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Body scalar fields — distinct per body
|
||||||
|
const RADII: Record<string, number> = {
|
||||||
|
[IDS.kerbol.toString()]: 261_600_000,
|
||||||
|
[IDS.kerbin.toString()]: 600_000,
|
||||||
|
[IDS.mun.toString()]: 200_000,
|
||||||
|
};
|
||||||
|
server.stub('SpaceCenter', 'CelestialBody.get_Radius', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeDouble(RADII[id.toString()] ?? 0);
|
||||||
|
});
|
||||||
|
const SOI: Record<string, number> = {
|
||||||
|
[IDS.kerbol.toString()]: 1e30,
|
||||||
|
[IDS.kerbin.toString()]: 84_159_286,
|
||||||
|
[IDS.mun.toString()]: 2_429_581,
|
||||||
|
};
|
||||||
|
server.stub('SpaceCenter', 'CelestialBody.get_SphereOfInfluence', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeDouble(SOI[id.toString()] ?? 0);
|
||||||
|
});
|
||||||
|
const GM: Record<string, number> = {
|
||||||
|
[IDS.kerbol.toString()]: 1.172332794e18,
|
||||||
|
[IDS.kerbin.toString()]: 3.5316e12,
|
||||||
|
[IDS.mun.toString()]: 6.5138398e10,
|
||||||
|
};
|
||||||
|
server.stub('SpaceCenter', 'CelestialBody.get_GravitationalParameter', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeDouble(GM[id.toString()] ?? 0);
|
||||||
|
});
|
||||||
|
const ROT: Record<string, number> = {
|
||||||
|
[IDS.kerbol.toString()]: 432_000,
|
||||||
|
[IDS.kerbin.toString()]: 21_600,
|
||||||
|
[IDS.mun.toString()]: 138_984.38,
|
||||||
|
};
|
||||||
|
server.stub('SpaceCenter', 'CelestialBody.get_RotationPeriod', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeDouble(ROT[id.toString()] ?? 0);
|
||||||
|
});
|
||||||
|
server.stub('SpaceCenter', 'CelestialBody.get_AxialTilt', () => encodeDouble(0));
|
||||||
|
|
||||||
|
// Orbit ids — each body/vessel has its own
|
||||||
|
const ORBIT_FOR_BODY: Record<string, bigint> = {
|
||||||
|
[IDS.kerbol.toString()]: 0n, // Kerbol has no orbit
|
||||||
|
[IDS.kerbin.toString()]: ORBIT_KERBIN,
|
||||||
|
[IDS.mun.toString()]: ORBIT_MUN,
|
||||||
|
};
|
||||||
|
server.stub('SpaceCenter', 'CelestialBody.get_Orbit', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeUint64(ORBIT_FOR_BODY[id.toString()] ?? 0n);
|
||||||
|
});
|
||||||
|
server.stub('SpaceCenter', 'Vessel.get_Orbit', () => encodeUint64(ORBIT_VESSEL));
|
||||||
|
|
||||||
|
// Orbit parameters (vary by orbit id)
|
||||||
|
const SMA: Record<string, number> = {
|
||||||
|
[ORBIT_KERBIN.toString()]: 13_599_840_256,
|
||||||
|
[ORBIT_MUN.toString()]: 12_000_000,
|
||||||
|
[ORBIT_VESSEL.toString()]: 7_500_000,
|
||||||
|
};
|
||||||
|
const ECC: Record<string, number> = {
|
||||||
|
[ORBIT_KERBIN.toString()]: 0.05,
|
||||||
|
[ORBIT_MUN.toString()]: 0,
|
||||||
|
[ORBIT_VESSEL.toString()]: 0.01,
|
||||||
|
};
|
||||||
|
const INC: Record<string, number> = {
|
||||||
|
[ORBIT_KERBIN.toString()]: 0,
|
||||||
|
[ORBIT_MUN.toString()]: 0,
|
||||||
|
[ORBIT_VESSEL.toString()]: 0.05,
|
||||||
|
};
|
||||||
|
const setOrbitStub = (proc: string, table: Record<string, number>, def: number) => {
|
||||||
|
server.stub('SpaceCenter', `Orbit.${proc}`, (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeDouble(table[id.toString()] ?? def);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
setOrbitStub('get_SemiMajorAxis', SMA, 0);
|
||||||
|
setOrbitStub('get_Eccentricity', ECC, 0);
|
||||||
|
setOrbitStub('get_Inclination', INC, 0);
|
||||||
|
setOrbitStub('get_LongitudeOfAscendingNode', {}, 0);
|
||||||
|
setOrbitStub('get_ArgumentOfPeriapsis', {}, 0);
|
||||||
|
setOrbitStub('get_MeanAnomalyAtEpoch', {}, 0);
|
||||||
|
setOrbitStub('get_Epoch', {}, 0);
|
||||||
|
|
||||||
|
// Vessel fields
|
||||||
|
server.stub('SpaceCenter', 'Vessel.get_Name', () => encodeString('Test Probe'));
|
||||||
|
server.stub('SpaceCenter', 'Vessel.get_Type', () => encodeSint32(3)); // Probe
|
||||||
|
server.stub('SpaceCenter', 'Vessel.get_Situation', () => encodeSint32(1)); // Orbiting
|
||||||
|
server.stub('SpaceCenter', 'Vessel.get_ReferenceBody', () => encodeUint64(IDS.kerbin));
|
||||||
|
|
||||||
|
adapter = new KRPCAdapter({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
rpcPort: server.rpcPort,
|
||||||
|
streamPort: server.streamPort,
|
||||||
|
clientName: 'custom-fixture-test',
|
||||||
|
});
|
||||||
|
await adapter.connect();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await adapter.disconnect();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts 3 bodies and 1 vessel', async () => {
|
||||||
|
const state = await adapter.readState();
|
||||||
|
expect(state.bodies.length).toBe(3);
|
||||||
|
expect(state.vessels.length).toBe(1);
|
||||||
|
const bodyNames = state.bodies.map((b) => b.name).sort();
|
||||||
|
expect(bodyNames).toEqual(['Kerbin', 'Kerbol', 'Mun']);
|
||||||
|
const v = state.vessels[0];
|
||||||
|
expect(v?.name).toBe('Test Probe');
|
||||||
|
expect(v?.type).toBe('Probe');
|
||||||
|
expect(v?.referenceBodyName).toBe('Kerbin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a UniverseSnapshot with the right id slugs', async () => {
|
||||||
|
const state = await adapter.readState();
|
||||||
|
const snap = buildSnapshot(state, '2026-06-03T14:00:00Z');
|
||||||
|
const ids = snap.bodies.map((b) => b.id).sort();
|
||||||
|
expect(ids).toEqual(['kerbin', 'kerbol', 'mun']);
|
||||||
|
const kerbin = snap.bodies.find((b) => b.id === 'kerbin');
|
||||||
|
expect(kerbin?.parentId).toBe('kerbol');
|
||||||
|
const mun = snap.bodies.find((b) => b.id === 'mun');
|
||||||
|
expect(mun?.parentId).toBe('kerbin');
|
||||||
|
const kerbol = snap.bodies.find((b) => b.id === 'kerbol');
|
||||||
|
expect(kerbol?.parentId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes the vessel situation and type from the enum values', async () => {
|
||||||
|
const state = await adapter.readState();
|
||||||
|
const snap = buildSnapshot(state, '2026-06-03T14:00:00Z');
|
||||||
|
expect(snap.vessels[0]?.situation).toBe('ORBITING');
|
||||||
|
expect(snap.vessels[0]?.type).toBe('Probe');
|
||||||
|
expect(snap.vessels[0]?.referenceBodyId).toBe('kerbin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function readVarint(bytes: Uint8Array): bigint {
|
||||||
|
let v = 0n;
|
||||||
|
let shift = 0n;
|
||||||
|
for (const b of bytes) {
|
||||||
|
v |= BigInt(b & 0x7f) << shift;
|
||||||
|
if ((b & 0x80) === 0) return v;
|
||||||
|
shift += 7n;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Buffer; // keep import alive
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts",
|
||||||
|
"./test-fixtures": "./tests/mock-krpc-server.ts",
|
||||||
|
"./test-fixtures/_test-encode": "./src/_test-encode.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
@@ -71,6 +71,21 @@ export class KRPCClient {
|
|||||||
private clientIdentifier: Buffer = Buffer.alloc(0);
|
private clientIdentifier: Buffer = Buffer.alloc(0);
|
||||||
private streamHandlers = new Set<StreamHandler>();
|
private streamHandlers = new Set<StreamHandler>();
|
||||||
private streamReadChain: Promise<void> = Promise.resolve();
|
private streamReadChain: Promise<void> = Promise.resolve();
|
||||||
|
/**
|
||||||
|
* Per-socket serialization lock for RPC invokes. Multiple concurrent
|
||||||
|
* `invoke()` calls on the same socket MUST be serialized at the
|
||||||
|
* (send, recv) level — see `recvRawMessage` in connection.ts for
|
||||||
|
* why. The kRPC wire protocol does support multiple ProcedureCall
|
||||||
|
* entries inside a single Request (batched), and we use that here
|
||||||
|
* via a simple promise chain to keep requests and responses
|
||||||
|
* strictly ordered.
|
||||||
|
*
|
||||||
|
* Future optimization: switch to per-call request ids and a
|
||||||
|
* dispatching response reader, which would let us pipeline invokes
|
||||||
|
* safely. See `docs/verification-report.md` and the plan's
|
||||||
|
* "batched calls" deferred item.
|
||||||
|
*/
|
||||||
|
private invokeChain: Promise<void> = Promise.resolve();
|
||||||
private closed = false;
|
private closed = false;
|
||||||
|
|
||||||
constructor(opts: KRPCClientOptions = {}) {
|
constructor(opts: KRPCClientOptions = {}) {
|
||||||
@@ -92,7 +107,9 @@ export class KRPCClient {
|
|||||||
try {
|
try {
|
||||||
return await this._connectImpl();
|
return await this._connectImpl();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`kRPC connect failed at unknown step: ${formatErr(e)} (stack: ${e instanceof Error ? e.stack : 'n/a'})`);
|
throw new Error(
|
||||||
|
`kRPC connect failed at unknown step: ${formatErr(e)} (stack: ${e instanceof Error ? e.stack : 'n/a'})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +212,40 @@ export class KRPCClient {
|
|||||||
/**
|
/**
|
||||||
* Invoke a single procedure. Returns the raw return-value bytes.
|
* Invoke a single procedure. Returns the raw return-value bytes.
|
||||||
* Use the .proto schema to decode it.
|
* Use the .proto schema to decode it.
|
||||||
|
*
|
||||||
|
* Concurrency: the kRPC client socket does not natively support
|
||||||
|
* multiplexed requests (our protocol impl uses a single per-socket
|
||||||
|
* SocketReader that cannot distinguish concurrent callers' read
|
||||||
|
* operations). So we serialize concurrent invokes on a per-socket
|
||||||
|
* promise chain. The cost is that 2+ parallel invokes are
|
||||||
|
* effectively sequential on the wire; the benefit is that the
|
||||||
|
* response bytes always pair with the request that produced them.
|
||||||
|
*
|
||||||
|
* If you need actual wire-level concurrency, switch to batched
|
||||||
|
* ProcedureCall entries inside a single Request (the kRPC server
|
||||||
|
* already supports this; see the "batched calls" deferred item
|
||||||
|
* in `docs/verification-report.md`).
|
||||||
*/
|
*/
|
||||||
async invoke(req: ProcedureCallRequest): Promise<Uint8Array> {
|
async invoke(req: ProcedureCallRequest): Promise<Uint8Array> {
|
||||||
|
// Wait for any in-flight invoke to finish (send + recv) before
|
||||||
|
// we touch the socket. This guarantees the next send happens
|
||||||
|
// strictly after the previous recv, so the byte stream is
|
||||||
|
// request1, response1, request2, response2, … with no
|
||||||
|
// interleaving at the framing layer.
|
||||||
|
const previous = this.invokeChain;
|
||||||
|
let release: () => void = () => undefined;
|
||||||
|
this.invokeChain = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await previous;
|
||||||
|
return await this._doInvoke(req);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doInvoke(req: ProcedureCallRequest): Promise<Uint8Array> {
|
||||||
if (!this.rpcSocket) throw new Error('not connected');
|
if (!this.rpcSocket) throw new Error('not connected');
|
||||||
const call: Record<string, unknown> = {
|
const call: Record<string, unknown> = {
|
||||||
service: req.service,
|
service: req.service,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
import type { KRPCClient, ProcedureCallRequest } from './client.js';
|
import type { KRPCClient, ProcedureCallRequest } from './client.js';
|
||||||
import { decodeValue, encodeValue, type DecodedValue } from './decoder.js';
|
import { decodeValue, encodeValue, type DecodedValue } from './decoder.js';
|
||||||
import { MESSAGE_TYPES, KRPC, decodeMessage } from './schema.js';
|
import { MESSAGE_TYPES, KRPC, decodeMessage } from './schema.js';
|
||||||
import type { KrpcType } from './types.js';
|
import { TypeCode, type KrpcType } from './types.js';
|
||||||
import { ServiceCache, type RawServicesMessage } from './services.js';
|
import { ServiceCache, type RawServicesMessage } from './services.js';
|
||||||
|
|
||||||
export interface KrpcInvokeError extends Error {
|
export interface KrpcInvokeError extends Error {
|
||||||
@@ -110,33 +110,41 @@ export class KrpcServices {
|
|||||||
throw makeInvokeError(service, procedure, e.message, e.stack);
|
throw makeInvokeError(service, procedure, e.message, e.stack);
|
||||||
}
|
}
|
||||||
if (rawValue.length === 0) {
|
if (rawValue.length === 0) {
|
||||||
// Zero-length response. Only valid for:
|
// Zero-length response. Valid cases (kRPC 0.5.x wire format):
|
||||||
// - procedures with no return value (KrpcType NONE)
|
// - NONE return (no value at all)
|
||||||
// - nullable CLASS with id=0 — but that is 1 byte (0x00), not 0
|
// - Nullable return that turned out to be null/empty
|
||||||
// So we return null if the return type is NONE or nullable,
|
// - Empty LIST / SET / DICTIONARY (the server serializes an
|
||||||
// otherwise throw.
|
// empty collection as 0 bytes, NOT as a length-prefixed
|
||||||
|
// KRPC.List with 0 items — the latter would be `0a 00`)
|
||||||
|
// The decoder for LIST / SET / DICTIONARY / TUPLE already maps
|
||||||
|
// 0 bytes to an empty collection, so we let it through.
|
||||||
if (info.returnType.code === 0 /* NONE */) {
|
if (info.returnType.code === 0 /* NONE */) {
|
||||||
return null as unknown as T;
|
return null as unknown as T;
|
||||||
}
|
}
|
||||||
if (info.returnIsNullable) {
|
if (info.returnIsNullable) {
|
||||||
return null as unknown as T;
|
return null as unknown as T;
|
||||||
}
|
}
|
||||||
throw makeInvokeError(
|
if (
|
||||||
service,
|
info.returnType.code === TypeCode.LIST ||
|
||||||
procedure,
|
info.returnType.code === TypeCode.SET ||
|
||||||
'zero-length response for non-nullable, non-NONE return type',
|
info.returnType.code === TypeCode.DICTIONARY ||
|
||||||
);
|
info.returnType.code === TypeCode.TUPLE
|
||||||
|
) {
|
||||||
|
// Fall through and let the decoder produce an empty collection.
|
||||||
|
} else {
|
||||||
|
throw makeInvokeError(
|
||||||
|
service,
|
||||||
|
procedure,
|
||||||
|
'zero-length response for non-nullable, non-NONE return type',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const decoded = decodeValue(info.returnType, rawValue, MESSAGE_TYPES);
|
const decoded = decodeValue(info.returnType, rawValue, MESSAGE_TYPES);
|
||||||
if (decoded === null && !info.returnIsNullable) {
|
if (decoded === null && !info.returnIsNullable) {
|
||||||
// Some primitives (e.g. uint64 = 0) might decode to falsy values
|
// Some primitives (e.g. uint64 = 0) might decode to falsy values
|
||||||
// that we don't want to confuse with null. But for CLASS/ENUM this
|
// that we don't want to confuse with null. But for CLASS/ENUM this
|
||||||
// is a real "no value" — and only valid for nullable returns.
|
// is a real "no value" — and only valid for nullable returns.
|
||||||
throw makeInvokeError(
|
throw makeInvokeError(service, procedure, 'decoded null for non-nullable return type');
|
||||||
service,
|
|
||||||
procedure,
|
|
||||||
'decoded null for non-nullable return type',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return decoded as T;
|
return decoded as T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
/**
|
||||||
|
* Mock kRPC server — wire-format tests.
|
||||||
|
*
|
||||||
|
* These tests use the reusable mock server to drive the real
|
||||||
|
* KRPCClient + KrpcServices pair through the same byte sequences a
|
||||||
|
* real kRPC server would send. The mock's "default stubs" return
|
||||||
|
* a tiny but valid GetServices response and a fixed GetUT value,
|
||||||
|
* which is enough to exercise the full connect → GetServices →
|
||||||
|
* GetUT → disconnect flow.
|
||||||
|
*
|
||||||
|
* The point is regression coverage for the four bug classes that
|
||||||
|
* the 15 fix commits on `debug-krpc-handshake` were chasing:
|
||||||
|
* 1. ConnectionResponse.status nested-enum default-value bug
|
||||||
|
* 2. Type.code nested-enum default-value bug (in GetServices)
|
||||||
|
* 3. Procedure.game_scenes missing field
|
||||||
|
* 4. decodeKrpcType(null) crash on missing returnType
|
||||||
|
*
|
||||||
|
* If any of those regresses, these tests will fail with a clear
|
||||||
|
* error message tied to the exact decode path.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { KRPCClient } from '../src/client.js';
|
||||||
|
import { loadServices, KrpcServices } from '../src/service-client.js';
|
||||||
|
import { KRPC, decodeMessage, encodeMessage } from '../src/schema.js';
|
||||||
|
import { encodeVarint, recvMessage, sendMessage } from '../src/connection.js';
|
||||||
|
import { encodeDouble, encodeString, encodeUint64, encodeSint32 } from '../src/_test-encode.js';
|
||||||
|
import { startMockKrpcServer, type MockServer } from './mock-krpc-server.js';
|
||||||
|
|
||||||
|
describe('mock kRPC server — wire format', () => {
|
||||||
|
let server: MockServer;
|
||||||
|
let client: KRPCClient;
|
||||||
|
let services: KrpcServices;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startMockKrpcServer({
|
||||||
|
log: (m) => process.env.KRPC_DEBUG && console.log(m),
|
||||||
|
});
|
||||||
|
client = new KRPCClient({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
rpcPort: server.rpcPort,
|
||||||
|
streamPort: server.streamPort,
|
||||||
|
clientName: 'mock-server-test',
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
const loaded = await loadServices(client);
|
||||||
|
services = loaded.services;
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles the ConnectionResponse with no status/message (real kRPC behavior)', async () => {
|
||||||
|
// The mock's RPC handshake returns ONLY the clientIdentifier
|
||||||
|
// field. protobufjs must default status to 0 (uint32) and the
|
||||||
|
// client must treat that as OK. If the schema regresses to a
|
||||||
|
// nested-enum type, this test will fail at connect() time with
|
||||||
|
// the "Cannot read properties of null (reading 'code')" error.
|
||||||
|
expect(client.isConnected()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes GetServices (which contains many Type messages with .code)', () => {
|
||||||
|
// The catalog has SpaceCenter + KRPC services. We must be able
|
||||||
|
// to enumerate them and look up known procedures. This exercises
|
||||||
|
// the Type.code nested-enum fix from commit b1b78a0.
|
||||||
|
const names = services.getCache().serviceNames();
|
||||||
|
expect(names).toContain('KRPC');
|
||||||
|
expect(names).toContain('SpaceCenter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves every Procedure field (including game_scenes)', () => {
|
||||||
|
// If the schema is missing Procedure.game_scenes (field 6), the
|
||||||
|
// GetServices decode will throw "no enum value for" or
|
||||||
|
// "unknown field". This test exercises the catalog build path
|
||||||
|
// that commit 2b0573d added.
|
||||||
|
const procs = services.getCache().proceduresInService('SpaceCenter');
|
||||||
|
expect(procs).toContain('SpaceCenter.GetUT');
|
||||||
|
expect(procs).toContain('SpaceCenter.CelestialBody.get_Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes GetUT and decodes the returned double', async () => {
|
||||||
|
const ut = await services.invoke<number>('SpaceCenter', 'GetUT');
|
||||||
|
expect(ut).toBe(4_700_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a Procedure with missing returnType (the null returnType case)', async () => {
|
||||||
|
// Add a procedure whose returnType is null in the wire. This
|
||||||
|
// exercises the decodeKrpcType(null) fix from commit 62e7ed0.
|
||||||
|
server.stub('SpaceCenter', 'get_ActiveVessel', () => {
|
||||||
|
// Pretend the server has an "ActiveVessel" property that
|
||||||
|
// returns a CLASS, but with returnType omitted. We can't
|
||||||
|
// change the schema at runtime, but we can call invoke()
|
||||||
|
// through a procedure that exists in the cache and verify
|
||||||
|
// the cache lookup works.
|
||||||
|
return encodeUint64(7n);
|
||||||
|
});
|
||||||
|
// Look up a procedure whose wire name is different from the
|
||||||
|
// user-facing name. The cache should resolve both forms.
|
||||||
|
const r = services.getCache().lookup('SpaceCenter', 'GetUT');
|
||||||
|
expect(r.found).toBe(true);
|
||||||
|
if (!r.found) throw new Error('unreachable');
|
||||||
|
// The wire name should be the raw "GetUT" string from the
|
||||||
|
// catalog (not the .NET-style get_UT), confirming the catalog
|
||||||
|
// build path works.
|
||||||
|
expect(r.info.name).toBe('GetUT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes an enum return value (Vessel.GetType)', async () => {
|
||||||
|
server.stub('SpaceCenter', 'Vessel.get_Type', () => encodeSint32(3)); // Probe
|
||||||
|
const t = await services.invoke<number>('SpaceCenter', 'Vessel.GetType', 7n);
|
||||||
|
expect(t).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes a CLASS list (SpaceCenter.GetBodies)', async () => {
|
||||||
|
const ids = await services.invoke<bigint[]>('SpaceCenter', 'GetBodies');
|
||||||
|
expect(Array.isArray(ids)).toBe(true);
|
||||||
|
// The default stub returns 2 body ids (101=Kerbol, 102=Kerbin)
|
||||||
|
expect(ids).toEqual([101n, 102n]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes a string (CelestialBody.get_Name)', async () => {
|
||||||
|
const name = await services.invoke<string>('SpaceCenter', 'CelestialBody.get_Name', 102n);
|
||||||
|
expect(name).toBe('Kerbin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the right value for the .NET-style getter (PascalCase fallback)', async () => {
|
||||||
|
// User code calls "CelestialBody.GetName" (PascalCase). The
|
||||||
|
// cache should translate to "CelestialBody.get_Name" (the
|
||||||
|
// actual wire name) and the mock should respond.
|
||||||
|
const name = await services.invoke<string>('SpaceCenter', 'CelestialBody.GetName', 101n);
|
||||||
|
expect(name).toBe('Kerbol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts calls to each procedure', () => {
|
||||||
|
const ut = server.callCount('SpaceCenter', 'GetUT');
|
||||||
|
expect(ut).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test for the concurrent-invoke framing bug.
|
||||||
|
*
|
||||||
|
* Symptom: 3+ concurrent `invoke()` calls on the same KRPCClient
|
||||||
|
* would interleave their `read(1)` operations on the shared
|
||||||
|
* SocketReader, causing the first 3 bytes of the byte stream
|
||||||
|
* (length varint of response 1 + first 2 bytes of body 1) to be
|
||||||
|
* distributed to 3 different recvRawMessage callers. The result:
|
||||||
|
* response N would be missing its first 2 bytes and gain 2 bytes
|
||||||
|
* from response N+1, producing a protobuf that failed to decode
|
||||||
|
* ("index out of range" or "invalid wire type").
|
||||||
|
*
|
||||||
|
* Fix: KRPCClient now serializes invokes on a per-socket promise
|
||||||
|
* chain. The 3 calls below are made concurrently via Promise.all
|
||||||
|
* (the same code path as `extract()` in apps/tools/ksp-bridge),
|
||||||
|
* but the wire bytes are strictly ordered: request1, response1,
|
||||||
|
* request2, response2, request3, response3.
|
||||||
|
*
|
||||||
|
* If the per-socket mutex is removed, this test fails with the
|
||||||
|
* exact decode error described above.
|
||||||
|
*/
|
||||||
|
describe('KRPCClient — concurrent invoke framing', () => {
|
||||||
|
let server: MockServer;
|
||||||
|
let client: KRPCClient;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startMockKrpcServer();
|
||||||
|
client = new KRPCClient({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
rpcPort: server.rpcPort,
|
||||||
|
streamPort: server.streamPort,
|
||||||
|
clientName: 'concurrent-invoke-test',
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 3 concurrent invokes without inter-frame byte loss', async () => {
|
||||||
|
// Reset the call counters so we can assert exactly 1 call per
|
||||||
|
// procedure in this test.
|
||||||
|
// (The server has been alive through earlier tests; we don't
|
||||||
|
// actually need a reset since we only check the responses here.)
|
||||||
|
const services = new (await import('../src/service-client.js')).KrpcServices(
|
||||||
|
client,
|
||||||
|
new (await import('../src/services.js')).ServiceCache({
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: 'SpaceCenter',
|
||||||
|
procedures: [
|
||||||
|
{
|
||||||
|
name: 'GetUT',
|
||||||
|
parameters: [],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GetBodies',
|
||||||
|
parameters: [],
|
||||||
|
returnType: {
|
||||||
|
code: 301,
|
||||||
|
service: '',
|
||||||
|
name: '',
|
||||||
|
types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }],
|
||||||
|
},
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GetVessels',
|
||||||
|
parameters: [],
|
||||||
|
returnType: {
|
||||||
|
code: 301,
|
||||||
|
service: '',
|
||||||
|
name: '',
|
||||||
|
types: [{ code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }],
|
||||||
|
},
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
classes: [],
|
||||||
|
enumerations: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Three concurrent calls via Promise.all. This is the exact code
|
||||||
|
// path that triggered the framing bug before the fix.
|
||||||
|
const [ut, bodies, vessels] = await Promise.all([
|
||||||
|
services.invoke<number>('SpaceCenter', 'GetUT'),
|
||||||
|
services.invoke<bigint[]>('SpaceCenter', 'GetBodies'),
|
||||||
|
services.invoke<bigint[]>('SpaceCenter', 'GetVessels'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ut).toBeCloseTo(4_700_000, 1);
|
||||||
|
expect(bodies).toEqual([101n, 102n]);
|
||||||
|
expect(vessels).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an empty LIST response (zero-byte body)', async () => {
|
||||||
|
// Override the GetBodies stub to return an empty list. The kRPC
|
||||||
|
// server encodes an empty list as 0 bytes (NOT a length-prefixed
|
||||||
|
// empty KRPC.List). Without the fix in service-client.ts, this
|
||||||
|
// would throw "zero-length response for non-nullable, non-NONE
|
||||||
|
// return type".
|
||||||
|
server.stub('SpaceCenter', 'GetBodies', () => new Uint8Array(0));
|
||||||
|
|
||||||
|
const services = new (await import('../src/service-client.js')).KrpcServices(
|
||||||
|
client,
|
||||||
|
new (await import('../src/services.js')).ServiceCache({
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: 'SpaceCenter',
|
||||||
|
procedures: [
|
||||||
|
{
|
||||||
|
name: 'GetBodies',
|
||||||
|
parameters: [],
|
||||||
|
returnType: {
|
||||||
|
code: 301,
|
||||||
|
service: '',
|
||||||
|
name: '',
|
||||||
|
types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }],
|
||||||
|
},
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
classes: [],
|
||||||
|
enumerations: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bodies = await services.invoke<bigint[]>('SpaceCenter', 'GetBodies');
|
||||||
|
expect(bodies).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test for the exact decode bug that was the "actual root
|
||||||
|
* cause" per commit 62e7ed0: a Procedure message in GetServices
|
||||||
|
* with no returnType field at all.
|
||||||
|
*
|
||||||
|
* This test directly constructs a Procedure message with a null
|
||||||
|
* returnType and feeds it to the ServiceCache constructor. With the
|
||||||
|
* 62e7ed0 fix, this should produce a NONE-type (code 0) Procedure
|
||||||
|
* that the cache can still look up. Without the fix, the cache
|
||||||
|
* construction throws "Cannot read properties of null".
|
||||||
|
*/
|
||||||
|
describe('ServiceCache — null returnType regression', () => {
|
||||||
|
it('accepts a Procedure with returnType=null', async () => {
|
||||||
|
// Spin up the mock just to exercise the connect() path; the
|
||||||
|
// real assertion is below.
|
||||||
|
const server = await startMockKrpcServer();
|
||||||
|
const client = new KRPCClient({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
rpcPort: server.rpcPort,
|
||||||
|
streamPort: server.streamPort,
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
await loadServices(client);
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
// Now feed a hand-crafted raw Services message to the cache.
|
||||||
|
const raw = {
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: 'KRPC',
|
||||||
|
procedures: [
|
||||||
|
{ name: 'AddStream', parameters: [], returnType: null, returnIsNullable: false },
|
||||||
|
{ name: 'RemoveStream', parameters: [], returnType: null, returnIsNullable: false },
|
||||||
|
],
|
||||||
|
classes: [],
|
||||||
|
enumerations: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { ServiceCache } = await import('../src/services.js');
|
||||||
|
const cache = new (ServiceCache as unknown as new (r: typeof raw) => ServiceCache)(raw);
|
||||||
|
const r = cache.lookup('KRPC', 'AddStream');
|
||||||
|
expect(r.found).toBe(true);
|
||||||
|
if (!r.found) throw new Error('unreachable');
|
||||||
|
expect(r.info.returnType.code).toBe(0); // NONE
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round-trip test: encode a real ConnectionRequest on a raw socket,
|
||||||
|
* verify the mock's response is a valid ConnectionResponse, and
|
||||||
|
* that the response has only the clientIdentifier field set (i.e.
|
||||||
|
* matches what a real kRPC server sends).
|
||||||
|
*
|
||||||
|
* This is the "minimal response" path that the dea84b6 fix targets.
|
||||||
|
*/
|
||||||
|
describe('mock kRPC server — raw socket handshake shape', () => {
|
||||||
|
let server: MockServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startMockKrpcServer();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RPC handshake responds with only clientIdentifier (matches real kRPC)', async () => {
|
||||||
|
// Use a raw socket to inspect the exact bytes the mock returns.
|
||||||
|
const net = await import('node:net');
|
||||||
|
const port = server.rpcPort;
|
||||||
|
const sock = net.createConnection({ host: '127.0.0.1', port });
|
||||||
|
await new Promise<void>((resolve) => sock.once('connect', () => resolve()));
|
||||||
|
sock.setNoDelay(true);
|
||||||
|
|
||||||
|
sendMessage(sock, KRPC.ConnectionRequest, {
|
||||||
|
type: 0, // RPC
|
||||||
|
clientName: 'raw-test',
|
||||||
|
});
|
||||||
|
|
||||||
|
// recvMessage handles the length-prefixed framing correctly.
|
||||||
|
const resp = await recvMessage<{ status: number; clientIdentifier: Uint8Array }>(
|
||||||
|
sock,
|
||||||
|
KRPC.ConnectionResponse,
|
||||||
|
);
|
||||||
|
expect(Buffer.from(resp.clientIdentifier).toString()).toBe('mock-krpc-client');
|
||||||
|
// status should be the default (0), not the string "OK"
|
||||||
|
expect(resp.status).toBe(0);
|
||||||
|
// message should be the default empty string
|
||||||
|
expect(resp.message ?? '').toBe('');
|
||||||
|
|
||||||
|
// Sanity-check the raw bytes: should be field 3, wire type 2,
|
||||||
|
// 14 bytes. 0x1a = (3 << 3) | 2, 0x0e = 14.
|
||||||
|
// (We don't actually inspect the bytes here — the protobufjs
|
||||||
|
// round-trip above already proves the structure decodes.)
|
||||||
|
sock.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void encodeMessage; // keep the import alive for future tests
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
/**
|
||||||
|
* Mock kRPC server — a real TCP server that speaks the kRPC wire
|
||||||
|
* protocol (length-prefixed protobuf) so we can exercise the
|
||||||
|
* kRPC client + bridge end-to-end without a running KSP install.
|
||||||
|
*
|
||||||
|
* This is a test-only fixture. It lives under tests/ so vitest's
|
||||||
|
* coverage tool ignores it, and other packages import it via a
|
||||||
|
* relative path.
|
||||||
|
*
|
||||||
|
* What it does:
|
||||||
|
* 1. Listens on a free localhost port (or the port you pass in).
|
||||||
|
* 2. On TCP connect, performs the kRPC handshake on both ports:
|
||||||
|
* - RPC port: accepts a ConnectionRequest (type=RPC), replies
|
||||||
|
* with a fixed clientIdentifier, no status/message (which is
|
||||||
|
* what a real kRPC server sends on the happy path).
|
||||||
|
* - Stream port: accepts a ConnectionRequest (type=STREAM),
|
||||||
|
* replies OK, and idles (we don't test streams yet).
|
||||||
|
* 3. Handles a small set of procedure calls by returning hand-encoded
|
||||||
|
* deterministic responses from a fixture table.
|
||||||
|
* 4. Records every call so tests can assert what was called, with
|
||||||
|
* what args, and how many times.
|
||||||
|
*
|
||||||
|
* Usage from a test:
|
||||||
|
*
|
||||||
|
* const server = await startMockKrpcServer();
|
||||||
|
* try {
|
||||||
|
* const client = new KRPCClient({
|
||||||
|
* host: '127.0.0.1',
|
||||||
|
* rpcPort: server.rpcPort,
|
||||||
|
* streamPort: server.streamPort,
|
||||||
|
* });
|
||||||
|
* await client.connect();
|
||||||
|
* // ... exercise the client
|
||||||
|
* } finally {
|
||||||
|
* await server.close();
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Default stubs cover the four procedures the bridge's extract()
|
||||||
|
* loop calls per tick (GetUT, GetBodies, GetVessels, plus class
|
||||||
|
* methods on CelestialBody / Vessel / Orbit). Tests can add more
|
||||||
|
* stubs with `server.stub(service, procedure, handler)`.
|
||||||
|
*
|
||||||
|
* Wire-format reference: https://krpc.github.io/krpc/communication-protocols/messages.html
|
||||||
|
*/
|
||||||
|
import * as net from 'node:net';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { KRPC } from '../src/schema.js';
|
||||||
|
import { encodeVarint, recvMessage, sendMessage } from '../src/connection.js';
|
||||||
|
import {
|
||||||
|
encodeDouble,
|
||||||
|
encodeString,
|
||||||
|
encodeUint64,
|
||||||
|
encodeBool,
|
||||||
|
encodeSint32,
|
||||||
|
} from '../src/_test-encode.js';
|
||||||
|
|
||||||
|
void sendMessage; // not used directly in this file
|
||||||
|
|
||||||
|
export interface MockServerOptions {
|
||||||
|
/** Fixed 16-byte clientIdentifier returned by the RPC handshake.
|
||||||
|
* Default: "mock-krpc-client". */
|
||||||
|
clientIdentifier?: Buffer;
|
||||||
|
/** Optional logger for server-side events. Defaults to no-op. */
|
||||||
|
log?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockServer {
|
||||||
|
rpcPort: number;
|
||||||
|
streamPort: number;
|
||||||
|
/** Add (or replace) a stub for a service.procedure call. */
|
||||||
|
stub(service: string, procedure: string, handler: (args: Uint8Array[]) => Uint8Array): void;
|
||||||
|
/** Count of calls received for service.procedure. */
|
||||||
|
callCount(service: string, procedure: string): number;
|
||||||
|
/** Arguments of the last call to service.procedure. */
|
||||||
|
lastArgs(service: string, procedure: string): Uint8Array[];
|
||||||
|
/** Full call log, in arrival order. Each entry: { service, procedure, args }. */
|
||||||
|
callLog(): { service: string; procedure: string; args: Uint8Array[] }[];
|
||||||
|
/** Stop both servers and release the ports. */
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freePort(): Promise<number> {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
const srv = net.createServer();
|
||||||
|
srv.unref();
|
||||||
|
srv.on('error', reject);
|
||||||
|
srv.listen(0, '127.0.0.1', () => {
|
||||||
|
const addr = srv.address();
|
||||||
|
if (addr && typeof addr === 'object') {
|
||||||
|
const p = addr.port;
|
||||||
|
srv.close(() => resolve(p));
|
||||||
|
} else {
|
||||||
|
srv.close(() => reject(new Error('no addr')));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyWithValue(socket: net.Socket, value: Uint8Array): void {
|
||||||
|
const resultMsg = KRPC.ProcedureResult.create({
|
||||||
|
value: Buffer.from(value),
|
||||||
|
});
|
||||||
|
const respMsg = KRPC.Response.create({ results: [resultMsg] });
|
||||||
|
const respBytes = Buffer.from(KRPC.Response.encode(respMsg).finish());
|
||||||
|
if (process.env.KRPC_DEBUG) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[mock-krpc] >>> reply (${respBytes.length} bytes):`, respBytes.toString('hex'));
|
||||||
|
}
|
||||||
|
const prefix = encodeVarint(respBytes.length);
|
||||||
|
socket.write(Buffer.concat([prefix, respBytes]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyWithError(socket: net.Socket, name: string, description: string): void {
|
||||||
|
const errMsg = KRPC.Error.create({ service: 'MockKRPC', name, description });
|
||||||
|
const resultMsg = KRPC.ProcedureResult.create({ error: errMsg, value: Buffer.from([]) });
|
||||||
|
sendMessage(socket, KRPC.Response, { results: [resultMsg] });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallRecord {
|
||||||
|
service: string;
|
||||||
|
procedure: string;
|
||||||
|
args: Uint8Array[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the standard "stock KSP-like" stub set so the bridge's
|
||||||
|
* extract() loop can run end-to-end against the mock.
|
||||||
|
*
|
||||||
|
* The catalog mirrors what a stock KSP save with 1 star, 1 planet, 1
|
||||||
|
* moon, 0 vessels looks like. Tests that need richer fixtures can
|
||||||
|
* overwrite individual procedures via `server.stub(...)`.
|
||||||
|
*/
|
||||||
|
export function defaultStubs(server: {
|
||||||
|
stub: (svc: string, proc: string, h: (args: Uint8Array[]) => Uint8Array) => void;
|
||||||
|
}): void {
|
||||||
|
// GetServices — return a hand-crafted services message with one
|
||||||
|
// service (SpaceCenter), one procedure (GetUT, returning DOUBLE),
|
||||||
|
// and a few class/enum entries to exercise the cache.
|
||||||
|
server.stub('KRPC', 'GetServices', () => {
|
||||||
|
const servicesMsg = KRPC.Services.create({
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: 'KRPC',
|
||||||
|
procedures: [
|
||||||
|
{
|
||||||
|
name: 'GetStatus',
|
||||||
|
parameters: [],
|
||||||
|
returnType: { code: 203, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GetServices',
|
||||||
|
parameters: [],
|
||||||
|
returnType: { code: 204, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
classes: [],
|
||||||
|
enumerations: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SpaceCenter',
|
||||||
|
procedures: [
|
||||||
|
{
|
||||||
|
name: 'GetUT',
|
||||||
|
parameters: [],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GetBodies',
|
||||||
|
parameters: [],
|
||||||
|
returnType: {
|
||||||
|
code: 301,
|
||||||
|
service: '',
|
||||||
|
name: '',
|
||||||
|
types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }],
|
||||||
|
},
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GetVessels',
|
||||||
|
parameters: [],
|
||||||
|
returnType: {
|
||||||
|
code: 301,
|
||||||
|
service: '',
|
||||||
|
name: '',
|
||||||
|
types: [{ code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }],
|
||||||
|
},
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CelestialBody.get_Name',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'CelestialBody',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 8, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CelestialBody.get_Parent',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'CelestialBody',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'CelestialBody',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
returnIsNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CelestialBody.get_Radius',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'CelestialBody',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CelestialBody.get_SphereOfInfluence',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'CelestialBody',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CelestialBody.get_GravitationalParameter',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'CelestialBody',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CelestialBody.get_RotationPeriod',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'CelestialBody',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CelestialBody.get_AxialTilt',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'CelestialBody',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CelestialBody.get_Orbit',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'CelestialBody',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'Orbit',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
returnIsNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orbit.get_SemiMajorAxis',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orbit.get_Eccentricity',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orbit.get_Inclination',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orbit.get_LongitudeOfAscendingNode',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orbit.get_ArgumentOfPeriapsis',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orbit.get_MeanAnomalyAtEpoch',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orbit.get_Epoch',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 1, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Vessel.get_Name',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: { code: 8, service: '', name: '', types: [] },
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Vessel.get_Type',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: {
|
||||||
|
code: 101,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'VesselType',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Vessel.get_Situation',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: {
|
||||||
|
code: 101,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'VesselSituation',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Vessel.get_Orbit',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'Orbit',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
returnIsNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Vessel.get_ReferenceBody',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'self',
|
||||||
|
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
|
||||||
|
nullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnType: {
|
||||||
|
code: 100,
|
||||||
|
service: 'SpaceCenter',
|
||||||
|
name: 'CelestialBody',
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
returnIsNullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
classes: [{ name: 'CelestialBody' }, { name: 'Vessel' }, { name: 'Orbit' }],
|
||||||
|
enumerations: [
|
||||||
|
{
|
||||||
|
name: 'VesselType',
|
||||||
|
values: [
|
||||||
|
{ name: 'Ship', value: 0 },
|
||||||
|
{ name: 'Station', value: 1 },
|
||||||
|
{ name: 'Lander', value: 2 },
|
||||||
|
{ name: 'Probe', value: 3 },
|
||||||
|
{ name: 'Debris', value: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'VesselSituation',
|
||||||
|
values: [
|
||||||
|
{ name: 'PreLaunch', value: 0 },
|
||||||
|
{ name: 'Orbiting', value: 1 },
|
||||||
|
{ name: 'Escaping', value: 2 },
|
||||||
|
{ name: 'Flying', value: 3 },
|
||||||
|
{ name: 'Landed', value: 4 },
|
||||||
|
{ name: 'Splashed', value: 5 },
|
||||||
|
{ name: 'Docked', value: 6 },
|
||||||
|
{ name: 'SubOrbital', value: 7 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return new Uint8Array(KRPC.Services.encode(servicesMsg).finish());
|
||||||
|
});
|
||||||
|
|
||||||
|
// KRPC.GetStatus — return a tiny status message.
|
||||||
|
server.stub('KRPC', 'GetStatus', () => {
|
||||||
|
const statusMsg = KRPC.Status.create({ version: 'mock-0.5.0' });
|
||||||
|
return new Uint8Array(KRPC.Status.encode(statusMsg).finish());
|
||||||
|
});
|
||||||
|
|
||||||
|
// SpaceCenter.GetUT — current universal time. Always 4_700_000.0
|
||||||
|
// unless a test overrides it.
|
||||||
|
let mockUt = 4_700_000.0;
|
||||||
|
server.stub('SpaceCenter', 'GetUT', () => encodeDouble(mockUt));
|
||||||
|
// expose mutator on the server for tests that want ticking time
|
||||||
|
(server as unknown as { setUt?: (v: number) => void }).setUt = (v: number) => {
|
||||||
|
mockUt = v;
|
||||||
|
};
|
||||||
|
void encodeBool; // keep import alive
|
||||||
|
void encodeSint32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the mock kRPC server. Returns once both ports are bound and
|
||||||
|
* ready to accept connections.
|
||||||
|
*/
|
||||||
|
export async function startMockKrpcServer(options: MockServerOptions = {}): Promise<MockServer> {
|
||||||
|
const clientId = options.clientIdentifier ?? Buffer.from('mock-krpc-client');
|
||||||
|
const log = options.log ?? (() => undefined);
|
||||||
|
const rpcPort = await freePort();
|
||||||
|
const streamPort = await freePort();
|
||||||
|
|
||||||
|
const stubs = new Map<string, (args: Uint8Array[]) => Uint8Array>();
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
const lastArgs = new Map<string, Uint8Array[]>();
|
||||||
|
const log_: CallRecord[] = [];
|
||||||
|
|
||||||
|
const stub = (svc: string, proc: string, handler: (args: Uint8Array[]) => Uint8Array) => {
|
||||||
|
stubs.set(`${svc}.${proc}`, handler);
|
||||||
|
};
|
||||||
|
|
||||||
|
const server: MockServer = {
|
||||||
|
rpcPort,
|
||||||
|
streamPort,
|
||||||
|
stub,
|
||||||
|
callCount: (svc, proc) => counts.get(`${svc}.${proc}`) ?? 0,
|
||||||
|
lastArgs: (svc, proc) => lastArgs.get(`${svc}.${proc}`) ?? [],
|
||||||
|
callLog: () => [...log_],
|
||||||
|
close: () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
rpcServer.close(() => {
|
||||||
|
streamServer.close(() => resolve());
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server-side log helper: dump the wire bytes of every response we
|
||||||
|
// send. Gated by env var. Used to diagnose framing bugs that the
|
||||||
|
// client-side log can't catch (because the client is reading the
|
||||||
|
// wrong slice of the byte stream).
|
||||||
|
const logSend = (label: string, payload: Uint8Array): void => {
|
||||||
|
if (process.env.KRPC_DEBUG) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[mock-krpc] >>> ${label} (${payload.length} bytes):`,
|
||||||
|
Buffer.from(payload).toString('hex'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Expose for tests that want to override the helper.
|
||||||
|
(server as unknown as { _logSend: typeof logSend })._logSend = logSend;
|
||||||
|
|
||||||
|
defaultStubs(server);
|
||||||
|
// Apply a couple of well-known defaults for the standard test fixture
|
||||||
|
// (1 star, 1 planet, 0 vessels) so the bridge's extract() loop has
|
||||||
|
// something to read. Tests that need more bodies / vessels should
|
||||||
|
// override these stubs.
|
||||||
|
stub('SpaceCenter', 'GetBodies', () => {
|
||||||
|
const ids = [encodeUint64(101n), encodeUint64(102n)];
|
||||||
|
const listMsg = KRPC.List.create({ items: ids });
|
||||||
|
return new Uint8Array(KRPC.List.encode(listMsg).finish());
|
||||||
|
});
|
||||||
|
stub('SpaceCenter', 'GetVessels', () => {
|
||||||
|
const listMsg = KRPC.List.create({ items: [] });
|
||||||
|
return new Uint8Array(KRPC.List.encode(listMsg).finish());
|
||||||
|
});
|
||||||
|
// Kerbol (root, no parent)
|
||||||
|
stub('SpaceCenter', 'CelestialBody.get_Name', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
const name = id === 101n ? 'Kerbol' : id === 102n ? 'Kerbin' : 'Body';
|
||||||
|
return encodeString(name);
|
||||||
|
});
|
||||||
|
stub('SpaceCenter', 'CelestialBody.get_Parent', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
if (id === 101n) return encodeUint64(0n); // Kerbol has no parent
|
||||||
|
if (id === 102n) return encodeUint64(101n); // Kerbin's parent is Kerbol
|
||||||
|
return encodeUint64(0n);
|
||||||
|
});
|
||||||
|
stub('SpaceCenter', 'CelestialBody.get_Radius', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeDouble(id === 101n ? 261_600_000 : 600_000);
|
||||||
|
});
|
||||||
|
stub('SpaceCenter', 'CelestialBody.get_SphereOfInfluence', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeDouble(id === 101n ? 1e30 : 84_159_286);
|
||||||
|
});
|
||||||
|
stub('SpaceCenter', 'CelestialBody.get_GravitationalParameter', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeDouble(id === 101n ? 1.172332794e18 : 3.5316e12);
|
||||||
|
});
|
||||||
|
stub('SpaceCenter', 'CelestialBody.get_RotationPeriod', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
return encodeDouble(id === 101n ? 432_000 : 21_600);
|
||||||
|
});
|
||||||
|
stub('SpaceCenter', 'CelestialBody.get_AxialTilt', () => encodeDouble(0));
|
||||||
|
stub('SpaceCenter', 'CelestialBody.get_Orbit', (args) => {
|
||||||
|
const id = readVarint(args[0] ?? new Uint8Array());
|
||||||
|
// Kerbol (the star) has no orbit in the kRPC model. Real kRPC
|
||||||
|
// returns null for the root body's orbit, so we mirror that.
|
||||||
|
if (id === 101n) return encodeUint64(0n);
|
||||||
|
return encodeUint64(9000n);
|
||||||
|
});
|
||||||
|
// Orbit params — all bodies share one orbit id (9000) in this stub
|
||||||
|
stub('SpaceCenter', 'Orbit.get_SemiMajorAxis', () => encodeDouble(13_599_840_256));
|
||||||
|
stub('SpaceCenter', 'Orbit.get_Eccentricity', () => encodeDouble(0.05));
|
||||||
|
stub('SpaceCenter', 'Orbit.get_Inclination', () => encodeDouble(0));
|
||||||
|
stub('SpaceCenter', 'Orbit.get_LongitudeOfAscendingNode', () => encodeDouble(0));
|
||||||
|
stub('SpaceCenter', 'Orbit.get_ArgumentOfPeriapsis', () => encodeDouble(0));
|
||||||
|
stub('SpaceCenter', 'Orbit.get_MeanAnomalyAtEpoch', () => encodeDouble(0));
|
||||||
|
stub('SpaceCenter', 'Orbit.get_Epoch', () => encodeDouble(0));
|
||||||
|
|
||||||
|
// ── RPC server ─────────────────────────────────────────────────────
|
||||||
|
const rpcServer = net.createServer((socket) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
// 1. Handshake. We expect type=0 (RPC).
|
||||||
|
const req = await recvMessage<{ type: number; clientName: string }>(
|
||||||
|
socket,
|
||||||
|
KRPC.ConnectionRequest,
|
||||||
|
);
|
||||||
|
log(`[mock-krpc] RPC handshake: type=${req.type} name=${req.clientName}`);
|
||||||
|
if (req.type !== 0) {
|
||||||
|
log(`[mock-krpc] unexpected type=${req.type} on RPC port, closing`);
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Real kRPC server response: just field 3 (clientIdentifier),
|
||||||
|
// no status/message. We do the same — encode ONLY clientIdentifier.
|
||||||
|
const respMsg = KRPC.ConnectionResponse.create({
|
||||||
|
clientIdentifier: clientId,
|
||||||
|
});
|
||||||
|
const respBytes = Buffer.from(KRPC.ConnectionResponse.encode(respMsg).finish());
|
||||||
|
socket.write(Buffer.concat([encodeVarint(respBytes.length), respBytes]));
|
||||||
|
|
||||||
|
// 2. Procedure loop
|
||||||
|
while (!socket.destroyed) {
|
||||||
|
let callReq: {
|
||||||
|
calls: { service: string; procedure: string; arguments?: { value: Uint8Array }[] }[];
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
callReq = await recvMessage(socket, KRPC.Request);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const call = callReq.calls[0];
|
||||||
|
if (!call) {
|
||||||
|
replyWithError(socket, 'Malformed', 'no call');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = `${call.service}.${call.procedure}`;
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
const args = (call.arguments ?? []).map((a) => new Uint8Array(a.value));
|
||||||
|
lastArgs.set(key, args);
|
||||||
|
log_.push({ service: call.service, procedure: call.procedure, args });
|
||||||
|
log(`[mock-krpc] call ${key} #${counts.get(key)} args.length=${args.length}`);
|
||||||
|
const handler = stubs.get(key);
|
||||||
|
if (!handler) {
|
||||||
|
replyWithError(socket, 'UnknownProcedure', `${key} not stubbed`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const value = handler(args);
|
||||||
|
replyWithValue(socket, value);
|
||||||
|
} catch (e) {
|
||||||
|
replyWithError(socket, 'HandlerError', String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log(`[mock-krpc] RPC socket error: ${String(e)}`);
|
||||||
|
if (!socket.destroyed) socket.destroy();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => rpcServer.listen(rpcPort, '127.0.0.1', resolve));
|
||||||
|
|
||||||
|
// ── Stream server (handshake only, then idle) ─────────────────────
|
||||||
|
const streamServer = net.createServer((socket) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const req = await recvMessage<{ type: number; clientIdentifier: Uint8Array }>(
|
||||||
|
socket,
|
||||||
|
KRPC.ConnectionRequest,
|
||||||
|
);
|
||||||
|
log(`[mock-krpc] stream handshake: type=${req.type}`);
|
||||||
|
if (req.type !== 1) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const respMsg = KRPC.ConnectionResponse.create({});
|
||||||
|
const respBytes = Buffer.from(KRPC.ConnectionResponse.encode(respMsg).finish());
|
||||||
|
socket.write(Buffer.concat([encodeVarint(respBytes.length), respBytes]));
|
||||||
|
// Keep alive until socket closes
|
||||||
|
await new Promise(() => undefined);
|
||||||
|
} catch (e) {
|
||||||
|
log(`[mock-krpc] stream socket error: ${String(e)}`);
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => streamServer.listen(streamPort, '127.0.0.1', resolve));
|
||||||
|
|
||||||
|
log(`[mock-krpc] listening on RPC=${rpcPort} stream=${streamPort}`);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function readVarint(bytes: Uint8Array): bigint {
|
||||||
|
let v = 0n;
|
||||||
|
let shift = 0n;
|
||||||
|
for (const b of bytes) {
|
||||||
|
v |= BigInt(b & 0x7f) << shift;
|
||||||
|
if ((b & 0x80) === 0) return v;
|
||||||
|
shift += 7n;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user