feat(core): programs' threads, and FILIUS' echo server and generic client after Java, measured against Java
Programs run as generators that yield what they wait for (threads.ts); the engine resumes them after every event. TCP connect, send, receive, accept and name resolution exist as such steps; the blocking calls wrap them. EchoRun records eight stories in Java FILIUS, frames, tables and each program's window lines; echoJava.test.ts plays them card by card. Not taken from Java: a connection without a route hangs for ever; a server switched off leaves its connections' last FIN unanswered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// Programs' threads (threads.ts) and the echo server and generic client
|
||||
// (apps/echo.ts) where echoJava.test.ts's stories do not go: how threads
|
||||
// wake, a taken port, a name, no route, lost data, a new start.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { deviceConfiguration, pause, waitUntil, type NetworkRuntimeEngine, type Steps, type TopologySnapshotInput } from '../src/index.js';
|
||||
import { link, pc, startedEngine, uuid } from './fixtures.js';
|
||||
|
||||
const SERVER = uuid(0x2001);
|
||||
const CLIENT = uuid(0x2002);
|
||||
|
||||
/** 10.0.0.1 (echo server, DNS server with echo.filius.de) and 10.0.0.2 (client), on one cable. */
|
||||
function network(changes: Partial<TopologySnapshotInput> = {}): TopologySnapshotInput {
|
||||
return {
|
||||
nodes: [pc(SERVER, uuid(0x2011)), pc(CLIENT, uuid(0x2012))],
|
||||
links: [link(SERVER, uuid(0x2011), CLIENT, uuid(0x2012))],
|
||||
deviceConfigurations: new Map([
|
||||
[SERVER, deviceConfiguration('10.0.0.1', '255.255.255.0', '', '')],
|
||||
[CLIENT, deviceConfiguration('10.0.0.2', '255.255.255.0', '', '10.0.0.1')],
|
||||
]),
|
||||
echoServersByNodeId: new Map([[SERVER, { port: 55555, isActive: true }]]),
|
||||
simpleClientsByNodeId: new Map([[CLIENT, { targetAddress: '', targetPort: 55555 }]]),
|
||||
...changes,
|
||||
};
|
||||
}
|
||||
|
||||
const advance = (engine: NetworkRuntimeEngine, toMilliseconds: number) => engine.handle({ type: 'advance', toMilliseconds });
|
||||
const kinds = (lines: readonly { kind: string }[]) => lines.map((line) => line.kind);
|
||||
|
||||
describe("programs' threads", () => {
|
||||
it('wake when their condition holds, right after the event that made it so', () => {
|
||||
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
|
||||
let flag = false;
|
||||
const woken: number[] = [];
|
||||
engine.startThread(
|
||||
(function* (): Steps {
|
||||
yield* waitUntil(() => flag);
|
||||
woken.push(engine.state.currentTimeMilliseconds);
|
||||
})(),
|
||||
);
|
||||
engine.handle({ type: 'schedule', deadlineMilliseconds: 300, kind: { kind: 'parityMarker', label: 'a' } });
|
||||
advance(engine, 200);
|
||||
flag = true;
|
||||
expect(woken).toEqual([]);
|
||||
advance(engine, 1_000);
|
||||
expect(woken).toEqual([300]);
|
||||
});
|
||||
|
||||
it('go on after a pause, after the threads that are ready at that time', () => {
|
||||
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
|
||||
const order: string[] = [];
|
||||
let ready = false;
|
||||
engine.startThread(
|
||||
(function* (): Steps {
|
||||
yield pause(engine.state.currentTimeMilliseconds);
|
||||
order.push(`paused ${engine.state.currentTimeMilliseconds}`);
|
||||
})(),
|
||||
);
|
||||
engine.startThread(
|
||||
(function* (): Steps {
|
||||
yield* waitUntil(() => ready);
|
||||
order.push(`waited ${engine.state.currentTimeMilliseconds}`);
|
||||
})(),
|
||||
);
|
||||
ready = true;
|
||||
engine.resumeThreads();
|
||||
advance(engine, 0);
|
||||
expect(order).toEqual(['waited 0', 'paused 0']);
|
||||
});
|
||||
|
||||
it('wake from a wait by its time only once, not again in a later wait', () => {
|
||||
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
|
||||
let flag = false;
|
||||
const woken: string[] = [];
|
||||
engine.startThread(
|
||||
(function* (): Steps {
|
||||
const first = yield* waitUntil(() => flag, 500);
|
||||
woken.push(`first ${first} ${engine.state.currentTimeMilliseconds}`);
|
||||
const second = yield* waitUntil(() => false, 2_000);
|
||||
woken.push(`second ${second} ${engine.state.currentTimeMilliseconds}`);
|
||||
})(),
|
||||
);
|
||||
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: { kind: 'parityMarker', label: 'a' } });
|
||||
flag = true;
|
||||
advance(engine, 3_000);
|
||||
// The first wait ended at 100 by its condition; its wake-up at 500 does not end the second.
|
||||
expect(woken).toEqual(['first true 100', 'second false 2000']);
|
||||
});
|
||||
|
||||
it('resume in the order they began when the same event wakes them', () => {
|
||||
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
|
||||
let flag = false;
|
||||
const order: string[] = [];
|
||||
for (const name of ['first', 'second', 'third']) {
|
||||
engine.startThread(
|
||||
(function* (): Steps {
|
||||
yield* waitUntil(() => flag);
|
||||
order.push(name);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
flag = true;
|
||||
engine.resumeThreads();
|
||||
expect(order).toEqual(['first', 'second', 'third']);
|
||||
});
|
||||
|
||||
it('do not wait for what already holds: no other thread runs in between', () => {
|
||||
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
|
||||
let flag = false;
|
||||
const order: string[] = [];
|
||||
engine.startThread(
|
||||
(function* (): Steps {
|
||||
yield* waitUntil(() => flag);
|
||||
order.push('woken');
|
||||
})(),
|
||||
);
|
||||
engine.startThread(
|
||||
(function* (): Steps {
|
||||
flag = true;
|
||||
yield* waitUntil(() => true);
|
||||
order.push('went on');
|
||||
})(),
|
||||
);
|
||||
expect(order).toEqual(['went on', 'woken']);
|
||||
});
|
||||
|
||||
it('end with the simulation', () => {
|
||||
const engine = startedEngine(network());
|
||||
expect(engine.echoServer(SERVER)).toBeDefined();
|
||||
engine.handle({ type: 'stop' });
|
||||
expect(engine.echoServer(SERVER)).toBeUndefined();
|
||||
expect(engine.simpleClient(CLIENT)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the echo server and the generic client', () => {
|
||||
it('switches the server off when its port is taken (sw_serveranwendung_msg6)', () => {
|
||||
const engine = startedEngine(network({ echoServersByNodeId: new Map([[SERVER, { port: 7, isActive: false }]]) }));
|
||||
expect(engine.openTcpServerSocket(SERVER, 7)).toBeDefined();
|
||||
const server = engine.echoServer(SERVER)!;
|
||||
server.setActive(true);
|
||||
advance(engine, 100);
|
||||
expect(kinds(server.log.all)).toEqual(['acceptingStarted', 'serverSocketFailed', 'acceptingStopped']);
|
||||
expect(server.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('connects to a name through the DNS server', () => {
|
||||
const engine = startedEngine(
|
||||
network({
|
||||
dnsServerConfigurationsByNodeId: new Map([[SERVER, { recursiveResolutionEnabled: false, records: [{ name: 'echo.filius.de', type: 'A', ttlSeconds: 3600, target: '10.0.0.1' }] }]]),
|
||||
}),
|
||||
);
|
||||
const client = engine.simpleClient(CLIENT)!;
|
||||
client.connect('echo.filius.de', 55555);
|
||||
advance(engine, 3_000);
|
||||
client.send('Hallo');
|
||||
advance(engine, 6_000);
|
||||
expect(kinds(client.log.all)).toEqual(['connected', 'sent', 'received']);
|
||||
expect(engine.echoServer(SERVER)!.log.all[0]).toMatchObject({ kind: 'connectionAccepted', address: '10.0.0.2' });
|
||||
});
|
||||
|
||||
it('fails to connect to a name no server knows, without a frame (sw_socket_msg1)', () => {
|
||||
const engine = startedEngine(network({ deviceConfigurations: new Map([[CLIENT, deviceConfiguration('10.0.0.2', '255.255.255.0', '', '')]]) }));
|
||||
const client = engine.simpleClient(CLIENT)!;
|
||||
client.connect('echo.filius.de', 55555);
|
||||
advance(engine, 5_000);
|
||||
expect(client.log.all).toEqual([{ kind: 'connectFailed', failure: 'unresolved', timeMilliseconds: 0 }]);
|
||||
expect(engine.capture.frames(uuid(0x2012))).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails at once to connect to an address without a route, without a frame (Java waits for ever)', () => {
|
||||
const engine = startedEngine(network());
|
||||
const client = engine.simpleClient(CLIENT)!;
|
||||
advance(engine, 100);
|
||||
client.connect('10.9.9.9', 55555);
|
||||
expect(client.log.all).toEqual([{ kind: 'connectFailed', failure: 'failed', timeMilliseconds: 100 }]);
|
||||
advance(engine, 5_000);
|
||||
expect(engine.capture.frames(uuid(0x2012))).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a send that no one acknowledges and the connection it ends (sw_tcpsocket_msg8)', () => {
|
||||
const engine = startedEngine(network());
|
||||
const client = engine.simpleClient(CLIENT)!;
|
||||
client.connect('10.0.0.1', 55555);
|
||||
advance(engine, 3_000);
|
||||
engine.setGlobalPacketLossEnabled(true);
|
||||
client.send('verloren');
|
||||
advance(engine, 40_000);
|
||||
expect(client.log.all.filter((line) => line.kind === 'sendFailed')).toMatchObject([{ kind: 'sendFailed', failure: 'timedOut' }]);
|
||||
expect(kinds(client.log.all)).toContain('connectionClosed');
|
||||
expect(client.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('starts anew with a new simulation: from the snapshot, with empty windows', () => {
|
||||
const engine = startedEngine(network());
|
||||
engine.echoServer(SERVER)!.setActive(false);
|
||||
engine.simpleClient(CLIENT)!.send('Hallo');
|
||||
engine.handle({ type: 'stop' });
|
||||
engine.handle({ type: 'start', snapshot: engine.state.topologySnapshot, seed: 14, initialTimeMilliseconds: 0 });
|
||||
expect(engine.echoServer(SERVER)!.isActive).toBe(true);
|
||||
expect(engine.echoServer(SERVER)!.log.all).toEqual([]);
|
||||
expect(engine.simpleClient(CLIENT)!.log.all).toEqual([]);
|
||||
});
|
||||
|
||||
it('hands out a message that came before the connection closed (Java drops it)', () => {
|
||||
const engine = startedEngine(network({ echoServersByNodeId: new Map(), simpleClientsByNodeId: new Map() }));
|
||||
const listener = engine.openTcpServerSocket(SERVER, 80)!;
|
||||
const client = engine.openTcpClientSocket(CLIENT, '10.0.0.1', 80)!;
|
||||
expect(engine.connectTcpWithResult(client)).toBe('connected');
|
||||
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listener)![0]!;
|
||||
advance(engine, engine.state.currentTimeMilliseconds + 100);
|
||||
expect(engine.sendTcp(accepted, new TextEncoder().encode('zuletzt'))).toBe(true);
|
||||
engine.closeTcpSocket(accepted);
|
||||
advance(engine, engine.state.currentTimeMilliseconds + 1_000);
|
||||
expect(engine.state.socketsById.get(client)!.tcpState).not.toBe('ESTABLISHED');
|
||||
const received: string[] = [];
|
||||
engine.startThread(
|
||||
(function* (): Steps {
|
||||
for (;;) {
|
||||
const next = yield* engine.receiveTcpSteps(client);
|
||||
received.push(next.type === 'message' ? new TextDecoder().decode(next.payload) : next.type);
|
||||
if (next.type !== 'message') return;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
expect(received).toEqual(['zuletzt', 'notConnected']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user