From 3a1a8948d231ef32dc077dbcb2de86e54c231c14 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 17 Jul 2026 01:05:29 +0000 Subject: [PATCH] Normalise tracked text files and formatter scope --- .dockerignore | 60 ++-- .gitattributes | 6 +- .prettierignore | 3 +- docs/setup_own_server_cn.md | 304 +++++++++--------- src/apps/_test/storageAdapterContract.ts | 15 +- src/apps/cli/.gitignore | 16 +- src/apps/cli/Dockerfile | 222 ++++++------- src/apps/cli/testdeno/bench-couchdb.ts | 170 +++------- src/apps/cli/testdeno/bench-latency-sweep.ts | 7 +- src/apps/cli/testdeno/bench-network-cases.ts | 126 ++------ src/apps/cli/testdeno/bench-p2p.ts | 144 +++------ .../testdeno/helpers/benchmarkVerification.ts | 21 +- .../cli/testdeno/test-benchmark-contract.ts | 110 ++----- 13 files changed, 474 insertions(+), 730 deletions(-) diff --git a/.dockerignore b/.dockerignore index ec80feb0..ecc5fbaf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,33 +1,33 @@ -# Git history -.git/ -.gitignore - -# Dependencies — re-installed inside Docker -node_modules/ -src/apps/cli/node_modules/ - -# Pre-built CLI output — rebuilt inside Docker -src/apps/cli/dist/ - -# Obsidian plugin build outputs -main.js -main_org.js -pouchdb-browser.js -production/ - -# Test coverage and reports +# Git history +.git/ +.gitignore + +# Dependencies — re-installed inside Docker +node_modules/ +src/apps/cli/node_modules/ + +# Pre-built CLI output — rebuilt inside Docker +src/apps/cli/dist/ + +# Obsidian plugin build outputs +main.js +main_org.js +pouchdb-browser.js +production/ + +# Test coverage and reports coverage/ test/bench-network/bench-results/ src/apps/cli/testdeno/bench-results/ - -# Local environment / secrets -.env -*.env -.test.env - -# local config files -*.local - -# OS artefacts -.DS_Store -Thumbs.db + +# Local environment / secrets +.env +*.env +.test.env + +# local config files +*.local + +# OS artefacts +.DS_Store +Thumbs.db diff --git a/.gitattributes b/.gitattributes index 61460f30..34862a3b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,6 +13,11 @@ *.mjs 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 *.png binary *.jpg binary @@ -21,4 +26,3 @@ *.ico binary *.woff2 binary *.woff binary -*.sh text eol=lf diff --git a/.prettierignore b/.prettierignore index a4a95572..0802ff9a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ pouchdb-browser.js main_org.js main.js -_types/** \ No newline at end of file +_types/** +src/lib/** diff --git a/docs/setup_own_server_cn.md b/docs/setup_own_server_cn.md index 1c88ed61..809c02e3 100644 --- a/docs/setup_own_server_cn.md +++ b/docs/setup_own_server_cn.md @@ -1,152 +1,152 @@ -# 在你自己的服务器上设置 CouchDB - -## 目录 -- [配置 CouchDB](#配置-CouchDB) -- [运行 CouchDB](#运行-CouchDB) - - [Docker CLI](#docker-cli) - - [Docker Compose](#docker-compose) -- [创建数据库](#创建数据库) -- [从移动设备访问](#从移动设备访问) - - [移动设备测试](#移动设备测试) - - [设置你的域名](#设置你的域名) ---- - -> 注:提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。推荐直接使用该 docker-compose 配置进行搭建。(若使用,请查阅链接中的文档,而不是这个文档) - -## 配置 CouchDB - -设置 CouchDB 的最简单方法是使用 [CouchDB docker image]((https://hub.docker.com/_/couchdb)). - -需要修改一些 `local.ini` 中的配置,以让它可以用于 Self-hosted LiveSync,如下: - -``` -[couchdb] -single_node=true -max_document_size = 50000000 - -[chttpd] -require_valid_user = true -max_http_request_size = 4294967296 - -[chttpd_auth] -require_valid_user = true -authentication_redirect = /_utils/session.html - -[httpd] -WWW-Authenticate = Basic realm="couchdb" -enable_cors = true - -[cors] -origins = app://obsidian.md,capacitor://localhost,http://localhost -credentials = true -headers = accept, authorization, content-type, origin, referer -methods = GET, PUT, POST, HEAD, DELETE -max_age = 3600 -``` - -## 运行 CouchDB - -### Docker CLI - -你可以通过指定 `local.ini` 配置运行 CouchDB: - -``` -$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb -``` -*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径* - -后台运行: -``` -$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb -``` -*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径* - -### Docker Compose -创建一个文件夹, 将你的 `local.ini` 放在文件夹内, 然后在文件夹内创建 `docker-compose.yml`. 请确保对 `local.ini` 有读写权限并且确保在容器运行后能创建 `data` 文件夹. 文件夹结构大概如下: -``` -obsidian-livesync -├── docker-compose.yml -└── local.ini -``` - -可以参照以下内容编辑 `docker-compose.yml`: -```yaml -services: - couchdb: - image: couchdb - container_name: obsidian-livesync - user: 1000:1000 - environment: - - COUCHDB_USER=admin - - COUCHDB_PASSWORD=password - volumes: - - ./data:/opt/couchdb/data - - ./local.ini:/opt/couchdb/etc/local.ini - ports: - - 5984:5984 - restart: unless-stopped -``` - -最后, 创建并启动容器: -``` -# -d will launch detached so the container runs in background -docker-compose up -d -``` - -## 创建数据库 - -CouchDB 部署成功后, 需要手动创建一个数据库, 方便插件连接并同步. - -1. 访问 `http://localhost:5984/_utils`, 输入帐号密码后进入管理页面 -2. 点击 Create Database, 然后根据个人喜好创建数据库 - -## 从移动设备访问 -如果你想要从移动设备访问 Self-hosted LiveSync,你需要一个合法的 SSL 证书。 - -### 移动设备测试 -测试时,[localhost.run](http://localhost.run/) 这一类的反向隧道服务很实用。(非必须,只是用于终端设备不方便 ssh 的时候的备选方案) - -``` -$ ssh -R 80:localhost:5984 nokey@localhost.run -Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts. - -=============================================================================== -Welcome to localhost.run! - -Follow your favourite reverse tunnel at [https://twitter.com/localhost_run]. - -**You need a SSH key to access this service.** -If you get a permission denied follow Gitlab's most excellent howto: -https://docs.gitlab.com/ee/ssh/ -*Only rsa and ed25519 keys are supported* - -To set up and manage custom domains go to https://admin.localhost.run/ - -More details on custom domains (and how to enable subdomains of your custom -domain) at https://localhost.run/docs/custom-domains - -To explore using localhost.run visit the documentation site: -https://localhost.run/docs/ - -=============================================================================== - - -** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. ** - -xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run -Connection to localhost.run closed by remote host. -Connection to localhost.run closed. -``` - -https://xxxxxxxx.localhost.run 即为临时服务器地址。 - -### 设置你的域名 - -设置一个指向你服务器的 A 记录,并根据需要设置反向代理。 - -Note: 不推荐将 CouchDB 挂载到根目录 -可以使用 Caddy 很方便的给服务器加上 SSL 功能 - -提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。 - -注意检查服务器日志,当心恶意访问。 +# 在你自己的服务器上设置 CouchDB + +## 目录 +- [配置 CouchDB](#配置-CouchDB) +- [运行 CouchDB](#运行-CouchDB) + - [Docker CLI](#docker-cli) + - [Docker Compose](#docker-compose) +- [创建数据库](#创建数据库) +- [从移动设备访问](#从移动设备访问) + - [移动设备测试](#移动设备测试) + - [设置你的域名](#设置你的域名) +--- + +> 注:提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。推荐直接使用该 docker-compose 配置进行搭建。(若使用,请查阅链接中的文档,而不是这个文档) + +## 配置 CouchDB + +设置 CouchDB 的最简单方法是使用 [CouchDB docker image]((https://hub.docker.com/_/couchdb)). + +需要修改一些 `local.ini` 中的配置,以让它可以用于 Self-hosted LiveSync,如下: + +``` +[couchdb] +single_node=true +max_document_size = 50000000 + +[chttpd] +require_valid_user = true +max_http_request_size = 4294967296 + +[chttpd_auth] +require_valid_user = true +authentication_redirect = /_utils/session.html + +[httpd] +WWW-Authenticate = Basic realm="couchdb" +enable_cors = true + +[cors] +origins = app://obsidian.md,capacitor://localhost,http://localhost +credentials = true +headers = accept, authorization, content-type, origin, referer +methods = GET, PUT, POST, HEAD, DELETE +max_age = 3600 +``` + +## 运行 CouchDB + +### Docker CLI + +你可以通过指定 `local.ini` 配置运行 CouchDB: + +``` +$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb +``` +*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径* + +后台运行: +``` +$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb +``` +*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径* + +### Docker Compose +创建一个文件夹, 将你的 `local.ini` 放在文件夹内, 然后在文件夹内创建 `docker-compose.yml`. 请确保对 `local.ini` 有读写权限并且确保在容器运行后能创建 `data` 文件夹. 文件夹结构大概如下: +``` +obsidian-livesync +├── docker-compose.yml +└── local.ini +``` + +可以参照以下内容编辑 `docker-compose.yml`: +```yaml +services: + couchdb: + image: couchdb + container_name: obsidian-livesync + user: 1000:1000 + environment: + - COUCHDB_USER=admin + - COUCHDB_PASSWORD=password + volumes: + - ./data:/opt/couchdb/data + - ./local.ini:/opt/couchdb/etc/local.ini + ports: + - 5984:5984 + restart: unless-stopped +``` + +最后, 创建并启动容器: +``` +# -d will launch detached so the container runs in background +docker-compose up -d +``` + +## 创建数据库 + +CouchDB 部署成功后, 需要手动创建一个数据库, 方便插件连接并同步. + +1. 访问 `http://localhost:5984/_utils`, 输入帐号密码后进入管理页面 +2. 点击 Create Database, 然后根据个人喜好创建数据库 + +## 从移动设备访问 +如果你想要从移动设备访问 Self-hosted LiveSync,你需要一个合法的 SSL 证书。 + +### 移动设备测试 +测试时,[localhost.run](http://localhost.run/) 这一类的反向隧道服务很实用。(非必须,只是用于终端设备不方便 ssh 的时候的备选方案) + +``` +$ ssh -R 80:localhost:5984 nokey@localhost.run +Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts. + +=============================================================================== +Welcome to localhost.run! + +Follow your favourite reverse tunnel at [https://twitter.com/localhost_run]. + +**You need a SSH key to access this service.** +If you get a permission denied follow Gitlab's most excellent howto: +https://docs.gitlab.com/ee/ssh/ +*Only rsa and ed25519 keys are supported* + +To set up and manage custom domains go to https://admin.localhost.run/ + +More details on custom domains (and how to enable subdomains of your custom +domain) at https://localhost.run/docs/custom-domains + +To explore using localhost.run visit the documentation site: +https://localhost.run/docs/ + +=============================================================================== + + +** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. ** + +xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run +Connection to localhost.run closed by remote host. +Connection to localhost.run closed. +``` + +https://xxxxxxxx.localhost.run 即为临时服务器地址。 + +### 设置你的域名 + +设置一个指向你服务器的 A 记录,并根据需要设置反向代理。 + +Note: 不推荐将 CouchDB 挂载到根目录 +可以使用 Caddy 很方便的给服务器加上 SSL 功能 + +提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。 + +注意检查服务器日志,当心恶意访问。 diff --git a/src/apps/_test/storageAdapterContract.ts b/src/apps/_test/storageAdapterContract.ts index 5693e198..20876c72 100644 --- a/src/apps/_test/storageAdapterContract.ts +++ b/src/apps/_test/storageAdapterContract.ts @@ -87,7 +87,10 @@ export const storageAdapterContractCases: readonly StorageAdapterContractCase[] 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.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"); @@ -101,8 +104,14 @@ export const storageAdapterContractCases: readonly StorageAdapterContractCase[] 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"); + 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" + ); }, }, ]; diff --git a/src/apps/cli/.gitignore b/src/apps/cli/.gitignore index e775b86b..2a77ce13 100644 --- a/src/apps/cli/.gitignore +++ b/src/apps/cli/.gitignore @@ -1,9 +1,9 @@ -.livesync -test/* -!test/*.sh -test/test-init.local.sh -node_modules -.*.json -*.env -!.test.env +.livesync +test/* +!test/*.sh +test/test-init.local.sh +node_modules +.*.json +*.env +!.test.env bench-results \ No newline at end of file diff --git a/src/apps/cli/Dockerfile b/src/apps/cli/Dockerfile index bf6b9bf7..d8c2856f 100644 --- a/src/apps/cli/Dockerfile +++ b/src/apps/cli/Dockerfile @@ -1,111 +1,111 @@ -# syntax=docker/dockerfile:1 -# -# Self-hosted LiveSync CLI — Docker image -# -# Build (from the repository root): -# docker build -f src/apps/cli/Dockerfile -t livesync-cli . -# -# Run: -# docker run --rm -v /path/to/your/vault:/data livesync-cli sync -# docker run --rm -v /path/to/your/vault:/data livesync-cli ls -# docker run --rm -v /path/to/your/vault:/data livesync-cli init-settings -# docker run --rm -v /path/to/your/vault:/data livesync-cli --help -# -# The first positional argument (database-path) is automatically set to /data. -# Mount your vault at /data, or override with: -e LIVESYNC_DB_PATH=/other/path -# -# P2P (WebRTC) networking — important notes -# ----------------------------------------- -# The P2P replicator (p2p-host / p2p-sync / p2p-peers) uses WebRTC, which -# generates ICE candidates of three kinds: -# -# host — the container's bridge IP (172.17.x.x). Unreachable from outside -# the Docker bridge, so LAN peers cannot connect via this candidate. -# srflx — the host's public IP, obtained via STUN reflection. Works fine -# over the internet even with the default bridge network. -# relay — traffic relayed through a TURN server. Always reachable regardless -# of network mode. -# -# Recommended network modes per use-case: -# -# LAN P2P (Linux only) -# docker run --network host ... -# This exposes the real host IP as the 'host' candidate so LAN peers can -# connect directly. --network host is not available on Docker Desktop for -# macOS or Windows. -# -# LAN P2P (macOS / Windows Docker Desktop) -# Configure a TURN server in settings (P2P_turnServers / P2P_turnUsername / -# P2P_turnCredential). All data is then relayed through the TURN server, -# bypassing the bridge-network limitation. -# -# Internet P2P -# Default bridge network is sufficient; the srflx candidate carries the -# host's public IP and peers can connect normally. -# -# CouchDB sync only (no P2P) -# Default bridge network. No special configuration required. - -# ───────────────────────────────────────────────────────────────────────────── -# Stage 1 — builder -# Full Node.js environment to compile native modules and bundle the CLI. -# ───────────────────────────────────────────────────────────────────────────── -FROM node:22-slim AS builder - -# Build tools required by native Node.js addons (mainly leveldown) -RUN apt-get update \ - && apt-get install -y --no-install-recommends python3 make g++ \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /build - -# Install workspace dependencies first (layer-cache friendly) -COPY package.json package-lock.json ./ -RUN npm install - -# Copy the full source tree and build the CLI bundle -COPY . . -RUN cd src/apps/cli && npm run build - -# ───────────────────────────────────────────────────────────────────────────── -# Stage 2 — runtime-deps -# Install only the external (unbundled) packages that the CLI requires at -# runtime. Native addons are compiled here against the same base image that -# the final runtime stage uses. -# ───────────────────────────────────────────────────────────────────────────── -FROM node:22-slim AS runtime-deps - -# Build tools required to compile native addons -RUN apt-get update \ - && apt-get install -y --no-install-recommends python3 make g++ \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /deps - -# package.json lists only the packages that the CLI requires -COPY src/apps/cli/package.json ./package.json -RUN npm install --omit=dev - -# ───────────────────────────────────────────────────────────────────────────── -# Stage 3 — runtime -# Minimal image: pre-compiled native modules + CLI bundle only. -# No build tools are included, keeping the image small. -# ───────────────────────────────────────────────────────────────────────────── -FROM node:22-slim - -WORKDIR /app - -# Copy pre-compiled external node_modules from runtime-deps stage -COPY --from=runtime-deps /deps/node_modules ./node_modules - -# Copy the built CLI bundle from builder stage -COPY --from=builder /build/src/apps/cli/dist ./dist - -# Install entrypoint wrapper -COPY src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli -RUN chmod +x /usr/local/bin/livesync-cli - -# Mount your vault / local database directory here -VOLUME ["/data"] - -ENTRYPOINT ["livesync-cli"] +# syntax=docker/dockerfile:1 +# +# Self-hosted LiveSync CLI — Docker image +# +# Build (from the repository root): +# docker build -f src/apps/cli/Dockerfile -t livesync-cli . +# +# Run: +# docker run --rm -v /path/to/your/vault:/data livesync-cli sync +# docker run --rm -v /path/to/your/vault:/data livesync-cli ls +# docker run --rm -v /path/to/your/vault:/data livesync-cli init-settings +# docker run --rm -v /path/to/your/vault:/data livesync-cli --help +# +# The first positional argument (database-path) is automatically set to /data. +# Mount your vault at /data, or override with: -e LIVESYNC_DB_PATH=/other/path +# +# P2P (WebRTC) networking — important notes +# ----------------------------------------- +# The P2P replicator (p2p-host / p2p-sync / p2p-peers) uses WebRTC, which +# generates ICE candidates of three kinds: +# +# host — the container's bridge IP (172.17.x.x). Unreachable from outside +# the Docker bridge, so LAN peers cannot connect via this candidate. +# srflx — the host's public IP, obtained via STUN reflection. Works fine +# over the internet even with the default bridge network. +# relay — traffic relayed through a TURN server. Always reachable regardless +# of network mode. +# +# Recommended network modes per use-case: +# +# LAN P2P (Linux only) +# docker run --network host ... +# This exposes the real host IP as the 'host' candidate so LAN peers can +# connect directly. --network host is not available on Docker Desktop for +# macOS or Windows. +# +# LAN P2P (macOS / Windows Docker Desktop) +# Configure a TURN server in settings (P2P_turnServers / P2P_turnUsername / +# P2P_turnCredential). All data is then relayed through the TURN server, +# bypassing the bridge-network limitation. +# +# Internet P2P +# Default bridge network is sufficient; the srflx candidate carries the +# host's public IP and peers can connect normally. +# +# CouchDB sync only (no P2P) +# Default bridge network. No special configuration required. + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 1 — builder +# Full Node.js environment to compile native modules and bundle the CLI. +# ───────────────────────────────────────────────────────────────────────────── +FROM node:22-slim AS builder + +# Build tools required by native Node.js addons (mainly leveldown) +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +# Install workspace dependencies first (layer-cache friendly) +COPY package.json package-lock.json ./ +RUN npm install + +# Copy the full source tree and build the CLI bundle +COPY . . +RUN cd src/apps/cli && npm run build + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 2 — runtime-deps +# Install only the external (unbundled) packages that the CLI requires at +# runtime. Native addons are compiled here against the same base image that +# the final runtime stage uses. +# ───────────────────────────────────────────────────────────────────────────── +FROM node:22-slim AS runtime-deps + +# Build tools required to compile native addons +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /deps + +# package.json lists only the packages that the CLI requires +COPY src/apps/cli/package.json ./package.json +RUN npm install --omit=dev + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 3 — runtime +# Minimal image: pre-compiled native modules + CLI bundle only. +# No build tools are included, keeping the image small. +# ───────────────────────────────────────────────────────────────────────────── +FROM node:22-slim + +WORKDIR /app + +# Copy pre-compiled external node_modules from runtime-deps stage +COPY --from=runtime-deps /deps/node_modules ./node_modules + +# Copy the built CLI bundle from builder stage +COPY --from=builder /build/src/apps/cli/dist ./dist + +# Install entrypoint wrapper +COPY src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli +RUN chmod +x /usr/local/bin/livesync-cli + +# Mount your vault / local database directory here +VOLUME ["/data"] + +ENTRYPOINT ["livesync-cli"] diff --git a/src/apps/cli/testdeno/bench-couchdb.ts b/src/apps/cli/testdeno/bench-couchdb.ts index dfe78aa1..abb133ff 100644 --- a/src/apps/cli/testdeno/bench-couchdb.ts +++ b/src/apps/cli/testdeno/bench-couchdb.ts @@ -1,17 +1,8 @@ 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 { createCouchdbDatabase, startCouchdb, stopCouchdb } from "./helpers/docker.ts"; +import { createDeterministicDataset } from "./helpers/dataset.ts"; import { type BenchmarkVerificationMode, parseBenchmarkVerificationMode, @@ -81,10 +72,7 @@ function readEnvStringArray(name: string, fallback: string[]): string[] { try { 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; } } catch { @@ -119,60 +107,34 @@ 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", - ), + 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.", + "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"), - ), + verificationMode: parseBenchmarkVerificationMode(Deno.env.get("BENCH_VERIFY_MODE")), repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)), repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)), }; @@ -193,20 +155,17 @@ export type CouchdbProxyHandle = { directionalDelayMs: number; }; -export function startCouchdbProxy( - options: { - backendUri: string; - proxyUri: string; - requestedRttMs: number; - delay?: (milliseconds: number) => Promise; - }, -): CouchdbProxyHandle { +export function startCouchdbProxy(options: { + backendUri: string; + proxyUri: string; + requestedRttMs: number; + delay?: (milliseconds: number) => Promise; +}): CouchdbProxyHandle { 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((resolve) => setTimeout(resolve, milliseconds))); + const delay = + options.delay ?? ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))); const controller = new AbortController(); const listener = Deno.serve( @@ -256,14 +215,13 @@ export function startCouchdbProxy( 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 request-path and ${halfDelayMs}ms response-path delay`, stop: async () => { controller.abort(); await listener.finished.catch(() => {}); @@ -287,21 +245,14 @@ async function main(): Promise { await initSettingsFile(settingsB); if (config.managedCouchdb) { - await startCouchdb( - config.couchdbBackendUri, - config.couchdbUser, - config.couchdbPassword, - config.couchdbDbname, - ); + await startCouchdb(config.couchdbBackendUri, config.couchdbUser, config.couchdbPassword, config.couchdbDbname); } else { - console.log( - `[INFO] using externally managed CouchDB: ${config.couchdbBackendUri}`, - ); + console.log(`[INFO] using externally managed CouchDB: ${config.couchdbBackendUri}`); await createCouchdbDatabase( config.couchdbBackendUri, config.couchdbUser, config.couchdbPassword, - config.couchdbDbname, + config.couchdbDbname ); } @@ -356,28 +307,15 @@ async function main(): Promise { 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 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 result = { caseName: config.caseName, @@ -409,40 +347,24 @@ async function main(): Promise { 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(); diff --git a/src/apps/cli/testdeno/bench-latency-sweep.ts b/src/apps/cli/testdeno/bench-latency-sweep.ts index 3624b1d5..dfd597f6 100644 --- a/src/apps/cli/testdeno/bench-latency-sweep.ts +++ b/src/apps/cli/testdeno/bench-latency-sweep.ts @@ -65,9 +65,7 @@ async function runBenchmark(options: { repeatIndex: number; repeatCount: number; }): Promise> { - 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 env = { ...Deno.env.toObject(), @@ -156,8 +154,7 @@ async function main(): Promise { 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.", + 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, diff --git a/src/apps/cli/testdeno/bench-network-cases.ts b/src/apps/cli/testdeno/bench-network-cases.ts index 5795800b..e10978ea 100644 --- a/src/apps/cli/testdeno/bench-network-cases.ts +++ b/src/apps/cli/testdeno/bench-network-cases.ts @@ -27,26 +27,16 @@ 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()) - }` + `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-` + + `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}` ); } function buildBaseEnv(): Record { 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_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"), @@ -59,7 +49,7 @@ function buildBaseEnv(): Record { function withScopeEnv( env: Record, - options: Pick, + options: Pick ): Record { return { ...env, @@ -79,25 +69,15 @@ 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/", - ); + 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.", + description: "Standard self-hosted CouchDB path through a local latency proxy.", dataPath: "Device A -> CouchDB -> Device B", trustBoundary: "CouchDB operator and network path", measurementScope: @@ -115,8 +95,7 @@ export function buildCases(): BenchmarkCase[] { defineCase({ name: "p2p-direct-local", 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", trustBoundary: "Nostr relay for signalling metadata; no TURN relay", measurementScope: @@ -133,8 +112,7 @@ export function buildCases(): BenchmarkCase[] { 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", + BENCH_P2P_CANDIDATE_PATH_VERIFICATION: "turn-disabled-but-selected-ice-pair-not-collected", }, }), defineCase({ @@ -142,8 +120,7 @@ export function buildCases(): BenchmarkCase[] { 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", + 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.", @@ -160,10 +137,8 @@ export function buildCases(): BenchmarkCase[] { 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", + 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.", @@ -184,12 +159,9 @@ export function buildCases(): BenchmarkCase[] { 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", + 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: [ @@ -211,10 +183,8 @@ export function buildCases(): BenchmarkCase[] { 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", + 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: [ @@ -237,10 +207,8 @@ export function buildCases(): BenchmarkCase[] { 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", + 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: [ @@ -265,8 +233,7 @@ export function buildCases(): BenchmarkCase[] { 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", + 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: @@ -291,8 +258,7 @@ export function buildCases(): BenchmarkCase[] { defineCase({ name: "p2p-user-turn", 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", trustBoundary: "User-controlled TURN server", measurementScope: @@ -319,11 +285,9 @@ async function runCase( testCase: BenchmarkCase, outputDir: string, repeatIndex: number, - repeatCount: number, + repeatCount: number ): Promise> { - 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 taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb"; const env = { @@ -334,12 +298,8 @@ async function runCase( BENCH_REPEAT_COUNT: String(repeatCount), }; - const repeatLabel = repeatCount > 1 - ? ` (${repeatIndex}/${repeatCount})` - : ""; - console.log( - `[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`, - ); + 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, @@ -355,10 +315,7 @@ async function runCase( 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; return { ...testCase, repeatIndex, @@ -369,10 +326,7 @@ async function runCase( } 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 .split(",") .map((v) => v.trim()) @@ -382,9 +336,7 @@ function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] { const found = byName.get(name); if (!found) { 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; @@ -392,10 +344,7 @@ function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] { } async function main(): Promise { - 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()}`; await Deno.mkdir(outputDir, { recursive: true }); @@ -412,16 +361,14 @@ async function main(): Promise { availableCases: allCases, }, null, - 2, - ), + 2 + ) ); const results: Record[] = []; for (const testCase of cases) { for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) { - results.push( - await runCase(testCase, outputDir, repeatIndex, repeatCount), - ); + results.push(await runCase(testCase, outputDir, repeatIndex, repeatCount)); } } @@ -431,10 +378,7 @@ async function main(): Promise { repeatCount, 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(`[bench-cases] result directory: ${outputDir}`); } diff --git a/src/apps/cli/testdeno/bench-p2p.ts b/src/apps/cli/testdeno/bench-p2p.ts index 05512694..535d3f4e 100644 --- a/src/apps/cli/testdeno/bench-p2p.ts +++ b/src/apps/cli/testdeno/bench-p2p.ts @@ -1,9 +1,5 @@ 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, @@ -13,9 +9,7 @@ import { stopLocalRelayIfStarted, } from "./helpers/p2p.ts"; import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; -import { - createDeterministicDataset, -} from "./helpers/dataset.ts"; +import { createDeterministicDataset } from "./helpers/dataset.ts"; import { type BenchmarkVerificationMode, parseBenchmarkVerificationMode, @@ -107,10 +101,7 @@ function readEnvStringArray(name: string, fallback: string[]): string[] { try { 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; } } catch { @@ -147,48 +138,31 @@ 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", - ), + 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.", + "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"), - ), + 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,9 +176,7 @@ function readOptionalResultPath(): string | undefined { return raw; } -async function readLatestP2PConnectionStats( - statsPath: string, -): Promise { +async function readLatestP2PConnectionStats(statsPath: string): Promise { try { const text = await Deno.readTextFile(statsPath); const lines = text @@ -252,7 +224,7 @@ async function main(): Promise { config.appId, config.relay, "~.*", - config.turnServers, + config.turnServers ), applyP2pSettings( clientSettings, @@ -261,21 +233,13 @@ async function main(): Promise { config.appId, config.relay, "~.*", - config.turnServers, + config.turnServers ), ]); await Promise.all([ - applyP2pTestTweaks( - hostSettings, - "p2p-bench-host", - config.passphrase, - ), - applyP2pTestTweaks( - clientSettings, - "p2p-bench-client", - config.passphrase, - ), + applyP2pTestTweaks(hostSettings, "p2p-bench-host", config.passphrase), + applyP2pTestTweaks(clientSettings, "p2p-bench-client", config.passphrase), ]); const seedFiles = await createDeterministicDataset({ @@ -293,25 +257,15 @@ async function main(): Promise { await runCliOrFail(hostVault, "--settings", hostSettings, "mirror"); const mirrorElapsed = nowMs() - mirrorStart; - const host = startCliInBackground( - hostVault, - "--settings", - hostSettings, - "p2p-host", - ); + const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host"); try { const hostReadyStart = nowMs(); await host.waitUntilContains("P2P host is running", 20000); const hostReadyElapsed = nowMs() - hostReadyStart; const peerDiscoveryCommandStart = nowMs(); - const peer = await discoverPeer( - clientVault, - clientSettings, - config.peersTimeoutSeconds, - ); - const peerDiscoveryCommandElapsed = nowMs() - - peerDiscoveryCommandStart; + const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds); + const peerDiscoveryCommandElapsed = nowMs() - peerDiscoveryCommandStart; const syncStart = nowMs(); await runCliOrFail( @@ -320,7 +274,7 @@ async function main(): Promise { clientSettings, "p2p-sync", peer.id, - String(config.syncTimeoutSeconds), + String(config.syncTimeoutSeconds) ); const syncElapsed = nowMs() - syncStart; @@ -328,28 +282,24 @@ async function main(): Promise { seedFiles.entries, config.verificationMode, async (entry) => { - const pulledPath = workDir.join( - `pulled-${entry.relativePath.replaceAll("/", "_")}`, - ); + const pulledPath = workDir.join(`pulled-${entry.relativePath.replaceAll("/", "_")}`); await runCliOrFail( clientVault, "--settings", clientSettings, "pull", entry.relativePath, - pulledPath, + pulledPath ); await assertFilesEqual( entry.absolutePath, 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 = { caseName: config.caseName, mode: "p2p-cli-benchmark", @@ -363,17 +313,15 @@ async function main(): Promise { 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, + 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.", + ? "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, @@ -389,39 +337,25 @@ async function main(): Promise { mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)), peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds, - peerDiscoveryCommandElapsedMs: Number( - peerDiscoveryCommandElapsed.toFixed(1), - ), + 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, - ), - ), + 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), - ); + 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)}, ` + + `[Benchmark] mirrored ${seedFiles.totalFiles} files (${formatBytes( + seedFiles.totalBytes + )}) in ${formatMs(mirrorElapsed)}, ` + `synced in ${formatMs(syncElapsed)} ` + - `(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`, + `(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)` ); } finally { await host.stop(); diff --git a/src/apps/cli/testdeno/helpers/benchmarkVerification.ts b/src/apps/cli/testdeno/helpers/benchmarkVerification.ts index ace22ed3..8e0538e5 100644 --- a/src/apps/cli/testdeno/helpers/benchmarkVerification.ts +++ b/src/apps/cli/testdeno/helpers/benchmarkVerification.ts @@ -10,9 +10,7 @@ export type BenchmarkVerificationResult = { }; 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 { @@ -23,7 +21,7 @@ async function sha256(bytes: Uint8Array): Promise { export function parseBenchmarkVerificationMode( raw: string | undefined, - fallback: BenchmarkVerificationMode = "sample", + fallback: BenchmarkVerificationMode = "sample" ): BenchmarkVerificationMode { const value = raw?.trim().toLowerCase(); if (!value) return fallback; @@ -31,10 +29,7 @@ export function parseBenchmarkVerificationMode( 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]; const md = entries.find((entry) => entry.kind === "md"); @@ -48,15 +43,11 @@ export function selectVerificationEntries( return [...selected.values()]; } -export async function computeDatasetDigestSha256( - entries: DatasetEntry[], -): Promise { +export async function computeDatasetDigestSha256(entries: DatasetEntry[]): Promise { 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}`, - ); + manifest.push(`${entry.kind}\t${entry.relativePath}\t${entry.size}\t${contentDigest}`); } return await sha256(new TextEncoder().encode(manifest.join("\n"))); } @@ -64,7 +55,7 @@ export async function computeDatasetDigestSha256( export async function verifyBenchmarkDataset( entries: DatasetEntry[], mode: BenchmarkVerificationMode, - verifyEntry: (entry: DatasetEntry) => Promise, + verifyEntry: (entry: DatasetEntry) => Promise ): Promise { const selected = selectVerificationEntries(entries, mode); for (const entry of selected) { diff --git a/src/apps/cli/testdeno/test-benchmark-contract.ts b/src/apps/cli/testdeno/test-benchmark-contract.ts index 6b189219..342f1660 100644 --- a/src/apps/cli/testdeno/test-benchmark-contract.ts +++ b/src/apps/cli/testdeno/test-benchmark-contract.ts @@ -1,10 +1,7 @@ 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 { parseBenchmarkVerificationMode, selectVerificationEntries } from "./helpers/benchmarkVerification.ts"; import type { DatasetEntry } from "./helpers/dataset.ts"; function getFreePort(): number { @@ -24,20 +21,10 @@ function getCase(cases: BenchmarkCase[], name: string): BenchmarkCase { 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`, - ); + 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 - ), - ); + assert(Array.isArray(parsed), `${testCase.name} limitations must be an array`); + assert(parsed.every((item) => typeof item === "string" && item.trim().length > 0)); return parsed; } @@ -46,36 +33,14 @@ Deno.test("benchmark cases record scope and limitations for paper use", () => { 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, - ); + 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`, - ); + assertEquals(testCase.env.BENCH_VERIFY_MODE, "all", `${testCase.name} must verify the complete dataset`); } }); @@ -89,7 +54,7 @@ Deno.test("CouchDB latency proxy applies half the requested RTT in each directio port: backendPort, onListen() {}, }, - () => new Response("ok"), + () => new Response("ok") ); const proxy = startCouchdbProxy({ backendUri: `http://127.0.0.1:${backendPort}`, @@ -143,34 +108,22 @@ 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", () => { 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); 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", - ); + 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`, + 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`, + testCase.limitations.some((limitation) => limitation.includes("does not shape the selected WebRTC")), + `${name} must avoid claiming that the P2P note-data path was shaped` ); } }); @@ -182,42 +135,31 @@ 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_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", + 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", - ) + 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", () => { 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); assertEquals(testCase.runner, "couchdb"); 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"); assert( - testCase.limitations.some((limitation) => - limitation.includes("not the WebRTC P2P data path") - ), - `${name} must remain scoped to the CouchDB remote-store path`, + testCase.limitations.some((limitation) => limitation.includes("not the WebRTC P2P data path")), + `${name} must remain scoped to the CouchDB remote-store path` ); } });