Compare commits

..

2 Commits

Author SHA1 Message Date
vorotamoroz 3a1a8948d2 Normalise tracked text files and formatter scope 2026-07-17 01:05:29 +00:00
vorotamoroz e114f66fb2 Harden release workflow validation and retries 2026-07-17 09:50:27 +09:00
13 changed files with 474 additions and 730 deletions
+30 -30
View File
@@ -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
+5 -1
View File
@@ -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
+2 -1
View File
@@ -1,4 +1,5 @@
pouchdb-browser.js
main_org.js
main.js
_types/**
_types/**
src/lib/**
+152 -152
View File
@@ -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。
注意检查服务器日志,当心恶意访问。
+12 -3
View File
@@ -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"
);
},
},
];
+8 -8
View File
@@ -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
+111 -111
View File
@@ -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"]
+46 -124
View File
@@ -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<void>;
},
): CouchdbProxyHandle {
export function startCouchdbProxy(options: {
backendUri: string;
proxyUri: string;
requestedRttMs: number;
delay?: (milliseconds: number) => Promise<void>;
}): 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<void>((resolve) => setTimeout(resolve, milliseconds)));
const delay =
options.delay ?? ((milliseconds: number) => new Promise<void>((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<void> {
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<void> {
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
const syncBElapsed = nowMs() - syncBStart;
const verification = await verifyBenchmarkDataset(
seedFiles.entries,
config.verificationMode,
async (entry) => {
const pulledPath = workDir.join(
`pulled-${entry.relativePath.split("/").join("_")}`,
);
await runCliOrFail(
vaultB,
"--settings",
settingsB,
"pull",
entry.relativePath,
pulledPath,
);
await assertFilesEqual(
entry.absolutePath,
pulledPath,
`file mismatch after CouchDB sync: ${entry.relativePath}`,
);
},
);
const 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<void> {
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();
+2 -5
View File
@@ -65,9 +65,7 @@ async function runBenchmark(options: {
repeatIndex: number;
repeatCount: number;
}): 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 env = {
...Deno.env.toObject(),
@@ -156,8 +154,7 @@ async function main(): Promise<void> {
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,
+35 -91
View File
@@ -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<string, string> {
return {
BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"),
BENCH_MD_MIN_SIZE_BYTES: readEnvString(
"BENCH_MD_MIN_SIZE_BYTES",
"512",
),
BENCH_MD_MAX_SIZE_BYTES: readEnvString(
"BENCH_MD_MAX_SIZE_BYTES",
"2048",
),
BENCH_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<string, string> {
function withScopeEnv(
env: Record<string, string>,
options: Pick<BenchmarkCase, "measurementScope" | "limitations">,
options: Pick<BenchmarkCase, "measurementScope" | "limitations">
): Record<string, string> {
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<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 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<string, unknown>;
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<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()}`;
await Deno.mkdir(outputDir, { recursive: true });
@@ -412,16 +361,14 @@ async function main(): Promise<void> {
availableCases: allCases,
},
null,
2,
),
2
)
);
const results: Record<string, unknown>[] = [];
for (const testCase of cases) {
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
results.push(
await runCase(testCase, outputDir, repeatIndex, repeatCount),
);
results.push(await runCase(testCase, outputDir, repeatIndex, repeatCount));
}
}
@@ -431,10 +378,7 @@ async function main(): Promise<void> {
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}`);
}
+39 -105
View File
@@ -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<P2PConnectionStats | undefined> {
async function readLatestP2PConnectionStats(statsPath: string): Promise<P2PConnectionStats | undefined> {
try {
const text = await Deno.readTextFile(statsPath);
const lines = text
@@ -252,7 +224,7 @@ async function main(): Promise<void> {
config.appId,
config.relay,
"~.*",
config.turnServers,
config.turnServers
),
applyP2pSettings(
clientSettings,
@@ -261,21 +233,13 @@ async function main(): Promise<void> {
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<void> {
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<void> {
clientSettings,
"p2p-sync",
peer.id,
String(config.syncTimeoutSeconds),
String(config.syncTimeoutSeconds)
);
const syncElapsed = nowMs() - syncStart;
@@ -328,28 +282,24 @@ async function main(): Promise<void> {
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<void> {
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<void> {
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();
@@ -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<string> {
@@ -23,7 +21,7 @@ async function sha256(bytes: Uint8Array): Promise<string> {
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<string> {
export async function computeDatasetDigestSha256(entries: DatasetEntry[]): Promise<string> {
const manifest: string[] = [];
for (const entry of entries) {
const contentDigest = await sha256(await Deno.readFile(entry.absolutePath));
manifest.push(
`${entry.kind}\t${entry.relativePath}\t${entry.size}\t${contentDigest}`,
);
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<void>,
verifyEntry: (entry: DatasetEntry) => Promise<void>
): Promise<BenchmarkVerificationResult> {
const selected = selectVerificationEntries(entries, mode);
for (const entry of selected) {
@@ -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`
);
}
});