Compare commits

..

1 Commits

Author SHA1 Message Date
vorotamoroz 0bba0177ac test: cover CLI entry compatibility 2026-07-03 08:28:16 +00:00
53 changed files with 1054 additions and 3564 deletions
+1 -3
View File
@@ -16,9 +16,7 @@ pouchdb-browser.js
production/
# Test coverage and reports
coverage/
test/bench-network/bench-results/
src/apps/cli/testdeno/bench-results/
coverage/
# Local environment / secrets
.env
-43
View File
@@ -9,9 +9,6 @@ on:
- '.github/workflows/cli-deno-tests.yml'
- 'src/apps/cli/**'
- 'src/lib/src/API/processSetting.ts'
- 'src/lib/src/replication/trystero/**'
- 'src/lib/src/rpc/**'
- 'test/bench-network/**'
- 'package.json'
- 'package-lock.json'
pull_request:
@@ -19,9 +16,6 @@ on:
- '.github/workflows/cli-deno-tests.yml'
- 'src/apps/cli/**'
- 'src/lib/src/API/processSetting.ts'
- 'src/lib/src/replication/trystero/**'
- 'src/lib/src/rpc/**'
- 'test/bench-network/**'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
@@ -161,40 +155,3 @@ jobs:
run: |
docker stop couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true
docker rm couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true
compose-p2p-e2e:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Show Docker versions
run: |
docker --version
docker compose version
- name: Run Compose CLI P2P E2E
env:
CLI_E2E_TASK: test:p2p-sync
RELAY: ws://nostr-relay:7777/
PEERS_TIMEOUT: '20'
SYNC_TIMEOUT: '60'
LIVESYNC_USE_COTURN: '0'
TURN_SERVERS: none
LIVESYNC_P2P_PEERS_RETRY: '1'
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000'
BENCH_LIVESYNC_TEST_TEE: '0'
run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner run-livesync-cli-e2e
- name: Show Compose diagnostics
if: failure()
run: |
docker compose -f test/bench-network/compose.yml ps
docker compose -f test/bench-network/compose.yml logs --no-color couchdb nostr-relay || true
- name: Stop Compose services
if: always()
run: docker compose -f test/bench-network/compose.yml down -v --remove-orphans
@@ -1,96 +0,0 @@
# Run the Compose-packaged CLI P2P smoke benchmark.
#
# This workflow is intentionally manual-only. It exercises the local Compose
# package for CouchDB + Nostr relay + CLI runner, and uploads the benchmark JSON
# results for inspection without adding benchmark work to pull-request CI.
name: cli-p2p-compose-smoke
on:
workflow_dispatch:
inputs:
cases:
description: 'Comma-separated benchmark cases'
required: false
default: 'couchdb-baseline,p2p-direct-local'
signalling_cases:
description: 'Comma-separated signalling-shim P2P benchmark cases'
required: false
default: 'p2p-signalling-netem-home-wifi'
md_files:
description: 'Markdown file count'
required: false
default: '2'
bin_files:
description: 'Binary file count'
required: false
default: '1'
couchdb_rtt_ms:
description: 'Requested CouchDB RTT in milliseconds'
required: false
default: '20'
permissions:
contents: read
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Show Docker versions
run: |
docker --version
docker compose version
- name: Run Compose P2P smoke benchmark
env:
BENCH_CASES: ${{ inputs.cases || 'couchdb-baseline,p2p-direct-local' }}
BENCH_MD_FILE_COUNT: ${{ inputs.md_files || '2' }}
BENCH_MD_MIN_SIZE_BYTES: '128'
BENCH_MD_MAX_SIZE_BYTES: '256'
BENCH_BIN_FILE_COUNT: ${{ inputs.bin_files || '1' }}
BENCH_BIN_SIZE_BYTES: '512'
BENCH_COUCHDB_RTT_MS: ${{ inputs.couchdb_rtt_ms || '20' }}
BENCH_SYNC_TIMEOUT: '180'
BENCH_PEERS_TIMEOUT: '20'
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000'
BENCH_LIVESYNC_TEST_TEE: '0'
run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner
- name: Run Compose P2P signalling-shim smoke benchmark
env:
BENCH_CASES: ${{ inputs.signalling_cases || 'p2p-signalling-netem-home-wifi' }}
BENCH_MD_FILE_COUNT: ${{ inputs.md_files || '2' }}
BENCH_MD_MIN_SIZE_BYTES: '128'
BENCH_MD_MAX_SIZE_BYTES: '256'
BENCH_BIN_FILE_COUNT: ${{ inputs.bin_files || '1' }}
BENCH_BIN_SIZE_BYTES: '512'
BENCH_SYNC_TIMEOUT: '180'
BENCH_PEERS_TIMEOUT: '60'
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000'
BENCH_LIVESYNC_TEST_TEE: '0'
NETEM_PROFILE: 'home-wifi'
run: docker compose -f test/bench-network/compose.yml --profile signalling-shim run --build --rm bench-runner-signalling-shim
- name: Show Compose diagnostics
if: failure()
run: |
docker compose -f test/bench-network/compose.yml ps
docker compose -f test/bench-network/compose.yml --profile signalling-shim logs --no-color couchdb nostr-relay p2p-signalling-shim || true
- name: Upload benchmark results
if: always()
uses: actions/upload-artifact@v4
with:
name: cli-p2p-compose-smoke-results
path: test/bench-network/bench-results/**
if-no-files-found: warn
- name: Stop Compose services
if: always()
run: docker compose -f test/bench-network/compose.yml down -v --remove-orphans
-20
View File
@@ -47,12 +47,6 @@ npm test # Run Harness based vitest tests (requires Docker services)
Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness.
### Unreleased change notes
Keep changes that may belong in a future release under `## Unreleased` at the top of `updates.md` when they do not justify an immediate release. Do not add a date to this virtual version. Move relevant entries under the real version and ordinal release date when preparing that release, then leave an empty `## Unreleased` section for subsequent work.
Use this section for durable release-note candidates, including compatibility-relevant internal maintenance, rather than tasks, local diagnostics, or implementation journals. Categorise user-visible behaviour separately from internal changes and testing.
### Auto-copy to test vaults
To facilitate development and testing, the build process can automatically copy the built plugin to specified test vault
@@ -154,20 +148,6 @@ Hence, the new feature should be implemented as follows:
- **Service Hub** (`src/modules/services/`): Central service registry using dependency injection
- **Common Library** (`src/lib/`): Platform-independent sync logic, shared with other tools
### Conflict Merge Policy
Markdown conflict auto-merge should behave like a conservative three-way merge. The guiding rule is to merge changes when they touch non-overlapping regions, and to keep a manual conflict when the edits overlap semantically.
When in doubt, prefer the safer outcome: preserve data, keep the conflict visible, and ask the user rather than silently discarding content or choosing one side.
- If one side deletes a line and the other side leaves that same line unchanged, treat it as a safe deletion. The deleted line must not be reintroduced into the merged result.
- If one side inserts new content in a different region while the other side deletes an unchanged old region, preserve the insertion and the deletion.
- If one side deletes a line and the other side modifies that same line, keep the conflict for user resolution.
- If both sides insert different content at the same position, keep both insertions in a deterministic order unless the surrounding deletion context indicates that they are competing replacements.
- Avoid resolving conflicts by simply choosing the newest revision unless the user has explicitly selected that behaviour.
This policy is intentionally aligned with the conflict checkboxes and compatibility settings: automatic merge should remove avoidable prompts, but it must not silently choose between overlapping user intentions.
### File Structure Conventions
- **Platform-specific code**: Use `.platform.ts` suffix (replaced with `.obsidian.ts` in production builds via esbuild)
+2 -1
View File
@@ -65,7 +65,7 @@ All guidelines and conventions listed below are disclosed and maintained solely
- livesync-serverpeer / webpeer
- Pseudo-clients that assist in WebRTC peer-to-peer communication.
- Metadata (File metadata)
- A database document that stores properties of a file, including its filename, path, size, modification time, and references (hashes) of the chunks that comprise the file's content. Conflict state is carried by the surrounding PouchDB/CouchDB revision metadata rather than by a separate history field inside the file metadata document. In Self-hosted LiveSync, file metadata is stored separately from the actual file content to enable efficient synchronisation and versioning.
- A database document that stores properties of a file, including its filename, path, size, modification time, conflict history, and references (hashes) of the chunks that comprise the file's content. In Self-hosted LiveSync, metadata is stored separately from the actual file content to enable efficient synchronisation and versioning.
- OneShot Sync
- A single, immediate bidirectional synchronisation (pull then push) triggered on demand or on specific events, as opposed to continuous (live) replication.
- Overwrite Server Data with This Device's Files
@@ -100,3 +100,4 @@ All guidelines and conventions listed below are disclosed and maintained solely
- An optimisation that groups multiple local file edits together over a short delay before committing them to the local database, reducing the number of database write operations.
- WebRTC P2P (Peer-to-Peer)
- A synchronisation method enabling direct communication between devices without a central server database.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "0.25.80",
"version": "0.25.79",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+5 -21
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "0.25.80",
"version": "0.25.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "0.25.80",
"version": "0.25.79",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -58,7 +58,6 @@
"@vitest/browser": "^4.1.8",
"@vitest/browser-playwright": "^4.1.8",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.1.0",
"dotenv-cli": "^11.0.0",
"esbuild": "0.28.1",
"esbuild-plugin-inline-worker": "^0.1.1",
@@ -4181,7 +4180,6 @@
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -4946,20 +4944,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vrtmrz/obsidian-test-session": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.1.0.tgz",
"integrity": "sha512-asBOIRTc3xK5GF5ds5mkxN6vsO4RE8o7puvVjoJiGYSlxoFa9jzr8FwAO13CyoOriHF05pOZfTB+eQmL1aNb/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@types/node": ">=20",
"playwright": ">=1.50.0"
}
},
"node_modules/@wdio/config": {
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz",
@@ -16226,7 +16210,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "0.25.80-cli",
"version": "0.25.79-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -16252,7 +16236,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "0.25.80-webapp",
"version": "0.25.79-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.47"
},
@@ -16267,7 +16251,7 @@
}
},
"src/apps/webpeer": {
"version": "0.25.80-webpeer",
"version": "0.25.79-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.47"
},
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "0.25.80",
"version": "0.25.79",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -105,7 +105,6 @@
"@vitest/browser": "^4.1.8",
"@vitest/browser-playwright": "^4.1.8",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.1.0",
"dotenv-cli": "^11.0.0",
"esbuild": "0.28.1",
"esbuild-plugin-inline-worker": "^0.1.1",
-108
View File
@@ -1,108 +0,0 @@
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
/** One platform-neutral storage adapter contract case. */
export interface StorageAdapterContractCase {
readonly name: string;
run(adapter: IStorageAdapter): Promise<void>;
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${message}\nactual=${JSON.stringify(actual)}\nexpected=${JSON.stringify(expected)}`);
}
}
async function assertRejects(operation: () => Promise<unknown>, message: string): Promise<void> {
try {
await operation();
} catch {
return;
}
throw new Error(message);
}
/** Passing baseline shared by Node, FSAPI, and future storage adapters. */
export const storageAdapterContractCases: readonly StorageAdapterContractCase[] = [
{
name: "reports missing paths consistently",
async run(adapter) {
assertEqual(await adapter.exists("missing.txt"), false, "missing path should not exist");
assertEqual(await adapter.stat("missing.txt"), null, "missing stat should be null");
assertEqual(await adapter.trystat("missing.txt"), null, "missing trystat should be null");
},
},
{
name: "creates parent directories for nested text writes",
async run(adapter) {
await adapter.write("notes/nested/note.md", "hello");
assertEqual(await adapter.read("notes/nested/note.md"), "hello", "text should round-trip");
assert(await adapter.exists("notes/nested/note.md"), "written text path should exist");
assertEqual((await adapter.stat("notes/nested/note.md"))?.type, "file", "written path should be a file");
},
},
{
name: "round-trips exact binary bytes",
async run(adapter) {
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
await adapter.writeBinary("binary/blob.bin", expected.buffer.slice(0));
const result = await adapter.readBinary("binary/blob.bin");
assertEqual([...new Uint8Array(result)], [...expected], "binary data should round-trip exactly");
assertEqual(result.byteLength, expected.byteLength, "binary result should have the exact visible length");
},
},
{
name: "creates and extends text through append",
async run(adapter) {
await adapter.append("logs/events.log", "first");
await adapter.append("logs/events.log", ":second");
assertEqual(await adapter.read("logs/events.log"), "first:second", "append should create then extend text");
},
},
{
name: "lists direct files and folders",
async run(adapter) {
await adapter.mkdir("listing/folder");
await adapter.write("listing/file.txt", "content");
const listed = await adapter.list("listing");
assertEqual([...listed.files].sort(), ["listing/file.txt"], "list should contain the direct file");
assertEqual([...listed.folders].sort(), ["listing/folder"], "list should contain the direct folder");
},
},
{
name: "removes files and directory trees",
async run(adapter) {
await adapter.write("remove/file.txt", "content");
await adapter.write("remove/folder/nested.txt", "content");
await adapter.remove("remove/file.txt");
assertEqual(await adapter.exists("remove/file.txt"), false, "file should be removed");
await adapter.remove("remove/folder");
assertEqual(await adapter.exists("remove/folder"), false, "directory tree should be removed");
},
},
{
name: "keeps operations inside the configured root",
async run(adapter) {
await assertRejects(() => adapter.exists("../outside"), "parent traversal should be rejected");
await assertRejects(() => adapter.write("nested/../outside", "content"), "nested traversal should be rejected");
await assertRejects(() => adapter.read("/absolute"), "absolute paths should be rejected");
await assertRejects(() => adapter.read("C:\\absolute"), "drive-qualified paths should be rejected");
await assertRejects(() => adapter.read("nested\\outside"), "backslash-separated paths should be rejected");
await assertRejects(() => adapter.remove(""), "removing the configured root should be rejected");
},
},
{
name: "uses the empty path only for root-safe operations",
async run(adapter) {
await adapter.mkdir("");
assertEqual(await adapter.exists(""), true, "the configured root should exist");
assertEqual((await adapter.stat(""))?.type, "folder", "the configured root should be a folder");
assertEqual(await adapter.list(""), { files: [], folders: [] }, "the configured root should be listable");
await assertRejects(() => adapter.write("", "content"), "writing over the configured root should be rejected");
await assertRejects(() => adapter.append("", "content"), "appending to the configured root should be rejected");
},
},
];
+11 -14
View File
@@ -2,22 +2,20 @@ import type { UXDataWriteOptions } from "@lib/common/types";
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
import type { NodeStat } from "./NodeTypes";
import { fsPromises as fs, path } from "@/apps/cli/node-compat";
import { validateStoragePath } from "@/apps/storagePath";
/**
* Storage adapter implementation for Node.js
*/
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
constructor(private readonly basePath: string) {}
constructor(private basePath: string) {}
private resolvePath(p: string, allowRoot: boolean = true): string {
return path.join(this.basePath, validateStoragePath(p, allowRoot));
private resolvePath(p: string): string {
return path.join(this.basePath, p);
}
async exists(p: string): Promise<boolean> {
const fullPath = this.resolvePath(p);
try {
await fs.access(fullPath);
await fs.access(this.resolvePath(p));
return true;
} catch {
return false;
@@ -25,9 +23,8 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
}
async trystat(p: string): Promise<NodeStat | null> {
const fullPath = this.resolvePath(p);
try {
const stat = await fs.stat(fullPath);
const stat = await fs.stat(this.resolvePath(p));
return {
size: stat.size,
mtime: Math.floor(stat.mtimeMs),
@@ -48,7 +45,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
}
async remove(p: string): Promise<void> {
const fullPath = this.resolvePath(p, false);
const fullPath = this.resolvePath(p);
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
await fs.rm(fullPath, { recursive: true, force: true });
@@ -58,17 +55,17 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
}
async read(p: string): Promise<string> {
return await fs.readFile(this.resolvePath(p, false), "utf-8");
return await fs.readFile(this.resolvePath(p), "utf-8");
}
async readBinary(p: string): Promise<ArrayBuffer> {
const buffer = await fs.readFile(this.resolvePath(p, false));
const buffer = await fs.readFile(this.resolvePath(p));
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required in environments where Buffer.buffer is ArrayBufferLike
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
}
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(p, false);
const fullPath = this.resolvePath(p);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, data, "utf-8");
@@ -80,7 +77,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
}
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(p, false);
const fullPath = this.resolvePath(p);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, new Uint8Array(data));
@@ -92,7 +89,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
}
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(p, false);
const fullPath = this.resolvePath(p);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.appendFile(fullPath, data, "utf-8");
@@ -2,10 +2,9 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
import { NodeStorageAdapter } from "./NodeStorageAdapter";
describe("NodeStorageAdapter", () => {
describe("NodeStorageAdapter binary I/O", () => {
const tempDirs: string[] = [];
async function createAdapter() {
@@ -18,12 +17,6 @@ describe("NodeStorageAdapter", () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
for (const contractCase of storageAdapterContractCases) {
it(contractCase.name, async () => {
await contractCase.run(await createAdapter());
});
}
it("writes and reads binary data without corruption", async () => {
const adapter = await createAdapter();
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
-78
View File
@@ -4,21 +4,12 @@ import type { ServiceContext } from "@lib/services/base/ServiceBase";
import { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
import { LiveSyncError } from "@lib/common/LSError";
import { getPeerConnectionStats } from "@lib/rpc/transports/DiagRTCPeerConnections.utils";
import { appendFile } from "node:fs/promises";
type CLIP2PPeer = {
peerId: string;
name: string;
};
type CandidateSummary = {
id: string | "unknown";
candidateType: string | "unknown";
protocol: string | "unknown";
relayProtocol: string | "unknown";
};
function delay(ms: number): Promise<void> {
return new Promise((resolve) => compatGlobal.setTimeout(resolve, ms));
}
@@ -90,74 +81,6 @@ function resolvePeer(peers: CLIP2PPeer[], peerToken: string): CLIP2PPeer | undef
return undefined;
}
function getReportValue<T extends string | number>(
report: Record<string, unknown> | undefined,
key: string
): T | "unknown" {
const value = report?.[key];
return typeof value === "string" || typeof value === "number" ? (value as T) : "unknown";
}
function summariseCandidate(reports: unknown[], candidateId: string | "unknown"): CandidateSummary | undefined {
if (candidateId === "unknown") {
return undefined;
}
const report = reports.map((r) => r as Record<string, unknown>).find((r) => r.id === candidateId);
if (!report) {
return undefined;
}
return {
id: candidateId,
candidateType: getReportValue<string>(report, "candidateType"),
protocol: getReportValue<string>(report, "protocol"),
relayProtocol: getReportValue<string>(report, "relayProtocol"),
};
}
async function writePeerConnectionStatsIfRequested(
replicator: LiveSyncTrysteroReplicator,
peer: CLIP2PPeer
): Promise<void> {
const outputPath = process.env.LIVESYNC_P2P_STATS_JSONL?.trim();
if (!outputPath) {
return;
}
const peerConnection = replicator.rawHost?.room?.getPeers()[peer.peerId];
const stats = peerConnection ? await getPeerConnectionStats(`cli-p2p-${peer.peerId}`, peerConnection) : undefined;
const localCandidate = summariseCandidate(stats?.reports ?? [], stats?.localCandidateId ?? "unknown");
const remoteCandidate = summariseCandidate(stats?.reports ?? [], stats?.remoteCandidateId ?? "unknown");
const selectedPath =
localCandidate && remoteCandidate
? `${localCandidate.candidateType}<->${remoteCandidate.candidateType}`
: "unknown";
const payload = {
generatedAt: new Date().toISOString(),
command: "p2p-sync",
peerId: peer.peerId,
peerName: peer.name,
candidatePathCollected: !!stats?.selectedPair,
selectedPath,
selectedPair: stats
? {
id: stats.selectedPairId,
state: stats.state,
currentRoundTripTime: stats.currentRoundTripTime,
totalRoundTripTime: stats.totalRoundTripTime,
requestsSent: stats.requestsSent,
responsesReceived: stats.responsesReceived,
packetsDiscardedOnSend: stats.packetsDiscardedOnSend,
bytesSent: stats.bytesSent,
bytesReceived: stats.bytesReceived,
}
: undefined,
localCandidate,
remoteCandidate,
};
await appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
}
export async function syncWithPeer(
core: LiveSyncBaseCore<ServiceContext, never>,
peerToken: string,
@@ -195,7 +118,6 @@ export async function syncWithPeer(
: LiveSyncError.fromError(err ?? "P2P sync failed while requesting remote sync");
}
await writePeerConnectionStatsIfRequested(replicator, targetPeer);
return targetPeer;
} finally {
await replicator.close();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "0.25.80-cli",
"version": "0.25.79-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
+53 -201
View File
@@ -1,25 +1,10 @@
import { TempDir } from "./helpers/temp.ts";
import {
applyRemoteSyncSettings,
initSettingsFile,
} from "./helpers/settings.ts";
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
import {
createCouchdbDatabase,
startCouchdb,
stopCouchdb,
} from "./helpers/docker.ts";
import {
createDeterministicDataset,
} from "./helpers/dataset.ts";
import {
type BenchmarkVerificationMode,
parseBenchmarkVerificationMode,
verifyBenchmarkDataset,
} from "./helpers/benchmarkVerification.ts";
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
type BenchmarkConfig = {
caseName: string;
couchdbBackendUri: string;
couchdbProxyUri: string;
couchdbUser: string;
@@ -36,15 +21,6 @@ type BenchmarkConfig = {
requestedRttMs: number;
passphrase: string;
encrypt: boolean;
managedCouchdb: boolean;
simulationTier: string;
networkProfile: string;
networkModel: string;
measurementScope: string;
limitations: string[];
verificationMode: BenchmarkVerificationMode;
repeatIndex: number;
repeatCount: number;
};
function readEnvString(name: string, fallback: string): string {
@@ -73,30 +49,6 @@ function readEnvBool(name: string, fallback: boolean): boolean {
return /^(1|true|yes|on)$/i.test(raw.trim());
}
function readEnvStringArray(name: string, fallback: string[]): string[] {
const raw = Deno.env.get(name)?.trim();
if (!raw) {
return fallback;
}
try {
const parsed = JSON.parse(raw);
if (
Array.isArray(parsed) &&
parsed.every((item) => typeof item === "string")
) {
return parsed;
}
} catch {
// Fall through to pipe-separated parsing for hand-written invocations.
}
return raw
.split("|")
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function nowMs(): number {
return performance.now();
}
@@ -118,63 +70,22 @@ function formatBytes(value: number): string {
function buildConfig(): BenchmarkConfig {
return {
caseName: readEnvString("BENCH_CASE", "couchdb-baseline"),
couchdbBackendUri: readEnvString(
"BENCH_COUCHDB_BACKEND_URI",
"http://127.0.0.1:5989",
),
couchdbProxyUri: readEnvString(
"BENCH_COUCHDB_URI",
"http://127.0.0.1:15989",
),
couchdbUser: readEnvString(
"BENCH_COUCHDB_USER",
readEnvString("username", "admin"),
),
couchdbPassword: readEnvString(
"BENCH_COUCHDB_PASSWORD",
readEnvString("password", "password"),
),
couchdbDbname: readEnvString(
"BENCH_COUCHDB_DBNAME",
`bench-couchdb-${Date.now()}`,
),
couchdbBackendUri: readEnvString("BENCH_COUCHDB_BACKEND_URI", "http://127.0.0.1:5989"),
couchdbProxyUri: readEnvString("BENCH_COUCHDB_URI", "http://127.0.0.1:15989"),
couchdbUser: readEnvString("BENCH_COUCHDB_USER", readEnvString("username", "admin")),
couchdbPassword: readEnvString("BENCH_COUCHDB_PASSWORD", readEnvString("password", "password")),
couchdbDbname: readEnvString("BENCH_COUCHDB_DBNAME", `bench-couchdb-${Date.now()}`),
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
mdMinSizeBytes: Math.floor(
readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024),
),
mdMaxSizeBytes: Math.floor(
readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024),
),
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)),
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)),
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)),
binSizeBytes: Math.floor(
readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024),
),
binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024)),
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
requestedRttMs: Math.floor(readEnvNumber("BENCH_COUCHDB_RTT_MS", 50)),
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
encrypt: readEnvBool("BENCH_ENCRYPT", true),
managedCouchdb: readEnvBool("BENCH_COUCHDB_MANAGED", true),
simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"),
networkProfile: readEnvString(
"BENCH_NETWORK_PROFILE",
"http-latency-proxy",
),
networkModel: readEnvString("BENCH_NETWORK_MODEL", "local-http-proxy"),
measurementScope: readEnvString(
"BENCH_MEASUREMENT_SCOPE",
"Two one-shot synchronisation phases through a CouchDB-compatible remote-store path.",
),
limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [
"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)),
};
}
@@ -186,27 +97,33 @@ function readOptionalResultPath(): string | undefined {
return raw;
}
export type CouchdbProxyHandle = {
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()];
}
type ProxyHandle = {
stop: () => Promise<void>;
applied: boolean;
note: string;
directionalDelayMs: number;
};
export function startCouchdbProxy(
options: {
backendUri: string;
proxyUri: string;
requestedRttMs: number;
delay?: (milliseconds: number) => Promise<void>;
},
): CouchdbProxyHandle {
function startCouchdbProxy(options: { backendUri: string; proxyUri: string; requestedRttMs: number }): ProxyHandle {
const backend = new URL(options.backendUri);
const proxy = new URL(options.proxyUri);
const halfDelayMs = options.requestedRttMs / 2;
const delay = options.delay ??
((milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
const halfDelayMs = Math.max(1, Math.floor(options.requestedRttMs / 2));
const controller = new AbortController();
const listener = Deno.serve(
@@ -220,7 +137,7 @@ export function startCouchdbProxy(
},
},
async (request) => {
await delay(halfDelayMs);
await new Promise((resolve) => setTimeout(resolve, halfDelayMs));
const targetUrl = new URL(request.url);
targetUrl.protocol = backend.protocol;
@@ -249,21 +166,18 @@ export function startCouchdbProxy(
const responseHeaders = new Headers(upstream.headers);
responseHeaders.delete("content-length");
const responseBody = await upstream.arrayBuffer();
await delay(halfDelayMs);
return new Response(responseBody, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
},
}
);
return {
applied: true,
directionalDelayMs: halfDelayMs,
note:
`local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms request-path and ${halfDelayMs}ms response-path delay`,
note: `local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms pre-forward delay`,
stop: async () => {
controller.abort();
await listener.finished.catch(() => {});
@@ -286,24 +200,7 @@ async function main(): Promise<void> {
await initSettingsFile(settingsA);
await initSettingsFile(settingsB);
if (config.managedCouchdb) {
await startCouchdb(
config.couchdbBackendUri,
config.couchdbUser,
config.couchdbPassword,
config.couchdbDbname,
);
} else {
console.log(
`[INFO] using externally managed CouchDB: ${config.couchdbBackendUri}`,
);
await createCouchdbDatabase(
config.couchdbBackendUri,
config.couchdbUser,
config.couchdbPassword,
config.couchdbDbname,
);
}
await startCouchdb(config.couchdbBackendUri, config.couchdbUser, config.couchdbPassword, config.couchdbDbname);
const proxy = startCouchdbProxy({
backendUri: config.couchdbBackendUri,
@@ -356,99 +253,54 @@ async function main(): Promise<void> {
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
const syncBElapsed = nowMs() - syncBStart;
const verification = await verifyBenchmarkDataset(
seedFiles.entries,
config.verificationMode,
async (entry) => {
const pulledPath = workDir.join(
`pulled-${entry.relativePath.split("/").join("_")}`,
);
await runCliOrFail(
vaultB,
"--settings",
settingsB,
"pull",
entry.relativePath,
pulledPath,
);
await assertFilesEqual(
entry.absolutePath,
pulledPath,
`file mismatch after CouchDB sync: ${entry.relativePath}`,
);
},
);
const sampleFiles = pickSampleFiles(seedFiles.entries);
for (const sample of sampleFiles) {
const pulledPath = workDir.join(`pulled-${sample.relativePath.split("/").join("_")}`);
await runCliOrFail(vaultB, "--settings", settingsB, "pull", sample.relativePath, pulledPath);
await assertFilesEqual(
sample.absolutePath,
pulledPath,
`sample file mismatch after CouchDB sync: ${sample.relativePath}`
);
}
const result = {
caseName: config.caseName,
mode: "couchdb-cli-benchmark",
couchdbBackendUri: config.couchdbBackendUri,
couchdbProxyUri: config.couchdbProxyUri,
couchdbDbname: config.couchdbDbname,
managedCouchdb: config.managedCouchdb,
simulationTier: config.simulationTier,
networkProfile: config.networkProfile,
networkModel: config.networkModel,
measurementScope: config.measurementScope,
limitations: config.limitations,
repeatIndex: config.repeatIndex,
repeatCount: config.repeatCount,
rttRequestedMs: config.requestedRttMs,
proxyApplied: proxy.applied,
proxyNote: proxy.note,
proxyDirectionalDelayMs: proxy.directionalDelayMs,
proxyConfiguredRttMs: proxy.directionalDelayMs * 2,
proxyDelayApplication: "request-and-response",
datasetSeed: config.datasetSeed,
datasetDirName: config.datasetDirName,
totalFiles: seedFiles.totalFiles,
totalBytes: seedFiles.totalBytes,
mdFileCount: seedFiles.mdCount,
binFileCount: seedFiles.binCount,
...verification,
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
syncAElapsedMs: Number(syncAElapsed.toFixed(1)),
syncBElapsedMs: Number(syncBElapsed.toFixed(1)),
totalSyncElapsedMs: Number(
(syncAElapsed + syncBElapsed).toFixed(1),
),
throughputBytesPerSec: Number(
(seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000))
.toFixed(
2,
),
),
totalSyncElapsedMs: Number((syncAElapsed + syncBElapsed).toFixed(1)),
throughputBytesPerSec: Number((seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000)).toFixed(2)),
throughputMiBPerSec: Number(
(seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000) /
1024 /
1024).toFixed(4),
(seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000) / 1024 / 1024).toFixed(4)
),
};
if (resultPath) {
await Deno.writeTextFile(
resultPath,
JSON.stringify(result, null, 2),
);
await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2));
}
console.log(JSON.stringify(result, null, 2));
console.error(
`[Benchmark] couchdb mirrored ${seedFiles.totalFiles} files (${
formatBytes(seedFiles.totalBytes)
}) in ${
formatMs(
mirrorElapsed,
)
}, synced in ${
formatMs(syncAElapsed + syncBElapsed)
} (${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`,
`[Benchmark] couchdb mirrored ${seedFiles.totalFiles} files (${formatBytes(seedFiles.totalBytes)}) in ${formatMs(
mirrorElapsed
)}, synced in ${formatMs(syncAElapsed + syncBElapsed)} (${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`
);
} finally {
await proxy.stop();
if (config.managedCouchdb) {
await stopCouchdb().catch(() => {});
}
await stopCouchdb().catch(() => {});
}
}
@@ -1,175 +0,0 @@
type SweepResult = {
name: string;
runner: "p2p" | "couchdb";
rttMs?: number;
repeatIndex: number;
repeatCount: number;
result: Record<string, unknown>;
};
function readEnvString(name: string, fallback: string): string {
const value = Deno.env.get(name)?.trim();
return value && value.length > 0 ? value : fallback;
}
function timestamp(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-` +
`${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
);
}
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[] {
const values = raw
.split(",")
.map((value) => Number(value.trim()))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.floor(value));
if (values.length === 0) {
throw new Error(`BENCH_SWEEP_RTT_MS must contain at least one positive number, got '${raw}'`);
}
return values;
}
function buildBaseEnv(): Record<string, string> {
return {
BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"),
BENCH_MD_MIN_SIZE_BYTES: readEnvString("BENCH_MD_MIN_SIZE_BYTES", "512"),
BENCH_MD_MAX_SIZE_BYTES: readEnvString("BENCH_MD_MAX_SIZE_BYTES", "2048"),
BENCH_BIN_FILE_COUNT: readEnvString("BENCH_BIN_FILE_COUNT", "5"),
BENCH_BIN_SIZE_BYTES: readEnvString("BENCH_BIN_SIZE_BYTES", "8192"),
BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"),
BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"),
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"),
};
}
async function runBenchmark(options: {
taskName: "bench:p2p" | "bench:couchdb";
name: string;
outputDir: string;
env: Record<string, string>;
repeatIndex: number;
repeatCount: number;
}): Promise<Record<string, unknown>> {
const suffix = options.repeatCount > 1
? `-r${String(options.repeatIndex).padStart(2, "0")}`
: "";
const resultPath = `${options.outputDir}/${options.name}${suffix}.json`;
const env = {
...Deno.env.toObject(),
...options.env,
BENCH_RESULT_JSON: resultPath,
BENCH_REPEAT_INDEX: String(options.repeatIndex),
BENCH_REPEAT_COUNT: String(options.repeatCount),
};
console.log(`[latency-sweep] running ${options.name}`);
const child = new Deno.Command("deno", {
args: ["task", options.taskName],
cwd: import.meta.dirname,
env,
stdin: "null",
stdout: "inherit",
stderr: "inherit",
}).spawn();
const status = await child.status;
if (status.code !== 0) {
throw new Error(`benchmark failed: ${options.name} (exit ${status.code})`);
}
return JSON.parse(await Deno.readTextFile(resultPath)) as Record<string, unknown>;
}
async function main(): Promise<void> {
const outRoot = readEnvString("BENCH_SWEEP_ROOT", `${import.meta.dirname}/bench-results`);
const outputDir = `${outRoot}/latency-sweep-${timestamp()}`;
const rtts = parseRttList(readEnvString("BENCH_SWEEP_RTT_MS", "20,50,100,150,300"));
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
const base = buildBaseEnv();
await Deno.mkdir(outputDir, { recursive: true });
const results: SweepResult[] = [];
if (readEnvString("BENCH_SWEEP_INCLUDE_P2P", "true") !== "false") {
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
const p2pResult = await runBenchmark({
taskName: "bench:p2p",
name: "p2p-direct-local",
outputDir,
repeatIndex,
repeatCount,
env: {
...base,
BENCH_CASE: "p2p-direct-local",
BENCH_TURN_SERVERS: "",
},
});
results.push({
name: "p2p-direct-local",
runner: "p2p",
repeatIndex,
repeatCount,
result: p2pResult,
});
}
}
for (const rtt of rtts) {
const name = `couchdb-rtt-${rtt}ms`;
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
const couchdbResult = await runBenchmark({
taskName: "bench:couchdb",
name,
outputDir,
repeatIndex,
repeatCount,
env: {
...base,
BENCH_CASE: name,
BENCH_COUCHDB_RTT_MS: String(rtt),
},
});
results.push({
name,
runner: "couchdb",
rttMs: rtt,
repeatIndex,
repeatCount,
result: couchdbResult,
});
}
}
const summary = {
generatedAt: new Date().toISOString(),
outputDir,
note:
"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,
repeatCount,
results,
};
await Deno.writeTextFile(`${outputDir}/summary.json`, JSON.stringify(summary, null, 2));
console.log(JSON.stringify(summary, null, 2));
console.log(`[latency-sweep] result directory: ${outputDir}`);
}
if (import.meta.main) {
main().catch((error) => {
console.error("[Fatal Error]", error);
Deno.exit(1);
});
}
@@ -1,447 +0,0 @@
export type BenchmarkCase = {
name: string;
runner: "p2p" | "couchdb";
description: string;
dataPath: string;
trustBoundary: string;
measurementScope: string;
limitations: string[];
env: Record<string, string>;
};
function readEnvString(name: string, fallback: string): string {
const value = Deno.env.get(name)?.trim();
return value && value.length > 0 ? value : fallback;
}
function readEnvInteger(name: string, fallback: number): number {
const value = readEnvString(name, String(fallback));
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`${name} must be a positive integer, got '${value}'`);
}
return parsed;
}
function timestamp(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${
pad(d.getUTCDate())
}-` +
`${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${
pad(d.getUTCSeconds())
}`
);
}
function buildBaseEnv(): Record<string, string> {
return {
BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"),
BENCH_MD_MIN_SIZE_BYTES: readEnvString(
"BENCH_MD_MIN_SIZE_BYTES",
"512",
),
BENCH_MD_MAX_SIZE_BYTES: readEnvString(
"BENCH_MD_MAX_SIZE_BYTES",
"2048",
),
BENCH_BIN_FILE_COUNT: readEnvString("BENCH_BIN_FILE_COUNT", "5"),
BENCH_BIN_SIZE_BYTES: readEnvString("BENCH_BIN_SIZE_BYTES", "8192"),
BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"),
BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"),
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"),
};
}
function withScopeEnv(
env: Record<string, string>,
options: Pick<BenchmarkCase, "measurementScope" | "limitations">,
): Record<string, string> {
return {
...env,
BENCH_MEASUREMENT_SCOPE: options.measurementScope,
BENCH_LIMITATIONS_JSON: JSON.stringify(options.limitations),
};
}
function defineCase(testCase: BenchmarkCase): BenchmarkCase {
return {
...testCase,
env: withScopeEnv(testCase.env, testCase),
};
}
export function buildCases(): BenchmarkCase[] {
const base = buildBaseEnv();
const couchdbRtt = readEnvString("BENCH_COUCHDB_RTT_MS", "20");
const tetheringVpnRtt = readEnvString("BENCH_TETHERING_VPN_RTT_MS", "120");
const localTurnServers = readEnvString(
"BENCH_LOCAL_TURN_SERVERS",
"turn:127.0.0.1:3478",
);
const shimCouchdbUri = readEnvString(
"BENCH_SHIM_COUCHDB_URI",
"http://couchdb-shim:5984",
);
const signallingShimRelay = readEnvString(
"BENCH_SIGNAL_SHIM_RELAY",
"ws://p2p-signalling-shim:7777/",
);
return [
defineCase({
name: "couchdb-baseline",
runner: "couchdb",
description:
"Standard self-hosted CouchDB path through a local latency proxy.",
dataPath: "Device A -> CouchDB -> Device B",
trustBoundary: "CouchDB operator and network path",
measurementScope:
"Two one-shot synchronisation phases through a CouchDB-compatible remote-store path with a local HTTP latency proxy.",
limitations: [
"This is not a full netem model of packet loss, jitter, MTU, bandwidth limits, or VPN encapsulation.",
"This result should be compared with P2P only as a remote-store baseline under the same deterministic dataset.",
],
env: {
...base,
BENCH_CASE: "couchdb-baseline",
BENCH_COUCHDB_RTT_MS: couchdbRtt,
},
}),
defineCase({
name: "p2p-direct-local",
runner: "p2p",
description:
"Preferred direct WebRTC P2P path with Nostr signalling and TURN disabled.",
dataPath: "Device A -> Device B",
trustBoundary: "Nostr relay for signalling metadata; no TURN relay",
measurementScope:
"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: [
"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.",
],
env: {
...base,
BENCH_CASE: "p2p-direct-local",
BENCH_TURN_SERVERS: "",
BENCH_SIMULATION_TIER: "1",
BENCH_NETWORK_PROFILE: "local-direct",
BENCH_NETWORK_MODEL: "local-runner-webrtc",
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
"turn-disabled-but-selected-ice-pair-not-collected",
},
}),
defineCase({
name: "couchdb-tethering-vpn-proxy",
runner: "couchdb",
description:
"Approximate smartphone tethering/VPN remote-database path using an HTTP latency proxy. This does not model loss, jitter, MTU, or VPN encapsulation.",
dataPath:
"Device A -> VPN/network path -> CouchDB -> VPN/network path -> Device B",
trustBoundary: "VPN/network path and CouchDB operator",
measurementScope:
"Two one-shot CouchDB synchronisation phases with additional requested RTT through the local HTTP proxy.",
limitations: [
"This approximates request latency only and does not model loss, jitter, MTU, bandwidth limits, carrier NAT, or VPN encapsulation.",
"Use the Tier 2 netem shim cases for a stronger constrained-network fixture.",
],
env: {
...base,
BENCH_CASE: "couchdb-tethering-vpn-proxy",
BENCH_COUCHDB_RTT_MS: tetheringVpnRtt,
},
}),
defineCase({
name: "couchdb-netem-home-wifi",
runner: "couchdb",
description:
"Tier 2 CouchDB path through the Compose netem TCP shim using the home-wifi profile.",
dataPath:
"Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B",
trustBoundary: "CouchDB operator and constrained network shim",
measurementScope:
"Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the home-wifi netem profile.",
limitations: [
"This shapes the CouchDB TCP path, not the WebRTC P2P data path.",
"The fixture remains a reproducible network emulation, not a field measurement on a real home network.",
],
env: {
...base,
BENCH_CASE: "couchdb-netem-home-wifi",
BENCH_COUCHDB_BACKEND_URI: shimCouchdbUri,
BENCH_COUCHDB_RTT_MS: "1",
BENCH_SIMULATION_TIER: "2",
BENCH_NETWORK_PROFILE: "home-wifi",
BENCH_NETWORK_MODEL: "compose-netem-tcp-shim",
},
}),
defineCase({
name: "couchdb-netem-tethering-vpn",
runner: "couchdb",
description:
"Tier 2 CouchDB path through the Compose netem TCP shim using a tethering-vpn profile.",
dataPath:
"Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B",
trustBoundary:
"CouchDB operator and constrained smartphone/VPN-like network shim",
measurementScope:
"Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the tethering-vpn netem profile.",
limitations: [
"This shapes the CouchDB TCP path, not the WebRTC P2P data path.",
"The profile approximates smartphone/VPN constraints but is not a field measurement on a real tethered VPN connection.",
],
env: {
...base,
BENCH_CASE: "couchdb-netem-tethering-vpn",
BENCH_COUCHDB_BACKEND_URI: shimCouchdbUri,
BENCH_COUCHDB_RTT_MS: "1",
BENCH_SIMULATION_TIER: "2",
BENCH_NETWORK_PROFILE: "tethering-vpn",
BENCH_NETWORK_MODEL: "compose-netem-tcp-shim",
},
}),
defineCase({
name: "p2p-smartphone-vpn-direct",
runner: "p2p",
description:
"Direct P2P case name for smartphone tethering/VPN measurements. In this local runner it is unshaped and should be treated as a wiring check unless executed on that network.",
dataPath:
"Device A -> Device B when WebRTC direct connectivity succeeds",
trustBoundary:
"Smartphone/VPN routing policy plus Nostr signalling metadata",
measurementScope:
"Structural placeholder for direct P2P measurements on a real smartphone tethering/VPN path.",
limitations: [
"In the local runner this is unshaped and must not be reported as smartphone, VPN, WAN, or Tier 2 evidence.",
"Use only when the command is executed on the intended real network path and the selected ICE candidate pair is recorded.",
],
env: {
...base,
BENCH_CASE: "p2p-smartphone-vpn-direct",
BENCH_TURN_SERVERS: "",
BENCH_SIMULATION_TIER: "unmeasured",
BENCH_NETWORK_PROFILE: "smartphone-vpn-direct-placeholder",
BENCH_NETWORK_MODEL: "local-runner-no-netem",
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
"structural-placeholder-only; selected ICE pair may be collected, but the path is not shaped",
},
}),
defineCase({
name: "p2p-signalling-netem-home-wifi",
runner: "p2p",
description:
"Tier 2 P2P path with only the Nostr signalling relay accessed through the home-wifi netem shim.",
dataPath:
"Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim",
trustBoundary:
"Nostr signalling metadata through constrained network shim; no TURN relay",
measurementScope:
"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: [
"The timed p2p-sync command includes signalling and WebRTC connection establishment.",
"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.",
],
env: {
...base,
BENCH_CASE: "p2p-signalling-netem-home-wifi",
BENCH_RELAY: signallingShimRelay,
BENCH_TURN_SERVERS: "",
BENCH_SIMULATION_TIER: "2",
BENCH_NETWORK_PROFILE: "home-wifi",
BENCH_NETWORK_MODEL: "compose-netem-signalling-shim",
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
"selected ICE pair collected; only Nostr signalling path is shaped",
},
}),
defineCase({
name: "p2p-signalling-netem-tethering-vpn",
runner: "p2p",
description:
"Tier 2 P2P path with only the Nostr signalling relay accessed through the tethering-vpn netem shim.",
dataPath:
"Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim",
trustBoundary:
"Nostr signalling metadata through constrained smartphone/VPN-like network shim; no TURN relay",
measurementScope:
"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: [
"The timed p2p-sync command includes signalling and WebRTC connection establishment.",
"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.",
],
env: {
...base,
BENCH_CASE: "p2p-signalling-netem-tethering-vpn",
BENCH_RELAY: signallingShimRelay,
BENCH_TURN_SERVERS: "",
BENCH_SIMULATION_TIER: "2",
BENCH_NETWORK_PROFILE: "tethering-vpn",
BENCH_NETWORK_MODEL: "compose-netem-signalling-shim",
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
"selected ICE pair collected; only Nostr signalling path is shaped",
},
}),
defineCase({
name: "p2p-user-turn",
runner: "p2p",
description:
"Optional fallback path through a local user-controlled TURN server.",
dataPath: "Device A -> user-controlled TURN -> Device B",
trustBoundary: "User-controlled TURN server",
measurementScope:
"Optional local TURN fallback wiring check with a user-controlled TURN server configured.",
limitations: [
"TURN configuration does not prove that the selected ICE path was relayed; interpret the recorded candidate pair.",
"This is not evidence for public TURN relay privacy, throughput, or availability.",
],
env: {
...base,
BENCH_CASE: "p2p-user-turn",
BENCH_TURN_SERVERS: localTurnServers,
BENCH_SIMULATION_TIER: "1",
BENCH_NETWORK_PROFILE: "local-turn-fallback",
BENCH_NETWORK_MODEL: "local-runner-webrtc-turn-configured",
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
"turn-configured; selected ICE pair may still be direct or relayed, so interpret the recorded candidate types",
},
}),
];
}
async function runCase(
testCase: BenchmarkCase,
outputDir: string,
repeatIndex: number,
repeatCount: number,
): Promise<Record<string, unknown>> {
const suffix = repeatCount > 1
? `-r${String(repeatIndex).padStart(2, "0")}`
: "";
const resultPath = `${outputDir}/${testCase.name}${suffix}.json`;
const taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb";
const env = {
...Deno.env.toObject(),
...testCase.env,
BENCH_RESULT_JSON: resultPath,
BENCH_REPEAT_INDEX: String(repeatIndex),
BENCH_REPEAT_COUNT: String(repeatCount),
};
const repeatLabel = repeatCount > 1
? ` (${repeatIndex}/${repeatCount})`
: "";
console.log(
`[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`,
);
const command = new Deno.Command("deno", {
args: ["task", taskName],
cwd: import.meta.dirname,
env,
stdin: "null",
stdout: "inherit",
stderr: "inherit",
});
const child = command.spawn();
const status = await child.status;
if (status.code !== 0) {
throw new Error(`case failed: ${testCase.name} (exit ${status.code})`);
}
const result = JSON.parse(await Deno.readTextFile(resultPath)) as Record<
string,
unknown
>;
return {
...testCase,
repeatIndex,
repeatCount,
resultPath,
result,
};
}
function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] {
const requested = readEnvString(
"BENCH_CASES",
"couchdb-baseline,p2p-direct-local",
);
const names = requested
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
const byName = new Map(allCases.map((c) => [c.name, c]));
return names.map((name) => {
const found = byName.get(name);
if (!found) {
throw new Error(
`Unknown BENCH_CASES entry '${name}'. Available: ${
allCases.map((c) => c.name).join(", ")
}`,
);
}
return found;
});
}
async function main(): Promise<void> {
const outRoot = readEnvString(
"BENCH_CASES_ROOT",
`${import.meta.dirname}/bench-results`,
);
const outputDir = `${outRoot}/cases-${timestamp()}`;
await Deno.mkdir(outputDir, { recursive: true });
const allCases = buildCases();
const cases = selectCases(allCases);
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
await Deno.writeTextFile(
`${outputDir}/case-manifest.json`,
JSON.stringify(
{
generatedAt: new Date().toISOString(),
repeatCount,
selectedCases: cases,
availableCases: allCases,
},
null,
2,
),
);
const results: Record<string, unknown>[] = [];
for (const testCase of cases) {
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
results.push(
await runCase(testCase, outputDir, repeatIndex, repeatCount),
);
}
}
const summary = {
generatedAt: new Date().toISOString(),
outputDir,
repeatCount,
results,
};
await Deno.writeTextFile(
`${outputDir}/summary.json`,
JSON.stringify(summary, null, 2),
);
console.log(JSON.stringify(summary, null, 2));
console.log(`[bench-cases] result directory: ${outputDir}`);
}
if (import.meta.main) {
main().catch((error) => {
console.error("[Fatal Error]", error);
Deno.exit(1);
});
}
@@ -1,435 +0,0 @@
import { join } from "@std/path";
import { startCliInBackground } from "./helpers/backgroundCli.ts";
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
import { discoverPeer } from "./helpers/p2p.ts";
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
type Role = "host" | "client";
type NetemSummary = {
enabled: boolean;
profile: string;
interface: string;
delayMs: number;
jitterMs: number;
lossPercent: number;
bandwidthMbit: number;
mtu: number;
tcQdisc?: string;
ipAddr?: string;
ipRoute?: string;
};
type HostReady = {
generatedAt: string;
totalFiles: number;
totalBytes: number;
mdFileCount: number;
binFileCount: number;
mirrorElapsedMs: number;
netem: NetemSummary;
};
type P2PConnectionStats = {
candidatePathCollected: boolean;
selectedPath: string;
localCandidate?: { candidateType: string; protocol: string; relayProtocol: string };
remoteCandidate?: { candidateType: string; protocol: string; relayProtocol: string };
};
function errorToRecord(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}
return {
name: "UnknownError",
message: String(error),
};
}
function readEnvString(name: string, fallback: string): string {
const value = Deno.env.get(name)?.trim();
return value && value.length > 0 ? value : fallback;
}
function readEnvNumber(name: string, fallback: number): number {
const raw = Deno.env.get(name);
if (raw === undefined || raw.trim() === "") {
return fallback;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(`${name} must be a non-negative number, got '${raw}'`);
}
return parsed;
}
function nowMs(): number {
return performance.now();
}
async function commandOutput(command: string, args: string[]): Promise<string> {
const output = await new Deno.Command(command, {
args,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).output();
const stdout = new TextDecoder().decode(output.stdout);
const stderr = new TextDecoder().decode(output.stderr);
if (!output.success) {
throw new Error(`${command} ${args.join(" ")} failed\nstdout: ${stdout}\nstderr: ${stderr}`);
}
return stdout.trim();
}
async function commandOk(command: string, args: string[]): Promise<void> {
await commandOutput(command, args);
}
async function applyNetemIfRequested(): Promise<NetemSummary> {
const enabled = readEnvString("BENCH_NETEM_ENABLED", "0") === "1";
const profile = readEnvString("NETEM_PROFILE", "home-wifi");
const iface = readEnvString("NETEM_INTERFACE", "eth0");
const delayMs = readEnvNumber("NETEM_DELAY_MS", 20);
const jitterMs = readEnvNumber("NETEM_JITTER_MS", 5);
const lossPercent = readEnvNumber("NETEM_LOSS_PERCENT", 0.1);
const bandwidthMbit = readEnvNumber("NETEM_BANDWIDTH_MBIT", 100);
const mtu = readEnvNumber("NETEM_MTU", 1500);
const summary: NetemSummary = {
enabled,
profile,
interface: iface,
delayMs,
jitterMs,
lossPercent,
bandwidthMbit,
mtu,
};
if (!enabled) {
return summary;
}
await commandOk("ip", ["link", "set", "dev", iface, "mtu", String(mtu)]);
await new Deno.Command("tc", { args: ["qdisc", "del", "dev", iface, "root"] }).output();
await commandOk("tc", [
"qdisc",
"add",
"dev",
iface,
"root",
"netem",
"delay",
`${delayMs}ms`,
`${jitterMs}ms`,
"loss",
`${lossPercent}%`,
"rate",
`${bandwidthMbit}mbit`,
]);
summary.tcQdisc = await commandOutput("tc", ["qdisc", "show", "dev", iface]);
summary.ipAddr = await commandOutput("ip", ["addr", "show", iface]);
summary.ipRoute = await commandOutput("ip", ["route"]);
return summary;
}
async function waitForFile(path: string, timeoutMs: number): Promise<void> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const stat = await Deno.stat(path);
if (stat.isFile) {
return;
}
} catch {
// wait
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${path}`);
}
async function readJsonFile<T>(path: string): Promise<T> {
return JSON.parse(await Deno.readTextFile(path)) as T;
}
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
const unique = new Map<string, DatasetEntry>();
for (const entry of [entries.find((e) => e.kind === "md"), entries.find((e) => e.kind === "bin"), entries.at(-1)]) {
if (entry) {
unique.set(entry.relativePath, entry);
}
}
return [...unique.values()];
}
async function readLatestP2PConnectionStats(path: string): Promise<P2PConnectionStats | undefined> {
try {
const lines = (await Deno.readTextFile(path))
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
return lines.length === 0 ? undefined : (JSON.parse(lines.at(-1)!) as P2PConnectionStats);
} catch {
return undefined;
}
}
function buildCommonConfig() {
const runId = readEnvString("BENCH_SPLIT_RUN_ID", readEnvString("BENCH_ROOM_ID", "bench-split-run"));
const baseWorkRoot = readEnvString("BENCH_SPLIT_WORK_ROOT", "/p2p-work");
return {
runId,
workRoot: join(baseWorkRoot, runId),
resultRoot: readEnvString("BENCH_SPLIT_RESULT_ROOT", "/workspace/src/apps/cli/testdeno/bench-results"),
relay: readEnvString("BENCH_RELAY", "ws://nostr-relay:7777/"),
appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"),
roomId: readEnvString("BENCH_ROOM_ID", "bench-split-room"),
passphrase: readEnvString("BENCH_PASSPHRASE", "bench-split-passphrase"),
turnServers: readEnvString("BENCH_TURN_SERVERS", ""),
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 20)),
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 512)),
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 2048)),
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 5)),
binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 8192)),
peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 60),
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 300),
nodeTimeoutMs: readEnvNumber("BENCH_SPLIT_NODE_TIMEOUT_MS", 360_000),
profile: readEnvString("BENCH_NETWORK_PROFILE", readEnvString("NETEM_PROFILE", "split-compose")),
};
}
async function prepareP2PSettings(
settingsPath: string,
peerName: string,
config: ReturnType<typeof buildCommonConfig>
) {
await initSettingsFile(settingsPath);
await applyP2pSettings(
settingsPath,
config.roomId,
config.passphrase,
config.appId,
config.relay,
"~.*",
config.turnServers
);
await applyP2pTestTweaks(settingsPath, peerName, config.passphrase);
}
async function runHost(): Promise<void> {
const config = buildCommonConfig();
const netem = await applyNetemIfRequested();
await Deno.mkdir(config.workRoot, { recursive: true });
await Deno.mkdir(config.resultRoot, { recursive: true });
const hostVault = join(config.workRoot, "vault-host");
const hostSettings = join(config.workRoot, "settings-host.json");
await Deno.mkdir(hostVault, { recursive: true });
await prepareP2PSettings(hostSettings, "p2p-split-host", config);
const seedFiles = await createDeterministicDataset({
rootDir: hostVault,
datasetDirName: config.datasetDirName,
seed: config.datasetSeed,
mdCount: config.mdFileCount,
mdMinSizeBytes: config.mdMinSizeBytes,
mdMaxSizeBytes: config.mdMaxSizeBytes,
binCount: config.binFileCount,
binSizeBytes: config.binSizeBytes,
});
await Deno.writeTextFile(
join(config.workRoot, "sample-files.json"),
JSON.stringify(pickSampleFiles(seedFiles.entries), null, 2)
);
const mirrorStart = nowMs();
await runCliOrFail(hostVault, "--settings", hostSettings, "mirror");
const mirrorElapsedMs = Number((nowMs() - mirrorStart).toFixed(1));
const hostReady: HostReady = {
generatedAt: new Date().toISOString(),
totalFiles: seedFiles.totalFiles,
totalBytes: seedFiles.totalBytes,
mdFileCount: seedFiles.mdCount,
binFileCount: seedFiles.binCount,
mirrorElapsedMs,
netem,
};
await Deno.writeTextFile(join(config.workRoot, "host-ready.json"), JSON.stringify(hostReady, null, 2));
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
try {
await host.waitUntilContains("P2P host is running", 20_000);
await Deno.writeTextFile(
join(config.workRoot, "p2p-host-ready.json"),
JSON.stringify({ generatedAt: new Date().toISOString() })
);
await waitForFile(join(config.workRoot, "client-done.json"), config.nodeTimeoutMs);
} finally {
await host.stop();
}
}
async function runClient(): Promise<void> {
const config = buildCommonConfig();
const netem = await applyNetemIfRequested();
await Deno.mkdir(config.resultRoot, { recursive: true });
await waitForFile(join(config.workRoot, "host-ready.json"), config.nodeTimeoutMs);
await waitForFile(join(config.workRoot, "p2p-host-ready.json"), config.nodeTimeoutMs);
const clientVault = join(config.workRoot, "vault-client");
const clientSettings = join(config.workRoot, "settings-client.json");
const statsPath = join(config.workRoot, "p2p-connection-stats.jsonl");
await Deno.mkdir(clientVault, { recursive: true });
await prepareP2PSettings(clientSettings, "p2p-split-client", config);
const hostReady = await readJsonFile<HostReady>(join(config.workRoot, "host-ready.json"));
const timestamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
const outputDir = join(config.resultRoot, `p2p-split-${config.profile}-${timestamp}`);
await Deno.mkdir(outputDir, { recursive: true });
const previousStatsPath = Deno.env.get("LIVESYNC_P2P_STATS_JSONL");
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", statsPath);
let stage = "peer-discovery";
let peerDiscoveryCommandElapsedMs: number | undefined;
let syncElapsedMs: number | undefined;
try {
const peerDiscoveryCommandStart = nowMs();
const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds);
peerDiscoveryCommandElapsedMs = Number((nowMs() - peerDiscoveryCommandStart).toFixed(1));
stage = "p2p-sync";
const syncStart = nowMs();
await runCliOrFail(
clientVault,
"--settings",
clientSettings,
"p2p-sync",
peer.id,
String(config.syncTimeoutSeconds)
);
syncElapsedMs = Number((nowMs() - syncStart).toFixed(1));
stage = "sample-verification";
const samples = await readJsonFile<DatasetEntry[]>(join(config.workRoot, "sample-files.json"));
for (const sample of samples) {
const pulledPath = join(config.workRoot, `pulled-${sample.relativePath.replaceAll("/", "_")}`);
await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath);
await assertFilesEqual(
sample.absolutePath,
pulledPath,
`sample file mismatch after split sync: ${sample.relativePath}`
);
}
const p2pConnectionStats = await readLatestP2PConnectionStats(statsPath);
const result = {
ok: true,
generatedAt: new Date().toISOString(),
caseName: "p2p-split-compose",
mode: "p2p-split-compose-benchmark",
runId: config.runId,
simulationTier: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1",
networkProfile: config.profile,
networkModel:
Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "split-compose-egress-netem" : "split-compose-no-netem",
relay: config.relay,
turnServers: config.turnServers,
turnEnabled: config.turnServers.trim().length > 0,
p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true,
p2pConnectionStats,
hostNetem: hostReady.netem,
clientNetem: netem,
totalFiles: hostReady.totalFiles,
totalBytes: hostReady.totalBytes,
mdFileCount: hostReady.mdFileCount,
binFileCount: hostReady.binFileCount,
mirrorElapsedMs: hostReady.mirrorElapsedMs,
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
peerDiscoveryCommandElapsedMs,
syncElapsedMs,
throughputBytesPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000)).toFixed(2)),
throughputMiBPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000) / 1024 / 1024).toFixed(4)),
};
await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
await Deno.writeTextFile(
join(config.workRoot, "client-done.json"),
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: true })
);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
const p2pConnectionStats = await readLatestP2PConnectionStats(statsPath);
const result = {
ok: false,
generatedAt: new Date().toISOString(),
caseName: "p2p-split-compose",
mode: "p2p-split-compose-benchmark",
runId: config.runId,
simulationTier: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1",
networkProfile: config.profile,
networkModel:
Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "split-compose-egress-netem" : "split-compose-no-netem",
relay: config.relay,
turnServers: config.turnServers,
turnEnabled: config.turnServers.trim().length > 0,
p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true,
p2pConnectionStats,
hostNetem: hostReady.netem,
clientNetem: netem,
totalFiles: hostReady.totalFiles,
totalBytes: hostReady.totalBytes,
mdFileCount: hostReady.mdFileCount,
binFileCount: hostReady.binFileCount,
mirrorElapsedMs: hostReady.mirrorElapsedMs,
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
peerDiscoveryCommandElapsedMs,
syncElapsedMs,
failure: {
stage,
...errorToRecord(error),
},
};
await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
await Deno.writeTextFile(
join(config.workRoot, "client-done.json"),
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: false })
);
console.log(JSON.stringify(result, null, 2));
throw error;
} finally {
if (previousStatsPath === undefined) {
Deno.env.delete("LIVESYNC_P2P_STATS_JSONL");
} else {
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath);
}
}
}
async function main(): Promise<void> {
const role = readEnvString("BENCH_P2P_SPLIT_ROLE", "") as Role;
if (role === "host") {
await runHost();
return;
}
if (role === "client") {
await runClient();
return;
}
throw new Error("BENCH_P2P_SPLIT_ROLE must be 'host' or 'client'");
}
if (import.meta.main) {
main().catch((error) => {
console.error("[Fatal Error]", error);
Deno.exit(1);
});
}
+109 -331
View File
@@ -1,34 +1,15 @@
import { TempDir } from "./helpers/temp.ts";
import {
applyP2pSettings,
applyP2pTestTweaks,
initSettingsFile,
} from "./helpers/settings.ts";
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
import { startCliInBackground } from "./helpers/backgroundCli.ts";
import {
discoverPeer,
maybeStartCoturn,
maybeStartLocalRelay,
stopCoturnIfStarted,
stopLocalRelayIfStarted,
} from "./helpers/p2p.ts";
import { discoverPeer, maybeStartLocalRelay, stopLocalRelayIfStarted } from "./helpers/p2p.ts";
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
import {
createDeterministicDataset,
} from "./helpers/dataset.ts";
import {
type BenchmarkVerificationMode,
parseBenchmarkVerificationMode,
verifyBenchmarkDataset,
} from "./helpers/benchmarkVerification.ts";
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
type BenchmarkConfig = {
caseName: string;
relay: string;
appId: string;
roomId: string;
passphrase: string;
turnServers: string;
datasetDirName: string;
datasetSeed: string;
mdFileCount: number;
@@ -38,47 +19,6 @@ type BenchmarkConfig = {
binSizeBytes: number;
peersTimeoutSeconds: number;
syncTimeoutSeconds: number;
simulationTier: string;
networkProfile: string;
networkModel: string;
candidatePathVerification: string;
measurementScope: string;
limitations: string[];
verificationMode: BenchmarkVerificationMode;
repeatIndex: number;
repeatCount: number;
};
type P2PConnectionStats = {
generatedAt: string;
command: string;
peerId: string;
peerName: string;
candidatePathCollected: boolean;
selectedPath: string;
selectedPair?: {
id: string;
state: string;
currentRoundTripTime: number | "unknown";
totalRoundTripTime: number | "unknown";
requestsSent: number | "unknown";
responsesReceived: number | "unknown";
packetsDiscardedOnSend: number | "unknown";
bytesSent: number | "unknown";
bytesReceived: number | "unknown";
};
localCandidate?: {
id: string;
candidateType: string;
protocol: string;
relayProtocol: string;
};
remoteCandidate?: {
id: string;
candidateType: string;
protocol: string;
relayProtocol: string;
};
};
function readEnvString(name: string, fallback: string): string {
@@ -99,30 +39,6 @@ function readEnvNumber(name: string, fallback: number): number {
return parsed;
}
function readEnvStringArray(name: string, fallback: string[]): string[] {
const raw = Deno.env.get(name)?.trim();
if (!raw) {
return fallback;
}
try {
const parsed = JSON.parse(raw);
if (
Array.isArray(parsed) &&
parsed.every((item) => typeof item === "string")
) {
return parsed;
}
} catch {
// Fall through to comma-separated parsing for hand-written invocations.
}
return raw
.split("|")
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function nowMs(): number {
return performance.now();
}
@@ -145,52 +61,19 @@ function formatBytes(value: number): string {
function buildConfig(): BenchmarkConfig {
return {
caseName: readEnvString("BENCH_CASE", "p2p-direct-local"),
relay: readEnvString("BENCH_RELAY", "ws://localhost:4000/"),
appId: readEnvString(
"BENCH_APP_ID",
"self-hosted-livesync-cli-benchmark",
),
appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"),
roomId: readEnvString("BENCH_ROOM_ID", `bench-room-${Date.now()}`),
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
turnServers: readEnvString("BENCH_TURN_SERVERS", ""),
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
mdMinSizeBytes: Math.floor(
readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024),
),
mdMaxSizeBytes: Math.floor(
readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024),
),
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)),
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)),
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)),
binSizeBytes: Math.floor(
readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024),
),
binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024)),
peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 20),
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"),
networkProfile: readEnvString("BENCH_NETWORK_PROFILE", "local-direct"),
networkModel: readEnvString(
"BENCH_NETWORK_MODEL",
"local-runner-webrtc",
),
candidatePathVerification: readEnvString(
"BENCH_P2P_CANDIDATE_PATH_VERIFICATION",
"not-collected",
),
measurementScope: readEnvString(
"BENCH_MEASUREMENT_SCOPE",
"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", [
"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)),
};
}
@@ -202,22 +85,21 @@ function readOptionalResultPath(): string | undefined {
return raw;
}
async function readLatestP2PConnectionStats(
statsPath: string,
): Promise<P2PConnectionStats | undefined> {
try {
const text = await Deno.readTextFile(statsPath);
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length === 0) {
return undefined;
}
return JSON.parse(lines[lines.length - 1]) as P2PConnectionStats;
} catch {
return undefined;
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 main(): Promise<void> {
@@ -225,214 +107,110 @@ async function main(): Promise<void> {
const resultPath = readOptionalResultPath();
const relayStarted = await maybeStartLocalRelay(config.relay);
const coturnStarted = await maybeStartCoturn(config.turnServers);
await using workDir = await TempDir.create("livesync-cli-p2p-bench");
const hostVault = workDir.join("vault-host");
const clientVault = workDir.join("vault-client");
const hostSettings = workDir.join("settings-host.json");
const clientSettings = workDir.join("settings-client.json");
const p2pStatsPath = workDir.join("p2p-connection-stats.jsonl");
const previousStatsPath = Deno.env.get("LIVESYNC_P2P_STATS_JSONL");
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", p2pStatsPath);
await Promise.all([
Deno.mkdir(hostVault, { recursive: true }),
Deno.mkdir(clientVault, { recursive: true }),
initSettingsFile(hostSettings),
initSettingsFile(clientSettings),
]);
await Promise.all([
applyP2pSettings(hostSettings, config.roomId, config.passphrase, config.appId, config.relay, "~.*"),
applyP2pSettings(clientSettings, config.roomId, config.passphrase, config.appId, config.relay, "~.*"),
]);
await Promise.all([
applyP2pTestTweaks(hostSettings, "p2p-bench-host", config.passphrase),
applyP2pTestTweaks(clientSettings, "p2p-bench-client", config.passphrase),
]);
const seedFiles = await createDeterministicDataset({
rootDir: hostVault,
datasetDirName: config.datasetDirName,
seed: config.datasetSeed,
mdCount: config.mdFileCount,
mdMinSizeBytes: config.mdMinSizeBytes,
mdMaxSizeBytes: config.mdMaxSizeBytes,
binCount: config.binFileCount,
binSizeBytes: config.binSizeBytes,
});
const mirrorStart = nowMs();
await runCliOrFail(hostVault, "--settings", hostSettings, "mirror");
const mirrorElapsed = nowMs() - mirrorStart;
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
try {
await Promise.all([
Deno.mkdir(hostVault, { recursive: true }),
Deno.mkdir(clientVault, { recursive: true }),
initSettingsFile(hostSettings),
initSettingsFile(clientSettings),
]);
const hostReadyStart = nowMs();
await host.waitUntilContains("P2P host is running", 20000);
const hostReadyElapsed = nowMs() - hostReadyStart;
await Promise.all([
applyP2pSettings(
hostSettings,
config.roomId,
config.passphrase,
config.appId,
config.relay,
"~.*",
config.turnServers,
),
applyP2pSettings(
clientSettings,
config.roomId,
config.passphrase,
config.appId,
config.relay,
"~.*",
config.turnServers,
),
]);
const peerDiscoveryStart = nowMs();
const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds);
const peerDiscoveryElapsed = nowMs() - peerDiscoveryStart;
await Promise.all([
applyP2pTestTweaks(
hostSettings,
"p2p-bench-host",
config.passphrase,
),
applyP2pTestTweaks(
clientSettings,
"p2p-bench-client",
config.passphrase,
),
]);
const seedFiles = await createDeterministicDataset({
rootDir: hostVault,
datasetDirName: config.datasetDirName,
seed: config.datasetSeed,
mdCount: config.mdFileCount,
mdMinSizeBytes: config.mdMinSizeBytes,
mdMaxSizeBytes: config.mdMaxSizeBytes,
binCount: config.binFileCount,
binSizeBytes: config.binSizeBytes,
});
const mirrorStart = nowMs();
await runCliOrFail(hostVault, "--settings", hostSettings, "mirror");
const mirrorElapsed = nowMs() - mirrorStart;
const host = startCliInBackground(
hostVault,
const syncStart = nowMs();
await runCliOrFail(
clientVault,
"--settings",
hostSettings,
"p2p-host",
clientSettings,
"p2p-sync",
peer.id,
String(config.syncTimeoutSeconds)
);
try {
const hostReadyStart = nowMs();
await host.waitUntilContains("P2P host is running", 20000);
const hostReadyElapsed = nowMs() - hostReadyStart;
const syncElapsed = nowMs() - syncStart;
const peerDiscoveryCommandStart = nowMs();
const peer = await discoverPeer(
clientVault,
clientSettings,
config.peersTimeoutSeconds,
const sampleFiles = pickSampleFiles(seedFiles.entries);
for (const sample of sampleFiles) {
const pulledPath = workDir.join(`pulled-${sample.relativePath.replaceAll("/", "_")}`);
await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath);
await assertFilesEqual(
sample.absolutePath,
pulledPath,
`sample file mismatch after sync: ${sample.relativePath}`
);
const peerDiscoveryCommandElapsed = nowMs() -
peerDiscoveryCommandStart;
const syncStart = nowMs();
await runCliOrFail(
clientVault,
"--settings",
clientSettings,
"p2p-sync",
peer.id,
String(config.syncTimeoutSeconds),
);
const syncElapsed = nowMs() - syncStart;
const verification = await verifyBenchmarkDataset(
seedFiles.entries,
config.verificationMode,
async (entry) => {
const pulledPath = workDir.join(
`pulled-${entry.relativePath.replaceAll("/", "_")}`,
);
await runCliOrFail(
clientVault,
"--settings",
clientSettings,
"pull",
entry.relativePath,
pulledPath,
);
await assertFilesEqual(
entry.absolutePath,
pulledPath,
`file mismatch after P2P sync: ${entry.relativePath}`,
);
},
);
const p2pConnectionStats = await readLatestP2PConnectionStats(
p2pStatsPath,
);
const result = {
caseName: config.caseName,
mode: "p2p-cli-benchmark",
relay: config.relay,
turnServers: config.turnServers,
turnEnabled: config.turnServers.trim().length > 0,
simulationTier: config.simulationTier,
networkProfile: config.networkProfile,
networkModel: config.networkModel,
measurementScope: config.measurementScope,
limitations: config.limitations,
repeatIndex: config.repeatIndex,
repeatCount: config.repeatCount,
p2pCandidatePathVerified:
p2pConnectionStats?.candidatePathCollected === true,
p2pCandidatePathVerification:
p2pConnectionStats?.candidatePathCollected
? "selected ICE candidate pair collected from RTCPeerConnection.getStats"
: config.candidatePathVerification,
p2pCandidatePathNote: p2pConnectionStats?.candidatePathCollected
? "The selected ICE candidate pair was collected by the CLI benchmark. Interpret the path from the candidate types; do not infer TURN use from configuration alone."
: config.turnServers.trim().length > 0
? "TURN is configured, so the selected WebRTC path may be direct, server-reflexive, or relayed. The selected ICE candidate pair was not exported by this run."
: "TURN is disabled, so a TURN-relayed path is not expected. The selected ICE candidate pair was not exported by this run.",
p2pConnectionStats,
appId: config.appId,
roomId: config.roomId,
datasetSeed: config.datasetSeed,
datasetDirName: config.datasetDirName,
peerId: peer.id,
peerName: peer.name,
totalFiles: seedFiles.totalFiles,
totalBytes: seedFiles.totalBytes,
mdFileCount: seedFiles.mdCount,
binFileCount: seedFiles.binCount,
...verification,
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)),
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
peerDiscoveryCommandElapsedMs: Number(
peerDiscoveryCommandElapsed.toFixed(1),
),
peerDiscoveryNote:
"p2p-peers waits for the requested timeout before printing discovered peers, so this is command duration, not first-peer latency.",
syncElapsedMs: Number(syncElapsed.toFixed(1)),
throughputBytesPerSec: Number(
(seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2),
),
throughputMiBPerSec: Number(
(seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024)
.toFixed(
4,
),
),
};
if (resultPath) {
await Deno.writeTextFile(
resultPath,
JSON.stringify(result, null, 2),
);
}
console.log(JSON.stringify(result, null, 2));
console.error(
`[Benchmark] mirrored ${seedFiles.totalFiles} files (${
formatBytes(
seedFiles.totalBytes,
)
}) in ${formatMs(mirrorElapsed)}, ` +
`synced in ${formatMs(syncElapsed)} ` +
`(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`,
);
} finally {
await host.stop();
}
const result = {
mode: "p2p-cli-benchmark",
relay: config.relay,
appId: config.appId,
roomId: config.roomId,
datasetSeed: config.datasetSeed,
datasetDirName: config.datasetDirName,
peerId: peer.id,
peerName: peer.name,
totalFiles: seedFiles.totalFiles,
totalBytes: seedFiles.totalBytes,
mdFileCount: seedFiles.mdCount,
binFileCount: seedFiles.binCount,
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)),
peerDiscoveryElapsedMs: Number(peerDiscoveryElapsed.toFixed(1)),
syncElapsedMs: Number(syncElapsed.toFixed(1)),
throughputBytesPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2)),
throughputMiBPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024).toFixed(4)),
};
if (resultPath) {
await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2));
}
console.log(JSON.stringify(result, null, 2));
console.error(
`[Benchmark] mirrored ${seedFiles.totalFiles} files (${formatBytes(seedFiles.totalBytes)}) in ${formatMs(mirrorElapsed)}, ` +
`synced in ${formatMs(syncElapsed)} ` +
`(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`
);
} finally {
if (previousStatsPath === undefined) {
Deno.env.delete("LIVESYNC_P2P_STATS_JSONL");
} else {
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath);
}
await stopCoturnIfStarted(coturnStarted);
await host.stop();
await stopLocalRelayIfStarted(relayStarted);
}
}
-4
View File
@@ -15,12 +15,8 @@
"test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts",
"test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts",
"test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts",
"test:benchmark-contract": "deno test --env-file=.test.env -A --no-check test-benchmark-contract.ts",
"bench:p2p": "deno run --env-file=.test.env -A --no-check bench-p2p.ts",
"bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts",
"bench:cases": "deno run --env-file=.test.env -A --no-check bench-network-cases.ts",
"bench:latency-sweep": "deno run --env-file=.test.env -A --no-check bench-latency-sweep.ts",
"bench:p2p-split-node": "deno run --env-file=.test.env -A --no-check bench-p2p-split-node.ts",
"bench:item1": "bash ./bench-run-item1.sh",
"bench:item1:full": "BENCH_MD_FILE_COUNT=1500 BENCH_MD_MIN_SIZE_BYTES=1024 BENCH_MD_MAX_SIZE_BYTES=20480 BENCH_BIN_FILE_COUNT=500 BENCH_BIN_SIZE_BYTES=102400 BENCH_COUCHDB_RTT_MS=50 bash ./bench-run-item1.sh",
"test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts",
@@ -1,80 +0,0 @@
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 -1
View File
@@ -9,7 +9,7 @@ function sleep(ms: number): Promise<void> {
}
async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
let timer: number | undefined;
try {
const connPromise = Deno.connect({ hostname, port });
const timeoutPromise = new Promise<never>((_, reject) => {
+4 -8
View File
@@ -76,10 +76,8 @@ export async function discoverPeer(
}
export async function maybeStartLocalRelay(relay: string): Promise<boolean> {
const shouldStart = isLocalP2pRelay(relay);
if (shouldStart) {
await startP2pRelay();
}
if (!isLocalP2pRelay(relay)) return false;
await startP2pRelay();
const endpoint = parseRelayEndpoint(relay);
await waitForPort(endpoint.hostname, endpoint.port, {
timeoutMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "15000"),
@@ -88,10 +86,8 @@ export async function maybeStartLocalRelay(relay: string): Promise<boolean> {
});
// Docker proxy accepts TCP connections instantly before the container's internal process is fully ready.
// Wait an additional few seconds to ensure strfry is actually accepting WebSockets.
if (shouldStart) {
await sleep(3000);
}
return shouldStart;
await sleep(3000);
return true;
}
export async function stopLocalRelayIfStarted(started: boolean): Promise<void> {
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env sh
set -eu
TASK="${CLI_E2E_TASK:-test:p2p-sync}"
case "$TASK" in
test:p2p-host|test:p2p-peers|test:p2p-sync|test:p2p-three-nodes|test:p2p-upload-download)
exec deno task "$TASK"
;;
*)
echo "Unknown CLI_E2E_TASK: $TASK" >&2
echo "Expected one of: test:p2p-host, test:p2p-peers, test:p2p-sync, test:p2p-three-nodes, test:p2p-upload-download" >&2
exit 2
;;
esac
@@ -1,223 +0,0 @@
import { assert, assertEquals, assertStringIncludes } from "@std/assert";
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 {
const found = cases.find((testCase) => testCase.name === name);
assert(found, `missing benchmark case: ${name}`);
return found;
}
function parsedLimitations(testCase: BenchmarkCase): string[] {
const raw = testCase.env.BENCH_LIMITATIONS_JSON;
assert(
raw,
`${testCase.name} must pass BENCH_LIMITATIONS_JSON to benchmark result output`,
);
const parsed = JSON.parse(raw);
assert(
Array.isArray(parsed),
`${testCase.name} limitations must be an array`,
);
assert(
parsed.every((item) =>
typeof item === "string" && item.trim().length > 0
),
);
return parsed;
}
Deno.test("benchmark cases record scope and limitations for paper use", () => {
const cases = buildCases();
assert(cases.length > 0);
for (const testCase of cases) {
assert(
testCase.description.trim().length > 0,
`${testCase.name} must describe the case`,
);
assert(
testCase.dataPath.trim().length > 0,
`${testCase.name} must describe the data path`,
);
assert(
testCase.trustBoundary.trim().length > 0,
`${testCase.name} must describe the trust boundary`,
);
assert(
testCase.measurementScope.trim().length > 0,
`${testCase.name} must describe the measurement scope`,
);
assert(
testCase.limitations.length > 0,
`${testCase.name} must list limitations`,
);
assertEquals(
testCase.env.BENCH_MEASUREMENT_SCOPE,
testCase.measurementScope,
);
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", () => {
const cases = buildCases();
for (
const name of [
"p2p-signalling-netem-home-wifi",
"p2p-signalling-netem-tethering-vpn",
]
) {
const testCase = getCase(cases, name);
assertEquals(testCase.runner, "p2p");
assertEquals(testCase.env.BENCH_TURN_SERVERS, "");
assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2");
assertEquals(
testCase.env.BENCH_NETWORK_MODEL,
"compose-netem-signalling-shim",
);
assertStringIncludes(testCase.dataPath, "WebRTC DataChannel");
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(
testCase.limitations.some((limitation) =>
limitation.includes("does not shape the selected WebRTC")
),
`${name} must avoid claiming that the P2P note-data path was shaped`,
);
}
});
Deno.test("placeholder and TURN cases are clearly non-evidence for broad P2P performance", () => {
const cases = buildCases();
const smartphone = getCase(cases, "p2p-smartphone-vpn-direct");
assertEquals(smartphone.env.BENCH_SIMULATION_TIER, "unmeasured");
assertEquals(smartphone.env.BENCH_NETWORK_MODEL, "local-runner-no-netem");
assert(
smartphone.limitations.some((limitation) =>
limitation.includes("must not be reported as smartphone")
),
"smartphone/VPN placeholder must not be usable as field evidence by accident",
);
const turn = getCase(cases, "p2p-user-turn");
assertStringIncludes(turn.env.BENCH_TURN_SERVERS, "turn:");
assert(
turn.limitations.some((limitation) =>
limitation.includes(
"does not prove that the selected ICE path was relayed",
)
),
"TURN case must require selected ICE candidate interpretation",
);
});
Deno.test("CouchDB netem cases are marked as remote-store baselines", () => {
const cases = buildCases();
for (
const name of ["couchdb-netem-home-wifi", "couchdb-netem-tethering-vpn"]
) {
const testCase = getCase(cases, name);
assertEquals(testCase.runner, "couchdb");
assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2");
assertEquals(
testCase.env.BENCH_NETWORK_MODEL,
"compose-netem-tcp-shim",
);
assertStringIncludes(testCase.measurementScope, "CouchDB");
assert(
testCase.limitations.some((limitation) =>
limitation.includes("not the WebRTC P2P data path")
),
`${name} must remain scoped to the CouchDB remote-store path`,
);
}
});
-28
View File
@@ -1,28 +0,0 @@
/**
* Validate the platform-neutral path vocabulary used by rooted storage adapters.
*
* Paths are slash-separated and relative to the root bound to the adapter. Root
* selection and authorisation happen before the adapter is constructed.
*/
export function validateStoragePath(storagePath: string, allowRoot: boolean = true): string {
if (storagePath === "") {
if (allowRoot) return storagePath;
throw new Error("The storage root is not a valid entry path");
}
// LiveSync normally rejects ':' in portable filenames before paths reach storage. A leading letter and colon
// therefore represents drive-qualified input or a leaked non-storage namespace, not a supported physical path.
if (storagePath.startsWith("/") || storagePath.startsWith("\\") || /^[A-Za-z]:/.test(storagePath)) {
throw new Error(`Storage paths must be relative to the configured root: ${storagePath}`);
}
if (storagePath.includes("\\")) {
throw new Error(`Storage paths must use forward slashes: ${storagePath}`);
}
const segments = storagePath.split("/");
if (segments.some((segment) => segment === "." || segment === "..")) {
throw new Error(`Storage paths must not contain traversal segments: ${storagePath}`);
}
return storagePath;
}
+12 -23
View File
@@ -1,13 +1,12 @@
import type { UXDataWriteOptions } from "@lib/common/types";
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
import type { FSAPIStat } from "./FSAPITypes";
import { validateStoragePath } from "@/apps/storagePath";
/**
* Storage adapter implementation for FileSystem API
*/
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
constructor(private readonly rootHandle: FileSystemDirectoryHandle) {}
constructor(private rootHandle: FileSystemDirectoryHandle) {}
/**
* Resolve a path to directory and file handles
@@ -16,9 +15,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
dirHandle: FileSystemDirectoryHandle;
fileName: string;
} | null> {
validateStoragePath(p, false);
try {
const parts = p.split("/").filter((part) => part !== "");
if (parts.length === 0) {
return null;
}
let currentHandle = this.rootHandle;
const fileName = parts[parts.length - 1];
@@ -38,8 +39,6 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
* Get file handle for a given path
*/
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
validateStoragePath(p);
if (p === "") return null;
const resolved = await this.resolvePath(p);
if (!resolved) return null;
@@ -54,7 +53,6 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
* Get directory handle for a given path
*/
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
validateStoragePath(p);
try {
const parts = p.split("/").filter((part) => part !== "");
if (parts.length === 0) {
@@ -72,20 +70,6 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
}
}
/** Resolve a writable file path after creating its parent directories. */
private async resolveWritablePath(p: string): Promise<{
dirHandle: FileSystemDirectoryHandle;
fileName: string;
} | null> {
validateStoragePath(p, false);
const parts = p.split("/").filter((part) => part !== "");
const fileName = parts.pop()!;
const parentPath = parts.join("/");
await this.mkdir(parentPath);
const dirHandle = await this.getDirectoryHandle(parentPath);
return dirHandle ? { dirHandle, fileName } : null;
}
async exists(p: string): Promise<boolean> {
const fileHandle = await this.getFileHandle(p);
if (fileHandle) return true;
@@ -126,7 +110,6 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
}
async mkdir(p: string): Promise<void> {
validateStoragePath(p);
const parts = p.split("/").filter((part) => part !== "");
let currentHandle = this.rootHandle;
@@ -163,11 +146,14 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
}
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
const resolved = await this.resolveWritablePath(p);
const resolved = await this.resolvePath(p);
if (!resolved) {
throw new Error(`Invalid path: ${p}`);
}
// Ensure parent directory exists
await this.mkdir(p.split("/").slice(0, -1).join("/"));
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
@@ -175,11 +161,14 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
}
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
const resolved = await this.resolveWritablePath(p);
const resolved = await this.resolvePath(p);
if (!resolved) {
throw new Error(`Invalid path: ${p}`);
}
// Ensure parent directory exists
await this.mkdir(p.split("/").slice(0, -1).join("/"));
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
@@ -1,81 +0,0 @@
import { describe, it } from "vitest";
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
class MemoryFileHandle {
readonly kind = "file";
private data = new Uint8Array();
constructor(readonly name: string) {}
async getFile(): Promise<File> {
return new File([this.data], this.name, { lastModified: 1 });
}
async createWritable(): Promise<FileSystemWritableFileStream> {
const handle = this;
return {
async write(data: FileSystemWriteChunkType) {
if (typeof data === "string") {
handle.data = new TextEncoder().encode(data);
} else if (data instanceof ArrayBuffer) {
handle.data = new Uint8Array(data.slice(0));
} else if (ArrayBuffer.isView(data)) {
handle.data = new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
} else {
throw new TypeError("Unsupported in-memory write type");
}
},
async close() {},
} as FileSystemWritableFileStream;
}
}
class MemoryDirectoryHandle {
readonly kind = "directory";
private readonly children = new Map<string, MemoryDirectoryHandle | MemoryFileHandle>();
constructor(readonly name: string) {}
async getDirectoryHandle(name: string, options?: FileSystemGetDirectoryOptions): Promise<FileSystemDirectoryHandle> {
const existing = this.children.get(name);
if (existing instanceof MemoryDirectoryHandle) return existing as unknown as FileSystemDirectoryHandle;
if (existing !== undefined || !options?.create) throw new DOMException("Directory not found", "NotFoundError");
const directory = new MemoryDirectoryHandle(name);
this.children.set(name, directory);
return directory as unknown as FileSystemDirectoryHandle;
}
async getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle> {
const existing = this.children.get(name);
if (existing instanceof MemoryFileHandle) return existing as unknown as FileSystemFileHandle;
if (existing !== undefined || !options?.create) throw new DOMException("File not found", "NotFoundError");
const file = new MemoryFileHandle(name);
this.children.set(name, file);
return file as unknown as FileSystemFileHandle;
}
async removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<void> {
const existing = this.children.get(name);
if (existing === undefined) throw new DOMException("Entry not found", "NotFoundError");
if (existing instanceof MemoryDirectoryHandle && !options?.recursive && existing.children.size > 0) {
throw new DOMException("Directory is not empty", "InvalidModificationError");
}
this.children.delete(name);
}
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
for (const [name, entry] of this.children) {
yield [name, entry as unknown as FileSystemHandle];
}
}
}
describe("FSAPIStorageAdapter", () => {
for (const contractCase of storageAdapterContractCases) {
it(contractCase.name, async () => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
await contractCase.run(new FSAPIStorageAdapter(root));
});
}
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "0.25.80-webapp",
"version": "0.25.79-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "0.25.80-webpeer",
"version": "0.25.79-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -51,7 +51,6 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
import { tryGetFilePath } from "@lib/common/utils.doc.ts";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
declare global {
@@ -1833,35 +1832,43 @@ ${messageFetch}${messageOverwrite}${messageMerge}
}
async configureHiddenFileSync(mode: keyof OPTIONAL_SYNC_FEATURES) {
const result = await configureHiddenFileSyncMode(mode, {
disable: async () => {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
},
enable: async () => {
this._log("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
},
true
);
},
initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true);
},
});
if (result == "ignored" || result == "disabled") {
if (
mode != "FETCH" &&
mode != "OVERWRITE" &&
mode != "MERGE" &&
mode != "DISABLE" &&
mode != "DISABLE_HIDDEN"
) {
return;
}
if (mode == "DISABLE" || mode == "DISABLE_HIDDEN") {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
return;
}
this._log("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
if (mode == "FETCH") {
await this.initialiseInternalFileSync("pullForce", true);
} else if (mode == "OVERWRITE") {
await this.initialiseInternalFileSync("pushForce", true);
} else if (mode == "MERGE") {
await this.initialiseInternalFileSync("safe", true);
}
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
},
true
);
// this.plugin.settings.useAdvancedMode = true;
// this.plugin.settings.syncInternalFiles = true;
@@ -1,45 +0,0 @@
type HiddenFileSyncMode = "FETCH" | "OVERWRITE" | "MERGE" | "DISABLE" | "DISABLE_HIDDEN";
type HiddenFileSyncDirection = "pullForce" | "pushForce" | "safe";
type ConfigureHiddenFileSyncHandlers = {
disable: () => Promise<void>;
enable: () => Promise<void>;
initialise: (direction: HiddenFileSyncDirection) => Promise<void>;
};
export type ConfigureHiddenFileSyncResult = "ignored" | "disabled" | "enabled";
function getInitialiseDirection(mode: keyof OPTIONAL_SYNC_FEATURES): HiddenFileSyncDirection | false {
if (mode == "FETCH") return "pullForce";
if (mode == "OVERWRITE") return "pushForce";
if (mode == "MERGE") return "safe";
return false;
}
function isDisableMode(mode: keyof OPTIONAL_SYNC_FEATURES): boolean {
return mode == "DISABLE" || mode == "DISABLE_HIDDEN";
}
function isHiddenFileSyncMode(mode: keyof OPTIONAL_SYNC_FEATURES): mode is HiddenFileSyncMode {
return mode == "FETCH" || mode == "OVERWRITE" || mode == "MERGE" || isDisableMode(mode);
}
export async function configureHiddenFileSyncMode(
mode: keyof OPTIONAL_SYNC_FEATURES,
handlers: ConfigureHiddenFileSyncHandlers
): Promise<ConfigureHiddenFileSyncResult> {
if (!isHiddenFileSyncMode(mode)) {
return "ignored";
}
if (isDisableMode(mode)) {
await handlers.disable();
return "disabled";
}
const direction = getInitialiseDirection(mode);
if (direction === false) {
return "ignored";
}
await handlers.enable();
await handlers.initialise(direction);
return "enabled";
}
@@ -1,47 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
describe("configureHiddenFileSyncMode", () => {
it.each([
["FETCH", "pullForce"],
["OVERWRITE", "pushForce"],
["MERGE", "safe"],
] as const)("enables hidden file sync before initialising %s", async (mode, direction) => {
const calls: string[] = [];
const result = await configureHiddenFileSyncMode(mode, {
disable: vi.fn(async () => {
calls.push("disable");
}),
enable: vi.fn(async () => {
calls.push("enable");
}),
initialise: vi.fn(async (actualDirection) => {
calls.push(`init:${actualDirection}`);
}),
});
expect(result).toBe("enabled");
expect(calls).toEqual(["enable", `init:${direction}`]);
});
it.each(["DISABLE", "DISABLE_HIDDEN"] as const)("disables hidden file sync immediately for %s", async (mode) => {
const calls: string[] = [];
const result = await configureHiddenFileSyncMode(mode, {
disable: vi.fn(async () => {
calls.push("disable");
}),
enable: vi.fn(async () => {
calls.push("enable");
}),
initialise: vi.fn(async (direction) => {
calls.push(`init:${direction}`);
}),
});
expect(result).toBe("disabled");
expect(calls).toEqual(["disable"]);
});
});
+1 -1
Submodule src/lib updated: 1cb156d463...cf9810dec5
-1
View File
@@ -1 +0,0 @@
bench-results/
-8
View File
@@ -1,8 +0,0 @@
FROM alpine:3.22
RUN apk add --no-cache iproute2
COPY test/bench-network/netem-smoke.sh /usr/local/bin/livesync-netem-smoke
RUN chmod +x /usr/local/bin/livesync-netem-smoke
CMD ["livesync-netem-smoke"]
-37
View File
@@ -1,37 +0,0 @@
# syntax=docker/dockerfile:1
FROM node:24-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ iproute2 \
&& rm -rf /var/lib/apt/lists/*
ENV DENO_INSTALL=/usr/local
RUN curl -fsSL https://deno.land/install.sh | sh
WORKDIR /workspace
COPY package.json package-lock.json ./
COPY src/apps/cli/package.json ./src/apps/cli/package.json
COPY src/apps/webapp/package.json ./src/apps/webapp/package.json
COPY src/apps/webpeer/package.json ./src/apps/webpeer/package.json
RUN npm ci
COPY . .
RUN npm run build -w self-hosted-livesync-cli
WORKDIR /workspace/src/apps/cli/testdeno
RUN deno cache --lock=deno.lock \
bench-network-cases.ts \
bench-latency-sweep.ts \
bench-p2p-split-node.ts \
bench-p2p.ts \
bench-couchdb.ts \
test-p2p-sync.ts
COPY test/bench-network/run-bench.sh /usr/local/bin/run-livesync-bench
COPY src/apps/cli/testdeno/run-cli-e2e.sh /usr/local/bin/run-livesync-cli-e2e
RUN chmod +x /usr/local/bin/run-livesync-bench /usr/local/bin/run-livesync-cli-e2e
CMD ["run-livesync-bench"]
-10
View File
@@ -1,10 +0,0 @@
# syntax=docker/dockerfile:1
FROM alpine:3.22
RUN apk add --no-cache iproute2 socat
COPY test/bench-network/netem-tcp-shim.sh /usr/local/bin/livesync-netem-tcp-shim
RUN chmod +x /usr/local/bin/livesync-netem-tcp-shim
CMD ["livesync-netem-tcp-shim"]
-272
View File
@@ -1,272 +0,0 @@
# Network benchmark package
This directory packages the CLI benchmark cases with Docker Compose. It is
intended for reproducible local benchmark runs where CouchDB, the Nostr
signalling relay, optional TURN, and the benchmark runner are fixed by the
Compose file.
## Quick smoke run
From the repository root:
```bash
docker compose -f test/bench-network/compose.yml run --rm bench-runner
```
By default this runs:
- `couchdb-baseline`
- `p2p-direct-local`
The dataset is intentionally small by default. Results are written to
`test/bench-network/bench-results/`.
## GitHub Actions smoke run
`.github/workflows/cli-p2p-compose-smoke.yml` provides a manual
`workflow_dispatch` smoke run for the same Compose package. It is intentionally
not a required check yet, because WebRTC peer discovery can still be slow or
environment-sensitive on GitHub-hosted runners. Keep the dataset small and use
the uploaded JSON artefact to inspect whether failures are caused by peer
discovery, synchronisation, CouchDB startup, or Docker networking.
## Select cases
```bash
BENCH_CASES=couchdb-baseline,p2p-direct-local,p2p-user-turn \
docker compose -f test/bench-network/compose.yml --profile turn run --rm bench-runner
```
Available local cases:
- `couchdb-baseline`
- `p2p-direct-local`
- `couchdb-tethering-vpn-proxy`
- `couchdb-netem-home-wifi`
- `couchdb-netem-tethering-vpn`
- `p2p-smartphone-vpn-direct`
- `p2p-user-turn`
Set `BENCH_REPEAT_COUNT` to run each selected case more than once. Repeated
results are written with suffixes such as `-r01`, `-r02`, and `-r03`, and the
summary records the repeat index for each run.
`p2p-smartphone-vpn-direct` is a structural case name. When it is run inside
this Compose package it is not a real smartphone tethering/VPN measurement; it
uses the local Compose network. Use it only for wiring checks unless the runner
is executed in an actual tethered/VPN environment.
## Comparison model
The primary local comparison is between a remote-database path and a direct P2P
path:
| 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 |
| `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
direct-transfer comparison. The Nostr relay is used for signalling in the P2P
case, but synchronised note content is transferred over the WebRTC DataChannel.
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
the recorded candidate types rather than from TURN configuration alone. Do not
report a signalling-only Tier 2 run as though the selected note-data path were
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
```bash
BENCH_MD_FILE_COUNT=100 \
BENCH_MD_MIN_SIZE_BYTES=512 \
BENCH_MD_MAX_SIZE_BYTES=2048 \
BENCH_BIN_FILE_COUNT=25 \
BENCH_BIN_SIZE_BYTES=8192 \
BENCH_COUCHDB_RTT_MS=20 \
BENCH_PEERS_TIMEOUT=60 \
docker compose -f test/bench-network/compose.yml run --rm bench-runner
```
The CouchDB latency model is the HTTP proxy inside `bench-couchdb.ts`. It adds
half of the requested RTT before forwarding each request and the other half
before returning its response. It does not model packet loss, jitter, MTU,
bandwidth limits, bufferbloat, or VPN encapsulation.
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
reported peer discovery command time should not be read as first-peer latency.
## Latency sweep
To run P2P once and CouchDB at several requested RTT values:
```bash
BENCH_COMMAND=latency-sweep \
BENCH_SWEEP_RTT_MS=20,50,100,150,300 \
BENCH_REPEAT_COUNT=3 \
BENCH_MD_FILE_COUNT=100 \
BENCH_MD_MIN_SIZE_BYTES=512 \
BENCH_MD_MAX_SIZE_BYTES=2048 \
BENCH_BIN_FILE_COUNT=25 \
BENCH_BIN_SIZE_BYTES=8192 \
BENCH_SYNC_TIMEOUT=300 \
BENCH_PEERS_TIMEOUT=60 \
docker compose -f test/bench-network/compose.yml run --rm bench-runner
```
This sweep is useful for finding where the remote CouchDB path falls behind the
local direct P2P path in the current HTTP-proxy latency model. It should not be
presented as a full smartphone/VPN model.
## Network emulation smoke
The optional `netem` profile checks whether a Linux runner can apply traffic
shaping inside a Compose-managed container. This is a fixture smoke test for a
second-tier simulation design; it does not produce synchronisation performance
results by itself.
```bash
docker compose -f test/bench-network/compose.yml --profile netem run --rm netem-smoke
```
The smoke writes `tc qdisc`, route, and interface details under
`test/bench-network/bench-results/`. Profile parameters can be overridden:
```bash
NETEM_PROFILE=tethering-vpn \
NETEM_DELAY_MS=140 \
NETEM_JITTER_MS=50 \
NETEM_LOSS_PERCENT=1.0 \
NETEM_BANDWIDTH_MBIT=10 \
NETEM_MTU=1380 \
docker compose -f test/bench-network/compose.yml --profile netem run --rm netem-smoke
```
## Split-container P2P emulation
The optional `p2p-split` profile runs the P2P host and client in separate
Compose services. Each service can apply `tc netem` to its own egress interface
and the client result records the selected WebRTC ICE candidate pair.
```bash
BENCH_MD_FILE_COUNT=2 \
BENCH_BIN_FILE_COUNT=1 \
BENCH_PEERS_TIMEOUT=10 \
BENCH_SPLIT_RUN_ID="$(date -u +%Y%m%d%H%M%S)" \
docker compose -f test/bench-network/compose.yml --profile p2p-split up \
--abort-on-container-exit --exit-code-from p2p-split-client \
p2p-split-host p2p-split-client
```
By default this uses the `home-wifi` profile (`20 ms` delay, `5 ms` jitter,
`0.1%` loss, `100 Mbit`, and `1500` MTU) on both P2P containers. Override the
same `NETEM_*` variables used by the TCP shim to model a stricter profile.
```bash
BENCH_MD_FILE_COUNT=100 \
BENCH_MD_MIN_SIZE_BYTES=512 \
BENCH_MD_MAX_SIZE_BYTES=2048 \
BENCH_BIN_FILE_COUNT=25 \
BENCH_BIN_SIZE_BYTES=8192 \
BENCH_PEERS_TIMEOUT=60 \
BENCH_SYNC_TIMEOUT=420 \
BENCH_SPLIT_RUN_ID="$(date -u +%Y%m%d%H%M%S)" \
BENCH_NETWORK_PROFILE=tethering-vpn \
NETEM_PROFILE=tethering-vpn \
NETEM_DELAY_MS=140 \
NETEM_JITTER_MS=50 \
NETEM_LOSS_PERCENT=1.0 \
NETEM_BANDWIDTH_MBIT=10 \
NETEM_MTU=1380 \
docker compose -f test/bench-network/compose.yml --profile p2p-split up \
--abort-on-container-exit --exit-code-from p2p-split-client \
p2p-split-host p2p-split-client
```
This is a Linux-only manual benchmark fixture, not a required pull-request CI
job. It shapes each P2P container's egress path, including signalling traffic,
and should be reported separately from the CouchDB TCP-shim measurements. The
result JSON includes `ok: true` for completed runs; failed runs still write a
summary with `ok: false` and a `failure` object before returning a non-zero
exit code.
Remove the shared work volume between repeated manual runs when you do not use
a unique `BENCH_SPLIT_RUN_ID`:
```bash
docker compose -f test/bench-network/compose.yml --profile p2p-split down --volumes
```
## P2P Signalling-Only Emulation
The optional `signalling-shim` profile shapes only the Nostr signalling relay
path. The P2P host and client run in the benchmark runner as usual, and the
configured relay URL points at a TCP netem shim in front of `nostr-relay`.
This is the preferred fixture when evaluating the hypothesis that P2P avoids a
constrained remote database data path while still depending on a signalling
server for rendezvous.
```bash
BENCH_CASES=p2p-signalling-netem-home-wifi \
docker compose -f test/bench-network/compose.yml --profile signalling-shim run --rm \
bench-runner-signalling-shim
```
For a stricter signalling path:
```bash
NETEM_PROFILE=tethering-vpn \
NETEM_DELAY_MS=140 \
NETEM_JITTER_MS=50 \
NETEM_LOSS_PERCENT=1.0 \
NETEM_BANDWIDTH_MBIT=10 \
NETEM_MTU=1380 \
BENCH_CASES=p2p-signalling-netem-tethering-vpn \
docker compose -f test/bench-network/compose.yml --profile signalling-shim run --rm \
bench-runner-signalling-shim
```
Use this separately from `p2p-split`. The `p2p-split` profile shapes each peer's
egress path, so it constrains both signalling and the selected WebRTC data
path. The `signalling-shim` profile constrains only relay access, which keeps
it focused on peer-to-signalling-server reachability rather than peer-to-peer
note-data transfer.
## Shimmed CouchDB benchmark
The optional `shim` profile runs a CouchDB benchmark through a TCP forwarding
container that applies `tc netem`. This is a manual Tier 2 synchronisation
measurement path; it is intentionally separate from required pull-request CI.
```bash
docker compose -f test/bench-network/compose.yml --profile shim run --rm bench-runner-shim
```
The default profile is `home-wifi`. A smartphone/VPN-like profile can be
requested by overriding both the shim parameters and the benchmark case:
```bash
NETEM_PROFILE=tethering-vpn \
NETEM_DELAY_MS=140 \
NETEM_JITTER_MS=50 \
NETEM_LOSS_PERCENT=1.0 \
NETEM_BANDWIDTH_MBIT=10 \
NETEM_MTU=1380 \
BENCH_CASES=couchdb-netem-tethering-vpn \
docker compose -f test/bench-network/compose.yml --profile shim run --rm bench-runner-shim
```
The benchmark result records `simulationTier`, `networkProfile`, and
`networkModel`. The shim also writes its applied `tc qdisc`, route, and
interface state under `test/bench-network/bench-results/`.
This shim currently measures the CouchDB path only. It does not shape or verify
the WebRTC P2P data path.
-338
View File
@@ -1,338 +0,0 @@
services:
couchdb:
image: couchdb:3.5.0
environment:
COUCHDB_USER: ${BENCH_COUCHDB_USER:-admin}
COUCHDB_PASSWORD: ${BENCH_COUCHDB_PASSWORD:-testpassword}
COUCHDB_SINGLE_NODE: "true"
healthcheck:
test:
[
"CMD-SHELL",
"curl -fsS -u ${BENCH_COUCHDB_USER:-admin}:${BENCH_COUCHDB_PASSWORD:-testpassword} http://127.0.0.1:5984/_up >/dev/null",
]
interval: 2s
timeout: 5s
retries: 30
nostr-relay:
image: ghcr.io/hoytech/strfry:latest
entrypoint: sh
command:
- -lc
- |
cat > /tmp/strfry.conf <<'EOF'
db = "./strfry-db/"
relay {
bind = "0.0.0.0"
port = 7777
nofiles = 65536
info {
name = "livesync bench relay"
description = "local relay for livesync compose benchmarks"
}
maxWebsocketPayloadSize = 131072
autoPingSeconds = 55
writePolicy {
plugin = ""
}
}
EOF
exec /app/strfry --config /tmp/strfry.conf relay
tmpfs:
- /app/strfry-db:rw,size=256m
healthcheck:
test: ["CMD-SHELL", "nc -z 127.0.0.1 7777"]
interval: 2s
timeout: 5s
retries: 30
coturn:
image: coturn/coturn:latest
command:
- --log-file=stdout
- --listening-port=3478
- --user=${BENCH_TURN_USERNAME:-testuser}:${BENCH_TURN_CREDENTIAL:-testpass}
- --realm=${BENCH_TURN_REALM:-livesync.test}
profiles:
- turn
bench-runner:
build:
context: ../..
dockerfile: test/bench-network/Dockerfile.runner
depends_on:
couchdb:
condition: service_healthy
nostr-relay:
condition: service_healthy
environment:
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
BENCH_CASES: ${BENCH_CASES:-couchdb-baseline,p2p-direct-local}
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results
BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results
BENCH_SWEEP_RTT_MS: ${BENCH_SWEEP_RTT_MS:-20,50,100,150,300}
BENCH_SWEEP_INCLUDE_P2P: ${BENCH_SWEEP_INCLUDE_P2P:-true}
BENCH_COUCHDB_MANAGED: "false"
BENCH_COUCHDB_BACKEND_URI: http://couchdb:5984
BENCH_COUCHDB_URI: http://127.0.0.1:15989
BENCH_COUCHDB_USER: ${BENCH_COUCHDB_USER:-admin}
BENCH_COUCHDB_PASSWORD: ${BENCH_COUCHDB_PASSWORD:-testpassword}
BENCH_RELAY: ws://nostr-relay:7777/
BENCH_LOCAL_TURN_SERVERS: turn:coturn:3478
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512}
BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048}
BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5}
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_TETHERING_VPN_RTT_MS: ${BENCH_TETHERING_VPN_RTT_MS:-120}
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60}
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
CLI_E2E_TASK: ${CLI_E2E_TASK:-test:p2p-sync}
RELAY: ${RELAY:-ws://nostr-relay:7777/}
PEERS_TIMEOUT: ${PEERS_TIMEOUT:-20}
SYNC_TIMEOUT: ${SYNC_TIMEOUT:-60}
LIVESYNC_USE_COTURN: ${LIVESYNC_USE_COTURN:-0}
TURN_SERVERS: ${TURN_SERVERS:-none}
LIVESYNC_P2P_PEERS_RETRY: ${LIVESYNC_P2P_PEERS_RETRY:-1}
volumes:
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
couchdb-shim:
build:
context: ../..
dockerfile: test/bench-network/Dockerfile.shim
profiles:
- shim
depends_on:
couchdb:
condition: service_healthy
cap_add:
- NET_ADMIN
environment:
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
NETEM_MTU: ${NETEM_MTU:-1500}
NETEM_RESULT_ROOT: /bench-results
SHIM_LISTEN_PORT: 5984
SHIM_TARGET_HOST: couchdb
SHIM_TARGET_PORT: 5984
volumes:
- ./bench-results:/bench-results
healthcheck:
test: ["CMD-SHELL", "nc -z 127.0.0.1 5984"]
interval: 2s
timeout: 5s
retries: 30
bench-runner-shim:
build:
context: ../..
dockerfile: test/bench-network/Dockerfile.runner
profiles:
- shim
depends_on:
couchdb-shim:
condition: service_healthy
environment:
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
BENCH_CASES: ${BENCH_CASES:-couchdb-netem-home-wifi}
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results
BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results
BENCH_COUCHDB_MANAGED: "false"
BENCH_COUCHDB_BACKEND_URI: http://couchdb-shim:5984
BENCH_SHIM_COUCHDB_URI: http://couchdb-shim:5984
BENCH_COUCHDB_URI: http://127.0.0.1:15989
BENCH_COUCHDB_USER: ${BENCH_COUCHDB_USER:-admin}
BENCH_COUCHDB_PASSWORD: ${BENCH_COUCHDB_PASSWORD:-testpassword}
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512}
BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048}
BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5}
BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192}
BENCH_COUCHDB_RTT_MS: ${BENCH_COUCHDB_RTT_MS:-1}
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
volumes:
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
p2p-signalling-shim:
build:
context: ../..
dockerfile: test/bench-network/Dockerfile.shim
profiles:
- signalling-shim
depends_on:
nostr-relay:
condition: service_healthy
cap_add:
- NET_ADMIN
environment:
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
NETEM_MTU: ${NETEM_MTU:-1500}
NETEM_RESULT_ROOT: /bench-results
SHIM_LISTEN_PORT: 7777
SHIM_TARGET_HOST: nostr-relay
SHIM_TARGET_PORT: 7777
volumes:
- ./bench-results:/bench-results
healthcheck:
test: ["CMD-SHELL", "nc -z 127.0.0.1 7777"]
interval: 2s
timeout: 5s
retries: 30
bench-runner-signalling-shim:
build:
context: ../..
dockerfile: test/bench-network/Dockerfile.runner
profiles:
- signalling-shim
depends_on:
p2p-signalling-shim:
condition: service_healthy
environment:
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
BENCH_CASES: ${BENCH_CASES:-p2p-signalling-netem-home-wifi}
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results
BENCH_SIGNAL_SHIM_RELAY: ws://p2p-signalling-shim:7777/
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512}
BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048}
BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5}
BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192}
BENCH_VERIFY_MODE: ${BENCH_VERIFY_MODE:-all}
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60}
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
volumes:
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
p2p-split-host:
build:
context: ../..
dockerfile: test/bench-network/Dockerfile.runner
profiles:
- p2p-split
depends_on:
nostr-relay:
condition: service_healthy
cap_add:
- NET_ADMIN
environment:
BENCH_COMMAND: p2p-split-node
BENCH_P2P_SPLIT_ROLE: host
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
BENCH_SPLIT_WORK_ROOT: /p2p-work
BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results
BENCH_RELAY: ws://nostr-relay:7777/
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
BENCH_PASSPHRASE: ${BENCH_PASSPHRASE:-bench-split-passphrase}
BENCH_TURN_SERVERS: ${BENCH_TURN_SERVERS:-}
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512}
BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048}
BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5}
BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192}
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60}
BENCH_NETEM_ENABLED: ${BENCH_NETEM_ENABLED:-1}
BENCH_NETWORK_PROFILE: ${BENCH_NETWORK_PROFILE:-home-wifi}
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
NETEM_MTU: ${NETEM_MTU:-1500}
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
volumes:
- p2p-split-work:/p2p-work
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
p2p-split-client:
build:
context: ../..
dockerfile: test/bench-network/Dockerfile.runner
profiles:
- p2p-split
depends_on:
nostr-relay:
condition: service_healthy
p2p-split-host:
condition: service_started
cap_add:
- NET_ADMIN
environment:
BENCH_COMMAND: p2p-split-node
BENCH_P2P_SPLIT_ROLE: client
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
BENCH_SPLIT_WORK_ROOT: /p2p-work
BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results
BENCH_RELAY: ws://nostr-relay:7777/
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
BENCH_PASSPHRASE: ${BENCH_PASSPHRASE:-bench-split-passphrase}
BENCH_TURN_SERVERS: ${BENCH_TURN_SERVERS:-}
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60}
BENCH_NETEM_ENABLED: ${BENCH_NETEM_ENABLED:-1}
BENCH_NETWORK_PROFILE: ${BENCH_NETWORK_PROFILE:-home-wifi}
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
NETEM_MTU: ${NETEM_MTU:-1500}
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
volumes:
- p2p-split-work:/p2p-work
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
netem-smoke:
build:
context: ../..
dockerfile: test/bench-network/Dockerfile.netem
profiles:
- netem
cap_add:
- NET_ADMIN
environment:
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
NETEM_MTU: ${NETEM_MTU:-1500}
NETEM_RESULT_ROOT: /bench-results
volumes:
- ./bench-results:/bench-results
volumes:
p2p-split-work:
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env sh
set -eu
profile="${NETEM_PROFILE:-home-wifi}"
iface="${NETEM_INTERFACE:-eth0}"
delay_ms="${NETEM_DELAY_MS:-20}"
jitter_ms="${NETEM_JITTER_MS:-5}"
loss_percent="${NETEM_LOSS_PERCENT:-0.1}"
bandwidth_mbit="${NETEM_BANDWIDTH_MBIT:-100}"
mtu="${NETEM_MTU:-1500}"
out_root="${NETEM_RESULT_ROOT:-/bench-results}"
timestamp="$(date -u +%Y%m%d-%H%M%S)"
out_dir="${out_root}/netem-smoke-${timestamp}"
out_file="${out_dir}/summary.json"
mkdir -p "$out_dir"
if ! ip link show "$iface" >/dev/null 2>&1; then
echo "Network interface '$iface' was not found" >&2
ip addr >&2
exit 2
fi
ip link set dev "$iface" mtu "$mtu"
tc qdisc del dev "$iface" root >/dev/null 2>&1 || true
tc qdisc add dev "$iface" root netem \
delay "${delay_ms}ms" "${jitter_ms}ms" \
loss "${loss_percent}%" \
rate "${bandwidth_mbit}mbit"
json_lines() {
awk '
{
gsub(/\\/, "\\\\");
gsub(/"/, "\\\"");
printf "%s \"%s\"", (NR == 1 ? "" : ",\n"), $0;
}
'
}
ip_addr="$(ip addr show "$iface" | json_lines)"
ip_route="$(ip route | json_lines)"
tc_qdisc="$(tc qdisc show dev "$iface" | json_lines)"
cat > "$out_file" <<EOF
{
"simulationTier": 2,
"mode": "netem-smoke",
"profile": "$profile",
"interface": "$iface",
"netem": {
"delayMs": $delay_ms,
"jitterMs": $jitter_ms,
"lossPercent": $loss_percent,
"bandwidthMbit": $bandwidth_mbit,
"mtu": $mtu
},
"ipAddr": [
$ip_addr
],
"ipRoute": [
$ip_route
],
"tcQdisc": [
$tc_qdisc
]
}
EOF
cat "$out_file"
echo "[netem-smoke] result file: $out_file"
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env sh
set -eu
profile="${NETEM_PROFILE:-home-wifi}"
iface="${NETEM_INTERFACE:-eth0}"
delay_ms="${NETEM_DELAY_MS:-20}"
jitter_ms="${NETEM_JITTER_MS:-5}"
loss_percent="${NETEM_LOSS_PERCENT:-0.1}"
bandwidth_mbit="${NETEM_BANDWIDTH_MBIT:-100}"
mtu="${NETEM_MTU:-1500}"
listen_port="${SHIM_LISTEN_PORT:-5984}"
target_host="${SHIM_TARGET_HOST:-couchdb}"
target_port="${SHIM_TARGET_PORT:-5984}"
out_root="${NETEM_RESULT_ROOT:-/bench-results}"
timestamp="$(date -u +%Y%m%d-%H%M%S)"
out_dir="${out_root}/netem-shim-${profile}-${timestamp}"
out_file="${out_dir}/summary.json"
json_lines() {
awk '
{
gsub(/\\/, "\\\\");
gsub(/"/, "\\\"");
printf "%s \"%s\"", (NR == 1 ? "" : ",\n"), $0;
}
'
}
mkdir -p "$out_dir"
if ! ip link show "$iface" >/dev/null 2>&1; then
echo "Network interface '$iface' was not found" >&2
ip addr >&2
exit 2
fi
ip link set dev "$iface" mtu "$mtu"
tc qdisc del dev "$iface" root >/dev/null 2>&1 || true
tc qdisc add dev "$iface" root netem \
delay "${delay_ms}ms" "${jitter_ms}ms" \
loss "${loss_percent}%" \
rate "${bandwidth_mbit}mbit"
ip_addr="$(ip addr show "$iface" | json_lines)"
ip_route="$(ip route | json_lines)"
tc_qdisc="$(tc qdisc show dev "$iface" | json_lines)"
cat > "$out_file" <<EOF
{
"simulationTier": 2,
"mode": "netem-tcp-shim",
"profile": "$profile",
"interface": "$iface",
"listenPort": $listen_port,
"targetHost": "$target_host",
"targetPort": $target_port,
"netem": {
"delayMs": $delay_ms,
"jitterMs": $jitter_ms,
"lossPercent": $loss_percent,
"bandwidthMbit": $bandwidth_mbit,
"mtu": $mtu
},
"ipAddr": [
$ip_addr
],
"ipRoute": [
$ip_route
],
"tcQdisc": [
$tc_qdisc
]
}
EOF
cat "$out_file"
echo "[netem-shim] forwarding 0.0.0.0:${listen_port} to ${target_host}:${target_port}"
echo "[netem-shim] result file: $out_file"
exec socat "TCP-LISTEN:${listen_port},fork,reuseaddr" "TCP:${target_host}:${target_port}"
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env sh
set -eu
case "${BENCH_COMMAND:-cases}" in
cases)
exec deno task bench:cases
;;
latency-sweep)
exec deno task bench:latency-sweep
;;
p2p-split-node)
exec deno task bench:p2p-split-node
;;
*)
echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2
echo "Expected one of: cases, latency-sweep, p2p-split-node" >&2
exit 2
;;
esac
+1 -3
View File
@@ -2,8 +2,6 @@
This directory contains the experimental real Obsidian end-to-end runner.
The generic application discovery, isolated-vault, plug-in installation, process lifecycle, CLI, CDP, and readiness implementation comes from `@vrtmrz/obsidian-test-session`. The small modules under `runner/` preserve LiveSync's existing imports and supply its plug-in ID and artefact location. LiveSync-specific fixtures, services, settings, workflows, and assertions remain in this repository.
The current smoke runner verifies only the launch path:
1. create a temporary vault,
@@ -20,7 +18,7 @@ The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. R
Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/community-plugins.json`. The smoke runner enables it through `app.plugins.setEnable(true)` after the vault window is available.
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here.
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence.
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
+103 -6
View File
@@ -1,6 +1,103 @@
export {
evalObsidianJson,
openVaultWithObsidianCli,
runObsidianCli,
type ObsidianCliResult,
} from "@vrtmrz/obsidian-test-session";
import { spawn } from "node:child_process";
export type ObsidianCliResult = {
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
};
function parseEvalJson(stdout: string): unknown {
const marker = "=> ";
const markerIndex = stdout.indexOf(marker);
const text = markerIndex >= 0 ? stdout.slice(markerIndex + marker.length) : stdout;
return JSON.parse(text.trim());
}
export async function runObsidianCli(
cliBinary: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
timeoutMs = Number(process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ?? 10000)
): Promise<ObsidianCliResult> {
return await new Promise((resolve, reject) => {
const child = spawn(cliBinary, args, {
stdio: ["ignore", "pipe", "pipe"],
env,
});
let stdout = "";
let stderr = "";
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`Obsidian CLI timed out: ${cliBinary} ${args.join(" ")}`));
}, timeoutMs);
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("exit", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal, stdout, stderr });
});
});
}
export async function openVaultWithObsidianCli(
cliBinary: string,
vaultPath: string,
env: NodeJS.ProcessEnv = process.env
): Promise<void> {
const result = await runObsidianCli(cliBinary, [`obsidian://open?path=${encodeURIComponent(vaultPath)}`], env);
if (result.code !== 0) {
throw new Error(
[
`Failed to open Obsidian vault through CLI. code=${result.code}, signal=${result.signal}`,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
export async function evalObsidianJson<T>(
cliBinary: string,
code: string,
env: NodeJS.ProcessEnv = process.env,
timeoutMs?: number
): Promise<T> {
const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env, timeoutMs);
if (result.code !== 0) {
throw new Error(
[
`Failed to evaluate Obsidian JavaScript through CLI. code=${result.code}, signal=${result.signal}`,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
try {
return parseEvalJson(result.stdout) as T;
} catch (error) {
throw new Error(
[
`Failed to parse Obsidian CLI eval JSON. code=${result.code}, signal=${result.signal}`,
error instanceof Error ? `parse error: ${error.message}` : undefined,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
+149 -7
View File
@@ -1,7 +1,149 @@
export {
discoverObsidianBinary,
discoverObsidianCli,
requireObsidianBinary,
requireObsidianCli,
type ObsidianDiscoveryResult,
} from "@vrtmrz/obsidian-test-session";
import { accessSync, constants, existsSync } from "node:fs";
import { resolve } from "node:path";
import { platform } from "node:process";
export type ObsidianDiscoveryResult = {
binary?: string;
source?: string;
checked: string[];
};
const defaultCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
aix: [],
android: [],
darwin: [
"/Applications/Obsidian.app/Contents/MacOS/Obsidian",
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
],
freebsd: [],
haiku: [],
linux: [
"_testdata/obsidian/squashfs-root/obsidian",
"_testdata/obsidian/squashfs-root/AppRun",
"_testdata/obsidian/Obsidian-1.12.7-arm64.AppImage",
"_testdata/obsidian/Obsidian-1.12.7-x86_64.AppImage",
"/usr/bin/obsidian",
"/usr/local/bin/obsidian",
"/snap/bin/obsidian",
"/opt/Obsidian/obsidian",
"/opt/obsidian/obsidian",
"/app/bin/obsidian",
],
openbsd: [],
sunos: [],
win32: ["C:\\Program Files\\Obsidian\\Obsidian.exe", "C:\\Program Files (x86)\\Obsidian\\Obsidian.exe"],
cygwin: [],
netbsd: [],
};
const defaultCliCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
aix: [],
android: [],
darwin: [
"/Applications/Obsidian.app/Contents/MacOS/obsidian-cli",
"/Applications/Obsidian.app/Contents/Resources/obsidian-cli",
],
freebsd: [],
haiku: [],
linux: [
"_testdata/obsidian/squashfs-root/obsidian-cli",
"/usr/bin/obsidian-cli",
"/usr/local/bin/obsidian-cli",
"/snap/bin/obsidian-cli",
"/opt/Obsidian/obsidian-cli",
"/opt/obsidian/obsidian-cli",
],
openbsd: [],
sunos: [],
win32: ["C:\\Program Files\\Obsidian\\obsidian-cli.exe", "C:\\Program Files (x86)\\Obsidian\\obsidian-cli.exe"],
cygwin: [],
netbsd: [],
};
function isUsableFile(path: string): boolean {
const resolvedPath = resolve(path);
if (!existsSync(resolvedPath)) {
return false;
}
if (platform === "win32") {
return true;
}
try {
accessSync(resolvedPath, constants.X_OK);
return true;
} catch {
return false;
}
}
export function discoverObsidianBinary(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
const checked: string[] = [];
const envBinary = env.OBSIDIAN_BINARY?.trim();
if (envBinary) {
checked.push(envBinary);
if (isUsableFile(envBinary)) {
return {
binary: resolve(envBinary),
source: "OBSIDIAN_BINARY",
checked,
};
}
}
const candidates = defaultCandidatesByPlatform[platform] ?? [];
for (const candidate of candidates) {
checked.push(candidate);
if (isUsableFile(candidate)) {
return {
binary: resolve(candidate),
source: "default-path",
checked,
};
}
}
return { checked };
}
export function requireObsidianBinary(env: NodeJS.ProcessEnv = process.env): string {
const result = discoverObsidianBinary(env);
if (!result.binary) {
throw new Error(
[
"Could not find an Obsidian executable.",
"Set OBSIDIAN_BINARY to the installed Obsidian executable path.",
`Checked paths: ${result.checked.length > 0 ? result.checked.join(", ") : "(none)"}`,
].join("\n")
);
}
return result.binary;
}
export function discoverObsidianCli(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
const checked: string[] = [];
const envBinary = env.OBSIDIAN_CLI?.trim();
if (envBinary) {
checked.push(envBinary);
if (isUsableFile(envBinary)) {
return {
binary: resolve(envBinary),
source: "OBSIDIAN_CLI",
checked,
};
}
}
const candidates = defaultCliCandidatesByPlatform[platform] ?? [];
for (const candidate of candidates) {
checked.push(candidate);
if (isUsableFile(candidate)) {
return {
binary: resolve(candidate),
source: "default-path",
checked,
};
}
}
return { checked };
}
+187 -17
View File
@@ -1,26 +1,196 @@
import {
cleanupStaleObsidianE2EProcesses as cleanupStaleProcesses,
launchObsidian as launchObsidianSession,
type LaunchObsidianOptions,
type ObsidianProcess,
type ObsidianProcessOutput,
} from "@vrtmrz/obsidian-test-session";
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { existsSync } from "node:fs";
import { dirname } from "node:path";
import { platform } from "node:process";
import { promisify } from "node:util";
export type { LaunchObsidianOptions, ObsidianProcess, ObsidianProcessOutput };
export type ObsidianProcess = {
process: ChildProcess;
output: () => { stdout: string; stderr: string };
stop: () => Promise<void>;
};
const STALE_PROCESS_PATTERN = "obsidian-livesync-e2e-state";
export type LaunchObsidianOptions = {
binary: string;
vaultPath: string;
homePath?: string;
xdgConfigPath?: string;
xdgCachePath?: string;
xdgDataPath?: string;
userDataPath?: string;
startupGraceMs?: number;
};
const execFileAsync = promisify(execFile);
function splitArgs(args: string): string[] {
return args.split(" ").filter((arg) => arg.length > 0);
}
function launchArgs(options: LaunchObsidianOptions): string[] {
const explicitArgs = process.env.E2E_OBSIDIAN_ARGS;
if (explicitArgs) {
return splitArgs(explicitArgs);
}
return [
"--no-sandbox",
"--disable-gpu",
"--disable-software-rasterizer",
...(process.env.E2E_OBSIDIAN_USE_USER_DATA_DIR !== "false" && options.userDataPath
? [`--user-data-dir=${options.userDataPath}`]
: []),
...(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT
? [`--remote-debugging-port=${process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT}`]
: []),
`obsidian://open?path=${encodeURIComponent(options.vaultPath)}`,
];
}
function shouldUseXvfb(): boolean {
if (process.env.E2E_OBSIDIAN_USE_XVFB === "false") {
return false;
}
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
return false;
}
return platform === "linux" && existsSync("/usr/bin/xvfb-run");
}
async function listChildPids(pid: number): Promise<number[]> {
if (platform === "win32") {
return [];
}
const { stdout } = await execFileAsync("ps", ["-o", "pid=", "--ppid", String(pid)]).catch(() => ({
stdout: "",
}));
const directChildren = stdout
.split("\n")
.map((line) => Number(line.trim()))
.filter((childPid) => Number.isInteger(childPid) && childPid > 0);
const descendants = await Promise.all(directChildren.map((childPid) => listChildPids(childPid)));
return [...directChildren, ...descendants.flat()];
}
async function killPids(pids: number[], signal: NodeJS.Signals): Promise<void> {
for (const pid of pids) {
if (pid === process.pid) {
continue;
}
try {
process.kill(pid, signal);
} catch {
// The process may have exited between discovery and signalling.
}
}
}
async function waitForExit(exitPromise: Promise<unknown>, timeoutMs: number): Promise<"exited" | "timeout"> {
const stopTimer = new Promise<"timeout">((resolve) => {
setTimeout(() => resolve("timeout"), timeoutMs);
});
const stopResult = await Promise.race([exitPromise.then(() => "exited" as const), stopTimer]);
return stopResult;
}
export async function cleanupStaleObsidianE2EProcesses(): Promise<void> {
await cleanupStaleProcesses(STALE_PROCESS_PATTERN);
if (process.env.E2E_OBSIDIAN_CLEANUP_STALE_PROCESSES === "false" || platform === "win32") {
return;
}
const { stdout } = await execFileAsync("pgrep", ["-f", "obsidian-livesync-e2e-state"]).catch(() => ({
stdout: "",
}));
const pids = stdout
.split("\n")
.map((line) => Number(line.trim()))
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
if (pids.length === 0) {
return;
}
await killPids(pids, "SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 1000));
await killPids(pids, "SIGKILL");
}
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
const configuredPort =
options.env?.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT;
return await launchObsidianSession({
...options,
remoteDebuggingPort:
options.remoteDebuggingPort ?? (configuredPort === undefined ? undefined : Number(configuredPort)),
staleProcessPattern: options.staleProcessPattern ?? STALE_PROCESS_PATTERN,
await cleanupStaleObsidianE2EProcesses();
const startupGraceMs = options.startupGraceMs ?? 1000;
const args = launchArgs(options);
const useXvfb = shouldUseXvfb();
const command = useXvfb ? "/usr/bin/xvfb-run" : options.binary;
const commandArgs = useXvfb ? ["-a", options.binary, ...args] : args;
const child = spawn(command, commandArgs, {
cwd: dirname(options.binary),
detached: true,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
...(options.homePath ? { HOME: options.homePath } : {}),
...(options.xdgConfigPath ? { XDG_CONFIG_HOME: options.xdgConfigPath } : {}),
...(options.xdgCachePath ? { XDG_CACHE_HOME: options.xdgCachePath } : {}),
...(options.xdgDataPath ? { XDG_DATA_HOME: options.xdgDataPath } : {}),
OBSIDIAN_DISABLE_GPU: process.env.OBSIDIAN_DISABLE_GPU ?? "1",
},
});
let stderr = "";
let stdout = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
const exitPromise = once(child, "exit").then(([code, signal]) => ({ code, signal }));
const timer = new Promise<"timeout">((resolve) => {
setTimeout(() => resolve("timeout"), startupGraceMs);
});
const firstResult = await Promise.race([exitPromise, timer]);
if (firstResult !== "timeout") {
throw new Error(
[
`Obsidian exited before the smoke timeout. code=${firstResult.code}, signal=${firstResult.signal}`,
stdout ? `stdout:\n${stdout}` : undefined,
stderr ? `stderr:\n${stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
return {
process: child,
output: () => ({ stdout, stderr }),
stop: async () => {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
const descendantPids = child.pid ? await listChildPids(child.pid) : [];
if (child.pid) {
try {
process.kill(-child.pid, "SIGTERM");
} catch {
child.kill("SIGTERM");
}
} else {
child.kill("SIGTERM");
}
await killPids(descendantPids.reverse(), "SIGTERM");
const stopResult = await waitForExit(exitPromise, 5000);
if (stopResult === "timeout") {
if (child.pid) {
try {
process.kill(-child.pid, "SIGKILL");
} catch {
child.kill("SIGKILL");
}
} else {
child.kill("SIGKILL");
}
await killPids(descendantPids, "SIGKILL");
await exitPromise;
}
},
};
}
+35 -9
View File
@@ -1,13 +1,39 @@
import {
installBuiltPlugin as installGenericBuiltPlugin,
type PluginInstallResult,
} from "@vrtmrz/obsidian-test-session";
import { copyFile, mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
export type { PluginInstallResult };
export type PluginInstallResult = {
pluginDir: string;
copied: string[];
};
const pluginId = "obsidian-livesync";
export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise<PluginInstallResult> {
return await installGenericBuiltPlugin(vaultPath, {
pluginId: "obsidian-livesync",
artifactRoot: rootDir,
});
const pluginDir = join(vaultPath, ".obsidian", "plugins", pluginId);
const copied: string[] = [];
await mkdir(pluginDir, { recursive: true });
const requiredArtifacts = ["main.js", "manifest.json"];
for (const artifact of requiredArtifacts) {
const source = resolve(rootDir, artifact);
if (!existsSync(source)) {
throw new Error(`Required plug-in artifact is missing: ${source}`);
}
await copyFile(source, join(pluginDir, artifact));
copied.push(artifact);
}
const optionalArtifacts = ["styles.css"];
for (const artifact of optionalArtifacts) {
const source = resolve(rootDir, artifact);
if (!existsSync(source)) {
continue;
}
await copyFile(source, join(pluginDir, artifact));
copied.push(artifact);
}
await writeFile(join(vaultPath, ".obsidian", "community-plugins.json"), JSON.stringify([pluginId], null, 4));
return { pluginDir, copied };
}
+41 -1
View File
@@ -1 +1,41 @@
export { waitForPluginReady, type PluginReadiness } from "@vrtmrz/obsidian-test-session";
import { evalObsidianJson } from "./cli.ts";
export type PluginReadiness = {
status: "ready";
pluginId: string;
pluginVersion: string;
vaultName: string;
};
export async function waitForPluginReady(
cliBinary: string,
env: NodeJS.ProcessEnv,
timeoutMs = Number(process.env.E2E_OBSIDIAN_READY_TIMEOUT_MS ?? 20000)
): Promise<PluginReadiness> {
const deadline = Date.now() + timeoutMs;
let lastOutput = "";
while (Date.now() < deadline) {
try {
const readiness = await evalObsidianJson<PluginReadiness>(
cliBinary,
[
"(async()=>JSON.stringify({",
"status:!!app.plugins.plugins['obsidian-livesync']?'ready':'pending',",
"pluginId:'obsidian-livesync',",
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version,",
"vaultName:app.vault.getName()",
"}))()",
].join(""),
env
);
if (readiness.status === "ready") {
return readiness;
}
} catch (error) {
lastOutput = error instanceof Error ? error.message : String(error);
// Keep polling until Obsidian exposes the vault-side CLI and plug-in state.
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Self-hosted LiveSync readiness through Obsidian CLI.\n${lastOutput}`);
}
+102 -7
View File
@@ -1,7 +1,16 @@
import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session";
import { evalObsidianJson, openVaultWithObsidianCli, runObsidianCli } from "./cli.ts";
import { launchObsidian, type ObsidianProcess } from "./launch.ts";
import { installBuiltPlugin, type PluginInstallResult } from "./pluginInstaller.ts";
import { waitForPluginReady, type PluginReadiness } from "./readiness.ts";
import type { TemporaryVault } from "./vault.ts";
import { obsidianRemoteDebuggingPort, preseedTrustedVaultState, trustVaultIfPrompted } from "./ui.ts";
export type ObsidianLiveSyncSession = ObsidianPluginSession;
export type ObsidianLiveSyncSession = {
app: ObsidianProcess;
cliEnv: NodeJS.ProcessEnv;
install: PluginInstallResult;
readiness: PluginReadiness;
};
export type StartObsidianLiveSyncSessionOptions = {
binary: string;
@@ -10,15 +19,101 @@ export type StartObsidianLiveSyncSessionOptions = {
startupGraceMs?: number;
};
async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 60000);
let lastOutput = "";
while (Date.now() < deadline) {
try {
const result = await evalObsidianJson<{ hasLiveSync: boolean }>(
cliBinary,
["JSON.stringify({", "hasLiveSync:!!app.plugins?.manifests?.['obsidian-livesync']", "})"].join(""),
env
);
lastOutput = JSON.stringify(result);
if (result.hasLiveSync) {
return;
}
} catch (error) {
lastOutput = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Obsidian plug-in catalogue through CLI.\n${lastOutput}`);
}
async function enableCommunityPlugins(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
const result = await runObsidianCli(cliBinary, ["eval", "code=(async()=>app.plugins.setEnable(true))()"], env);
if (result.code !== 0 || result.stdout.includes("Error:")) {
throw new Error(
[
`Failed to enable Obsidian community plug-ins through CLI. code=${result.code}, signal=${result.signal}`,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
async function reloadLiveSyncPlugin(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
const reload = await runObsidianCli(cliBinary, ["plugin:reload", "id=obsidian-livesync"], env);
if (reload.code !== 0 || !reload.stdout.includes("Reloaded: obsidian-livesync")) {
throw new Error(
[
`Failed to reload Self-hosted LiveSync through Obsidian CLI. code=${reload.code}, signal=${reload.signal}`,
reload.stdout ? `stdout:\n${reload.stdout}` : undefined,
reload.stderr ? `stderr:\n${reload.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
export async function startObsidianLiveSyncSession(
options: StartObsidianLiveSyncSessionOptions
): Promise<ObsidianLiveSyncSession> {
return await startObsidianPluginSession({
const install = await installBuiltPlugin(options.vault.path);
const remoteDebuggingPort = obsidianRemoteDebuggingPort();
const app = await launchObsidian({
binary: options.binary,
cliBinary: options.cliBinary,
vault: options.vault,
pluginId: "obsidian-livesync",
artifactRoot: process.cwd(),
vaultPath: options.vault.path,
homePath: options.vault.homePath,
xdgConfigPath: options.vault.xdgConfigPath,
xdgCachePath: options.vault.xdgCachePath,
xdgDataPath: options.vault.xdgDataPath,
userDataPath: options.vault.userDataPath,
startupGraceMs: options.startupGraceMs,
});
const cliEnv = {
...process.env,
HOME: options.vault.homePath,
XDG_CONFIG_HOME: options.vault.xdgConfigPath,
XDG_CACHE_HOME: options.vault.xdgCachePath,
XDG_DATA_HOME: options.vault.xdgDataPath,
};
try {
await preseedTrustedVaultState(remoteDebuggingPort, options.vault.id);
await openVaultWithObsidianCli(options.cliBinary, options.vault.path, cliEnv);
await trustVaultIfPrompted(remoteDebuggingPort);
await waitForPluginCatalogue(options.cliBinary, cliEnv);
await enableCommunityPlugins(options.cliBinary, cliEnv);
await reloadLiveSyncPlugin(options.cliBinary, cliEnv);
const readiness = await waitForPluginReady(options.cliBinary, cliEnv);
return { app, cliEnv, install, readiness };
} catch (error) {
const output = app.output();
await app.stop();
throw new Error(
[
error instanceof Error ? error.message : String(error),
output.stdout ? `Obsidian stdout:\n${output.stdout}` : undefined,
output.stderr ? `Obsidian stderr:\n${output.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
+70 -7
View File
@@ -1,11 +1,74 @@
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
import { chromium, type Page } from "playwright";
export {
obsidianRemoteDebuggingPort,
preseedTrustedVaultState,
trustVaultIfPrompted,
withObsidianPage,
} from "@vrtmrz/obsidian-test-session";
export function obsidianRemoteDebuggingPort(): number {
const port = Number(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? 9222);
process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT = String(port);
return port;
}
async function waitForCdp(port: number): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CDP_TIMEOUT_MS ?? 30000);
while (Date.now() < deadline) {
try {
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
if (response.ok) {
return;
}
} catch {
// Keep polling until Obsidian exposes the debugging endpoint.
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Obsidian DevTools endpoint on port ${port}`);
}
export async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
await waitForCdp(port);
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
try {
const context = browser.contexts()[0];
const page = context.pages()[0] ?? (await context.waitForEvent("page", { timeout: 10000 }));
return await operation(page);
} finally {
await browser.close();
}
}
export async function preseedTrustedVaultState(port: number, vaultId: string): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate((id) => {
localStorage.setItem(`enable-plugin-${id}`, "true");
}, vaultId);
await page.reload({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => undefined);
await page.waitForTimeout(1000);
});
}
export async function trustVaultIfPrompted(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_TRUST_PROMPT_TIMEOUT_MS ?? 30000);
while (Date.now() < deadline) {
const yesButton = page.getByRole("button", { name: "Yes" });
if (await yesButton.isVisible({ timeout: 1000 }).catch(() => false)) {
await yesButton.click();
await page.waitForTimeout(500);
continue;
}
const trustButton = page.getByText("Trust author and enable plugins");
if (await trustButton.isVisible({ timeout: 1000 }).catch(() => false)) {
await trustButton.click();
await page.waitForTimeout(500);
continue;
}
const workspace = page.locator(".workspace");
if (await workspace.isVisible({ timeout: 1000 }).catch(() => false)) {
return;
}
}
});
}
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
await withObsidianPage(port, async (page) => {
+90 -10
View File
@@ -1,14 +1,94 @@
import {
createTemporaryVault as createGenericTemporaryVault,
type TemporaryVault,
} from "@vrtmrz/obsidian-test-session";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
export type { TemporaryVault };
export type TemporaryVault = {
path: string;
name: string;
id: string;
homePath: string;
xdgConfigPath: string;
xdgCachePath: string;
xdgDataPath: string;
userDataPath: string;
dispose: () => Promise<void>;
};
export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise<TemporaryVault> {
return await createGenericTemporaryVault({
prefix,
pluginIds: ["obsidian-livesync"],
idPrefix: "livesync-e2e",
});
const vaultPath = await mkdtemp(join(tmpdir(), prefix));
const statePath = await mkdtemp(join(tmpdir(), `${prefix}state-`));
const name = vaultPath.split(/[\\/]/).pop() ?? "obsidian-livesync-e2e";
await mkdir(join(vaultPath, ".obsidian"), { recursive: true });
const homePath = join(statePath, "home");
const xdgConfigPath = join(statePath, "xdg-config");
const xdgCachePath = join(statePath, "xdg-cache");
const xdgDataPath = join(statePath, "xdg-data");
const userDataPath = join(statePath, "user-data");
const id = `livesync-e2e-${Date.now()}`;
await mkdir(homePath, { recursive: true });
await mkdir(xdgConfigPath, { recursive: true });
await mkdir(xdgCachePath, { recursive: true });
await mkdir(xdgDataPath, { recursive: true });
await mkdir(userDataPath, { recursive: true });
await writeFile(
join(vaultPath, ".obsidian", "app.json"),
JSON.stringify({ legacyEditor: false, safeMode: false }, null, 4)
);
await writeFile(
join(vaultPath, ".obsidian", "community-plugins.json"),
JSON.stringify(["obsidian-livesync"], null, 4)
);
await writeObsidianVaultRegistry(id, vaultPath, name, homePath, xdgConfigPath, userDataPath);
return {
path: vaultPath,
name,
id,
homePath,
xdgConfigPath,
xdgCachePath,
xdgDataPath,
userDataPath,
dispose: async () => {
if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true") {
console.log(`Keeping temporary vault: ${vaultPath}`);
console.log(`Keeping temporary Obsidian state: ${statePath}`);
return;
}
await Promise.all([
rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
rm(statePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
]);
},
};
}
async function writeObsidianVaultRegistry(
vaultId: string,
vaultPath: string,
vaultName: string,
homePath: string,
xdgConfigPath: string,
userDataPath: string
): Promise<void> {
const vaultRecord = {
path: vaultPath,
ts: Date.now(),
open: true,
name: vaultName,
};
const registry = {
cli: true,
vaults: {
[vaultId]: vaultRecord,
},
};
const registryText = JSON.stringify(registry, null, 4);
for (const configRoot of [join(homePath, ".config"), xdgConfigPath]) {
const obsidianConfigDir = join(configRoot, "obsidian");
await mkdir(obsidianConfigDir, { recursive: true });
await writeFile(join(obsidianConfigDir, "obsidian.json"), registryText);
}
await writeFile(join(userDataPath, "obsidian.json"), registryText);
await writeFile(join(userDataPath, `${vaultId}.json`), JSON.stringify(vaultRecord, null, 4));
}
+36 -32
View File
@@ -3,38 +3,6 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
## Unreleased
### Improved (CLI and Webapp)
- Rooted storage adapters now reject absolute, drive-qualified, backslash-separated, and traversal paths. They also prevent file writes, appends, and removal from targeting the configured root itself.
- File System Access API storage can now create files below previously missing parent directories, matching the existing Node behaviour.
### Testing
- Added shared Node and File System Access API storage contract coverage for metadata, text and binary operations, append, listing, removal, path containment, and empty-root handling.
- Added a Compose-based P2P end-to-end smoke test and repeatable network benchmark cases for local performance investigations.
### Miscellaneous
- Split the internal storage adapter contract into focused capability views without changing existing runtime behaviour.
## 0.25.80
7th July, 2026
### Fixed
- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993).
- Behaviour change:
- When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result.
- When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side.
- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994).
- Behaviour change:
- Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text.
- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992).
- Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled.
## 0.25.79
29th June, 2026
@@ -128,5 +96,41 @@ Also, this update is a very large one, even if we had a lot of time, and we had
- Some dependencies have been updated.
- Now we check the compatibility with iOS 15 in the CI tests to ensure the plugin continues to work on older iOS versions even after we upgrade some dependencies.
## 0.25.74
8th June, 2026
### Fixed
- Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication or boot-sequence scan (#941).
- Prevented the automatic merging of conflicted revisions when one of the revisions has been deleted, which was causing deleted files to reappear (#911).
- The startup sequence now saves the state more effectively (Thank you so much for @bmcyver)!
## Only CLI
8th June, 2026
I should also consider the version numbering for the CLI...
### Improved
- Added new remote database management commands: `remote-status`, `unlock-remote`, `lock-remote`, and `mark-resolved`.
- --vault option is now available for daemon and mirror commands! (Thank you so much for @starskyzheng)!
- Decoupled the database directory path from the actual vault directory path using the `--vault` (or `-V`) option.
### Fixed (preventive)
- Validated that the specified vault path exists and is indeed a directory before starting the CLI.
- Integrated path resolution and validations for one-off commands (such as `'push'`, `'pull'`, `'cat'`, `'rm'`, `'info'`, and `'resolve'`) against the decoupled vault path instead of the database path.
## 0.25.73
4th June, 2026
### Fixed
- Adjust CouchDB's database name checking to its specification (#926).
- `Reset Syncronisation on This Device` for minio and P2P is now working properly.
Full notes are in
[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).
-58
View File
@@ -4,64 +4,6 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
## 0.25.80
7th July, 2026
### Fixed
- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993).
- Behaviour change:
- When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result.
- When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side.
- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994).
- Behaviour change:
- Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text.
- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992).
- Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled.
## 0.25.79
29th June, 2026
### Fixed
- Fast Fetch now retries transient stream interruptions and resumes from the latest persisted checkpoint, instead of starting over after ordinary network or platform interruptions (#977, PR #978; commonlib PR #59). Thank you so much for @apple-ouyang for the fix!
- Simple Fetch now remembers the selected setup choices while an interrupted Fetch All operation is still pending, so users are not asked the same questions again on retry (#977, PR #978). Thank you so much for @apple-ouyang for the fix!
- No longer hidden storage events, such as `.git` paths, reach the normal target-file filter when internal file synchronisation is disabled. This avoids noisy non-target logs before those files are skipped (commonlib PR #60). Thank you so much for @apple-ouyang for the fix!
- Fixed an issue where a file deleted from storage could be resurrected by the offline scanner because the database tombstone was not written when the storage file was already gone (commonlib PR #56). Thank you so much for @cosmic-fire-eng for the fix!
### Improved
- Local database maintenance commands now ask before applying the required chunk settings, and can apply those prerequisites before continuing (#980, PR #981). Thank you so much for @apple-ouyang for the improvement!
- Improved CouchDB replication event handling by using the new `StreamInbox` helper from `octagonal-wheels` (commonlib PR #62).
### Documentation
- Added `nginx` to the setup documentation table of contents (PR #976). Thank you so much for @kiraventom for the improvement!
### Miscellaneous
- Updated `octagonal-wheels` to `0.1.47` across the plug-in and workspace packages to use the newly published helper modules.
## 0.25.78
23rd June, 2026
### Fixed
- No longer fast synchronisation (a.k.a. Fast Fetch) causes a rewind and re-fetch of the entire database when some errors occur during the process (#972, PR #973). Thank you so much for @apple-ouyang for the fix!
### Improved
- Overhauled the Object Storage (e.g., MinIO and S3) replication engine ('Journal Replicator 2nd Edition').
- It now leverages the standard Web Streams API for a resilient, backpressure-aware architecture, reducing memory footprints/temporary storage usage on large vaults.
- Decoupled the physical storage logic to make it easier to add new storage backends in the future.
- Stricter compliance with CouchDB's replication protocol (proper `_revisions` transfers with `new_edits: false`) when using Object Storage.
### Testing
- Added comprehensive unit tests for the new `JournalSyncCore` engine, covering streams, backpressure, and `new_edits: false` validation.
- Improved integration test workflows in the CI pipeline to run MinIO tests automatically using standard environment variables.
## 0.25.77
19th June, 2026