Compare commits

..
Author SHA1 Message Date
vorotamoroz 3c71650c58 Point at merged commonlib rename guard 2026-07-16 10:55:29 +00:00
vorotamoroz 37a6db05b9 test: cover case-only file renames in Obsidian 2026-07-16 09:26:45 +00:00
vorotamoroz 203ccfb74a fix: guard case-only rename recovery 2026-07-16 09:15:21 +00:00
metrovoc 31d30ed4f0 fix: synchronise case-only file renames safely 2026-07-16 09:12:37 +00:00
vorotamoroz 7439b75cd0 feat: distinguish remote operation and request activity 2026-07-16 07:20:55 +00:00
vorotamoroz 9ffde2dd3e fix: align chunk reads with remote delivery
Consume commonlib's delivery lifecycle, classify finite replication entry points, and document why the five-minute inactivity fuse is only a leak safety valve. Record the remote-activity counter correction and preserve continuous replication's unbounded live channel.
2026-07-16 01:44:58 +00:00
30 changed files with 793 additions and 785 deletions
+1 -5
View File
@@ -13,11 +13,6 @@
*.mjs text eol=lf *.mjs text eol=lf
*.css text eol=lf *.css text eol=lf
# Extensionless text files
.gitignore text eol=lf
.dockerignore text eol=lf
Dockerfile text eol=lf
# Binary files — no line ending conversion # Binary files — no line ending conversion
*.png binary *.png binary
*.jpg binary *.jpg binary
@@ -26,3 +21,4 @@ Dockerfile text eol=lf
*.ico binary *.ico binary
*.woff2 binary *.woff2 binary
*.woff binary *.woff binary
*.sh text eol=lf
+14 -6
View File
@@ -60,16 +60,24 @@ jobs:
exit 1 exit 1
fi fi
node utils/release-notes.mjs validate "${VERSION}" node utils/release-notes.mjs validate "${VERSION}"
git fetch --tags --force
if git rev-parse --verify --quiet "refs/tags/${VERSION}" >/dev/null; then
echo "Tag already exists: ${VERSION}" >&2
exit 1
fi
if git rev-parse --verify --quiet "refs/tags/${VERSION}-cli" >/dev/null; then
echo "Tag already exists: ${VERSION}-cli" >&2
exit 1
fi
- name: Ensure and push release tags - name: Create and push release tags
env: env:
VERSION: ${{ inputs.version }} VERSION: ${{ inputs.version }}
EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }}
run: | run: |
set -euo pipefail set -euo pipefail
git fetch --tags --force git tag "${VERSION}"
node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}" git tag "${VERSION}-cli"
git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli" git push origin "${VERSION}" "${VERSION}-cli"
- name: Dispatch release workflows - name: Dispatch release workflows
env: env:
@@ -92,7 +100,7 @@ jobs:
VERSION: ${{ inputs.version }} VERSION: ${{ inputs.version }}
run: | run: |
{ {
echo "Ensured tags \`${VERSION}\` and \`${VERSION}-cli\` point to the reviewed release commit." echo "Created tags \`${VERSION}\` and \`${VERSION}-cli\`."
echo "" echo ""
echo "Dispatched the plug-in release workflow for \`${VERSION}\`. After approval for the release environment, it creates a draft GitHub Release." echo "Dispatched the plug-in release workflow for \`${VERSION}\`. After approval for the release environment, it creates a draft GitHub Release."
echo "Dispatched the CLI Docker workflow for \`${VERSION}-cli\`. It publishes the version, major-minor, latest, and SHA-qualified image tags." echo "Dispatched the CLI Docker workflow for \`${VERSION}-cli\`. It publishes the version, major-minor, latest, and SHA-qualified image tags."
+1
View File
@@ -78,6 +78,7 @@ jobs:
git switch -c "${BRANCH}" git switch -c "${BRANCH}"
npm version "${VERSION}" --no-git-tag-version npm version "${VERSION}" --no-git-tag-version
node utils/release-notes.mjs prepare "${VERSION}" node utils/release-notes.mjs prepare "${VERSION}"
npm run pretty:json
npm run build:lib:types npm run build:lib:types
git add package.json package-lock.json manifest.json versions.json updates.md src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json _types git add package.json package-lock.json manifest.json versions.json updates.md src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json _types
+26 -17
View File
@@ -1,5 +1,10 @@
name: Release Obsidian Plugin name: Release Obsidian Plugin
on: on:
push:
# Sequence of patterns matched against refs/tags
tags:
- '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10
- '!*-cli' # Exclude command-line interface tags
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
@@ -28,25 +33,29 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0 # otherwise, you will failed to push refs to dest repo
submodules: recursive submodules: recursive
ref: ${{ inputs.tag }} ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '24.x' node-version: '24.x' # You might need to adjust this value to your own version
- name: Validate release # Get the version number and put it in a variable
env: - name: Get Version
TAG: ${{ inputs.tag }} id: version
run: | run: |
set -euo pipefail if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
node utils/release-notes.mjs validate "${TAG}" TAG="${{ inputs.tag }}"
HEAD_SHA="$(git rev-parse HEAD)" DRAFT="${{ inputs.draft }}"
TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")" PRERELEASE="${{ inputs.prerelease }}"
if [[ "${HEAD_SHA}" != "${TAG_SHA}" ]]; then else
echo "Checked-out commit is ${HEAD_SHA}, but tag ${TAG} points to ${TAG_SHA}." >&2 TAG="${GITHUB_REF_NAME}"
exit 1 DRAFT="true"
PRERELEASE="false"
fi fi
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "draft=${DRAFT}" >> $GITHUB_OUTPUT
echo "prerelease=${PRERELEASE}" >> $GITHUB_OUTPUT
# Build the plugin # Build the plugin
- name: Build - name: Build
id: build id: build
@@ -75,7 +84,7 @@ jobs:
main.js main.js
manifest.json manifest.json
styles.css styles.css
name: ${{ inputs.tag }} name: ${{ steps.version.outputs.tag }}
tag_name: ${{ inputs.tag }} tag_name: ${{ steps.version.outputs.tag }}
draft: ${{ inputs.draft }} draft: ${{ steps.version.outputs.draft }}
prerelease: ${{ inputs.prerelease }} prerelease: ${{ steps.version.outputs.prerelease }}
-1
View File
@@ -2,4 +2,3 @@ pouchdb-browser.js
main_org.js main_org.js
main.js main.js
_types/** _types/**
src/lib/**
+1 -2
View File
@@ -274,8 +274,7 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
- Run the `Prepare Release PR` workflow with the target version. It creates the release branch, updates versions, regenerates the `_types` fallback definitions used by the community plug-in scan, moves the `## Unreleased` notes to the target version, commits the release preparation, pushes the branch, and opens a draft release PR. - Run the `Prepare Release PR` workflow with the target version. It creates the release branch, updates versions, regenerates the `_types` fallback definitions used by the community plug-in scan, moves the `## Unreleased` notes to the target version, commits the release preparation, pushes the branch, and opens a draft release PR.
- Do not tag the release branch when the PR is first created. Polish the release PR first, especially `updates.md`. - Do not tag the release branch when the PR is first created. Polish the release PR first, especially `updates.md`.
- Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, ensures that both the plug-in tag (for example, `0.25.81`) and the CLI tag (for example, `0.25.81-cli`) point to that commit, and explicitly dispatches the plug-in and CLI publishing workflows. The workflow can be retried when existing tags already point to the reviewed commit, but stops if either tag points elsewhere. - Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, pushes both the plug-in tag (for example, `0.25.81`) and the CLI tag (for example, `0.25.81-cli`) to that commit, and explicitly dispatches the plug-in and CLI publishing workflows. An explicit dispatch is required because tags pushed with `GITHUB_TOKEN` do not trigger tag-push workflows.
- The plug-in publishing workflow is intentionally dispatch-only. Pushing a plug-in tag directly does not publish a GitHub Release; use `Finalise Release Tags`, or dispatch `Release Obsidian Plugin` explicitly for recovery or a pre-release. The CLI Docker workflow retains its documented branch, tag, and manual triggers.
- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. Confirm that the CLI workflow has published the fixed version tag, the major-minor moving tag (for example, `0.25-cli`), `latest`, and the SHA-qualified tag. - Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. Confirm that the CLI workflow has published the fixed version tag, the major-minor moving tag (for example, `0.25-cli`), `latest`, and the SHA-qualified tag.
- Publish the draft GitHub Release as the latest stable release while keeping the release PR in draft and leaving `main` unchanged. Record the state in the PR with: 'Release `<version>` has been published as the latest stable release. This pull request intentionally remains in draft, and `main` has not yet been updated. Merge is on hold until BRAT validation is complete.' - Publish the draft GitHub Release as the latest stable release while keeping the release PR in draft and leaving `main` unchanged. Record the state in the PR with: 'Release `<version>` has been published as the latest stable release. This pull request intentionally remains in draft, and `main` has not yet been updated. Merge is on hold until BRAT validation is complete.'
- Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release. - Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "obsidian-livesync", "id": "obsidian-livesync",
"name": "Self-hosted LiveSync", "name": "Self-hosted LiveSync",
"version": "0.25.83", "version": "0.25.82",
"minAppVersion": "1.7.2", "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.", "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", "author": "vorotamoroz",
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.25.83", "version": "0.25.82",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.25.83", "version": "0.25.82",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"src/apps/cli", "src/apps/cli",
@@ -16226,7 +16226,7 @@
}, },
"src/apps/cli": { "src/apps/cli": {
"name": "self-hosted-livesync-cli", "name": "self-hosted-livesync-cli",
"version": "0.25.83-cli", "version": "0.25.82-cli",
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"minimatch": "^10.2.5", "minimatch": "^10.2.5",
@@ -16252,7 +16252,7 @@
}, },
"src/apps/webapp": { "src/apps/webapp": {
"name": "livesync-webapp", "name": "livesync-webapp",
"version": "0.25.83-webapp", "version": "0.25.82-webapp",
"dependencies": { "dependencies": {
"octagonal-wheels": "^0.1.51" "octagonal-wheels": "^0.1.51"
}, },
@@ -16267,7 +16267,7 @@
} }
}, },
"src/apps/webpeer": { "src/apps/webpeer": {
"version": "0.25.83-webpeer", "version": "0.25.82-webpeer",
"dependencies": { "dependencies": {
"octagonal-wheels": "^0.1.51" "octagonal-wheels": "^0.1.51"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.25.83", "version": "0.25.82",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "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", "main": "main.js",
"type": "module", "type": "module",
+3 -12
View File
@@ -87,10 +87,7 @@ export const storageAdapterContractCases: readonly StorageAdapterContractCase[]
name: "keeps operations inside the configured root", name: "keeps operations inside the configured root",
async run(adapter) { async run(adapter) {
await assertRejects(() => adapter.exists("../outside"), "parent traversal should be rejected"); await assertRejects(() => adapter.exists("../outside"), "parent traversal should be rejected");
await assertRejects( await assertRejects(() => adapter.write("nested/../outside", "content"), "nested traversal should be rejected");
() => adapter.write("nested/../outside", "content"),
"nested traversal should be rejected"
);
await assertRejects(() => adapter.read("/absolute"), "absolute paths 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("C:\\absolute"), "drive-qualified paths should be rejected");
await assertRejects(() => adapter.read("nested\\outside"), "backslash-separated paths should be rejected"); await assertRejects(() => adapter.read("nested\\outside"), "backslash-separated paths should be rejected");
@@ -104,14 +101,8 @@ export const storageAdapterContractCases: readonly StorageAdapterContractCase[]
assertEqual(await adapter.exists(""), true, "the configured root should exist"); 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.stat(""))?.type, "folder", "the configured root should be a folder");
assertEqual(await adapter.list(""), { files: [], folders: [] }, "the configured root should be listable"); assertEqual(await adapter.list(""), { files: [], folders: [] }, "the configured root should be listable");
await assertRejects( await assertRejects(() => adapter.write("", "content"), "writing over the configured root should be rejected");
() => adapter.write("", "content"), await assertRejects(() => adapter.append("", "content"), "appending to the configured root should be rejected");
"writing over the configured root should be rejected"
);
await assertRejects(
() => adapter.append("", "content"),
"appending to the configured root should be rejected"
);
}, },
}, },
]; ];
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "self-hosted-livesync-cli", "name": "self-hosted-livesync-cli",
"private": true, "private": true,
"version": "0.25.83-cli", "version": "0.25.82-cli",
"main": "dist/index.cjs", "main": "dist/index.cjs",
"type": "module", "type": "module",
"scripts": { "scripts": {
+124 -46
View File
@@ -1,8 +1,17 @@
import { TempDir } from "./helpers/temp.ts"; 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 { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
import { createCouchdbDatabase, startCouchdb, stopCouchdb } from "./helpers/docker.ts"; import {
import { createDeterministicDataset } from "./helpers/dataset.ts"; createCouchdbDatabase,
startCouchdb,
stopCouchdb,
} from "./helpers/docker.ts";
import {
createDeterministicDataset,
} from "./helpers/dataset.ts";
import { import {
type BenchmarkVerificationMode, type BenchmarkVerificationMode,
parseBenchmarkVerificationMode, parseBenchmarkVerificationMode,
@@ -72,7 +81,10 @@ function readEnvStringArray(name: string, fallback: string[]): string[] {
try { try {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) { if (
Array.isArray(parsed) &&
parsed.every((item) => typeof item === "string")
) {
return parsed; return parsed;
} }
} catch { } catch {
@@ -107,34 +119,60 @@ function formatBytes(value: number): string {
function buildConfig(): BenchmarkConfig { function buildConfig(): BenchmarkConfig {
return { return {
caseName: readEnvString("BENCH_CASE", "couchdb-baseline"), caseName: readEnvString("BENCH_CASE", "couchdb-baseline"),
couchdbBackendUri: readEnvString("BENCH_COUCHDB_BACKEND_URI", "http://127.0.0.1:5989"), couchdbBackendUri: readEnvString(
couchdbProxyUri: readEnvString("BENCH_COUCHDB_URI", "http://127.0.0.1:15989"), "BENCH_COUCHDB_BACKEND_URI",
couchdbUser: readEnvString("BENCH_COUCHDB_USER", readEnvString("username", "admin")), "http://127.0.0.1:5989",
couchdbPassword: readEnvString("BENCH_COUCHDB_PASSWORD", readEnvString("password", "password")), ),
couchdbDbname: readEnvString("BENCH_COUCHDB_DBNAME", `bench-couchdb-${Date.now()}`), 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"), datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)), mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)), mdMinSizeBytes: Math.floor(
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)), 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)), 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), syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
requestedRttMs: Math.floor(readEnvNumber("BENCH_COUCHDB_RTT_MS", 50)), requestedRttMs: Math.floor(readEnvNumber("BENCH_COUCHDB_RTT_MS", 50)),
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`), passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
encrypt: readEnvBool("BENCH_ENCRYPT", true), encrypt: readEnvBool("BENCH_ENCRYPT", true),
managedCouchdb: readEnvBool("BENCH_COUCHDB_MANAGED", true), managedCouchdb: readEnvBool("BENCH_COUCHDB_MANAGED", true),
simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"), simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"),
networkProfile: readEnvString("BENCH_NETWORK_PROFILE", "http-latency-proxy"), networkProfile: readEnvString(
"BENCH_NETWORK_PROFILE",
"http-latency-proxy",
),
networkModel: readEnvString("BENCH_NETWORK_MODEL", "local-http-proxy"), networkModel: readEnvString("BENCH_NETWORK_MODEL", "local-http-proxy"),
measurementScope: readEnvString( measurementScope: readEnvString(
"BENCH_MEASUREMENT_SCOPE", "BENCH_MEASUREMENT_SCOPE",
"Two one-shot synchronisation phases through a CouchDB-compatible remote-store path." "Two one-shot synchronisation phases through a CouchDB-compatible remote-store path.",
), ),
limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [ limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [
"This benchmark result is scoped to the configured dataset, remote store, and network model.", "This benchmark result is scoped to the configured dataset, remote store, and network model.",
]), ]),
verificationMode: parseBenchmarkVerificationMode(Deno.env.get("BENCH_VERIFY_MODE")), verificationMode: parseBenchmarkVerificationMode(
Deno.env.get("BENCH_VERIFY_MODE"),
),
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)), repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)), repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
}; };
@@ -155,17 +193,20 @@ export type CouchdbProxyHandle = {
directionalDelayMs: number; directionalDelayMs: number;
}; };
export function startCouchdbProxy(options: { export function startCouchdbProxy(
backendUri: string; options: {
proxyUri: string; backendUri: string;
requestedRttMs: number; proxyUri: string;
delay?: (milliseconds: number) => Promise<void>; requestedRttMs: number;
}): CouchdbProxyHandle { delay?: (milliseconds: number) => Promise<void>;
},
): CouchdbProxyHandle {
const backend = new URL(options.backendUri); const backend = new URL(options.backendUri);
const proxy = new URL(options.proxyUri); const proxy = new URL(options.proxyUri);
const halfDelayMs = options.requestedRttMs / 2; const halfDelayMs = options.requestedRttMs / 2;
const delay = const delay = options.delay ??
options.delay ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds))); ((milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
const controller = new AbortController(); const controller = new AbortController();
const listener = Deno.serve( const listener = Deno.serve(
@@ -215,13 +256,14 @@ export function startCouchdbProxy(options: {
statusText: upstream.statusText, statusText: upstream.statusText,
headers: responseHeaders, headers: responseHeaders,
}); });
} },
); );
return { return {
applied: true, applied: true,
directionalDelayMs: halfDelayMs, 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 request-path and ${halfDelayMs}ms response-path delay`,
stop: async () => { stop: async () => {
controller.abort(); controller.abort();
await listener.finished.catch(() => {}); await listener.finished.catch(() => {});
@@ -245,14 +287,21 @@ async function main(): Promise<void> {
await initSettingsFile(settingsB); await initSettingsFile(settingsB);
if (config.managedCouchdb) { if (config.managedCouchdb) {
await startCouchdb(config.couchdbBackendUri, config.couchdbUser, config.couchdbPassword, config.couchdbDbname); await startCouchdb(
config.couchdbBackendUri,
config.couchdbUser,
config.couchdbPassword,
config.couchdbDbname,
);
} else { } else {
console.log(`[INFO] using externally managed CouchDB: ${config.couchdbBackendUri}`); console.log(
`[INFO] using externally managed CouchDB: ${config.couchdbBackendUri}`,
);
await createCouchdbDatabase( await createCouchdbDatabase(
config.couchdbBackendUri, config.couchdbBackendUri,
config.couchdbUser, config.couchdbUser,
config.couchdbPassword, config.couchdbPassword,
config.couchdbDbname config.couchdbDbname,
); );
} }
@@ -307,15 +356,28 @@ async function main(): Promise<void> {
await runCliOrFail(vaultB, "--settings", settingsB, "sync"); await runCliOrFail(vaultB, "--settings", settingsB, "sync");
const syncBElapsed = nowMs() - syncBStart; const syncBElapsed = nowMs() - syncBStart;
const verification = await verifyBenchmarkDataset(seedFiles.entries, config.verificationMode, async (entry) => { const verification = await verifyBenchmarkDataset(
const pulledPath = workDir.join(`pulled-${entry.relativePath.split("/").join("_")}`); seedFiles.entries,
await runCliOrFail(vaultB, "--settings", settingsB, "pull", entry.relativePath, pulledPath); config.verificationMode,
await assertFilesEqual( async (entry) => {
entry.absolutePath, const pulledPath = workDir.join(
pulledPath, `pulled-${entry.relativePath.split("/").join("_")}`,
`file mismatch after CouchDB sync: ${entry.relativePath}` );
); await runCliOrFail(
}); vaultB,
"--settings",
settingsB,
"pull",
entry.relativePath,
pulledPath,
);
await assertFilesEqual(
entry.absolutePath,
pulledPath,
`file mismatch after CouchDB sync: ${entry.relativePath}`,
);
},
);
const result = { const result = {
caseName: config.caseName, caseName: config.caseName,
@@ -347,24 +409,40 @@ async function main(): Promise<void> {
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
syncAElapsedMs: Number(syncAElapsed.toFixed(1)), syncAElapsedMs: Number(syncAElapsed.toFixed(1)),
syncBElapsedMs: Number(syncBElapsed.toFixed(1)), syncBElapsedMs: Number(syncBElapsed.toFixed(1)),
totalSyncElapsedMs: Number((syncAElapsed + syncBElapsed).toFixed(1)), totalSyncElapsedMs: Number(
throughputBytesPerSec: Number((seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000)).toFixed(2)), (syncAElapsed + syncBElapsed).toFixed(1),
),
throughputBytesPerSec: Number(
(seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000))
.toFixed(
2,
),
),
throughputMiBPerSec: Number( throughputMiBPerSec: Number(
(seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000) / 1024 / 1024).toFixed(4) (seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000) /
1024 /
1024).toFixed(4),
), ),
}; };
if (resultPath) { 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.log(JSON.stringify(result, null, 2));
console.error( console.error(
`[Benchmark] couchdb mirrored ${seedFiles.totalFiles} files (${formatBytes( `[Benchmark] couchdb mirrored ${seedFiles.totalFiles} files (${
seedFiles.totalBytes formatBytes(seedFiles.totalBytes)
)}) in ${formatMs(mirrorElapsed)}, synced in ${formatMs( }) in ${
syncAElapsed + syncBElapsed formatMs(
)} (${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)` mirrorElapsed,
)
}, synced in ${
formatMs(syncAElapsed + syncBElapsed)
} (${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`,
); );
} finally { } finally {
await proxy.stop(); await proxy.stop();
+5 -2
View File
@@ -65,7 +65,9 @@ async function runBenchmark(options: {
repeatIndex: number; repeatIndex: number;
repeatCount: number; repeatCount: number;
}): Promise<Record<string, unknown>> { }): Promise<Record<string, unknown>> {
const suffix = options.repeatCount > 1 ? `-r${String(options.repeatIndex).padStart(2, "0")}` : ""; const suffix = options.repeatCount > 1
? `-r${String(options.repeatIndex).padStart(2, "0")}`
: "";
const resultPath = `${options.outputDir}/${options.name}${suffix}.json`; const resultPath = `${options.outputDir}/${options.name}${suffix}.json`;
const env = { const env = {
...Deno.env.toObject(), ...Deno.env.toObject(),
@@ -154,7 +156,8 @@ async function main(): Promise<void> {
const summary = { const summary = {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
outputDir, 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.", 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, rtts,
repeatCount, repeatCount,
results, results,
+91 -35
View File
@@ -27,16 +27,26 @@ function timestamp(): string {
const d = new Date(); const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0"); const pad = (n: number) => String(n).padStart(2, "0");
return ( return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-` + `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${
`${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}` pad(d.getUTCDate())
}-` +
`${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${
pad(d.getUTCSeconds())
}`
); );
} }
function buildBaseEnv(): Record<string, string> { function buildBaseEnv(): Record<string, string> {
return { return {
BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"), BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"),
BENCH_MD_MIN_SIZE_BYTES: readEnvString("BENCH_MD_MIN_SIZE_BYTES", "512"), BENCH_MD_MIN_SIZE_BYTES: readEnvString(
BENCH_MD_MAX_SIZE_BYTES: readEnvString("BENCH_MD_MAX_SIZE_BYTES", "2048"), "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_FILE_COUNT: readEnvString("BENCH_BIN_FILE_COUNT", "5"),
BENCH_BIN_SIZE_BYTES: readEnvString("BENCH_BIN_SIZE_BYTES", "8192"), BENCH_BIN_SIZE_BYTES: readEnvString("BENCH_BIN_SIZE_BYTES", "8192"),
BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"), BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"),
@@ -49,7 +59,7 @@ function buildBaseEnv(): Record<string, string> {
function withScopeEnv( function withScopeEnv(
env: Record<string, string>, env: Record<string, string>,
options: Pick<BenchmarkCase, "measurementScope" | "limitations"> options: Pick<BenchmarkCase, "measurementScope" | "limitations">,
): Record<string, string> { ): Record<string, string> {
return { return {
...env, ...env,
@@ -69,15 +79,25 @@ export function buildCases(): BenchmarkCase[] {
const base = buildBaseEnv(); const base = buildBaseEnv();
const couchdbRtt = readEnvString("BENCH_COUCHDB_RTT_MS", "20"); const couchdbRtt = readEnvString("BENCH_COUCHDB_RTT_MS", "20");
const tetheringVpnRtt = readEnvString("BENCH_TETHERING_VPN_RTT_MS", "120"); const tetheringVpnRtt = readEnvString("BENCH_TETHERING_VPN_RTT_MS", "120");
const localTurnServers = readEnvString("BENCH_LOCAL_TURN_SERVERS", "turn:127.0.0.1:3478"); const localTurnServers = readEnvString(
const shimCouchdbUri = readEnvString("BENCH_SHIM_COUCHDB_URI", "http://couchdb-shim:5984"); "BENCH_LOCAL_TURN_SERVERS",
const signallingShimRelay = readEnvString("BENCH_SIGNAL_SHIM_RELAY", "ws://p2p-signalling-shim:7777/"); "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 [ return [
defineCase({ defineCase({
name: "couchdb-baseline", name: "couchdb-baseline",
runner: "couchdb", runner: "couchdb",
description: "Standard self-hosted CouchDB path through a local latency proxy.", description:
"Standard self-hosted CouchDB path through a local latency proxy.",
dataPath: "Device A -> CouchDB -> Device B", dataPath: "Device A -> CouchDB -> Device B",
trustBoundary: "CouchDB operator and network path", trustBoundary: "CouchDB operator and network path",
measurementScope: measurementScope:
@@ -95,7 +115,8 @@ export function buildCases(): BenchmarkCase[] {
defineCase({ defineCase({
name: "p2p-direct-local", name: "p2p-direct-local",
runner: "p2p", runner: "p2p",
description: "Preferred direct WebRTC P2P path with Nostr signalling and TURN disabled.", description:
"Preferred direct WebRTC P2P path with Nostr signalling and TURN disabled.",
dataPath: "Device A -> Device B", dataPath: "Device A -> Device B",
trustBoundary: "Nostr relay for signalling metadata; no TURN relay", trustBoundary: "Nostr relay for signalling metadata; no TURN relay",
measurementScope: measurementScope:
@@ -112,7 +133,8 @@ export function buildCases(): BenchmarkCase[] {
BENCH_SIMULATION_TIER: "1", BENCH_SIMULATION_TIER: "1",
BENCH_NETWORK_PROFILE: "local-direct", BENCH_NETWORK_PROFILE: "local-direct",
BENCH_NETWORK_MODEL: "local-runner-webrtc", BENCH_NETWORK_MODEL: "local-runner-webrtc",
BENCH_P2P_CANDIDATE_PATH_VERIFICATION: "turn-disabled-but-selected-ice-pair-not-collected", BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
"turn-disabled-but-selected-ice-pair-not-collected",
}, },
}), }),
defineCase({ defineCase({
@@ -120,7 +142,8 @@ export function buildCases(): BenchmarkCase[] {
runner: "couchdb", runner: "couchdb",
description: description:
"Approximate smartphone tethering/VPN remote-database path using an HTTP latency proxy. This does not model loss, jitter, MTU, or VPN encapsulation.", "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", dataPath:
"Device A -> VPN/network path -> CouchDB -> VPN/network path -> Device B",
trustBoundary: "VPN/network path and CouchDB operator", trustBoundary: "VPN/network path and CouchDB operator",
measurementScope: measurementScope:
"Two one-shot CouchDB synchronisation phases with additional requested RTT through the local HTTP proxy.", "Two one-shot CouchDB synchronisation phases with additional requested RTT through the local HTTP proxy.",
@@ -137,8 +160,10 @@ export function buildCases(): BenchmarkCase[] {
defineCase({ defineCase({
name: "couchdb-netem-home-wifi", name: "couchdb-netem-home-wifi",
runner: "couchdb", runner: "couchdb",
description: "Tier 2 CouchDB path through the Compose netem TCP shim using the home-wifi profile.", description:
dataPath: "Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B", "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", trustBoundary: "CouchDB operator and constrained network shim",
measurementScope: measurementScope:
"Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the home-wifi netem profile.", "Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the home-wifi netem profile.",
@@ -159,9 +184,12 @@ export function buildCases(): BenchmarkCase[] {
defineCase({ defineCase({
name: "couchdb-netem-tethering-vpn", name: "couchdb-netem-tethering-vpn",
runner: "couchdb", runner: "couchdb",
description: "Tier 2 CouchDB path through the Compose netem TCP shim using a tethering-vpn profile.", description:
dataPath: "Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B", "Tier 2 CouchDB path through the Compose netem TCP shim using a tethering-vpn profile.",
trustBoundary: "CouchDB operator and constrained smartphone/VPN-like network shim", dataPath:
"Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B",
trustBoundary:
"CouchDB operator and constrained smartphone/VPN-like network shim",
measurementScope: measurementScope:
"Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the tethering-vpn netem profile.", "Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the tethering-vpn netem profile.",
limitations: [ limitations: [
@@ -183,8 +211,10 @@ export function buildCases(): BenchmarkCase[] {
runner: "p2p", runner: "p2p",
description: 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.", "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", dataPath:
trustBoundary: "Smartphone/VPN routing policy plus Nostr signalling metadata", "Device A -> Device B when WebRTC direct connectivity succeeds",
trustBoundary:
"Smartphone/VPN routing policy plus Nostr signalling metadata",
measurementScope: measurementScope:
"Structural placeholder for direct P2P measurements on a real smartphone tethering/VPN path.", "Structural placeholder for direct P2P measurements on a real smartphone tethering/VPN path.",
limitations: [ limitations: [
@@ -207,8 +237,10 @@ export function buildCases(): BenchmarkCase[] {
runner: "p2p", runner: "p2p",
description: description:
"Tier 2 P2P path with only the Nostr signalling relay accessed through the home-wifi netem shim.", "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", dataPath:
trustBoundary: "Nostr signalling metadata through constrained network shim; no TURN relay", "Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim",
trustBoundary:
"Nostr signalling metadata through constrained network shim; no TURN relay",
measurementScope: 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.", "One fresh CLI p2p-sync command where only Nostr signalling access is shaped by the home-wifi netem profile; the selected WebRTC note-data path is unshaped.",
limitations: [ limitations: [
@@ -233,7 +265,8 @@ export function buildCases(): BenchmarkCase[] {
runner: "p2p", runner: "p2p",
description: description:
"Tier 2 P2P path with only the Nostr signalling relay accessed through the tethering-vpn netem shim.", "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", dataPath:
"Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim",
trustBoundary: trustBoundary:
"Nostr signalling metadata through constrained smartphone/VPN-like network shim; no TURN relay", "Nostr signalling metadata through constrained smartphone/VPN-like network shim; no TURN relay",
measurementScope: measurementScope:
@@ -258,7 +291,8 @@ export function buildCases(): BenchmarkCase[] {
defineCase({ defineCase({
name: "p2p-user-turn", name: "p2p-user-turn",
runner: "p2p", runner: "p2p",
description: "Optional fallback path through a local user-controlled TURN server.", description:
"Optional fallback path through a local user-controlled TURN server.",
dataPath: "Device A -> user-controlled TURN -> Device B", dataPath: "Device A -> user-controlled TURN -> Device B",
trustBoundary: "User-controlled TURN server", trustBoundary: "User-controlled TURN server",
measurementScope: measurementScope:
@@ -285,9 +319,11 @@ async function runCase(
testCase: BenchmarkCase, testCase: BenchmarkCase,
outputDir: string, outputDir: string,
repeatIndex: number, repeatIndex: number,
repeatCount: number repeatCount: number,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const suffix = repeatCount > 1 ? `-r${String(repeatIndex).padStart(2, "0")}` : ""; const suffix = repeatCount > 1
? `-r${String(repeatIndex).padStart(2, "0")}`
: "";
const resultPath = `${outputDir}/${testCase.name}${suffix}.json`; const resultPath = `${outputDir}/${testCase.name}${suffix}.json`;
const taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb"; const taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb";
const env = { const env = {
@@ -298,8 +334,12 @@ async function runCase(
BENCH_REPEAT_COUNT: String(repeatCount), BENCH_REPEAT_COUNT: String(repeatCount),
}; };
const repeatLabel = repeatCount > 1 ? ` (${repeatIndex}/${repeatCount})` : ""; const repeatLabel = repeatCount > 1
console.log(`[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`); ? ` (${repeatIndex}/${repeatCount})`
: "";
console.log(
`[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`,
);
const command = new Deno.Command("deno", { const command = new Deno.Command("deno", {
args: ["task", taskName], args: ["task", taskName],
cwd: import.meta.dirname, cwd: import.meta.dirname,
@@ -315,7 +355,10 @@ async function runCase(
throw new Error(`case failed: ${testCase.name} (exit ${status.code})`); throw new Error(`case failed: ${testCase.name} (exit ${status.code})`);
} }
const result = JSON.parse(await Deno.readTextFile(resultPath)) as Record<string, unknown>; const result = JSON.parse(await Deno.readTextFile(resultPath)) as Record<
string,
unknown
>;
return { return {
...testCase, ...testCase,
repeatIndex, repeatIndex,
@@ -326,7 +369,10 @@ async function runCase(
} }
function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] { function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] {
const requested = readEnvString("BENCH_CASES", "couchdb-baseline,p2p-direct-local"); const requested = readEnvString(
"BENCH_CASES",
"couchdb-baseline,p2p-direct-local",
);
const names = requested const names = requested
.split(",") .split(",")
.map((v) => v.trim()) .map((v) => v.trim())
@@ -336,7 +382,9 @@ function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] {
const found = byName.get(name); const found = byName.get(name);
if (!found) { if (!found) {
throw new Error( throw new Error(
`Unknown BENCH_CASES entry '${name}'. Available: ${allCases.map((c) => c.name).join(", ")}` `Unknown BENCH_CASES entry '${name}'. Available: ${
allCases.map((c) => c.name).join(", ")
}`,
); );
} }
return found; return found;
@@ -344,7 +392,10 @@ function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] {
} }
async function main(): Promise<void> { async function main(): Promise<void> {
const outRoot = readEnvString("BENCH_CASES_ROOT", `${import.meta.dirname}/bench-results`); const outRoot = readEnvString(
"BENCH_CASES_ROOT",
`${import.meta.dirname}/bench-results`,
);
const outputDir = `${outRoot}/cases-${timestamp()}`; const outputDir = `${outRoot}/cases-${timestamp()}`;
await Deno.mkdir(outputDir, { recursive: true }); await Deno.mkdir(outputDir, { recursive: true });
@@ -361,14 +412,16 @@ async function main(): Promise<void> {
availableCases: allCases, availableCases: allCases,
}, },
null, null,
2 2,
) ),
); );
const results: Record<string, unknown>[] = []; const results: Record<string, unknown>[] = [];
for (const testCase of cases) { for (const testCase of cases) {
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) { for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
results.push(await runCase(testCase, outputDir, repeatIndex, repeatCount)); results.push(
await runCase(testCase, outputDir, repeatIndex, repeatCount),
);
} }
} }
@@ -378,7 +431,10 @@ async function main(): Promise<void> {
repeatCount, repeatCount,
results, results,
}; };
await Deno.writeTextFile(`${outputDir}/summary.json`, JSON.stringify(summary, null, 2)); await Deno.writeTextFile(
`${outputDir}/summary.json`,
JSON.stringify(summary, null, 2),
);
console.log(JSON.stringify(summary, null, 2)); console.log(JSON.stringify(summary, null, 2));
console.log(`[bench-cases] result directory: ${outputDir}`); console.log(`[bench-cases] result directory: ${outputDir}`);
} }
+105 -39
View File
@@ -1,5 +1,9 @@
import { TempDir } from "./helpers/temp.ts"; 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 { startCliInBackground } from "./helpers/backgroundCli.ts";
import { import {
discoverPeer, discoverPeer,
@@ -9,7 +13,9 @@ import {
stopLocalRelayIfStarted, stopLocalRelayIfStarted,
} from "./helpers/p2p.ts"; } from "./helpers/p2p.ts";
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
import { createDeterministicDataset } from "./helpers/dataset.ts"; import {
createDeterministicDataset,
} from "./helpers/dataset.ts";
import { import {
type BenchmarkVerificationMode, type BenchmarkVerificationMode,
parseBenchmarkVerificationMode, parseBenchmarkVerificationMode,
@@ -101,7 +107,10 @@ function readEnvStringArray(name: string, fallback: string[]): string[] {
try { try {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) { if (
Array.isArray(parsed) &&
parsed.every((item) => typeof item === "string")
) {
return parsed; return parsed;
} }
} catch { } catch {
@@ -138,31 +147,48 @@ function buildConfig(): BenchmarkConfig {
return { return {
caseName: readEnvString("BENCH_CASE", "p2p-direct-local"), caseName: readEnvString("BENCH_CASE", "p2p-direct-local"),
relay: readEnvString("BENCH_RELAY", "ws://localhost:4000/"), 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()}`), roomId: readEnvString("BENCH_ROOM_ID", `bench-room-${Date.now()}`),
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`), passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
turnServers: readEnvString("BENCH_TURN_SERVERS", ""), turnServers: readEnvString("BENCH_TURN_SERVERS", ""),
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"), datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)), mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)), mdMinSizeBytes: Math.floor(
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)), 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)), 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), peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 20),
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240), syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"), simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"),
networkProfile: readEnvString("BENCH_NETWORK_PROFILE", "local-direct"), networkProfile: readEnvString("BENCH_NETWORK_PROFILE", "local-direct"),
networkModel: readEnvString("BENCH_NETWORK_MODEL", "local-runner-webrtc"), networkModel: readEnvString(
candidatePathVerification: readEnvString("BENCH_P2P_CANDIDATE_PATH_VERIFICATION", "not-collected"), "BENCH_NETWORK_MODEL",
"local-runner-webrtc",
),
candidatePathVerification: readEnvString(
"BENCH_P2P_CANDIDATE_PATH_VERIFICATION",
"not-collected",
),
measurementScope: readEnvString( measurementScope: readEnvString(
"BENCH_MEASUREMENT_SCOPE", "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." "One fresh CLI p2p-sync command, including process start-up and WebRTC connection establishment; the earlier peer-list observation command is excluded.",
), ),
limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [ limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [
"This benchmark result is scoped to the configured dataset, network model, and selected ICE path.", "This benchmark result is scoped to the configured dataset, network model, and selected ICE path.",
]), ]),
verificationMode: parseBenchmarkVerificationMode(Deno.env.get("BENCH_VERIFY_MODE")), verificationMode: parseBenchmarkVerificationMode(
Deno.env.get("BENCH_VERIFY_MODE"),
),
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)), repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)), repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
}; };
@@ -176,7 +202,9 @@ function readOptionalResultPath(): string | undefined {
return raw; return raw;
} }
async function readLatestP2PConnectionStats(statsPath: string): Promise<P2PConnectionStats | undefined> { async function readLatestP2PConnectionStats(
statsPath: string,
): Promise<P2PConnectionStats | undefined> {
try { try {
const text = await Deno.readTextFile(statsPath); const text = await Deno.readTextFile(statsPath);
const lines = text const lines = text
@@ -224,7 +252,7 @@ async function main(): Promise<void> {
config.appId, config.appId,
config.relay, config.relay,
"~.*", "~.*",
config.turnServers config.turnServers,
), ),
applyP2pSettings( applyP2pSettings(
clientSettings, clientSettings,
@@ -233,13 +261,21 @@ async function main(): Promise<void> {
config.appId, config.appId,
config.relay, config.relay,
"~.*", "~.*",
config.turnServers config.turnServers,
), ),
]); ]);
await Promise.all([ await Promise.all([
applyP2pTestTweaks(hostSettings, "p2p-bench-host", config.passphrase), applyP2pTestTweaks(
applyP2pTestTweaks(clientSettings, "p2p-bench-client", config.passphrase), hostSettings,
"p2p-bench-host",
config.passphrase,
),
applyP2pTestTweaks(
clientSettings,
"p2p-bench-client",
config.passphrase,
),
]); ]);
const seedFiles = await createDeterministicDataset({ const seedFiles = await createDeterministicDataset({
@@ -257,15 +293,25 @@ async function main(): Promise<void> {
await runCliOrFail(hostVault, "--settings", hostSettings, "mirror"); await runCliOrFail(hostVault, "--settings", hostSettings, "mirror");
const mirrorElapsed = nowMs() - mirrorStart; const mirrorElapsed = nowMs() - mirrorStart;
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host"); const host = startCliInBackground(
hostVault,
"--settings",
hostSettings,
"p2p-host",
);
try { try {
const hostReadyStart = nowMs(); const hostReadyStart = nowMs();
await host.waitUntilContains("P2P host is running", 20000); await host.waitUntilContains("P2P host is running", 20000);
const hostReadyElapsed = nowMs() - hostReadyStart; const hostReadyElapsed = nowMs() - hostReadyStart;
const peerDiscoveryCommandStart = nowMs(); const peerDiscoveryCommandStart = nowMs();
const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds); const peer = await discoverPeer(
const peerDiscoveryCommandElapsed = nowMs() - peerDiscoveryCommandStart; clientVault,
clientSettings,
config.peersTimeoutSeconds,
);
const peerDiscoveryCommandElapsed = nowMs() -
peerDiscoveryCommandStart;
const syncStart = nowMs(); const syncStart = nowMs();
await runCliOrFail( await runCliOrFail(
@@ -274,7 +320,7 @@ async function main(): Promise<void> {
clientSettings, clientSettings,
"p2p-sync", "p2p-sync",
peer.id, peer.id,
String(config.syncTimeoutSeconds) String(config.syncTimeoutSeconds),
); );
const syncElapsed = nowMs() - syncStart; const syncElapsed = nowMs() - syncStart;
@@ -282,24 +328,28 @@ async function main(): Promise<void> {
seedFiles.entries, seedFiles.entries,
config.verificationMode, config.verificationMode,
async (entry) => { async (entry) => {
const pulledPath = workDir.join(`pulled-${entry.relativePath.replaceAll("/", "_")}`); const pulledPath = workDir.join(
`pulled-${entry.relativePath.replaceAll("/", "_")}`,
);
await runCliOrFail( await runCliOrFail(
clientVault, clientVault,
"--settings", "--settings",
clientSettings, clientSettings,
"pull", "pull",
entry.relativePath, entry.relativePath,
pulledPath pulledPath,
); );
await assertFilesEqual( await assertFilesEqual(
entry.absolutePath, entry.absolutePath,
pulledPath, pulledPath,
`file mismatch after P2P sync: ${entry.relativePath}` `file mismatch after P2P sync: ${entry.relativePath}`,
); );
} },
); );
const p2pConnectionStats = await readLatestP2PConnectionStats(p2pStatsPath); const p2pConnectionStats = await readLatestP2PConnectionStats(
p2pStatsPath,
);
const result = { const result = {
caseName: config.caseName, caseName: config.caseName,
mode: "p2p-cli-benchmark", mode: "p2p-cli-benchmark",
@@ -313,15 +363,17 @@ async function main(): Promise<void> {
limitations: config.limitations, limitations: config.limitations,
repeatIndex: config.repeatIndex, repeatIndex: config.repeatIndex,
repeatCount: config.repeatCount, repeatCount: config.repeatCount,
p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true, p2pCandidatePathVerified:
p2pCandidatePathVerification: p2pConnectionStats?.candidatePathCollected p2pConnectionStats?.candidatePathCollected === true,
? "selected ICE candidate pair collected from RTCPeerConnection.getStats" p2pCandidatePathVerification:
: config.candidatePathVerification, p2pConnectionStats?.candidatePathCollected
? "selected ICE candidate pair collected from RTCPeerConnection.getStats"
: config.candidatePathVerification,
p2pCandidatePathNote: p2pConnectionStats?.candidatePathCollected 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." ? "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 : 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 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.", : "TURN is disabled, so a TURN-relayed path is not expected. The selected ICE candidate pair was not exported by this run.",
p2pConnectionStats, p2pConnectionStats,
appId: config.appId, appId: config.appId,
roomId: config.roomId, roomId: config.roomId,
@@ -337,25 +389,39 @@ async function main(): Promise<void> {
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)), hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)),
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds, peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
peerDiscoveryCommandElapsedMs: Number(peerDiscoveryCommandElapsed.toFixed(1)), peerDiscoveryCommandElapsedMs: Number(
peerDiscoveryCommandElapsed.toFixed(1),
),
peerDiscoveryNote: peerDiscoveryNote:
"p2p-peers waits for the requested timeout before printing discovered peers, so this is command duration, not first-peer latency.", "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)), syncElapsedMs: Number(syncElapsed.toFixed(1)),
throughputBytesPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2)), throughputBytesPerSec: Number(
throughputMiBPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024).toFixed(4)), (seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2),
),
throughputMiBPerSec: Number(
(seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024)
.toFixed(
4,
),
),
}; };
if (resultPath) { 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.log(JSON.stringify(result, null, 2));
console.error( console.error(
`[Benchmark] mirrored ${seedFiles.totalFiles} files (${formatBytes( `[Benchmark] mirrored ${seedFiles.totalFiles} files (${
seedFiles.totalBytes formatBytes(
)}) in ${formatMs(mirrorElapsed)}, ` + seedFiles.totalBytes,
)
}) in ${formatMs(mirrorElapsed)}, ` +
`synced in ${formatMs(syncElapsed)} ` + `synced in ${formatMs(syncElapsed)} ` +
`(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)` `(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`,
); );
} finally { } finally {
await host.stop(); await host.stop();
@@ -10,7 +10,9 @@ export type BenchmarkVerificationResult = {
}; };
function toHex(bytes: ArrayBuffer): string { function toHex(bytes: ArrayBuffer): string {
return [...new Uint8Array(bytes)].map((value) => value.toString(16).padStart(2, "0")).join(""); return [...new Uint8Array(bytes)]
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
} }
async function sha256(bytes: Uint8Array): Promise<string> { async function sha256(bytes: Uint8Array): Promise<string> {
@@ -21,7 +23,7 @@ async function sha256(bytes: Uint8Array): Promise<string> {
export function parseBenchmarkVerificationMode( export function parseBenchmarkVerificationMode(
raw: string | undefined, raw: string | undefined,
fallback: BenchmarkVerificationMode = "sample" fallback: BenchmarkVerificationMode = "sample",
): BenchmarkVerificationMode { ): BenchmarkVerificationMode {
const value = raw?.trim().toLowerCase(); const value = raw?.trim().toLowerCase();
if (!value) return fallback; if (!value) return fallback;
@@ -29,7 +31,10 @@ export function parseBenchmarkVerificationMode(
throw new Error(`BENCH_VERIFY_MODE must be 'all' or 'sample', got '${raw}'`); throw new Error(`BENCH_VERIFY_MODE must be 'all' or 'sample', got '${raw}'`);
} }
export function selectVerificationEntries(entries: DatasetEntry[], mode: BenchmarkVerificationMode): DatasetEntry[] { export function selectVerificationEntries(
entries: DatasetEntry[],
mode: BenchmarkVerificationMode,
): DatasetEntry[] {
if (mode === "all" || entries.length === 0) return [...entries]; if (mode === "all" || entries.length === 0) return [...entries];
const md = entries.find((entry) => entry.kind === "md"); const md = entries.find((entry) => entry.kind === "md");
@@ -43,11 +48,15 @@ export function selectVerificationEntries(entries: DatasetEntry[], mode: Benchma
return [...selected.values()]; return [...selected.values()];
} }
export async function computeDatasetDigestSha256(entries: DatasetEntry[]): Promise<string> { export async function computeDatasetDigestSha256(
entries: DatasetEntry[],
): Promise<string> {
const manifest: string[] = []; const manifest: string[] = [];
for (const entry of entries) { for (const entry of entries) {
const contentDigest = await sha256(await Deno.readFile(entry.absolutePath)); const contentDigest = await sha256(await Deno.readFile(entry.absolutePath));
manifest.push(`${entry.kind}\t${entry.relativePath}\t${entry.size}\t${contentDigest}`); manifest.push(
`${entry.kind}\t${entry.relativePath}\t${entry.size}\t${contentDigest}`,
);
} }
return await sha256(new TextEncoder().encode(manifest.join("\n"))); return await sha256(new TextEncoder().encode(manifest.join("\n")));
} }
@@ -55,7 +64,7 @@ export async function computeDatasetDigestSha256(entries: DatasetEntry[]): Promi
export async function verifyBenchmarkDataset( export async function verifyBenchmarkDataset(
entries: DatasetEntry[], entries: DatasetEntry[],
mode: BenchmarkVerificationMode, mode: BenchmarkVerificationMode,
verifyEntry: (entry: DatasetEntry) => Promise<void> verifyEntry: (entry: DatasetEntry) => Promise<void>,
): Promise<BenchmarkVerificationResult> { ): Promise<BenchmarkVerificationResult> {
const selected = selectVerificationEntries(entries, mode); const selected = selectVerificationEntries(entries, mode);
for (const entry of selected) { for (const entry of selected) {
@@ -1,7 +1,10 @@
import { assert, assertEquals, assertStringIncludes } from "@std/assert"; import { assert, assertEquals, assertStringIncludes } from "@std/assert";
import { type BenchmarkCase, buildCases } from "./bench-network-cases.ts"; import { type BenchmarkCase, buildCases } from "./bench-network-cases.ts";
import { startCouchdbProxy } from "./bench-couchdb.ts"; import { startCouchdbProxy } from "./bench-couchdb.ts";
import { parseBenchmarkVerificationMode, selectVerificationEntries } from "./helpers/benchmarkVerification.ts"; import {
parseBenchmarkVerificationMode,
selectVerificationEntries,
} from "./helpers/benchmarkVerification.ts";
import type { DatasetEntry } from "./helpers/dataset.ts"; import type { DatasetEntry } from "./helpers/dataset.ts";
function getFreePort(): number { function getFreePort(): number {
@@ -21,10 +24,20 @@ function getCase(cases: BenchmarkCase[], name: string): BenchmarkCase {
function parsedLimitations(testCase: BenchmarkCase): string[] { function parsedLimitations(testCase: BenchmarkCase): string[] {
const raw = testCase.env.BENCH_LIMITATIONS_JSON; const raw = testCase.env.BENCH_LIMITATIONS_JSON;
assert(raw, `${testCase.name} must pass BENCH_LIMITATIONS_JSON to benchmark result output`); assert(
raw,
`${testCase.name} must pass BENCH_LIMITATIONS_JSON to benchmark result output`,
);
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
assert(Array.isArray(parsed), `${testCase.name} limitations must be an array`); assert(
assert(parsed.every((item) => typeof item === "string" && item.trim().length > 0)); Array.isArray(parsed),
`${testCase.name} limitations must be an array`,
);
assert(
parsed.every((item) =>
typeof item === "string" && item.trim().length > 0
),
);
return parsed; return parsed;
} }
@@ -33,14 +46,36 @@ Deno.test("benchmark cases record scope and limitations for paper use", () => {
assert(cases.length > 0); assert(cases.length > 0);
for (const testCase of cases) { for (const testCase of cases) {
assert(testCase.description.trim().length > 0, `${testCase.name} must describe the case`); assert(
assert(testCase.dataPath.trim().length > 0, `${testCase.name} must describe the data path`); testCase.description.trim().length > 0,
assert(testCase.trustBoundary.trim().length > 0, `${testCase.name} must describe the trust boundary`); `${testCase.name} must describe the case`,
assert(testCase.measurementScope.trim().length > 0, `${testCase.name} must describe the measurement scope`); );
assert(testCase.limitations.length > 0, `${testCase.name} must list limitations`); assert(
assertEquals(testCase.env.BENCH_MEASUREMENT_SCOPE, testCase.measurementScope); 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(parsedLimitations(testCase), testCase.limitations);
assertEquals(testCase.env.BENCH_VERIFY_MODE, "all", `${testCase.name} must verify the complete dataset`); assertEquals(
testCase.env.BENCH_VERIFY_MODE,
"all",
`${testCase.name} must verify the complete dataset`,
);
} }
}); });
@@ -54,7 +89,7 @@ Deno.test("CouchDB latency proxy applies half the requested RTT in each directio
port: backendPort, port: backendPort,
onListen() {}, onListen() {},
}, },
() => new Response("ok") () => new Response("ok"),
); );
const proxy = startCouchdbProxy({ const proxy = startCouchdbProxy({
backendUri: `http://127.0.0.1:${backendPort}`, backendUri: `http://127.0.0.1:${backendPort}`,
@@ -108,22 +143,34 @@ Deno.test("benchmark verification mode selects either all files or a labelled sa
Deno.test("P2P signalling-shim cases do not claim to shape the note-data path", () => { Deno.test("P2P signalling-shim cases do not claim to shape the note-data path", () => {
const cases = buildCases(); const cases = buildCases();
for (const name of ["p2p-signalling-netem-home-wifi", "p2p-signalling-netem-tethering-vpn"]) { for (
const name of [
"p2p-signalling-netem-home-wifi",
"p2p-signalling-netem-tethering-vpn",
]
) {
const testCase = getCase(cases, name); const testCase = getCase(cases, name);
assertEquals(testCase.runner, "p2p"); assertEquals(testCase.runner, "p2p");
assertEquals(testCase.env.BENCH_TURN_SERVERS, ""); assertEquals(testCase.env.BENCH_TURN_SERVERS, "");
assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2"); assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2");
assertEquals(testCase.env.BENCH_NETWORK_MODEL, "compose-netem-signalling-shim"); assertEquals(
testCase.env.BENCH_NETWORK_MODEL,
"compose-netem-signalling-shim",
);
assertStringIncludes(testCase.dataPath, "WebRTC DataChannel"); assertStringIncludes(testCase.dataPath, "WebRTC DataChannel");
assertStringIncludes(testCase.dataPath, "Nostr signalling"); assertStringIncludes(testCase.dataPath, "Nostr signalling");
assertStringIncludes(testCase.measurementScope, "fresh CLI p2p-sync"); assertStringIncludes(testCase.measurementScope, "fresh CLI p2p-sync");
assert( assert(
testCase.limitations.some((limitation) => limitation.includes("connection establishment")), testCase.limitations.some((limitation) =>
`${name} must state that connection establishment is timed` limitation.includes("connection establishment")
),
`${name} must state that connection establishment is timed`,
); );
assert( assert(
testCase.limitations.some((limitation) => limitation.includes("does not shape the selected WebRTC")), testCase.limitations.some((limitation) =>
`${name} must avoid claiming that the P2P note-data path was shaped` limitation.includes("does not shape the selected WebRTC")
),
`${name} must avoid claiming that the P2P note-data path was shaped`,
); );
} }
}); });
@@ -135,31 +182,42 @@ Deno.test("placeholder and TURN cases are clearly non-evidence for broad P2P per
assertEquals(smartphone.env.BENCH_SIMULATION_TIER, "unmeasured"); assertEquals(smartphone.env.BENCH_SIMULATION_TIER, "unmeasured");
assertEquals(smartphone.env.BENCH_NETWORK_MODEL, "local-runner-no-netem"); assertEquals(smartphone.env.BENCH_NETWORK_MODEL, "local-runner-no-netem");
assert( assert(
smartphone.limitations.some((limitation) => limitation.includes("must not be reported as smartphone")), smartphone.limitations.some((limitation) =>
"smartphone/VPN placeholder must not be usable as field evidence by accident" 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"); const turn = getCase(cases, "p2p-user-turn");
assertStringIncludes(turn.env.BENCH_TURN_SERVERS, "turn:"); assertStringIncludes(turn.env.BENCH_TURN_SERVERS, "turn:");
assert( assert(
turn.limitations.some((limitation) => turn.limitations.some((limitation) =>
limitation.includes("does not prove that the selected ICE path was relayed") limitation.includes(
"does not prove that the selected ICE path was relayed",
)
), ),
"TURN case must require selected ICE candidate interpretation" "TURN case must require selected ICE candidate interpretation",
); );
}); });
Deno.test("CouchDB netem cases are marked as remote-store baselines", () => { Deno.test("CouchDB netem cases are marked as remote-store baselines", () => {
const cases = buildCases(); const cases = buildCases();
for (const name of ["couchdb-netem-home-wifi", "couchdb-netem-tethering-vpn"]) { for (
const name of ["couchdb-netem-home-wifi", "couchdb-netem-tethering-vpn"]
) {
const testCase = getCase(cases, name); const testCase = getCase(cases, name);
assertEquals(testCase.runner, "couchdb"); assertEquals(testCase.runner, "couchdb");
assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2"); assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2");
assertEquals(testCase.env.BENCH_NETWORK_MODEL, "compose-netem-tcp-shim"); assertEquals(
testCase.env.BENCH_NETWORK_MODEL,
"compose-netem-tcp-shim",
);
assertStringIncludes(testCase.measurementScope, "CouchDB"); assertStringIncludes(testCase.measurementScope, "CouchDB");
assert( assert(
testCase.limitations.some((limitation) => limitation.includes("not the WebRTC P2P data path")), testCase.limitations.some((limitation) =>
`${name} must remain scoped to the CouchDB remote-store path` limitation.includes("not the WebRTC P2P data path")
),
`${name} must remain scoped to the CouchDB remote-store path`,
); );
} }
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "livesync-webapp", "name": "livesync-webapp",
"private": true, "private": true,
"version": "0.25.83-webapp", "version": "0.25.82-webapp",
"type": "module", "type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API", "description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "webpeer", "name": "webpeer",
"private": true, "private": true,
"version": "0.25.83-webpeer", "version": "0.25.82-webpeer",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -30,8 +30,7 @@ export class ModuleConflictResolver extends AbstractModule {
private async _resolveConflictByDeletingRev( private async _resolveConflictByDeletingRev(
path: FilePathWithPrefix, path: FilePathWithPrefix,
deleteRevision: string, deleteRevision: string,
subTitle = "", subTitle = ""
showNotice = true
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> { ): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> {
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`; const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
if (!(await this.core.fileHandler.deleteRevisionFromDB(path, deleteRevision))) { if (!(await this.core.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
@@ -59,7 +58,7 @@ export class ModuleConflictResolver extends AbstractModule {
this._log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE); this._log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR; return MISSING_OR_ERROR;
} }
const level = subTitle.indexOf("same") !== -1 || !showNotice ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE; const level = subTitle.indexOf("same") !== -1 ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
this._log(`${path} has been merged automatically`, level); this._log(`${path} has been merged automatically`, level);
return AUTO_MERGED; return AUTO_MERGED;
} }
@@ -166,7 +165,7 @@ export class ModuleConflictResolver extends AbstractModule {
}); });
} }
private async _anyResolveConflictByNewest(filename: FilePathWithPrefix, showNotice = true): Promise<boolean> { private async _anyResolveConflictByNewest(filename: FilePathWithPrefix): Promise<boolean> {
const currentRev = await this.core.databaseFileAccess.fetchEntryMeta(filename, undefined, true); const currentRev = await this.core.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
if (currentRev == false) { if (currentRev == false) {
this._log(`Could not get current revision of ${filename}`); this._log(`Could not get current revision of ${filename}`);
@@ -204,7 +203,7 @@ export class ModuleConflictResolver extends AbstractModule {
this._log( this._log(
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}` `conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
); );
await this._resolveConflictByDeletingRev(filename, mTimeAndRev[i][1], "NEWEST", showNotice); await this.services.conflict.resolveByDeletingRevision(filename, mTimeAndRev[i][1], "NEWEST");
} }
return true; return true;
} }
@@ -215,14 +214,13 @@ export class ModuleConflictResolver extends AbstractModule {
let i = 0; let i = 0;
for (const file of files) { for (const file of files) {
i++; if (i++ % 10)
if (i % 10 === 0)
this._log( this._log(
`Check and Processing ${i} / ${files.length}`, `Check and Processing ${i} / ${files.length}`,
LOG_LEVEL_NOTICE, LOG_LEVEL_NOTICE,
"resolveAllConflictedFilesByNewerOnes" "resolveAllConflictedFilesByNewerOnes"
); );
await this._anyResolveConflictByNewest(file, false); await this.services.conflict.resolveByNewest(file);
} }
this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes"); this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
} }
@@ -1,118 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
DEFAULT_SETTINGS,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
type FilePathWithPrefix,
type MetaEntry,
} from "@lib/common/types";
import { ModuleConflictResolver } from "./ModuleConflictResolver";
function createModule(files: FilePathWithPrefix[] = []) {
const core = {
_services: {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
setting: {
saveSettingData: vi.fn(async () => undefined),
},
conflict: {
resolveByNewest: vi.fn(async () => true),
},
},
settings: DEFAULT_SETTINGS,
fileHandler: {
deleteRevisionFromDB: vi.fn(async () => true),
dbToStorage: vi.fn(async () => true),
},
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => []),
},
storageAccess: {
getFileNames: vi.fn(async () => files),
},
} as any;
Object.defineProperty(core, "services", { get: () => core._services });
const module = new ModuleConflictResolver(core);
module._log = vi.fn();
return { module };
}
describe("ModuleConflictResolver bulk newest resolution", () => {
it("retains the success notice for a non-bulk newest resolution", async () => {
const { module } = createModule();
const path = "example.md" as FilePathWithPrefix;
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
({
_id: "doc-id",
_rev: rev ?? "2-current",
path,
ctime: 1,
mtime: rev ? 1 : 2,
size: 0,
children: [],
type: "plain",
eden: {},
}) as unknown as MetaEntry
);
module.core.databaseFileAccess.getConflictedRevs = vi
.fn()
.mockResolvedValueOnce(["1-old"])
.mockResolvedValue([]);
await (module as any)._anyResolveConflictByNewest(path);
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_NOTICE);
});
it("logs a successful bulk newest resolution without displaying a notice", async () => {
const { module } = createModule();
const path = "example.md" as FilePathWithPrefix;
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
({
_id: "doc-id",
_rev: rev ?? "2-current",
path,
ctime: 1,
mtime: rev ? 1 : 2,
size: 0,
children: [],
type: "plain",
eden: {},
}) as unknown as MetaEntry
);
module.core.databaseFileAccess.getConflictedRevs = vi
.fn()
.mockResolvedValueOnce(["1-old"])
.mockResolvedValue([]);
await (module as any)._anyResolveConflictByNewest(path, false);
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_INFO);
});
it("updates notice-level progress once every ten checked files", async () => {
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
const { module } = createModule(files);
const resolveByNewest = vi.spyOn(module as any, "_anyResolveConflictByNewest").mockResolvedValue(true);
await (module as any)._resolveAllConflictedFilesByNewerOnes();
expect(resolveByNewest).toHaveBeenCalledTimes(11);
expect(resolveByNewest).toHaveBeenCalledWith(files[0], false);
expect(module._log).toHaveBeenCalledWith(
"Check and Processing 10 / 11",
LOG_LEVEL_NOTICE,
"resolveAllConflictedFilesByNewerOnes"
);
expect(module._log).toHaveBeenCalledTimes(3);
});
});
+2 -9
View File
@@ -5,18 +5,11 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid
## Unreleased ## Unreleased
## 0.25.83
16th July, 2026
Our plug-in continues to improve every day thanks to all of your contributions. We have finally resolved an issue first reported in 2023. Thank you for everything you contribute.
### Fixed ### Fixed
- Fixed the 📲 remote-activity indicator remaining visible after CouchDB requests had completed. - Fixed the 📲 remote-activity indicator remaining visible after CouchDB requests had completed.
- Fixed missing chunks being reported unavailable while an in-progress on-demand fetch or finite replication could still deliver them. Reads now follow the actual delivery lifecycle and recheck the local database when it finishes. - Fixed missing chunks being reported unavailable while an in-progress on-demand fetch or finite replication could still deliver them. Reads now follow the actual delivery lifecycle and recheck the local database when it finishes.
- Fixed an issue where changing only the letter case of a file name within the same directory could delete it on other devices when 'Handle files as Case-Sensitive' was disabled. Directory case changes remain unsupported (#198, PR #1014; [commonlib PR #68](https://github.com/vrtmrz/livesync-commonlib/pull/68)). Thank you to @metrovoc for the fix! - Fixed an issue where changing only the letter case of a file name within the same directory could delete it on other devices when 'Handle files as Case-Sensitive' was disabled. Directory case changes remain unsupported (#198).
- Fixed **Resolve All conflicted files by the newer one** displaying a separate success notice for every resolved file and updating its progress notice for nine out of every ten checked files. Successful per-file results are now logged, progress updates every ten files, and errors and non-bulk success notices remain visible (#1016, PR #1017). Thank you to @apple-ouyang for the fix!
### Improved ### Improved
@@ -30,7 +23,7 @@ Recently, I created a repository called Fancy Kit and have been trying to build
### Fixed ### Fixed
- Refreshed the remote Security Seed before each replication, preventing a client that remained open during a remote database rebuild from uploading data encrypted with the previous seed (#1018, PR #1019). Thank you to @apple-ouyang for the fix! - Refreshed the remote Security Seed before each replication, preventing a client that remained open during a remote database rebuild from uploading data encrypted with the previous seed (#1018).
- The P2P **Start Sync & Close** action now waits for synchronisation to settle before closing the dialogue, avoiding premature release of screen-awake protection while work remains in flight. - The P2P **Start Sync & Close** action now waits for synchronisation to settle before closing the dialogue, avoiding premature release of screen-awake protection while work remains in flight.
### Improved ### Improved
+2 -2
View File
@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync, writeSync } from "fs"; import { readFileSync, writeFileSync } from "fs";
const updatesPath = "updates.md"; const updatesPath = "updates.md";
@@ -8,7 +8,7 @@ const updatesPath = "updates.md";
// allowed. // allowed.
function fail(message) { function fail(message) {
writeSync(process.stderr.fd, `${message}\n`); console.error(message);
process.exit(1); process.exit(1);
} }
-64
View File
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { ensureTags } from "./release-tags.mjs";
const releaseNotesScript = fileURLToPath(new URL("./release-notes.mjs", import.meta.url)); const releaseNotesScript = fileURLToPath(new URL("./release-notes.mjs", import.meta.url));
const versionBumpScript = const versionBumpScript =
@@ -12,7 +11,6 @@ const versionBumpScript =
const workspaceUpdateScript = fileURLToPath(new URL("../update-workspaces.mjs", import.meta.url)); const workspaceUpdateScript = fileURLToPath(new URL("../update-workspaces.mjs", import.meta.url));
const prepareReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/prepare-release.yml", import.meta.url)); const prepareReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/prepare-release.yml", import.meta.url));
const finaliseReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/finalise-release.yml", import.meta.url)); const finaliseReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/finalise-release.yml", import.meta.url));
const releaseWorkflow = fileURLToPath(new URL("../.github/workflows/release.yml", import.meta.url));
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
afterEach(() => { afterEach(() => {
@@ -41,28 +39,6 @@ function runNode(script: string, args: string[], cwd: string, env: Record<string
}); });
} }
function createTagGit(expectedRevision: string, initialTags: Record<string, string> = {}) {
const tags = new Map(Object.entries(initialTags));
const git = (args: string[], allowMissing = false): string | undefined => {
if (args[0] === "rev-parse") {
const revision = args.at(-1);
if (revision === `${expectedRevision}^{commit}`) return expectedRevision;
const tagMatch = revision?.match(/^refs\/tags\/(.+)\^\{commit\}$/);
if (tagMatch) {
const commit = tags.get(tagMatch[1]);
if (commit !== undefined) return commit;
if (allowMissing) return undefined;
}
}
if (args[0] === "tag" && args.length === 3) {
tags.set(args[1], args[2]);
return "";
}
throw new Error(`Unexpected git command: ${args.join(" ")}`);
};
return { git, tags };
}
function createReleaseFixture(version = "0.25.81"): string { function createReleaseFixture(version = "0.25.81"): string {
const directory = makeTemporaryDirectory(); const directory = makeTemporaryDirectory();
writeJson(directory, "package.json", { version }); writeJson(directory, "package.json", { version });
@@ -161,50 +137,10 @@ describe("release workflow", () => {
const workflow = readFileSync(finaliseReleaseWorkflow, "utf8"); const workflow = readFileSync(finaliseReleaseWorkflow, "utf8");
expect(workflow).toContain("actions: write"); expect(workflow).toContain("actions: write");
expect(workflow).toContain('node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}"');
expect(workflow).toContain('git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli"');
expect(workflow).not.toContain("Tag already exists");
expect(workflow).toContain("gh workflow run release.yml"); expect(workflow).toContain("gh workflow run release.yml");
expect(workflow).toContain("gh workflow run cli-docker.yml"); expect(workflow).toContain("gh workflow run cli-docker.yml");
expect(workflow).toContain("dry_run=false"); expect(workflow).toContain("dry_run=false");
}); });
it("publishes only by explicit dispatch and validates the selected release", () => {
const workflow = readFileSync(releaseWorkflow, "utf8");
expect(workflow).not.toMatch(/^\s+push:/m);
expect(workflow).toContain("ref: ${{ inputs.tag }}");
expect(workflow).toContain('node utils/release-notes.mjs validate "${TAG}"');
expect(workflow).toContain('TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")"');
expect(workflow).not.toContain("Get Version");
});
});
describe("release tags", () => {
it("creates missing tags and accepts matching tags on retry", () => {
const head = "a".repeat(40);
const { git, tags } = createTagGit(head);
const messages: string[] = [];
ensureTags("0.25.84", head, git, (message) => messages.push(message));
expect(tags.get("0.25.84")).toBe(head);
expect(tags.get("0.25.84-cli")).toBe(head);
ensureTags("0.25.84", head, git, (message) => messages.push(message));
expect(messages).toContain(`Tag 0.25.84 already points to the expected commit ${head}.`);
expect(messages).toContain(`Tag 0.25.84-cli already points to the expected commit ${head}.`);
});
it("rejects an existing release tag that points to another commit without creating missing tags", () => {
const previousHead = "b".repeat(40);
const expectedHead = "a".repeat(40);
const { git, tags } = createTagGit(expectedHead, { "0.25.84-cli": previousHead });
expect(() => ensureTags("0.25.84", expectedHead, git)).toThrow(
`Tag 0.25.84-cli points to ${previousHead}; expected ${expectedHead}.`
);
expect(tags.has("0.25.84")).toBe(false);
});
}); });
describe("version bump", () => { describe("version bump", () => {
-73
View File
@@ -1,73 +0,0 @@
import { spawnSync } from "node:child_process";
import { writeSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
function fail(message) {
writeSync(process.stderr.fd, `${message}\n`);
process.exit(1);
}
function assertVersion(version) {
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Invalid release version: ${version}`);
}
}
function git(args, allowMissing = false) {
const result = spawnSync("git", args, { encoding: "utf8" });
if (allowMissing && result.status === 1 && result.stdout === "" && result.stderr === "") {
return undefined;
}
if (result.error) {
throw new Error(`Could not run git ${args.join(" ")}: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(result.stderr.trim() || `git ${args.join(" ")} exited with status ${result.status}.`);
}
return result.stdout.trim();
}
function resolveCommit(revision, runGit) {
return runGit(["rev-parse", "--verify", `${revision}^{commit}`]);
}
function resolveTag(tag, runGit) {
return runGit(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`], true);
}
export function ensureTags(version, expectedRevision, runGit = git, log = console.log) {
assertVersion(version);
const expectedCommit = resolveCommit(expectedRevision, runGit);
const tags = [version, `${version}-cli`];
const existing = tags.map((tag) => ({ tag, commit: resolveTag(tag, runGit) }));
for (const { tag, commit } of existing) {
if (commit !== undefined && commit !== expectedCommit) {
throw new Error(`Tag ${tag} points to ${commit}; expected ${expectedCommit}.`);
}
}
for (const { tag, commit } of existing) {
if (commit === undefined) {
runGit(["tag", tag, expectedCommit]);
log(`Created tag ${tag} at ${expectedCommit}.`);
} else {
log(`Tag ${tag} already points to the expected commit ${expectedCommit}.`);
}
}
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
if (isMain) {
const [command, version, expectedRevision] = process.argv.slice(2);
if (command !== "ensure" || !version || !expectedRevision) {
fail("Usage: node utils/release-tags.mjs ensure <version> <expected-commit>");
}
try {
ensureTags(version, expectedRevision);
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
}
+1 -2
View File
@@ -4,6 +4,5 @@
"1.0.1": "0.9.12", "1.0.1": "0.9.12",
"1.0.0": "0.9.7", "1.0.0": "0.9.7",
"0.25.81": "1.7.2", "0.25.81": "1.7.2",
"0.25.82": "1.7.2", "0.25.82": "1.7.2"
"0.25.83": "1.7.2"
} }