feat(core): RIP after Java's code

Where the Swift template departs from Java's filius.software.rip, Java's
code rules: a learned route gets 16 hops at the router's next beacon after
it expired (RIPTable.check), not when it expires; messages count from any
source port; each router draws its pauses from a random of its own (Java:
an unseeded Random per router), which the core seeds from the scenario and
the router's id (ripSeed), so that the Java oracle can give it the same.

A route of hop count 0 to each own network, the first beacon after a
second, then every 25.2 to 35.2 s; a message per interface without the
routes through it; a new route or a longer one from the same router sends
at once; forwarding by the table alone, fewest hops before longest mask.
Messages are read with Java's parser (javaIpCheck, javaParseInt, moved from
fls to core for it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 19:29:48 +00:00
co-authored by Claude Opus 5.5
parent 313786e738
commit a4036f12a3
10 changed files with 522 additions and 45 deletions
+227 -9
View File
@@ -24,6 +24,7 @@ import {
icmpMessage,
interfaceKey,
JavaCompatibleRandom,
ripSeed,
NetworkRuntimeEngine,
portSnapshot,
stableMacAddress,
@@ -379,15 +380,6 @@ describe('UDP sockets', () => {
expect(arrival(true)).toEqual({ returnedAt: 165, waiting: 1, receivedAt: 165 });
});
it('draws ephemeral ports without disturbing the seeded RIP jitter', () => {
const nodeId = uuid(0x15f1);
const input = { nodes: [pc(nodeId, uuid(0x15f2))], links: [], deviceConfigurations: new Map([[nodeId, device('192.168.10.10')]]) };
const socketEngine = startedEngine(input);
const ripOnlyEngine = startedEngine(input);
expect(socketEngine.bindUdpSocket(nodeId)).toBeDefined();
expect(socketEngine.nextRipJitterMilliseconds()).toBe(ripOnlyEngine.nextRipJitterMilliseconds());
});
it('broadcasts on every interface, and the receiver replies to its last peer', () => {
const [routerId, routerPortA, routerPortB, pcAId, pcAPort, pcBId, pcBPort] = [0x15e1, 0x15e2, 0x15e3, 0x15e4, 0x15e5, 0x15e6, 0x15e7].map(
@@ -1925,3 +1917,229 @@ describe("Java's IP and ICMP threads", () => {
expect(engine.state.currentTimeMilliseconds).toBe(500);
});
});
describe('RIP', () => {
const [routerAId, routerAShared, routerALan, routerBId, routerBShared, routerBLan] = [0x1501, 0x1511, 0x1512, 0x1502, 0x1513, 0x1514].map(uuid) as [
string,
string,
string,
string,
string,
string,
];
const [lanPcId, lanPcPort] = [0x1503, 0x1515].map(uuid) as [string, string];
/**
* Router A (10.0.0.1, 192.168.0.1/24) and B (10.0.0.2, 172.16.0.1/16) on
* one cable, both with RIP; a PC on A's LAN if asked for.
*/
const ripTopology = (options: { manualRoutes?: boolean; lanPc?: boolean } = {}) =>
startedEngine(
{
nodes: [
{ id: routerAId, kind: 'router', ports: [portSnapshot(routerAShared, 'rt1'), portSnapshot(routerALan, 'rt2')] },
{ id: routerBId, kind: 'router', ports: [portSnapshot(routerBShared, 'rt1'), portSnapshot(routerBLan, 'rt2')] },
...(options.lanPc === true ? [pc(lanPcId, lanPcPort)] : []),
],
links: [
link(routerAId, routerAShared, routerBId, routerBShared),
...(options.lanPc === true ? [link(routerAId, routerALan, lanPcId, lanPcPort)] : []),
],
...(options.lanPc === true ? { deviceConfigurations: new Map([[lanPcId, device('192.168.0.2', { gateway: '192.168.0.1' })]]) } : {}),
interfaceConfigurations: new Map([
[interfaceKey(routerAId, routerAShared), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
[interfaceKey(routerAId, routerALan), { ipAddress: '192.168.0.1', subnetMask: '255.255.255.0' }],
[interfaceKey(routerBId, routerBShared), { ipAddress: '10.0.0.2', subnetMask: '255.255.255.0' }],
[interfaceKey(routerBId, routerBLan), { ipAddress: '172.16.0.1', subnetMask: '255.255.0.0' }],
]),
...(options.manualRoutes === true
? { manualRoutesByNodeId: new Map([[routerAId, [{ destinationNetwork: '203.0.113.0', subnetMask: '255.255.255.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }]]]) }
: {}),
ripEnabledByNodeId: new Map([
[routerAId, true],
[routerBId, true],
]),
},
15,
);
const route = (engine: NetworkRuntimeEngine, nodeId: string, network: string) =>
engine.state.ripTablesByNodeId.get(nodeId)?.find((each) => each.destinationNetwork === network);
const beacons = (engine: NetworkRuntimeEngine, nodeId: string) =>
engine.state.pendingEvents.filter((event) => event.kind.kind === 'ripBeacon' && event.kind.nodeId === nodeId).map((event) => event.deadlineMilliseconds);
const advertisements = (engine: NetworkRuntimeEngine, receiverId: string) =>
engine.state.packetTraces
.filter((trace) => trace.nodeId === receiverId && trace.detail === 'UDP queued' && trace.beforeHeaders.some((header) => header.name === 'destinationPort' && header.value === '520'))
.map((trace) => trace.timeMilliseconds);
/** When the router sent its table (on its first interface). */
const sentTables = (engine: NetworkRuntimeEngine, nodeId: string) =>
engine.state.packetTraces
.filter((trace) => trace.nodeId === nodeId && trace.detail?.startsWith('RIP advertisement') === true)
.map((trace) => trace.timeMilliseconds)
.filter((time, index, times) => times.indexOf(time) === index);
/** A message as a router at 10.0.0.2 sends it, from any port, straight to A: the time it arrives. */
const fromB = (engine: NetworkRuntimeEngine, text: string, sourcePort = 521): number => {
const identity = engine.allocatePacketIdentity();
engine.sendIPv4Packet(routerBId, {
identity,
senderIPAddress: '10.0.0.2',
receiverIPAddress: '10.0.0.1',
timeToLive: 1,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort, destinationPort: 520, payload: new TextEncoder().encode(text) } },
});
while (!(engine.state.deliveredIPv4PacketsByNodeId.get(routerAId) ?? []).some((delivered) => delivered.packet.identity === identity)) {
engine.handle({ type: 'advance', toMilliseconds: Math.min(...engine.state.pendingEvents.map((event) => event.deadlineMilliseconds)) });
}
return engine.state.currentTimeMilliseconds;
};
it('starts with a route of hop count 0 to each own network that never expires, the first beacon after a second', () => {
const engine = ripTopology();
expect(engine.state.ripTablesByNodeId.get(routerAId)).toEqual([
{ destinationNetwork: '10.0.0.0', subnetMask: '255.255.255.0', nextHop: '10.0.0.1', interfaceIPAddress: '10.0.0.1', origin: 'connected', isEditable: false, metric: 0 },
{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', nextHop: '192.168.0.1', interfaceIPAddress: '192.168.0.1', origin: 'connected', isEditable: false, metric: 0 },
]);
expect(beacons(engine, routerAId)).toEqual([1_000]);
// The server's socket on 520, the beacon's on 521.
expect(engine.bindUdpSocket(routerAId, { localPort: 520 })).toBeUndefined();
expect(engine.bindUdpSocket(routerAId, { localPort: 521 })).toBeUndefined();
});
it("converges with split horizon, each router's pauses drawn from a random of its own", () => {
const engine = ripTopology();
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ nextHop: '10.0.0.2', interfaceIPAddress: '10.0.0.1', metric: 1, origin: 'rip', expiresAtMilliseconds: 1_110 + 75_000 });
expect(route(engine, routerBId, '192.168.0.0')).toMatchObject({ nextHop: '10.0.0.1', metric: 1 });
const payloads = (engine.state.deliveredIPv4PacketsByNodeId.get(routerBId) ?? [])
.filter((delivered) => delivered.packet.senderIPAddress === '10.0.0.1')
.map((delivered) => (delivered.packet.payload.kind === 'udp' ? new TextDecoder().decode(delivered.packet.payload.datagram.payload) : ''));
// A's first message, on the cable to B: its LAN, not the shared network.
expect(payloads[0]).toBe('10.0.0.1\n10.0.0.1\n16\n75000\n192.168.0.0 255.255.255.0 0');
// Both beacons at 1 s; each learns a route 55 ms later and sends at once, then pauses.
expect(advertisements(engine, routerBId)).toEqual([1_055, 1_110]);
const random = new JavaCompatibleRandom(ripSeed(15, routerAId));
random.nextRipJitterMilliseconds();
expect(beacons(engine, routerAId)).toEqual([1_055 + random.nextRipJitterMilliseconds()]);
});
it('names its first address as the public one in every message', () => {
const engine = ripTopology({ lanPc: true });
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
const payloads = (engine.state.deliveredIPv4PacketsByNodeId.get(lanPcId) ?? []).map((delivered) =>
delivered.packet.payload.kind === 'udp' ? new TextDecoder().decode(delivered.packet.payload.datagram.payload) : '',
);
// On the LAN: from 192.168.0.1, public 10.0.0.1; after it learned B's network.
expect(payloads.at(-1)).toBe('192.168.0.1\n10.0.0.1\n16\n75000\n10.0.0.0 255.255.255.0 0\n172.16.0.0 255.255.0.0 1');
});
it('draws the pauses unmoved by the ports sockets draw', () => {
const plain = ripTopology();
const withSocket = ripTopology();
withSocket.bindUdpSocket(routerAId);
plain.handle({ type: 'advance', toMilliseconds: 2_000 });
withSocket.handle({ type: 'advance', toMilliseconds: 2_000 });
expect(beacons(withSocket, routerAId)).toEqual(beacons(plain, routerAId));
});
it('marks a learned route unreachable at the first beacon after it expired, and keeps it (Java: RIPTable.check)', () => {
const engine = ripTopology();
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
engine.setGlobalPacketLossEnabled(true);
const expiry = route(engine, routerAId, '172.16.0.0')!.expiresAtMilliseconds!;
engine.handle({ type: 'advance', toMilliseconds: expiry + 1 });
expect(route(engine, routerAId, '172.16.0.0')?.metric).toBe(1);
const [beacon] = beacons(engine, routerAId) as [number];
engine.handle({ type: 'advance', toMilliseconds: beacon - 1 });
expect(route(engine, routerAId, '172.16.0.0')?.metric).toBe(1);
engine.handle({ type: 'advance', toMilliseconds: beacon });
expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ metric: 16, expiresAtMilliseconds: expiry });
});
it('routes with its table alone: a manual route does not count', () => {
const engine = ripTopology({ manualRoutes: true });
const packet: IPv4Packet = {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.0.0.1',
receiverIPAddress: '203.0.113.20',
timeToLive: 64,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort: 6000, destinationPort: 7000, payload: new Uint8Array() } },
};
expect(engine.sendIPv4Packet(routerAId, packet)).toEqual({ type: 'icmpError', packetIdentity: packet.identity, nodeId: routerAId, kind: 'destinationNetworkUnreachable' });
});
it('forwards along the fewest hops, then the longest mask, and not along 16 hops', () => {
const engine = ripTopology();
// The /24 through a router at 10.0.0.3, which is not there.
fromB(engine, '10.0.0.3\n10.0.0.3\n16\n75000\n203.0.113.0 255.255.255.0 4');
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n203.0.0.0 255.255.0.0 2\n40.0.0.0 255.0.0.0 3');
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n40.0.0.0 255.0.0.0 15');
const packet: IPv4Packet = {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '192.168.0.1',
receiverIPAddress: '203.0.113.20',
timeToLive: 64,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort: 6000, destinationPort: 7000, payload: new Uint8Array() } },
};
engine.sendIPv4Packet(routerAId, packet);
letFramesArrive(engine);
// Through the /16 with 3 hops, not the /24 with 5.
expect(engine.state.packetTraces.some((trace) => trace.nodeId === routerBId && trace.packetIdentity === packet.identity)).toBe(true);
expect(route(engine, routerAId, '203.0.113.0')).toMatchObject({ metric: 5, nextHop: '10.0.0.3' });
// 40.0.0.0 went to 16 hops from the router it went through: no route.
expect(route(engine, routerAId, '40.0.0.0')?.metric).toBe(16);
const unreachable: IPv4Packet = { ...packet, identity: engine.allocatePacketIdentity(), receiverIPAddress: '40.0.0.1' };
expect(engine.sendIPv4Packet(routerAId, unreachable)).toEqual({
type: 'icmpError',
packetIdentity: unreachable.identity,
nodeId: routerAId,
kind: 'destinationNetworkUnreachable',
});
});
it("learns after Java's rules, from a message of any port, and ignores what is not for it", () => {
const engine = ripTopology();
let arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 3\n31.0.0.0 255.0.0.0 15\n32.0.0.0 255.0.0.0 16', 4000);
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 4, nextHop: '10.0.0.2', expiresAtMilliseconds: arrived + 75_000 });
// 15 + 1 is unreachable: not taken.
expect(route(engine, routerAId, '31.0.0.0')).toBeUndefined();
expect(route(engine, routerAId, '32.0.0.0')).toBeUndefined();
// A new route sends the table at once.
expect(sentTables(engine, routerAId)).toContain(arrived);
// Own networks stay; the message's own infinity counts too.
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n4\n75000\n192.168.0.0 255.255.255.0 0\n33.0.0.0 255.0.0.0 4');
expect(route(engine, routerAId, '192.168.0.0')).toMatchObject({ metric: 0, nextHop: '192.168.0.1' });
expect(route(engine, routerAId, '33.0.0.0')).toBeUndefined();
expect(sentTables(engine, routerAId)).not.toContain(arrived);
// Shorter from the router it goes through: taken, not sent at once.
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 1');
expect(route(engine, routerAId, '30.0.0.0')?.metric).toBe(2);
expect(sentTables(engine, routerAId)).not.toContain(arrived);
// Longer from the router it goes through: taken, and sent at once.
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 6');
expect(route(engine, routerAId, '30.0.0.0')?.metric).toBe(7);
expect(sentTables(engine, routerAId)).toContain(arrived);
// Shorter through another router: it goes there now; not shorter: it stays.
fromB(engine, '10.0.0.3\n10.0.0.3\n16\n75000\n30.0.0.0 255.0.0.0 2');
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 3, nextHop: '10.0.0.3', interfaceIPAddress: '10.0.0.1' });
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 2');
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 3, nextHop: '10.0.0.3' });
// Empty lines at the end do not count (Java's split).
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n39.0.0.0 255.0.0.0 1\n\n');
expect(route(engine, routerAId, '39.0.0.0')?.metric).toBe(2);
// From elsewhere, or from itself, or not a message: nothing.
fromB(engine, '10.9.0.2\n10.0.0.2\n16\n75000\n34.0.0.0 255.0.0.0 1');
fromB(engine, '10.0.0.1\n10.0.0.1\n16\n75000\n35.0.0.0 255.0.0.0 1');
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n0\n36.0.0.0 255.0.0.0 1');
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n37.0.0.0 255.0.0.0 1\n38.0.0.0 255.0.0.0');
for (const network of ['34.0.0.0', '35.0.0.0', '36.0.0.0', '37.0.0.0']) expect(route(engine, routerAId, network), network).toBeUndefined();
});
it('forgets its tables on stop', () => {
const engine = ripTopology();
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
engine.handle({ type: 'stop' });
expect(engine.state.ripTablesByNodeId.size).toBe(0);
});
});