feat(core): DNS after Java, measured with Java FILIUS

DNS follows Java's DNSServer and Resolver, not Swift's typed format:
Java's text messages (dns.ts), a server on port 53 answering from its
records (no question in the answer, RCODE 0, a referral with NS and A in
the answer section), an iterative resolver that waits five round trips per
query and remembers only the servers it was referred to. Recursion throws
NotPortedError until the next step. The snapshot carries the DNS servers
switched on. receiveUdp returns as soon as a datagram is there, as Java's
empfangen does.

dnsJava.test.ts compares DnsRun's five lookups; four match frame by frame,
the missing server waits for Java's ARP (named as not yet like Java).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:53:32 +00:00
co-authored by Claude Opus 5.5
parent 24ebcea67f
commit 8869c865b1
8 changed files with 588 additions and 4 deletions
+96
View File
@@ -0,0 +1,96 @@
// DNS as Java FILIUS runs it. tools/java-oracle's DnsRun called a client's
// resolver on dns-lan's network in Java FILIUS and wrote every frame each
// card saw and what the call returned (dns-java-<story>.java.json). The
// engine makes the same call: the frames must come in the same order, with
// the same content, at about the same distance from each other (javaRun.ts),
// the call must return the same, after about as long.
//
// Java draws each message's ID with Math.random and a client's free port with
// its random; both are compared as placeholders, and an answer must carry the
// ID of its question.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { DnsAddressResolution, NetworkRuntimeEngine } from '../src/index.js';
import { GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } from './javaRun.js';
import type { Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const CLIENT = '00000000-0000-0000-0000-000000000101';
const LOST_CLIENT = '00000000-0000-0000-0000-000000000104';
interface JavaRun {
frames: Record<string, JavaFrame[]>;
events: [number, string][];
}
const STORIES: Record<string, (engine: NetworkRuntimeEngine) => DnsAddressResolution> = {
address: (engine) => engine.resolveDnsAddress(CLIENT, 'web.filius.de'),
mail: (engine) => engine.resolveDnsMailServer(CLIENT, 'filius.de'),
referral: (engine) => engine.resolveDnsAddress(CLIENT, 'www.schule.de'),
unknown: (engine) => engine.resolveDnsAddress(CLIENT, 'x.unbekannt.org'),
noServer: (engine) => engine.resolveDnsAddress(LOST_CLIENT, 'web.filius.de'),
};
/** Java's words for the result: the address, null, or the exception. */
function javaResult(resolution: DnsAddressResolution): string {
return resolution.type === 'timedOut' ? 'failed TimeOutException' : `returned ${resolution.address ?? 'null'}`;
}
/** IDs and free ports as placeholders; each ID stands for the question it first appeared in. */
function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
const ids = new Map<string, string>();
return frames.map((frame) => ({
time: frame.time,
text: frame.text
.replace(/ID=(\d+)/, (_, id: string) => {
if (!ids.has(id)) ids.set(id, `q${ids.size + 1}`);
return `ID=${ids.get(id)}`;
})
.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole)),
}));
}
/**
* Stories the engine does not play like Java yet, with the reason; each must
* still differ from Java (else it belongs to the others) and return the same.
*
* noServer: Java's transport thread sends while the resolver already waits,
* and asks ARP twice, a round trip apart. The engine's ARP (Swift's) blocks
* the call and asks again at once — rebuilt after Java in its own step.
*/
const NOT_YET_LIKE_JAVA: Record<string, string> = {
noServer: "ARP: Java's second request comes a round trip after the first, while the resolver waits",
};
describe('DNS as Java FILIUS runs it', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'dns-lan.scenario.json'), 'utf8')) as Scenario;
for (const [name, story] of Object.entries(STORIES)) {
it(name, () => {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `dns-java-${name}.java.json`), 'utf8')) as JavaRun;
const { engine, frames } = watchedEngine(scenario);
engine.handle({ type: 'advance', toMilliseconds: 500 });
const called = engine.state.currentTimeMilliseconds;
const result = story(engine);
const returned = engine.state.currentTimeMilliseconds;
const [[javaCalled], [javaReturned, javaOutcome]] = java.events as [[number, string], [number, string]];
expect(`${javaOutcome.split(' ').slice(-2).join(' ')}`).toBe(javaResult(result).split(' ').slice(-2).join(' '));
const expected = gaps(normalized(javaFrames(java.frames)));
const actual = gaps(normalized(frames()));
if (name in NOT_YET_LIKE_JAVA) {
expect(JSON.stringify(actual), NOT_YET_LIKE_JAVA[name]).not.toBe(JSON.stringify(expected));
return;
}
expect(Math.abs(returned - called - (javaReturned - javaCalled)), 'duration of the call').toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
expect(actual[index]?.text, `frame #${index + 1}`).toBe(expected[index]?.text);
expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
}
});
}
});