feat(core): a cable carries one frame after the other, as in Java
Java's SimplexVerbindung sleeps a cable's delay with the port's buffer locked: frames on one cable leave one after the other, and a frame shows in the packet log when it leaves. The engine now queues a frame in the output buffer until the one before has arrived (ethernetLinkDeparture). Swift sends a cable's frames at once; the option swiftCables keeps that for the Swift comparison, which goes on checking everything else trace by trace. With the queue, the Java comparisons match within 120 ms (was 200); a state of the TCP stories within 250 ms, where the engine's calls block one after the other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -248,6 +248,28 @@ describe('event queue and virtual clock', () => {
|
||||
]).toEqual([0, 0, 0, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it('forgets which cables are busy on stop', () => {
|
||||
const [clientId, clientPortId, serverId, serverPortId] = [0x25, 0x26, 0x27, 0x28].map(uuid) as [string, string, string, string];
|
||||
const input = {
|
||||
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
|
||||
links: [link(clientId, clientPortId, serverId, serverPortId)],
|
||||
deviceConfigurations: new Map([
|
||||
[clientId, device('10.5.0.1')],
|
||||
[serverId, device('10.5.0.2')],
|
||||
]),
|
||||
};
|
||||
const engine = startedEngine(input);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
engine.openTcpServerSocket(serverId, 80);
|
||||
expect(engine.connectTcp(engine.openTcpClientSocket(clientId, '10.5.0.2', 80)!)).toBe(true);
|
||||
|
||||
engine.handle({ type: 'stop' });
|
||||
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
|
||||
engine.resolveMacAddress(clientId, '10.5.0.2');
|
||||
|
||||
expect(engine.state.packetTraces.find((trace) => trace.layer === 'physical' && trace.operation === 'sent')?.timeMilliseconds).toBe(0);
|
||||
});
|
||||
|
||||
it('forgets TCP sessions and accepted sockets on stop', () => {
|
||||
const [clientId, clientPortId, serverId, serverPortId] = [0x21, 0x22, 0x23, 0x24].map(uuid) as [string, string, string, string];
|
||||
const engine = startedEngine({
|
||||
@@ -925,6 +947,62 @@ describe('TCP sockets', () => {
|
||||
expect(engine.state.socketsById.get(listenerId)?.tcpState).toBe('LISTEN');
|
||||
});
|
||||
|
||||
it('carries one frame after the other on a cable, as Java (Swift: all at once)', () => {
|
||||
const departures = (swiftCables: boolean) => {
|
||||
const [clientId, clientPortId, serverId, serverPortId] = [0x17a1, 0x17b1, 0x17a2, 0x17b2].map(uuid) as [string, string, string, string];
|
||||
const engine = new NetworkRuntimeEngine(14, undefined, { swiftCables });
|
||||
engine.handle({
|
||||
type: 'start',
|
||||
snapshot: topologySnapshot({
|
||||
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
|
||||
links: [link(clientId, clientPortId, serverId, serverPortId)],
|
||||
deviceConfigurations: new Map([
|
||||
[clientId, device('10.23.0.1')],
|
||||
[serverId, device('10.23.0.2')],
|
||||
]),
|
||||
}),
|
||||
seed: 14,
|
||||
initialTimeMilliseconds: 0,
|
||||
});
|
||||
engine.openTcpServerSocket(serverId, 80);
|
||||
const client = engine.openTcpClientSocket(clientId, '10.23.0.2', 80)!;
|
||||
expect(engine.connectTcp(client)).toBe(true);
|
||||
const closedAt = engine.state.currentTimeMilliseconds;
|
||||
engine.closeTcpSocket(client);
|
||||
// The server answers the FIN with its ACK and, closing by itself, its FIN at once.
|
||||
const physical = (nodeId: string, operation: string) =>
|
||||
engine.state.packetTraces
|
||||
.filter((trace) => trace.nodeId === nodeId && trace.layer === 'physical' && trace.operation === operation && trace.timeMilliseconds > closedAt)
|
||||
.map((trace) => trace.timeMilliseconds - closedAt);
|
||||
return { sent: physical(serverId, 'sent'), received: physical(clientId, 'received') };
|
||||
};
|
||||
|
||||
expect(departures(false)).toEqual({ sent: [55, 110], received: [110, 165] });
|
||||
expect(departures(true)).toEqual({ sent: [55, 55], received: [110, 110] });
|
||||
});
|
||||
|
||||
it('queues a third frame behind the second on a busy cable', () => {
|
||||
// Four DHCP clients start at once and broadcast their DISCOVER; the switch
|
||||
// floods three of them onto each client's cable.
|
||||
const switchId = uuid(0x17c0);
|
||||
const hosts = [0, 1, 2, 3].map((index) => ({ id: uuid(0x17c1 + 2 * index), portId: uuid(0x17c2 + 2 * index), switchPortId: uuid(0x17d0 + index) }));
|
||||
const engine = startedEngine({
|
||||
nodes: [
|
||||
{ id: switchId, kind: 'networkSwitch', ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)) },
|
||||
...hosts.map((host) => pc(host.id, host.portId)),
|
||||
],
|
||||
links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)),
|
||||
deviceConfigurations: new Map(hosts.map((host) => [host.id, device('0.0.0.0')])),
|
||||
dhcpClientConfigurationsByNodeId: new Map(hosts.map((host) => [host.id, { isEnabled: true }])),
|
||||
});
|
||||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||||
|
||||
const received = engine.state.packetTraces
|
||||
.filter((trace) => trace.nodeId === hosts[0]!.id && trace.layer === 'physical' && trace.operation === 'received')
|
||||
.map((trace) => trace.timeMilliseconds);
|
||||
expect(received).toEqual([110, 165, 220]);
|
||||
});
|
||||
|
||||
it('ends TIME_WAIT with the next segment that comes', () => {
|
||||
const { engine, clientId, serverId } = pair(0x1771, 0x1772, '10.21.0');
|
||||
engine.openTcpServerSocket(serverId, 9090);
|
||||
|
||||
@@ -14,16 +14,17 @@
|
||||
//
|
||||
// Java runs on threads in real time, at the default speed of 90 % (55 ms a
|
||||
// cable, a round trip time of 2750 ms). Its gaps match the engine's within a
|
||||
// few milliseconds, except where a card sends two frames at once: Java's
|
||||
// cable carries one frame at a time (SimplexVerbindung sleeps the delay with
|
||||
// the port's buffer locked, and the sender waits), the engine's cable all of
|
||||
// them together — up to three cables' delay, measured. A gap may differ by
|
||||
// GAP_TOLERANCE_MILLISECONDS; a wrong round trip time (250 ms) may not.
|
||||
// few milliseconds; where they do not, it is Java's threads (a sender blocked
|
||||
// by a busy cable wakes late, up to some 110 ms measured), the log's lost
|
||||
// DISCOVER above, or a story whose calls the engine makes one after the
|
||||
// other. A gap may differ by GAP_TOLERANCE_MILLISECONDS; a wrong round trip
|
||||
// time (250 ms) may not. A cable carries one frame after the other in both
|
||||
// (engine.test.ts checks that: one cable's delay is below this tolerance).
|
||||
|
||||
import { NetworkRuntimeEngine, TCP_FLAG, type EthernetFrame } from '../src/index.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
export const GAP_TOLERANCE_MILLISECONDS = 200;
|
||||
export const GAP_TOLERANCE_MILLISECONDS = 120;
|
||||
const DUPLICATE_WINDOW_MILLISECONDS = 300;
|
||||
|
||||
export interface JavaFrame {
|
||||
|
||||
@@ -72,7 +72,8 @@ function observationJson(item: IcmpObservation): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function run(scenario: Scenario): Record<string, unknown> {
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed);
|
||||
// Swift's cables carry all frames at once; everything else is compared as it is.
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true });
|
||||
if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent);
|
||||
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
// client draws differs (Java's random, the engine's seed); it is compared as
|
||||
// "client". Times count from the client's SYN.
|
||||
//
|
||||
// The engine's calls block on its virtual clock one after the other, where
|
||||
// Java's server answers in a thread of its own: the server's answer starts
|
||||
// when the client's send returns, one cable's delay later than in Java.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -26,6 +23,10 @@ import type { Scenario } from './scenario.js';
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
|
||||
const STATE_UNSEEN_MILLISECONDS = 20;
|
||||
// The engine's calls block one after the other: in exchange, the server's
|
||||
// answer starts when the client's send returns, and the client closes when
|
||||
// the server's send returns — two cables' delay each, later than in Java.
|
||||
const STATE_TOLERANCE_MILLISECONDS = 250;
|
||||
const SERVER = '00000000-0000-0000-0000-000000000101';
|
||||
const CLIENT = '00000000-0000-0000-0000-000000000102';
|
||||
const OTHER_CLIENT = '00000000-0000-0000-0000-000000000103';
|
||||
@@ -109,7 +110,7 @@ function expectSameStates(label: string, actual: [number, string][], expected: [
|
||||
for (const [time, state] of expected) {
|
||||
const index = actual.findIndex(([, each], candidate) => candidate >= next && each === state);
|
||||
expect(index, `${label}: ${state} (Java at ${time} ms)`).toBeGreaterThanOrEqual(0);
|
||||
expect(Math.abs(actual[index]![0] - time), `${label}: time of ${state}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
||||
expect(Math.abs(actual[index]![0] - time), `${label}: time of ${state}`).toBeLessThanOrEqual(STATE_TOLERANCE_MILLISECONDS);
|
||||
matched.add(index);
|
||||
next = index + 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user