Strengthen CLI benchmark verification

This commit is contained in:
vorotamoroz
2026-07-12 10:54:16 +00:00
parent 8fec425d32
commit 895675ab09
8 changed files with 360 additions and 118 deletions
+59 -44
View File
@@ -11,8 +11,12 @@ import {
} from "./helpers/docker.ts"; } from "./helpers/docker.ts";
import { import {
createDeterministicDataset, createDeterministicDataset,
type DatasetEntry,
} from "./helpers/dataset.ts"; } from "./helpers/dataset.ts";
import {
type BenchmarkVerificationMode,
parseBenchmarkVerificationMode,
verifyBenchmarkDataset,
} from "./helpers/benchmarkVerification.ts";
type BenchmarkConfig = { type BenchmarkConfig = {
caseName: string; caseName: string;
@@ -38,6 +42,9 @@ type BenchmarkConfig = {
networkModel: string; networkModel: string;
measurementScope: string; measurementScope: string;
limitations: string[]; limitations: string[];
verificationMode: BenchmarkVerificationMode;
repeatIndex: number;
repeatCount: number;
}; };
function readEnvString(name: string, fallback: string): string { function readEnvString(name: string, fallback: string): string {
@@ -163,6 +170,11 @@ function buildConfig(): BenchmarkConfig {
limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [ limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [
"This benchmark result is scoped to the configured dataset, remote store, and network model.", "This benchmark result is scoped to the configured dataset, remote store, and network model.",
]), ]),
verificationMode: parseBenchmarkVerificationMode(
Deno.env.get("BENCH_VERIFY_MODE"),
),
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
}; };
} }
@@ -174,35 +186,27 @@ function readOptionalResultPath(): string | undefined {
return raw; return raw;
} }
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] { export type CouchdbProxyHandle = {
if (entries.length === 0) {
return [];
}
const md = entries.find((e) => e.kind === "md");
const bin = entries.find((e) => e.kind === "bin");
const middle = entries[Math.floor(entries.length / 2)];
const last = entries[entries.length - 1];
const unique = new Map<string, DatasetEntry>();
for (const entry of [md, bin, middle, last]) {
if (entry) {
unique.set(entry.relativePath, entry);
}
}
return [...unique.values()];
}
type ProxyHandle = {
stop: () => Promise<void>; stop: () => Promise<void>;
applied: boolean; applied: boolean;
note: string; note: string;
directionalDelayMs: number;
}; };
function startCouchdbProxy( export function startCouchdbProxy(
options: { backendUri: string; proxyUri: string; requestedRttMs: number }, options: {
): ProxyHandle { backendUri: string;
proxyUri: string;
requestedRttMs: number;
delay?: (milliseconds: number) => Promise<void>;
},
): CouchdbProxyHandle {
const backend = new URL(options.backendUri); const backend = new URL(options.backendUri);
const proxy = new URL(options.proxyUri); const proxy = new URL(options.proxyUri);
const halfDelayMs = Math.max(1, Math.floor(options.requestedRttMs / 2)); const halfDelayMs = options.requestedRttMs / 2;
const delay = options.delay ??
((milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
const controller = new AbortController(); const controller = new AbortController();
const listener = Deno.serve( const listener = Deno.serve(
@@ -216,7 +220,7 @@ function startCouchdbProxy(
}, },
}, },
async (request) => { async (request) => {
await new Promise((resolve) => setTimeout(resolve, halfDelayMs)); await delay(halfDelayMs);
const targetUrl = new URL(request.url); const targetUrl = new URL(request.url);
targetUrl.protocol = backend.protocol; targetUrl.protocol = backend.protocol;
@@ -245,6 +249,7 @@ function startCouchdbProxy(
const responseHeaders = new Headers(upstream.headers); const responseHeaders = new Headers(upstream.headers);
responseHeaders.delete("content-length"); responseHeaders.delete("content-length");
const responseBody = await upstream.arrayBuffer(); const responseBody = await upstream.arrayBuffer();
await delay(halfDelayMs);
return new Response(responseBody, { return new Response(responseBody, {
status: upstream.status, status: upstream.status,
@@ -256,8 +261,9 @@ function startCouchdbProxy(
return { return {
applied: true, applied: true,
directionalDelayMs: halfDelayMs,
note: note:
`local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms pre-forward delay`, `local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms request-path and ${halfDelayMs}ms response-path delay`,
stop: async () => { stop: async () => {
controller.abort(); controller.abort();
await listener.finished.catch(() => {}); await listener.finished.catch(() => {});
@@ -350,25 +356,28 @@ async function main(): Promise<void> {
await runCliOrFail(vaultB, "--settings", settingsB, "sync"); await runCliOrFail(vaultB, "--settings", settingsB, "sync");
const syncBElapsed = nowMs() - syncBStart; const syncBElapsed = nowMs() - syncBStart;
const sampleFiles = pickSampleFiles(seedFiles.entries); const verification = await verifyBenchmarkDataset(
for (const sample of sampleFiles) { seedFiles.entries,
const pulledPath = workDir.join( config.verificationMode,
`pulled-${sample.relativePath.split("/").join("_")}`, async (entry) => {
); const pulledPath = workDir.join(
await runCliOrFail( `pulled-${entry.relativePath.split("/").join("_")}`,
vaultB, );
"--settings", await runCliOrFail(
settingsB, vaultB,
"pull", "--settings",
sample.relativePath, settingsB,
pulledPath, "pull",
); entry.relativePath,
await assertFilesEqual( pulledPath,
sample.absolutePath, );
pulledPath, await assertFilesEqual(
`sample file mismatch after CouchDB sync: ${sample.relativePath}`, entry.absolutePath,
); pulledPath,
} `file mismatch after CouchDB sync: ${entry.relativePath}`,
);
},
);
const result = { const result = {
caseName: config.caseName, caseName: config.caseName,
@@ -382,15 +391,21 @@ async function main(): Promise<void> {
networkModel: config.networkModel, networkModel: config.networkModel,
measurementScope: config.measurementScope, measurementScope: config.measurementScope,
limitations: config.limitations, limitations: config.limitations,
repeatIndex: config.repeatIndex,
repeatCount: config.repeatCount,
rttRequestedMs: config.requestedRttMs, rttRequestedMs: config.requestedRttMs,
proxyApplied: proxy.applied, proxyApplied: proxy.applied,
proxyNote: proxy.note, proxyNote: proxy.note,
proxyDirectionalDelayMs: proxy.directionalDelayMs,
proxyConfiguredRttMs: proxy.directionalDelayMs * 2,
proxyDelayApplication: "request-and-response",
datasetSeed: config.datasetSeed, datasetSeed: config.datasetSeed,
datasetDirName: config.datasetDirName, datasetDirName: config.datasetDirName,
totalFiles: seedFiles.totalFiles, totalFiles: seedFiles.totalFiles,
totalBytes: seedFiles.totalBytes, totalBytes: seedFiles.totalBytes,
mdFileCount: seedFiles.mdCount, mdFileCount: seedFiles.mdCount,
binFileCount: seedFiles.binCount, binFileCount: seedFiles.binCount,
...verification,
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
syncAElapsedMs: Number(syncAElapsed.toFixed(1)), syncAElapsedMs: Number(syncAElapsed.toFixed(1)),
syncBElapsedMs: Number(syncBElapsed.toFixed(1)), syncBElapsedMs: Number(syncBElapsed.toFixed(1)),
+66 -24
View File
@@ -2,6 +2,8 @@ type SweepResult = {
name: string; name: string;
runner: "p2p" | "couchdb"; runner: "p2p" | "couchdb";
rttMs?: number; rttMs?: number;
repeatIndex: number;
repeatCount: number;
result: Record<string, unknown>; result: Record<string, unknown>;
}; };
@@ -19,6 +21,15 @@ function timestamp(): string {
); );
} }
function readEnvInteger(name: string, fallback: number): number {
const raw = readEnvString(name, String(fallback));
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`${name} must be a positive integer, got '${raw}'`);
}
return parsed;
}
function parseRttList(raw: string): number[] { function parseRttList(raw: string): number[] {
const values = raw const values = raw
.split(",") .split(",")
@@ -41,6 +52,7 @@ function buildBaseEnv(): Record<string, string> {
BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"), BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"),
BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"), BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"),
BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
BENCH_VERIFY_MODE: readEnvString("BENCH_VERIFY_MODE", "all"),
LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"), LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"),
}; };
} }
@@ -50,12 +62,19 @@ async function runBenchmark(options: {
name: string; name: string;
outputDir: string; outputDir: string;
env: Record<string, string>; env: Record<string, string>;
repeatIndex: number;
repeatCount: number;
}): Promise<Record<string, unknown>> { }): Promise<Record<string, unknown>> {
const resultPath = `${options.outputDir}/${options.name}.json`; const suffix = options.repeatCount > 1
? `-r${String(options.repeatIndex).padStart(2, "0")}`
: "";
const resultPath = `${options.outputDir}/${options.name}${suffix}.json`;
const env = { const env = {
...Deno.env.toObject(), ...Deno.env.toObject(),
...options.env, ...options.env,
BENCH_RESULT_JSON: resultPath, BENCH_RESULT_JSON: resultPath,
BENCH_REPEAT_INDEX: String(options.repeatIndex),
BENCH_REPEAT_COUNT: String(options.repeatCount),
}; };
console.log(`[latency-sweep] running ${options.name}`); console.log(`[latency-sweep] running ${options.name}`);
@@ -78,46 +97,69 @@ async function main(): Promise<void> {
const outRoot = readEnvString("BENCH_SWEEP_ROOT", `${import.meta.dirname}/bench-results`); const outRoot = readEnvString("BENCH_SWEEP_ROOT", `${import.meta.dirname}/bench-results`);
const outputDir = `${outRoot}/latency-sweep-${timestamp()}`; const outputDir = `${outRoot}/latency-sweep-${timestamp()}`;
const rtts = parseRttList(readEnvString("BENCH_SWEEP_RTT_MS", "20,50,100,150,300")); const rtts = parseRttList(readEnvString("BENCH_SWEEP_RTT_MS", "20,50,100,150,300"));
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
const base = buildBaseEnv(); const base = buildBaseEnv();
await Deno.mkdir(outputDir, { recursive: true }); await Deno.mkdir(outputDir, { recursive: true });
const results: SweepResult[] = []; const results: SweepResult[] = [];
if (readEnvString("BENCH_SWEEP_INCLUDE_P2P", "true") !== "false") { if (readEnvString("BENCH_SWEEP_INCLUDE_P2P", "true") !== "false") {
const p2pResult = await runBenchmark({ for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
taskName: "bench:p2p", const p2pResult = await runBenchmark({
name: "p2p-direct-local", taskName: "bench:p2p",
outputDir, name: "p2p-direct-local",
env: { outputDir,
...base, repeatIndex,
BENCH_CASE: "p2p-direct-local", repeatCount,
BENCH_TURN_SERVERS: "", env: {
}, ...base,
}); BENCH_CASE: "p2p-direct-local",
results.push({ name: "p2p-direct-local", runner: "p2p", result: p2pResult }); BENCH_TURN_SERVERS: "",
},
});
results.push({
name: "p2p-direct-local",
runner: "p2p",
repeatIndex,
repeatCount,
result: p2pResult,
});
}
} }
for (const rtt of rtts) { for (const rtt of rtts) {
const name = `couchdb-rtt-${rtt}ms`; const name = `couchdb-rtt-${rtt}ms`;
const couchdbResult = await runBenchmark({ for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
taskName: "bench:couchdb", const couchdbResult = await runBenchmark({
name, taskName: "bench:couchdb",
outputDir, name,
env: { outputDir,
...base, repeatIndex,
BENCH_CASE: name, repeatCount,
BENCH_COUCHDB_RTT_MS: String(rtt), env: {
}, ...base,
}); BENCH_CASE: name,
results.push({ name, runner: "couchdb", rttMs: rtt, result: couchdbResult }); BENCH_COUCHDB_RTT_MS: String(rtt),
},
});
results.push({
name,
runner: "couchdb",
rttMs: rtt,
repeatIndex,
repeatCount,
result: couchdbResult,
});
}
} }
const summary = { const summary = {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
outputDir, outputDir,
note: note:
"This sweep models additional remote CouchDB request latency through the existing HTTP proxy. It is not a full netem model of jitter, loss, MTU, bandwidth, or VPN encapsulation.", "This sweep applies half of each requested CouchDB RTT before forwarding requests and half before returning responses. It is not a full netem model of jitter, loss, MTU, bandwidth, or VPN encapsulation.",
rtts, rtts,
repeatCount,
results, results,
}; };
await Deno.writeTextFile(`${outputDir}/summary.json`, JSON.stringify(summary, null, 2)); await Deno.writeTextFile(`${outputDir}/summary.json`, JSON.stringify(summary, null, 2));
+8 -4
View File
@@ -52,6 +52,7 @@ function buildBaseEnv(): Record<string, string> {
BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"), BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"),
BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"), BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"),
BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
BENCH_VERIFY_MODE: readEnvString("BENCH_VERIFY_MODE", "all"),
LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"), LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"),
}; };
} }
@@ -119,9 +120,10 @@ export function buildCases(): BenchmarkCase[] {
dataPath: "Device A -> Device B", dataPath: "Device A -> Device B",
trustBoundary: "Nostr relay for signalling metadata; no TURN relay", trustBoundary: "Nostr relay for signalling metadata; no TURN relay",
measurementScope: measurementScope:
"One CLI P2P synchronisation phase over a local WebRTC DataChannel after Nostr signalling, with TURN disabled.", "One fresh CLI p2p-sync command, including process start-up and WebRTC connection establishment, with TURN disabled; the earlier peer-list observation command is excluded.",
limitations: [ limitations: [
"This does not measure first-peer discovery latency, public relay operation, mobile carrier behaviour, or TURN-relayed throughput.", "The timed command includes its own signalling and connection establishment, but not the earlier peer-list observation window.",
"This does not measure public relay operation, mobile carrier behaviour, or TURN-relayed throughput.",
"This small-dataset run should not be treated as a WAN, VPN, or large binary initial synchronisation measurement.", "This small-dataset run should not be treated as a WAN, VPN, or large binary initial synchronisation measurement.",
], ],
env: { env: {
@@ -240,8 +242,9 @@ export function buildCases(): BenchmarkCase[] {
trustBoundary: trustBoundary:
"Nostr signalling metadata through constrained network shim; no TURN relay", "Nostr signalling metadata through constrained network shim; no TURN relay",
measurementScope: measurementScope:
"Tier 2 P2P synchronisation where only the Nostr signalling path is shaped by the home-wifi netem profile.", "One fresh CLI p2p-sync command where only Nostr signalling access is shaped by the home-wifi netem profile; the selected WebRTC note-data path is unshaped.",
limitations: [ limitations: [
"The timed p2p-sync command includes signalling and WebRTC connection establishment.",
"This does not shape the selected WebRTC DataChannel note-data path.", "This does not shape the selected WebRTC DataChannel note-data path.",
"This supports only the claim that constrained signalling access does not place note data on the relay path when a non-relayed ICE path is selected.", "This supports only the claim that constrained signalling access does not place note data on the relay path when a non-relayed ICE path is selected.",
], ],
@@ -267,8 +270,9 @@ export function buildCases(): BenchmarkCase[] {
trustBoundary: trustBoundary:
"Nostr signalling metadata through constrained smartphone/VPN-like network shim; no TURN relay", "Nostr signalling metadata through constrained smartphone/VPN-like network shim; no TURN relay",
measurementScope: measurementScope:
"Tier 2 P2P synchronisation where only the Nostr signalling path is shaped by the tethering-vpn netem profile.", "One fresh CLI p2p-sync command where only Nostr signalling access is shaped by the tethering-vpn netem profile; the selected WebRTC note-data path is unshaped.",
limitations: [ limitations: [
"The timed p2p-sync command includes signalling and WebRTC connection establishment.",
"This does not shape the selected WebRTC DataChannel note-data path.", "This does not shape the selected WebRTC DataChannel note-data path.",
"The profile approximates constrained relay access and is not a field measurement on a real tethered VPN connection.", "The profile approximates constrained relay access and is not a field measurement on a real tethered VPN connection.",
], ],
+39 -38
View File
@@ -15,8 +15,12 @@ import {
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
import { import {
createDeterministicDataset, createDeterministicDataset,
type DatasetEntry,
} from "./helpers/dataset.ts"; } from "./helpers/dataset.ts";
import {
type BenchmarkVerificationMode,
parseBenchmarkVerificationMode,
verifyBenchmarkDataset,
} from "./helpers/benchmarkVerification.ts";
type BenchmarkConfig = { type BenchmarkConfig = {
caseName: string; caseName: string;
@@ -40,6 +44,9 @@ type BenchmarkConfig = {
candidatePathVerification: string; candidatePathVerification: string;
measurementScope: string; measurementScope: string;
limitations: string[]; limitations: string[];
verificationMode: BenchmarkVerificationMode;
repeatIndex: number;
repeatCount: number;
}; };
type P2PConnectionStats = { type P2PConnectionStats = {
@@ -174,11 +181,16 @@ function buildConfig(): BenchmarkConfig {
), ),
measurementScope: readEnvString( measurementScope: readEnvString(
"BENCH_MEASUREMENT_SCOPE", "BENCH_MEASUREMENT_SCOPE",
"One CLI P2P synchronisation phase over WebRTC DataChannel after signalling.", "One fresh CLI p2p-sync command, including process start-up and WebRTC connection establishment; the earlier peer-list observation command is excluded.",
), ),
limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [ limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [
"This benchmark result is scoped to the configured dataset, network model, and selected ICE path.", "This benchmark result is scoped to the configured dataset, network model, and selected ICE path.",
]), ]),
verificationMode: parseBenchmarkVerificationMode(
Deno.env.get("BENCH_VERIFY_MODE"),
),
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
}; };
} }
@@ -190,23 +202,6 @@ function readOptionalResultPath(): string | undefined {
return raw; return raw;
} }
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
if (entries.length === 0) {
return [];
}
const md = entries.find((e) => e.kind === "md");
const bin = entries.find((e) => e.kind === "bin");
const middle = entries[Math.floor(entries.length / 2)];
const last = entries[entries.length - 1];
const unique = new Map<string, DatasetEntry>();
for (const entry of [md, bin, middle, last]) {
if (entry) {
unique.set(entry.relativePath, entry);
}
}
return [...unique.values()];
}
async function readLatestP2PConnectionStats( async function readLatestP2PConnectionStats(
statsPath: string, statsPath: string,
): Promise<P2PConnectionStats | undefined> { ): Promise<P2PConnectionStats | undefined> {
@@ -329,25 +324,28 @@ async function main(): Promise<void> {
); );
const syncElapsed = nowMs() - syncStart; const syncElapsed = nowMs() - syncStart;
const sampleFiles = pickSampleFiles(seedFiles.entries); const verification = await verifyBenchmarkDataset(
for (const sample of sampleFiles) { seedFiles.entries,
const pulledPath = workDir.join( config.verificationMode,
`pulled-${sample.relativePath.replaceAll("/", "_")}`, async (entry) => {
); const pulledPath = workDir.join(
await runCliOrFail( `pulled-${entry.relativePath.replaceAll("/", "_")}`,
clientVault, );
"--settings", await runCliOrFail(
clientSettings, clientVault,
"pull", "--settings",
sample.relativePath, clientSettings,
pulledPath, "pull",
); entry.relativePath,
await assertFilesEqual( pulledPath,
sample.absolutePath, );
pulledPath, await assertFilesEqual(
`sample file mismatch after sync: ${sample.relativePath}`, entry.absolutePath,
); pulledPath,
} `file mismatch after P2P sync: ${entry.relativePath}`,
);
},
);
const p2pConnectionStats = await readLatestP2PConnectionStats( const p2pConnectionStats = await readLatestP2PConnectionStats(
p2pStatsPath, p2pStatsPath,
@@ -363,6 +361,8 @@ async function main(): Promise<void> {
networkModel: config.networkModel, networkModel: config.networkModel,
measurementScope: config.measurementScope, measurementScope: config.measurementScope,
limitations: config.limitations, limitations: config.limitations,
repeatIndex: config.repeatIndex,
repeatCount: config.repeatCount,
p2pCandidatePathVerified: p2pCandidatePathVerified:
p2pConnectionStats?.candidatePathCollected === true, p2pConnectionStats?.candidatePathCollected === true,
p2pCandidatePathVerification: p2pCandidatePathVerification:
@@ -385,6 +385,7 @@ async function main(): Promise<void> {
totalBytes: seedFiles.totalBytes, totalBytes: seedFiles.totalBytes,
mdFileCount: seedFiles.mdCount, mdFileCount: seedFiles.mdCount,
binFileCount: seedFiles.binCount, binFileCount: seedFiles.binCount,
...verification,
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)), hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)),
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds, peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
@@ -0,0 +1,80 @@
import type { DatasetEntry } from "./dataset.ts";
export type BenchmarkVerificationMode = "all" | "sample";
export type BenchmarkVerificationResult = {
verificationMode: BenchmarkVerificationMode;
verifiedFiles: number;
verificationComplete: boolean;
datasetDigestSha256: string;
};
function toHex(bytes: ArrayBuffer): string {
return [...new Uint8Array(bytes)]
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
}
async function sha256(bytes: Uint8Array): Promise<string> {
const input = new ArrayBuffer(bytes.byteLength);
new Uint8Array(input).set(bytes);
return toHex(await crypto.subtle.digest("SHA-256", input));
}
export function parseBenchmarkVerificationMode(
raw: string | undefined,
fallback: BenchmarkVerificationMode = "sample",
): BenchmarkVerificationMode {
const value = raw?.trim().toLowerCase();
if (!value) return fallback;
if (value === "all" || value === "sample") return value;
throw new Error(`BENCH_VERIFY_MODE must be 'all' or 'sample', got '${raw}'`);
}
export function selectVerificationEntries(
entries: DatasetEntry[],
mode: BenchmarkVerificationMode,
): DatasetEntry[] {
if (mode === "all" || entries.length === 0) return [...entries];
const md = entries.find((entry) => entry.kind === "md");
const bin = entries.find((entry) => entry.kind === "bin");
const middle = entries[Math.floor(entries.length / 2)];
const last = entries[entries.length - 1];
const selected = new Map<string, DatasetEntry>();
for (const entry of [md, bin, middle, last]) {
if (entry) selected.set(entry.relativePath, entry);
}
return [...selected.values()];
}
export async function computeDatasetDigestSha256(
entries: DatasetEntry[],
): Promise<string> {
const manifest: string[] = [];
for (const entry of entries) {
const contentDigest = await sha256(await Deno.readFile(entry.absolutePath));
manifest.push(
`${entry.kind}\t${entry.relativePath}\t${entry.size}\t${contentDigest}`,
);
}
return await sha256(new TextEncoder().encode(manifest.join("\n")));
}
export async function verifyBenchmarkDataset(
entries: DatasetEntry[],
mode: BenchmarkVerificationMode,
verifyEntry: (entry: DatasetEntry) => Promise<void>,
): Promise<BenchmarkVerificationResult> {
const selected = selectVerificationEntries(entries, mode);
for (const entry of selected) {
await verifyEntry(entry);
}
return {
verificationMode: mode,
verifiedFiles: selected.length,
verificationComplete: selected.length === entries.length,
datasetDigestSha256: await computeDatasetDigestSha256(entries),
};
}
@@ -1,5 +1,20 @@
import { assert, assertEquals, assertStringIncludes } from "@std/assert"; import { assert, assertEquals, assertStringIncludes } from "@std/assert";
import { type BenchmarkCase, buildCases } from "./bench-network-cases.ts"; import { type BenchmarkCase, buildCases } from "./bench-network-cases.ts";
import { startCouchdbProxy } from "./bench-couchdb.ts";
import {
parseBenchmarkVerificationMode,
selectVerificationEntries,
} from "./helpers/benchmarkVerification.ts";
import type { DatasetEntry } from "./helpers/dataset.ts";
function getFreePort(): number {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
try {
return (listener.addr as Deno.NetAddr).port;
} finally {
listener.close();
}
}
function getCase(cases: BenchmarkCase[], name: string): BenchmarkCase { function getCase(cases: BenchmarkCase[], name: string): BenchmarkCase {
const found = cases.find((testCase) => testCase.name === name); const found = cases.find((testCase) => testCase.name === name);
@@ -56,9 +71,76 @@ Deno.test("benchmark cases record scope and limitations for paper use", () => {
testCase.measurementScope, testCase.measurementScope,
); );
assertEquals(parsedLimitations(testCase), testCase.limitations); assertEquals(parsedLimitations(testCase), testCase.limitations);
assertEquals(
testCase.env.BENCH_VERIFY_MODE,
"all",
`${testCase.name} must verify the complete dataset`,
);
} }
}); });
Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
const backendPort = getFreePort();
const proxyPort = getFreePort();
const delays: number[] = [];
const backend = Deno.serve(
{
hostname: "127.0.0.1",
port: backendPort,
onListen() {},
},
() => new Response("ok"),
);
const proxy = startCouchdbProxy({
backendUri: `http://127.0.0.1:${backendPort}`,
proxyUri: `http://127.0.0.1:${proxyPort}`,
requestedRttMs: 20,
delay: (milliseconds) => {
delays.push(milliseconds);
return Promise.resolve();
},
});
try {
const response = await fetch(`http://127.0.0.1:${proxyPort}/probe`);
assertEquals(await response.text(), "ok");
assertEquals(proxy.directionalDelayMs, 10);
assertEquals(delays, [10, 10]);
} finally {
await proxy.stop();
await backend.shutdown();
}
const halfMillisecondProxy = startCouchdbProxy({
backendUri: "http://127.0.0.1:1",
proxyUri: `http://127.0.0.1:${getFreePort()}`,
requestedRttMs: 1,
delay: () => Promise.resolve(),
});
try {
assertEquals(halfMillisecondProxy.directionalDelayMs, 0.5);
} finally {
await halfMillisecondProxy.stop();
}
});
Deno.test("benchmark verification mode selects either all files or a labelled sample", () => {
const entries: DatasetEntry[] = [
{ kind: "md", relativePath: "a.md", absolutePath: "/a", size: 1 },
{ kind: "md", relativePath: "b.md", absolutePath: "/b", size: 1 },
{ kind: "bin", relativePath: "c.bin", absolutePath: "/c", size: 1 },
{ kind: "md", relativePath: "d.md", absolutePath: "/d", size: 1 },
{ kind: "bin", relativePath: "e.bin", absolutePath: "/e", size: 1 },
];
assertEquals(parseBenchmarkVerificationMode("ALL"), "all");
assertEquals(selectVerificationEntries(entries, "all").length, entries.length);
const sample = selectVerificationEntries(entries, "sample");
assert(sample.length > 0 && sample.length < entries.length);
assert(sample.some((entry) => entry.kind === "md"));
assert(sample.some((entry) => entry.kind === "bin"));
});
Deno.test("P2P signalling-shim cases do not claim to shape the note-data path", () => { Deno.test("P2P signalling-shim cases do not claim to shape the note-data path", () => {
const cases = buildCases(); const cases = buildCases();
for ( for (
@@ -77,6 +159,13 @@ Deno.test("P2P signalling-shim cases do not claim to shape the note-data path",
); );
assertStringIncludes(testCase.dataPath, "WebRTC DataChannel"); assertStringIncludes(testCase.dataPath, "WebRTC DataChannel");
assertStringIncludes(testCase.dataPath, "Nostr signalling"); assertStringIncludes(testCase.dataPath, "Nostr signalling");
assertStringIncludes(testCase.measurementScope, "fresh CLI p2p-sync");
assert(
testCase.limitations.some((limitation) =>
limitation.includes("connection establishment")
),
`${name} must state that connection establishment is timed`,
);
assert( assert(
testCase.limitations.some((limitation) => testCase.limitations.some((limitation) =>
limitation.includes("does not shape the selected WebRTC") limitation.includes("does not shape the selected WebRTC")
+17 -8
View File
@@ -64,16 +64,24 @@ path:
| Case | Data path | What is measured | What is not measured | | Case | Data path | What is measured | What is not measured |
| ------------------ | ------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | ------------------ | ------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `couchdb-baseline` | Device A -> CouchDB -> Device B | Two one-shot CLI synchronisation commands through a local HTTP latency proxy | Real WAN jitter, packet loss, bandwidth limits, VPN encapsulation, and server contention | | `couchdb-baseline` | Device A -> CouchDB -> Device B | Two one-shot CLI synchronisation commands through a local HTTP latency proxy | Real WAN jitter, packet loss, bandwidth limits, VPN encapsulation, and server contention |
| `p2p-direct-local` | Device A -> Device B after Nostr signalling | One CLI P2P synchronisation command over WebRTC DataChannel with TURN disabled | Public relay operation, mobile carrier behaviour, TURN relay throughput, and first-peer discovery latency | | `p2p-direct-local` | Device A -> Device B using Nostr signalling | One fresh CLI `p2p-sync` command, including process start-up and WebRTC connection establishment, with TURN disabled | Public relay operation, mobile carrier behaviour, and TURN relay throughput |
Use the CouchDB result as the remote-store baseline and the P2P result as the Use the CouchDB result as the remote-store baseline and the P2P result as the
direct-transfer comparison. The Nostr relay is used for signalling in the P2P direct-transfer comparison. The Nostr relay is used for signalling in the P2P
case, but synchronised note content is transferred over the WebRTC DataChannel. case, but synchronised note content is transferred over the WebRTC DataChannel.
The P2P result JSON records the selected WebRTC ICE candidate pair when the CLI The earlier `p2p-peers` observation command is excluded from the P2P timing,
but the timed `p2p-sync` command performs its own signalling and connection
establishment. The P2P result JSON records the selected WebRTC ICE candidate pair when the CLI
can collect it from `RTCPeerConnection.getStats()`. Interpret P2P paths from can collect it from `RTCPeerConnection.getStats()`. Interpret P2P paths from
the recorded candidate types rather than from TURN configuration alone. Do not the recorded candidate types rather than from TURN configuration alone. Do not
report P2P runs as Tier 2 constrained-network measurements until host and report a signalling-only Tier 2 run as though the selected note-data path were
client are captured under an equivalent shaped topology. also shaped.
Benchmark cases use `BENCH_VERIFY_MODE=all` by default. After the timed phase,
the runner retrieves and compares every generated file and records the verified
file count, whether verification was complete, and a SHA-256 digest of the
deterministic dataset. Set `BENCH_VERIFY_MODE=sample` only for exploratory
large-dataset runs where the additional verification time is impractical.
## Dataset and latency controls ## Dataset and latency controls
@@ -88,10 +96,10 @@ BENCH_PEERS_TIMEOUT=60 \
docker compose -f test/bench-network/compose.yml run --rm bench-runner docker compose -f test/bench-network/compose.yml run --rm bench-runner
``` ```
The current CouchDB latency model is the existing HTTP proxy inside The CouchDB latency model is the HTTP proxy inside `bench-couchdb.ts`. It adds
`bench-couchdb.ts`. It models a remote database path with additional request half of the requested RTT before forwarding each request and the other half
latency, but it does not model packet loss, jitter, MTU, bandwidth limits, before returning its response. It does not model packet loss, jitter, MTU,
bufferbloat, or VPN encapsulation. bandwidth limits, bufferbloat, or VPN encapsulation.
For P2P runs, `BENCH_PEERS_TIMEOUT` is passed to `p2p-peers`. That command waits For P2P runs, `BENCH_PEERS_TIMEOUT` is passed to `p2p-peers`. That command waits
for the requested observation window before printing discovered peers, so the for the requested observation window before printing discovered peers, so the
@@ -104,6 +112,7 @@ To run P2P once and CouchDB at several requested RTT values:
```bash ```bash
BENCH_COMMAND=latency-sweep \ BENCH_COMMAND=latency-sweep \
BENCH_SWEEP_RTT_MS=20,50,100,150,300 \ BENCH_SWEEP_RTT_MS=20,50,100,150,300 \
BENCH_REPEAT_COUNT=3 \
BENCH_MD_FILE_COUNT=100 \ BENCH_MD_FILE_COUNT=100 \
BENCH_MD_MIN_SIZE_BYTES=512 \ BENCH_MD_MIN_SIZE_BYTES=512 \
BENCH_MD_MAX_SIZE_BYTES=2048 \ BENCH_MD_MAX_SIZE_BYTES=2048 \
+2
View File
@@ -90,6 +90,7 @@ services:
BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048} BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048}
BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5} BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5}
BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192} BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192}
BENCH_VERIFY_MODE: ${BENCH_VERIFY_MODE:-all}
BENCH_COUCHDB_RTT_MS: ${BENCH_COUCHDB_RTT_MS:-20} BENCH_COUCHDB_RTT_MS: ${BENCH_COUCHDB_RTT_MS:-20}
BENCH_TETHERING_VPN_RTT_MS: ${BENCH_TETHERING_VPN_RTT_MS:-120} BENCH_TETHERING_VPN_RTT_MS: ${BENCH_TETHERING_VPN_RTT_MS:-120}
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300} BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
@@ -220,6 +221,7 @@ services:
BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048} BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048}
BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5} BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5}
BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192} BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192}
BENCH_VERIFY_MODE: ${BENCH_VERIFY_MODE:-all}
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300} BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60} BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60}
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000} LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}