mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-13 00:03:11 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3efc72bab9 | |||
| b8a4c824e2 |
@@ -17,8 +17,6 @@ production/
|
|||||||
|
|
||||||
# Test coverage and reports
|
# Test coverage and reports
|
||||||
coverage/
|
coverage/
|
||||||
test/bench-network/bench-results/
|
|
||||||
src/apps/cli/testdeno/bench-results/
|
|
||||||
|
|
||||||
# Local environment / secrets
|
# Local environment / secrets
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ on:
|
|||||||
- '.github/workflows/cli-deno-tests.yml'
|
- '.github/workflows/cli-deno-tests.yml'
|
||||||
- 'src/apps/cli/**'
|
- 'src/apps/cli/**'
|
||||||
- 'src/lib/src/API/processSetting.ts'
|
- 'src/lib/src/API/processSetting.ts'
|
||||||
- 'src/lib/src/replication/trystero/**'
|
|
||||||
- 'src/lib/src/rpc/**'
|
|
||||||
- 'test/bench-network/**'
|
|
||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'package-lock.json'
|
- 'package-lock.json'
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -19,9 +16,6 @@ on:
|
|||||||
- '.github/workflows/cli-deno-tests.yml'
|
- '.github/workflows/cli-deno-tests.yml'
|
||||||
- 'src/apps/cli/**'
|
- 'src/apps/cli/**'
|
||||||
- 'src/lib/src/API/processSetting.ts'
|
- 'src/lib/src/API/processSetting.ts'
|
||||||
- 'src/lib/src/replication/trystero/**'
|
|
||||||
- 'src/lib/src/rpc/**'
|
|
||||||
- 'test/bench-network/**'
|
|
||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'package-lock.json'
|
- 'package-lock.json'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -161,40 +155,3 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker stop couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true
|
docker stop couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true
|
||||||
docker rm couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true
|
docker rm couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true
|
||||||
|
|
||||||
compose-p2p-e2e:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Show Docker versions
|
|
||||||
run: |
|
|
||||||
docker --version
|
|
||||||
docker compose version
|
|
||||||
|
|
||||||
- name: Run Compose CLI P2P E2E
|
|
||||||
env:
|
|
||||||
CLI_E2E_TASK: test:p2p-sync
|
|
||||||
RELAY: ws://nostr-relay:7777/
|
|
||||||
PEERS_TIMEOUT: '20'
|
|
||||||
SYNC_TIMEOUT: '60'
|
|
||||||
LIVESYNC_USE_COTURN: '0'
|
|
||||||
TURN_SERVERS: none
|
|
||||||
LIVESYNC_P2P_PEERS_RETRY: '1'
|
|
||||||
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000'
|
|
||||||
BENCH_LIVESYNC_TEST_TEE: '0'
|
|
||||||
run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner run-livesync-cli-e2e
|
|
||||||
|
|
||||||
- name: Show Compose diagnostics
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
docker compose -f test/bench-network/compose.yml ps
|
|
||||||
docker compose -f test/bench-network/compose.yml logs --no-color couchdb nostr-relay || true
|
|
||||||
|
|
||||||
- name: Stop Compose services
|
|
||||||
if: always()
|
|
||||||
run: docker compose -f test/bench-network/compose.yml down -v --remove-orphans
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
# Run the Compose-packaged CLI P2P smoke benchmark.
|
|
||||||
#
|
|
||||||
# This workflow is intentionally manual-only. It exercises the local Compose
|
|
||||||
# package for CouchDB + Nostr relay + CLI runner, and uploads the benchmark JSON
|
|
||||||
# results for inspection without adding benchmark work to pull-request CI.
|
|
||||||
name: cli-p2p-compose-smoke
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
cases:
|
|
||||||
description: 'Comma-separated benchmark cases'
|
|
||||||
required: false
|
|
||||||
default: 'couchdb-baseline,p2p-direct-local'
|
|
||||||
signalling_cases:
|
|
||||||
description: 'Comma-separated signalling-shim P2P benchmark cases'
|
|
||||||
required: false
|
|
||||||
default: 'p2p-signalling-netem-home-wifi'
|
|
||||||
md_files:
|
|
||||||
description: 'Markdown file count'
|
|
||||||
required: false
|
|
||||||
default: '2'
|
|
||||||
bin_files:
|
|
||||||
description: 'Binary file count'
|
|
||||||
required: false
|
|
||||||
default: '1'
|
|
||||||
couchdb_rtt_ms:
|
|
||||||
description: 'Requested CouchDB RTT in milliseconds'
|
|
||||||
required: false
|
|
||||||
default: '20'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
smoke:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Show Docker versions
|
|
||||||
run: |
|
|
||||||
docker --version
|
|
||||||
docker compose version
|
|
||||||
|
|
||||||
- name: Run Compose P2P smoke benchmark
|
|
||||||
env:
|
|
||||||
BENCH_CASES: ${{ inputs.cases || 'couchdb-baseline,p2p-direct-local' }}
|
|
||||||
BENCH_MD_FILE_COUNT: ${{ inputs.md_files || '2' }}
|
|
||||||
BENCH_MD_MIN_SIZE_BYTES: '128'
|
|
||||||
BENCH_MD_MAX_SIZE_BYTES: '256'
|
|
||||||
BENCH_BIN_FILE_COUNT: ${{ inputs.bin_files || '1' }}
|
|
||||||
BENCH_BIN_SIZE_BYTES: '512'
|
|
||||||
BENCH_COUCHDB_RTT_MS: ${{ inputs.couchdb_rtt_ms || '20' }}
|
|
||||||
BENCH_SYNC_TIMEOUT: '180'
|
|
||||||
BENCH_PEERS_TIMEOUT: '20'
|
|
||||||
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000'
|
|
||||||
BENCH_LIVESYNC_TEST_TEE: '0'
|
|
||||||
run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner
|
|
||||||
|
|
||||||
- name: Run Compose P2P signalling-shim smoke benchmark
|
|
||||||
env:
|
|
||||||
BENCH_CASES: ${{ inputs.signalling_cases || 'p2p-signalling-netem-home-wifi' }}
|
|
||||||
BENCH_MD_FILE_COUNT: ${{ inputs.md_files || '2' }}
|
|
||||||
BENCH_MD_MIN_SIZE_BYTES: '128'
|
|
||||||
BENCH_MD_MAX_SIZE_BYTES: '256'
|
|
||||||
BENCH_BIN_FILE_COUNT: ${{ inputs.bin_files || '1' }}
|
|
||||||
BENCH_BIN_SIZE_BYTES: '512'
|
|
||||||
BENCH_SYNC_TIMEOUT: '180'
|
|
||||||
BENCH_PEERS_TIMEOUT: '60'
|
|
||||||
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000'
|
|
||||||
BENCH_LIVESYNC_TEST_TEE: '0'
|
|
||||||
NETEM_PROFILE: 'home-wifi'
|
|
||||||
run: docker compose -f test/bench-network/compose.yml --profile signalling-shim run --build --rm bench-runner-signalling-shim
|
|
||||||
|
|
||||||
- name: Show Compose diagnostics
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
docker compose -f test/bench-network/compose.yml ps
|
|
||||||
docker compose -f test/bench-network/compose.yml --profile signalling-shim logs --no-color couchdb nostr-relay p2p-signalling-shim || true
|
|
||||||
|
|
||||||
- name: Upload benchmark results
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: cli-p2p-compose-smoke-results
|
|
||||||
path: test/bench-network/bench-results/**
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Stop Compose services
|
|
||||||
if: always()
|
|
||||||
run: docker compose -f test/bench-network/compose.yml down -v --remove-orphans
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: Finalise Release Tags
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Release version, for example 0.25.81
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
release_branch:
|
||||||
|
description: Release PR branch. Defaults to the version with dots replaced by underscores.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
finalise:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: release
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Resolve release branch
|
||||||
|
id: branch
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
RELEASE_BRANCH_INPUT: ${{ inputs.release_branch }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BRANCH="${RELEASE_BRANCH_INPUT}"
|
||||||
|
if [[ -z "${BRANCH}" ]]; then
|
||||||
|
BRANCH="${VERSION//./_}"
|
||||||
|
fi
|
||||||
|
echo "name=${BRANCH}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ steps.branch.outputs.name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24.x"
|
||||||
|
|
||||||
|
- name: Validate release head
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
node utils/release-notes.mjs validate "${VERSION}"
|
||||||
|
git fetch --tags --force
|
||||||
|
if git rev-parse --verify --quiet "refs/tags/${VERSION}" >/dev/null; then
|
||||||
|
echo "Tag already exists: ${VERSION}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if git rev-parse --verify --quiet "refs/tags/${VERSION}-cli" >/dev/null; then
|
||||||
|
echo "Tag already exists: ${VERSION}-cli" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create and push release tags
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git tag "${VERSION}"
|
||||||
|
git tag "${VERSION}-cli"
|
||||||
|
git push origin "${VERSION}" "${VERSION}-cli"
|
||||||
|
|
||||||
|
- name: Summarise next steps
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "Created tags \`${VERSION}\` and \`${VERSION}-cli\`."
|
||||||
|
echo ""
|
||||||
|
echo "The plug-in release workflow is triggered by the \`${VERSION}\` tag and creates a draft release by default."
|
||||||
|
echo "The CLI Docker workflow is triggered by the \`${VERSION}-cli\` tag and publishes the version, major-minor, latest, and SHA-qualified image tags."
|
||||||
|
echo ""
|
||||||
|
echo "To create a pre-release instead of the default draft flow, run \`Release Obsidian Plugin\` manually with \`tag=${VERSION}\`, \`draft=false\`, and \`prerelease=true\`."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
name: Prepare Release PR
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Release version, for example 0.25.81
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
base_branch:
|
||||||
|
description: Base branch for the release PR
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: main
|
||||||
|
release_branch:
|
||||||
|
description: Release branch name. Defaults to the version with dots replaced by underscores.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
allow_empty_updates:
|
||||||
|
description: Allow an empty Unreleased section
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.base_branch }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24.x"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Prepare release changes
|
||||||
|
id: prepare
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
RELEASE_BRANCH_INPUT: ${{ inputs.release_branch }}
|
||||||
|
ALLOW_EMPTY_UPDATES: ${{ inputs.allow_empty_updates }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
BRANCH="${RELEASE_BRANCH_INPUT}"
|
||||||
|
if [[ -z "${BRANCH}" ]]; then
|
||||||
|
BRANCH="${VERSION//./_}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git ls-remote --exit-code --heads origin "${BRANCH}" >/dev/null 2>&1; then
|
||||||
|
echo "Release branch already exists: ${BRANCH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git switch -c "${BRANCH}"
|
||||||
|
npm version "${VERSION}" --no-git-tag-version
|
||||||
|
node utils/release-notes.mjs prepare "${VERSION}"
|
||||||
|
npm run pretty:json
|
||||||
|
|
||||||
|
git add package.json package-lock.json manifest.json versions.json updates.md src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "Releasing ${VERSION}"
|
||||||
|
git push --set-upstream origin "${BRANCH}"
|
||||||
|
|
||||||
|
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create draft release PR
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
BASE_BRANCH: ${{ inputs.base_branch }}
|
||||||
|
RELEASE_BRANCH: ${{ steps.prepare.outputs.branch }}
|
||||||
|
run: |
|
||||||
|
cat > /tmp/release-pr-body.md <<EOF
|
||||||
|
## Release checklist
|
||||||
|
|
||||||
|
- [ ] Review and polish \`updates.md\`
|
||||||
|
- [ ] Confirm \`manifest.json\`, \`versions.json\`, and workspace package versions
|
||||||
|
- [ ] Confirm CI has passed
|
||||||
|
- [ ] Run the finalise release workflow after the PR head is fixed
|
||||||
|
- [ ] Merge this PR after the draft or pre-release has been created
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gh pr create \
|
||||||
|
--base "${BASE_BRANCH}" \
|
||||||
|
--head "${RELEASE_BRANCH}" \
|
||||||
|
--draft \
|
||||||
|
--title "Releasing ${VERSION}" \
|
||||||
|
--body-file /tmp/release-pr-body.md
|
||||||
@@ -6,10 +6,26 @@ on:
|
|||||||
- '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10
|
- '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10
|
||||||
- '!*-cli' # Exclude command-line interface tags
|
- '!*-cli' # Exclude command-line interface tags
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Release tag to build
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
draft:
|
||||||
|
description: Create the GitHub Release as a draft
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
prerelease:
|
||||||
|
description: Mark the GitHub Release as a pre-release
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment: release
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -19,6 +35,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0 # otherwise, you will failed to push refs to dest repo
|
fetch-depth: 0 # otherwise, you will failed to push refs to dest repo
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -27,7 +44,18 @@ jobs:
|
|||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
echo "tag=$(git describe --abbrev=0 --tags)" >> $GITHUB_OUTPUT
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
|
TAG="${{ inputs.tag }}"
|
||||||
|
DRAFT="${{ inputs.draft }}"
|
||||||
|
PRERELEASE="${{ inputs.prerelease }}"
|
||||||
|
else
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
DRAFT="true"
|
||||||
|
PRERELEASE="false"
|
||||||
|
fi
|
||||||
|
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||||
|
echo "draft=${DRAFT}" >> $GITHUB_OUTPUT
|
||||||
|
echo "prerelease=${PRERELEASE}" >> $GITHUB_OUTPUT
|
||||||
# Build the plugin
|
# Build the plugin
|
||||||
- name: Build
|
- name: Build
|
||||||
id: build
|
id: build
|
||||||
@@ -58,4 +86,5 @@ jobs:
|
|||||||
styles.css
|
styles.css
|
||||||
name: ${{ steps.version.outputs.tag }}
|
name: ${{ steps.version.outputs.tag }}
|
||||||
tag_name: ${{ steps.version.outputs.tag }}
|
tag_name: ${{ steps.version.outputs.tag }}
|
||||||
draft: true
|
draft: ${{ steps.version.outputs.draft }}
|
||||||
|
prerelease: ${{ steps.version.outputs.prerelease }}
|
||||||
|
|||||||
@@ -47,12 +47,6 @@ npm test # Run Harness based vitest tests (requires Docker services)
|
|||||||
|
|
||||||
Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness.
|
Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness.
|
||||||
|
|
||||||
### Unreleased change notes
|
|
||||||
|
|
||||||
Keep changes that may belong in a future release under `## Unreleased` at the top of `updates.md` when they do not justify an immediate release. Do not add a date to this virtual version. Move relevant entries under the real version and ordinal release date when preparing that release, then leave an empty `## Unreleased` section for subsequent work.
|
|
||||||
|
|
||||||
Use this section for durable release-note candidates, including compatibility-relevant internal maintenance, rather than tasks, local diagnostics, or implementation journals. Categorise user-visible behaviour separately from internal changes and testing.
|
|
||||||
|
|
||||||
### Auto-copy to test vaults
|
### Auto-copy to test vaults
|
||||||
|
|
||||||
To facilitate development and testing, the build process can automatically copy the built plugin to specified test vault
|
To facilitate development and testing, the build process can automatically copy the built plugin to specified test vault
|
||||||
@@ -258,6 +252,47 @@ export class ModuleExample extends AbstractObsidianModule {
|
|||||||
|
|
||||||
In short, the situation remains unchanged for me, but it means you all become a little safer. Thank you for your understanding!
|
In short, the situation remains unchanged for me, but it means you all become a little safer. Thank you for your understanding!
|
||||||
|
|
||||||
|
## Release Notes
|
||||||
|
|
||||||
|
- Keep the top section of `updates.md` as `## Unreleased` during normal development.
|
||||||
|
- When opening a feature or fix PR, update `## Unreleased` in the same PR if the change is user-facing.
|
||||||
|
- Add only user-facing changes that help users understand what they gain, what has changed, or what they may need to do after updating.
|
||||||
|
- Avoid listing purely internal refactors, maintenance chores, generated-file changes, and dependency updates unless they affect users; group and label them when they are included.
|
||||||
|
- When preparing a release, replace `## Unreleased` with the target version heading (for example, `## 0.25.81`) and add a fresh empty `## Unreleased` section above it for the next cycle.
|
||||||
|
- Review and polish the released section in the release PR before tagging, because the content is embedded into the plug-in and may be reused as the GitHub Release notes.
|
||||||
|
|
||||||
|
## Release Workflow
|
||||||
|
|
||||||
|
This workflow is for maintainers. Contributors should update `## Unreleased` for user-facing feature or fix PRs, but do not need to run the release workflows.
|
||||||
|
The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `release` GitHub Environment. Configure Environment protection in the repository settings so tag creation and release publication require maintainer approval.
|
||||||
|
|
||||||
|
- Run the `Prepare Release PR` workflow with the target version. It creates the release branch, updates versions, moves the `## Unreleased` notes to the target version, commits the release preparation, pushes the branch, and opens a draft release PR.
|
||||||
|
- Do not tag the release branch when the PR is first created. Polish the release PR first, especially `updates.md`.
|
||||||
|
- Once the release PR head is fixed, run the `Finalise Release Tags` workflow. It validates the release branch and pushes both the plug-in tag (for example, `0.25.81`) and the CLI tag (for example, `0.25.81-cli`) to the same commit.
|
||||||
|
- The plug-in tag triggers the release workflow and creates a draft GitHub Release by default. The CLI tag triggers the Docker workflow and publishes the fixed version tag, the major-minor moving tag (for example, `0.25-cli`), `latest`, and the SHA-qualified tag.
|
||||||
|
- If a pre-release is needed, run the `Release Obsidian Plugin` workflow manually with the target tag, `draft=false`, and `prerelease=true`.
|
||||||
|
|
||||||
|
### Release Cheat Sheet
|
||||||
|
|
||||||
|
1. Before starting, add user-facing notes under `## Unreleased` in `updates.md`.
|
||||||
|
2. Run `Prepare Release PR` from GitHub Actions.
|
||||||
|
- `version`: the target version, for example `0.25.81`.
|
||||||
|
- `base_branch`: normally `main`.
|
||||||
|
- `release_branch`: leave blank to use the default branch name, for example `0_25_81`.
|
||||||
|
- `allow_empty_updates`: leave disabled unless the release intentionally has no user-facing notes.
|
||||||
|
3. Review the generated draft PR.
|
||||||
|
- Polish `updates.md`.
|
||||||
|
- Confirm `package.json`, `manifest.json`, `versions.json`, and workspace package versions.
|
||||||
|
- Confirm that `manifest.json` has the intended `minAppVersion`.
|
||||||
|
- Wait for the necessary CI checks.
|
||||||
|
4. When the PR head is fixed, run `Finalise Release Tags`.
|
||||||
|
- `version`: the same target version.
|
||||||
|
- `release_branch`: leave blank unless the release branch used a custom name.
|
||||||
|
5. Check the generated draft GitHub Release for the plug-in tag.
|
||||||
|
6. Check the CLI Docker workflow started from the `*-cli` tag.
|
||||||
|
7. Publish the draft GitHub Release when ready, then merge the release PR into `main`.
|
||||||
|
8. If the release should be a pre-release instead of a draft release, run `Release Obsidian Plugin` manually with the target `tag`, `draft=false`, and `prerelease=true`.
|
||||||
|
|
||||||
## Contribution Guidelines
|
## Contribution Guidelines
|
||||||
|
|
||||||
- Follow existing code style and conventions
|
- Follow existing code style and conventions
|
||||||
|
|||||||
+2
-1
@@ -65,7 +65,7 @@ All guidelines and conventions listed below are disclosed and maintained solely
|
|||||||
- livesync-serverpeer / webpeer
|
- livesync-serverpeer / webpeer
|
||||||
- Pseudo-clients that assist in WebRTC peer-to-peer communication.
|
- Pseudo-clients that assist in WebRTC peer-to-peer communication.
|
||||||
- Metadata (File metadata)
|
- Metadata (File metadata)
|
||||||
- A database document that stores properties of a file, including its filename, path, size, modification time, and references (hashes) of the chunks that comprise the file's content. Conflict state is carried by the surrounding PouchDB/CouchDB revision metadata rather than by a separate history field inside the file metadata document. In Self-hosted LiveSync, file metadata is stored separately from the actual file content to enable efficient synchronisation and versioning.
|
- A database document that stores properties of a file, including its filename, path, size, modification time, conflict history, and references (hashes) of the chunks that comprise the file's content. In Self-hosted LiveSync, metadata is stored separately from the actual file content to enable efficient synchronisation and versioning.
|
||||||
- OneShot Sync
|
- OneShot Sync
|
||||||
- A single, immediate bidirectional synchronisation (pull then push) triggered on demand or on specific events, as opposed to continuous (live) replication.
|
- A single, immediate bidirectional synchronisation (pull then push) triggered on demand or on specific events, as opposed to continuous (live) replication.
|
||||||
- Overwrite Server Data with This Device's Files
|
- Overwrite Server Data with This Device's Files
|
||||||
@@ -100,3 +100,4 @@ All guidelines and conventions listed below are disclosed and maintained solely
|
|||||||
- An optimisation that groups multiple local file edits together over a short delay before committing them to the local database, reducing the number of database write operations.
|
- An optimisation that groups multiple local file edits together over a short delay before committing them to the local database, reducing the number of database write operations.
|
||||||
- WebRTC P2P (Peer-to-Peer)
|
- WebRTC P2P (Peer-to-Peer)
|
||||||
- A synchronisation method enabling direct communication between devices without a central server database.
|
- A synchronisation method enabling direct communication between devices without a central server database.
|
||||||
|
|
||||||
|
|||||||
Generated
-16
@@ -58,7 +58,6 @@
|
|||||||
"@vitest/browser": "^4.1.8",
|
"@vitest/browser": "^4.1.8",
|
||||||
"@vitest/browser-playwright": "^4.1.8",
|
"@vitest/browser-playwright": "^4.1.8",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
"@vrtmrz/obsidian-test-session": "0.1.0",
|
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
@@ -4181,7 +4180,6 @@
|
|||||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
@@ -4946,20 +4944,6 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vrtmrz/obsidian-test-session": {
|
|
||||||
"version": "0.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.1.0.tgz",
|
|
||||||
"integrity": "sha512-asBOIRTc3xK5GF5ds5mkxN6vsO4RE8o7puvVjoJiGYSlxoFa9jzr8FwAO13CyoOriHF05pOZfTB+eQmL1aNb/A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/node": ">=20",
|
|
||||||
"playwright": ">=1.50.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@wdio/config": {
|
"node_modules/@wdio/config": {
|
||||||
"version": "9.27.0",
|
"version": "9.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz",
|
||||||
|
|||||||
@@ -105,7 +105,6 @@
|
|||||||
"@vitest/browser": "^4.1.8",
|
"@vitest/browser": "^4.1.8",
|
||||||
"@vitest/browser-playwright": "^4.1.8",
|
"@vitest/browser-playwright": "^4.1.8",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
"@vrtmrz/obsidian-test-session": "0.1.0",
|
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
|
||||||
|
|
||||||
/** One platform-neutral storage adapter contract case. */
|
|
||||||
export interface StorageAdapterContractCase {
|
|
||||||
readonly name: string;
|
|
||||||
run(adapter: IStorageAdapter): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): asserts condition {
|
|
||||||
if (!condition) throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
|
||||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
||||||
throw new Error(`${message}\nactual=${JSON.stringify(actual)}\nexpected=${JSON.stringify(expected)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertRejects(operation: () => Promise<unknown>, message: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await operation();
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Passing baseline shared by Node, FSAPI, and future storage adapters. */
|
|
||||||
export const storageAdapterContractCases: readonly StorageAdapterContractCase[] = [
|
|
||||||
{
|
|
||||||
name: "reports missing paths consistently",
|
|
||||||
async run(adapter) {
|
|
||||||
assertEqual(await adapter.exists("missing.txt"), false, "missing path should not exist");
|
|
||||||
assertEqual(await adapter.stat("missing.txt"), null, "missing stat should be null");
|
|
||||||
assertEqual(await adapter.trystat("missing.txt"), null, "missing trystat should be null");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "creates parent directories for nested text writes",
|
|
||||||
async run(adapter) {
|
|
||||||
await adapter.write("notes/nested/note.md", "hello");
|
|
||||||
assertEqual(await adapter.read("notes/nested/note.md"), "hello", "text should round-trip");
|
|
||||||
assert(await adapter.exists("notes/nested/note.md"), "written text path should exist");
|
|
||||||
assertEqual((await adapter.stat("notes/nested/note.md"))?.type, "file", "written path should be a file");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "round-trips exact binary bytes",
|
|
||||||
async run(adapter) {
|
|
||||||
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
|
|
||||||
await adapter.writeBinary("binary/blob.bin", expected.buffer.slice(0));
|
|
||||||
const result = await adapter.readBinary("binary/blob.bin");
|
|
||||||
assertEqual([...new Uint8Array(result)], [...expected], "binary data should round-trip exactly");
|
|
||||||
assertEqual(result.byteLength, expected.byteLength, "binary result should have the exact visible length");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "creates and extends text through append",
|
|
||||||
async run(adapter) {
|
|
||||||
await adapter.append("logs/events.log", "first");
|
|
||||||
await adapter.append("logs/events.log", ":second");
|
|
||||||
assertEqual(await adapter.read("logs/events.log"), "first:second", "append should create then extend text");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "lists direct files and folders",
|
|
||||||
async run(adapter) {
|
|
||||||
await adapter.mkdir("listing/folder");
|
|
||||||
await adapter.write("listing/file.txt", "content");
|
|
||||||
const listed = await adapter.list("listing");
|
|
||||||
assertEqual([...listed.files].sort(), ["listing/file.txt"], "list should contain the direct file");
|
|
||||||
assertEqual([...listed.folders].sort(), ["listing/folder"], "list should contain the direct folder");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "removes files and directory trees",
|
|
||||||
async run(adapter) {
|
|
||||||
await adapter.write("remove/file.txt", "content");
|
|
||||||
await adapter.write("remove/folder/nested.txt", "content");
|
|
||||||
await adapter.remove("remove/file.txt");
|
|
||||||
assertEqual(await adapter.exists("remove/file.txt"), false, "file should be removed");
|
|
||||||
await adapter.remove("remove/folder");
|
|
||||||
assertEqual(await adapter.exists("remove/folder"), false, "directory tree should be removed");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "keeps operations inside the configured root",
|
|
||||||
async run(adapter) {
|
|
||||||
await assertRejects(() => adapter.exists("../outside"), "parent traversal should be rejected");
|
|
||||||
await assertRejects(() => adapter.write("nested/../outside", "content"), "nested traversal should be rejected");
|
|
||||||
await assertRejects(() => adapter.read("/absolute"), "absolute paths should be rejected");
|
|
||||||
await assertRejects(() => adapter.read("C:\\absolute"), "drive-qualified paths should be rejected");
|
|
||||||
await assertRejects(() => adapter.read("nested\\outside"), "backslash-separated paths should be rejected");
|
|
||||||
await assertRejects(() => adapter.remove(""), "removing the configured root should be rejected");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "uses the empty path only for root-safe operations",
|
|
||||||
async run(adapter) {
|
|
||||||
await adapter.mkdir("");
|
|
||||||
assertEqual(await adapter.exists(""), true, "the configured root should exist");
|
|
||||||
assertEqual((await adapter.stat(""))?.type, "folder", "the configured root should be a folder");
|
|
||||||
assertEqual(await adapter.list(""), { files: [], folders: [] }, "the configured root should be listable");
|
|
||||||
await assertRejects(() => adapter.write("", "content"), "writing over the configured root should be rejected");
|
|
||||||
await assertRejects(() => adapter.append("", "content"), "appending to the configured root should be rejected");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -2,22 +2,20 @@ import type { UXDataWriteOptions } from "@lib/common/types";
|
|||||||
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||||
import type { NodeStat } from "./NodeTypes";
|
import type { NodeStat } from "./NodeTypes";
|
||||||
import { fsPromises as fs, path } from "@/apps/cli/node-compat";
|
import { fsPromises as fs, path } from "@/apps/cli/node-compat";
|
||||||
import { validateStoragePath } from "@/apps/storagePath";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage adapter implementation for Node.js
|
* Storage adapter implementation for Node.js
|
||||||
*/
|
*/
|
||||||
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||||
constructor(private readonly basePath: string) {}
|
constructor(private basePath: string) {}
|
||||||
|
|
||||||
private resolvePath(p: string, allowRoot: boolean = true): string {
|
private resolvePath(p: string): string {
|
||||||
return path.join(this.basePath, validateStoragePath(p, allowRoot));
|
return path.join(this.basePath, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists(p: string): Promise<boolean> {
|
async exists(p: string): Promise<boolean> {
|
||||||
const fullPath = this.resolvePath(p);
|
|
||||||
try {
|
try {
|
||||||
await fs.access(fullPath);
|
await fs.access(this.resolvePath(p));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -25,9 +23,8 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async trystat(p: string): Promise<NodeStat | null> {
|
async trystat(p: string): Promise<NodeStat | null> {
|
||||||
const fullPath = this.resolvePath(p);
|
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(fullPath);
|
const stat = await fs.stat(this.resolvePath(p));
|
||||||
return {
|
return {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: Math.floor(stat.mtimeMs),
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
@@ -48,7 +45,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(p: string): Promise<void> {
|
async remove(p: string): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p, false);
|
const fullPath = this.resolvePath(p);
|
||||||
const stat = await fs.stat(fullPath);
|
const stat = await fs.stat(fullPath);
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
await fs.rm(fullPath, { recursive: true, force: true });
|
await fs.rm(fullPath, { recursive: true, force: true });
|
||||||
@@ -58,17 +55,17 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async read(p: string): Promise<string> {
|
async read(p: string): Promise<string> {
|
||||||
return await fs.readFile(this.resolvePath(p, false), "utf-8");
|
return await fs.readFile(this.resolvePath(p), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
async readBinary(p: string): Promise<ArrayBuffer> {
|
async readBinary(p: string): Promise<ArrayBuffer> {
|
||||||
const buffer = await fs.readFile(this.resolvePath(p, false));
|
const buffer = await fs.readFile(this.resolvePath(p));
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required in environments where Buffer.buffer is ArrayBufferLike
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required in environments where Buffer.buffer is ArrayBufferLike
|
||||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p, false);
|
const fullPath = this.resolvePath(p);
|
||||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.writeFile(fullPath, data, "utf-8");
|
await fs.writeFile(fullPath, data, "utf-8");
|
||||||
|
|
||||||
@@ -80,7 +77,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p, false);
|
const fullPath = this.resolvePath(p);
|
||||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.writeFile(fullPath, new Uint8Array(data));
|
await fs.writeFile(fullPath, new Uint8Array(data));
|
||||||
|
|
||||||
@@ -92,7 +89,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p, false);
|
const fullPath = this.resolvePath(p);
|
||||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.appendFile(fullPath, data, "utf-8");
|
await fs.appendFile(fullPath, data, "utf-8");
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import * as fs from "node:fs/promises";
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
|
|
||||||
import { NodeStorageAdapter } from "./NodeStorageAdapter";
|
import { NodeStorageAdapter } from "./NodeStorageAdapter";
|
||||||
|
|
||||||
describe("NodeStorageAdapter", () => {
|
describe("NodeStorageAdapter binary I/O", () => {
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
async function createAdapter() {
|
async function createAdapter() {
|
||||||
@@ -18,12 +17,6 @@ describe("NodeStorageAdapter", () => {
|
|||||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const contractCase of storageAdapterContractCases) {
|
|
||||||
it(contractCase.name, async () => {
|
|
||||||
await contractCase.run(await createAdapter());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("writes and reads binary data without corruption", async () => {
|
it("writes and reads binary data without corruption", async () => {
|
||||||
const adapter = await createAdapter();
|
const adapter = await createAdapter();
|
||||||
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
|
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
|
||||||
|
|||||||
@@ -4,21 +4,12 @@ import type { ServiceContext } from "@lib/services/base/ServiceBase";
|
|||||||
import { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
|
import { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
|
||||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||||
import { LiveSyncError } from "@lib/common/LSError";
|
import { LiveSyncError } from "@lib/common/LSError";
|
||||||
import { getPeerConnectionStats } from "@lib/rpc/transports/DiagRTCPeerConnections.utils";
|
|
||||||
import { appendFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
type CLIP2PPeer = {
|
type CLIP2PPeer = {
|
||||||
peerId: string;
|
peerId: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CandidateSummary = {
|
|
||||||
id: string | "unknown";
|
|
||||||
candidateType: string | "unknown";
|
|
||||||
protocol: string | "unknown";
|
|
||||||
relayProtocol: string | "unknown";
|
|
||||||
};
|
|
||||||
|
|
||||||
function delay(ms: number): Promise<void> {
|
function delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => compatGlobal.setTimeout(resolve, ms));
|
return new Promise((resolve) => compatGlobal.setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
@@ -90,74 +81,6 @@ function resolvePeer(peers: CLIP2PPeer[], peerToken: string): CLIP2PPeer | undef
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReportValue<T extends string | number>(
|
|
||||||
report: Record<string, unknown> | undefined,
|
|
||||||
key: string
|
|
||||||
): T | "unknown" {
|
|
||||||
const value = report?.[key];
|
|
||||||
return typeof value === "string" || typeof value === "number" ? (value as T) : "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
function summariseCandidate(reports: unknown[], candidateId: string | "unknown"): CandidateSummary | undefined {
|
|
||||||
if (candidateId === "unknown") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const report = reports.map((r) => r as Record<string, unknown>).find((r) => r.id === candidateId);
|
|
||||||
if (!report) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: candidateId,
|
|
||||||
candidateType: getReportValue<string>(report, "candidateType"),
|
|
||||||
protocol: getReportValue<string>(report, "protocol"),
|
|
||||||
relayProtocol: getReportValue<string>(report, "relayProtocol"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writePeerConnectionStatsIfRequested(
|
|
||||||
replicator: LiveSyncTrysteroReplicator,
|
|
||||||
peer: CLIP2PPeer
|
|
||||||
): Promise<void> {
|
|
||||||
const outputPath = process.env.LIVESYNC_P2P_STATS_JSONL?.trim();
|
|
||||||
if (!outputPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const peerConnection = replicator.rawHost?.room?.getPeers()[peer.peerId];
|
|
||||||
const stats = peerConnection ? await getPeerConnectionStats(`cli-p2p-${peer.peerId}`, peerConnection) : undefined;
|
|
||||||
const localCandidate = summariseCandidate(stats?.reports ?? [], stats?.localCandidateId ?? "unknown");
|
|
||||||
const remoteCandidate = summariseCandidate(stats?.reports ?? [], stats?.remoteCandidateId ?? "unknown");
|
|
||||||
const selectedPath =
|
|
||||||
localCandidate && remoteCandidate
|
|
||||||
? `${localCandidate.candidateType}<->${remoteCandidate.candidateType}`
|
|
||||||
: "unknown";
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
command: "p2p-sync",
|
|
||||||
peerId: peer.peerId,
|
|
||||||
peerName: peer.name,
|
|
||||||
candidatePathCollected: !!stats?.selectedPair,
|
|
||||||
selectedPath,
|
|
||||||
selectedPair: stats
|
|
||||||
? {
|
|
||||||
id: stats.selectedPairId,
|
|
||||||
state: stats.state,
|
|
||||||
currentRoundTripTime: stats.currentRoundTripTime,
|
|
||||||
totalRoundTripTime: stats.totalRoundTripTime,
|
|
||||||
requestsSent: stats.requestsSent,
|
|
||||||
responsesReceived: stats.responsesReceived,
|
|
||||||
packetsDiscardedOnSend: stats.packetsDiscardedOnSend,
|
|
||||||
bytesSent: stats.bytesSent,
|
|
||||||
bytesReceived: stats.bytesReceived,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
localCandidate,
|
|
||||||
remoteCandidate,
|
|
||||||
};
|
|
||||||
await appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncWithPeer(
|
export async function syncWithPeer(
|
||||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||||
peerToken: string,
|
peerToken: string,
|
||||||
@@ -195,7 +118,6 @@ export async function syncWithPeer(
|
|||||||
: LiveSyncError.fromError(err ?? "P2P sync failed while requesting remote sync");
|
: LiveSyncError.fromError(err ?? "P2P sync failed while requesting remote sync");
|
||||||
}
|
}
|
||||||
|
|
||||||
await writePeerConnectionStatsIfRequested(replicator, targetPeer);
|
|
||||||
return targetPeer;
|
return targetPeer;
|
||||||
} finally {
|
} finally {
|
||||||
await replicator.close();
|
await replicator.close();
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import {
|
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
applyRemoteSyncSettings,
|
|
||||||
initSettingsFile,
|
|
||||||
} from "./helpers/settings.ts";
|
|
||||||
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
||||||
import {
|
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
||||||
createCouchdbDatabase,
|
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
|
||||||
startCouchdb,
|
|
||||||
stopCouchdb,
|
|
||||||
} from "./helpers/docker.ts";
|
|
||||||
import {
|
|
||||||
createDeterministicDataset,
|
|
||||||
} from "./helpers/dataset.ts";
|
|
||||||
import {
|
|
||||||
type BenchmarkVerificationMode,
|
|
||||||
parseBenchmarkVerificationMode,
|
|
||||||
verifyBenchmarkDataset,
|
|
||||||
} from "./helpers/benchmarkVerification.ts";
|
|
||||||
|
|
||||||
type BenchmarkConfig = {
|
type BenchmarkConfig = {
|
||||||
caseName: string;
|
|
||||||
couchdbBackendUri: string;
|
couchdbBackendUri: string;
|
||||||
couchdbProxyUri: string;
|
couchdbProxyUri: string;
|
||||||
couchdbUser: string;
|
couchdbUser: string;
|
||||||
@@ -36,15 +21,6 @@ type BenchmarkConfig = {
|
|||||||
requestedRttMs: number;
|
requestedRttMs: number;
|
||||||
passphrase: string;
|
passphrase: string;
|
||||||
encrypt: boolean;
|
encrypt: boolean;
|
||||||
managedCouchdb: boolean;
|
|
||||||
simulationTier: string;
|
|
||||||
networkProfile: string;
|
|
||||||
networkModel: string;
|
|
||||||
measurementScope: string;
|
|
||||||
limitations: string[];
|
|
||||||
verificationMode: BenchmarkVerificationMode;
|
|
||||||
repeatIndex: number;
|
|
||||||
repeatCount: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function readEnvString(name: string, fallback: string): string {
|
function readEnvString(name: string, fallback: string): string {
|
||||||
@@ -73,30 +49,6 @@ function readEnvBool(name: string, fallback: boolean): boolean {
|
|||||||
return /^(1|true|yes|on)$/i.test(raw.trim());
|
return /^(1|true|yes|on)$/i.test(raw.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function readEnvStringArray(name: string, fallback: string[]): string[] {
|
|
||||||
const raw = Deno.env.get(name)?.trim();
|
|
||||||
if (!raw) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (
|
|
||||||
Array.isArray(parsed) &&
|
|
||||||
parsed.every((item) => typeof item === "string")
|
|
||||||
) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to pipe-separated parsing for hand-written invocations.
|
|
||||||
}
|
|
||||||
|
|
||||||
return raw
|
|
||||||
.split("|")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter((item) => item.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowMs(): number {
|
function nowMs(): number {
|
||||||
return performance.now();
|
return performance.now();
|
||||||
}
|
}
|
||||||
@@ -118,63 +70,22 @@ function formatBytes(value: number): string {
|
|||||||
|
|
||||||
function buildConfig(): BenchmarkConfig {
|
function buildConfig(): BenchmarkConfig {
|
||||||
return {
|
return {
|
||||||
caseName: readEnvString("BENCH_CASE", "couchdb-baseline"),
|
couchdbBackendUri: readEnvString("BENCH_COUCHDB_BACKEND_URI", "http://127.0.0.1:5989"),
|
||||||
couchdbBackendUri: readEnvString(
|
couchdbProxyUri: readEnvString("BENCH_COUCHDB_URI", "http://127.0.0.1:15989"),
|
||||||
"BENCH_COUCHDB_BACKEND_URI",
|
couchdbUser: readEnvString("BENCH_COUCHDB_USER", readEnvString("username", "admin")),
|
||||||
"http://127.0.0.1:5989",
|
couchdbPassword: readEnvString("BENCH_COUCHDB_PASSWORD", readEnvString("password", "password")),
|
||||||
),
|
couchdbDbname: readEnvString("BENCH_COUCHDB_DBNAME", `bench-couchdb-${Date.now()}`),
|
||||||
couchdbProxyUri: readEnvString(
|
|
||||||
"BENCH_COUCHDB_URI",
|
|
||||||
"http://127.0.0.1:15989",
|
|
||||||
),
|
|
||||||
couchdbUser: readEnvString(
|
|
||||||
"BENCH_COUCHDB_USER",
|
|
||||||
readEnvString("username", "admin"),
|
|
||||||
),
|
|
||||||
couchdbPassword: readEnvString(
|
|
||||||
"BENCH_COUCHDB_PASSWORD",
|
|
||||||
readEnvString("password", "password"),
|
|
||||||
),
|
|
||||||
couchdbDbname: readEnvString(
|
|
||||||
"BENCH_COUCHDB_DBNAME",
|
|
||||||
`bench-couchdb-${Date.now()}`,
|
|
||||||
),
|
|
||||||
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
|
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
|
||||||
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
||||||
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
|
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
|
||||||
mdMinSizeBytes: Math.floor(
|
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)),
|
||||||
readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024),
|
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)),
|
||||||
),
|
|
||||||
mdMaxSizeBytes: Math.floor(
|
|
||||||
readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024),
|
|
||||||
),
|
|
||||||
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)),
|
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)),
|
||||||
binSizeBytes: Math.floor(
|
binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024)),
|
||||||
readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024),
|
|
||||||
),
|
|
||||||
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
|
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
|
||||||
requestedRttMs: Math.floor(readEnvNumber("BENCH_COUCHDB_RTT_MS", 50)),
|
requestedRttMs: Math.floor(readEnvNumber("BENCH_COUCHDB_RTT_MS", 50)),
|
||||||
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
|
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
|
||||||
encrypt: readEnvBool("BENCH_ENCRYPT", true),
|
encrypt: readEnvBool("BENCH_ENCRYPT", true),
|
||||||
managedCouchdb: readEnvBool("BENCH_COUCHDB_MANAGED", true),
|
|
||||||
simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"),
|
|
||||||
networkProfile: readEnvString(
|
|
||||||
"BENCH_NETWORK_PROFILE",
|
|
||||||
"http-latency-proxy",
|
|
||||||
),
|
|
||||||
networkModel: readEnvString("BENCH_NETWORK_MODEL", "local-http-proxy"),
|
|
||||||
measurementScope: readEnvString(
|
|
||||||
"BENCH_MEASUREMENT_SCOPE",
|
|
||||||
"Two one-shot synchronisation phases through a CouchDB-compatible remote-store path.",
|
|
||||||
),
|
|
||||||
limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [
|
|
||||||
"This benchmark result is scoped to the configured dataset, remote store, and network model.",
|
|
||||||
]),
|
|
||||||
verificationMode: parseBenchmarkVerificationMode(
|
|
||||||
Deno.env.get("BENCH_VERIFY_MODE"),
|
|
||||||
),
|
|
||||||
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
|
|
||||||
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,27 +97,33 @@ function readOptionalResultPath(): string | undefined {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CouchdbProxyHandle = {
|
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const md = entries.find((e) => e.kind === "md");
|
||||||
|
const bin = entries.find((e) => e.kind === "bin");
|
||||||
|
const middle = entries[Math.floor(entries.length / 2)];
|
||||||
|
const last = entries[entries.length - 1];
|
||||||
|
const unique = new Map<string, DatasetEntry>();
|
||||||
|
for (const entry of [md, bin, middle, last]) {
|
||||||
|
if (entry) {
|
||||||
|
unique.set(entry.relativePath, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...unique.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyHandle = {
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
applied: boolean;
|
applied: boolean;
|
||||||
note: string;
|
note: string;
|
||||||
directionalDelayMs: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function startCouchdbProxy(
|
function startCouchdbProxy(options: { backendUri: string; proxyUri: string; requestedRttMs: number }): ProxyHandle {
|
||||||
options: {
|
|
||||||
backendUri: string;
|
|
||||||
proxyUri: string;
|
|
||||||
requestedRttMs: number;
|
|
||||||
delay?: (milliseconds: number) => Promise<void>;
|
|
||||||
},
|
|
||||||
): CouchdbProxyHandle {
|
|
||||||
const backend = new URL(options.backendUri);
|
const backend = new URL(options.backendUri);
|
||||||
const proxy = new URL(options.proxyUri);
|
const proxy = new URL(options.proxyUri);
|
||||||
const halfDelayMs = options.requestedRttMs / 2;
|
const halfDelayMs = Math.max(1, Math.floor(options.requestedRttMs / 2));
|
||||||
const delay = options.delay ??
|
|
||||||
((milliseconds: number) =>
|
|
||||||
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
const listener = Deno.serve(
|
const listener = Deno.serve(
|
||||||
@@ -220,7 +137,7 @@ export function startCouchdbProxy(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
await delay(halfDelayMs);
|
await new Promise((resolve) => setTimeout(resolve, halfDelayMs));
|
||||||
|
|
||||||
const targetUrl = new URL(request.url);
|
const targetUrl = new URL(request.url);
|
||||||
targetUrl.protocol = backend.protocol;
|
targetUrl.protocol = backend.protocol;
|
||||||
@@ -249,21 +166,18 @@ export function startCouchdbProxy(
|
|||||||
const responseHeaders = new Headers(upstream.headers);
|
const responseHeaders = new Headers(upstream.headers);
|
||||||
responseHeaders.delete("content-length");
|
responseHeaders.delete("content-length");
|
||||||
const responseBody = await upstream.arrayBuffer();
|
const responseBody = await upstream.arrayBuffer();
|
||||||
await delay(halfDelayMs);
|
|
||||||
|
|
||||||
return new Response(responseBody, {
|
return new Response(responseBody, {
|
||||||
status: upstream.status,
|
status: upstream.status,
|
||||||
statusText: upstream.statusText,
|
statusText: upstream.statusText,
|
||||||
headers: responseHeaders,
|
headers: responseHeaders,
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
applied: true,
|
applied: true,
|
||||||
directionalDelayMs: halfDelayMs,
|
note: `local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms pre-forward delay`,
|
||||||
note:
|
|
||||||
`local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms request-path and ${halfDelayMs}ms response-path delay`,
|
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
await listener.finished.catch(() => {});
|
await listener.finished.catch(() => {});
|
||||||
@@ -286,24 +200,7 @@ async function main(): Promise<void> {
|
|||||||
await initSettingsFile(settingsA);
|
await initSettingsFile(settingsA);
|
||||||
await initSettingsFile(settingsB);
|
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}`,
|
|
||||||
);
|
|
||||||
await createCouchdbDatabase(
|
|
||||||
config.couchdbBackendUri,
|
|
||||||
config.couchdbUser,
|
|
||||||
config.couchdbPassword,
|
|
||||||
config.couchdbDbname,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxy = startCouchdbProxy({
|
const proxy = startCouchdbProxy({
|
||||||
backendUri: config.couchdbBackendUri,
|
backendUri: config.couchdbBackendUri,
|
||||||
@@ -356,99 +253,54 @@ async function main(): Promise<void> {
|
|||||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||||
const syncBElapsed = nowMs() - syncBStart;
|
const syncBElapsed = nowMs() - syncBStart;
|
||||||
|
|
||||||
const verification = await verifyBenchmarkDataset(
|
const sampleFiles = pickSampleFiles(seedFiles.entries);
|
||||||
seedFiles.entries,
|
for (const sample of sampleFiles) {
|
||||||
config.verificationMode,
|
const pulledPath = workDir.join(`pulled-${sample.relativePath.split("/").join("_")}`);
|
||||||
async (entry) => {
|
await runCliOrFail(vaultB, "--settings", settingsB, "pull", sample.relativePath, pulledPath);
|
||||||
const pulledPath = workDir.join(
|
await assertFilesEqual(
|
||||||
`pulled-${entry.relativePath.split("/").join("_")}`,
|
sample.absolutePath,
|
||||||
);
|
pulledPath,
|
||||||
await runCliOrFail(
|
`sample file mismatch after CouchDB sync: ${sample.relativePath}`
|
||||||
vaultB,
|
);
|
||||||
"--settings",
|
}
|
||||||
settingsB,
|
|
||||||
"pull",
|
|
||||||
entry.relativePath,
|
|
||||||
pulledPath,
|
|
||||||
);
|
|
||||||
await assertFilesEqual(
|
|
||||||
entry.absolutePath,
|
|
||||||
pulledPath,
|
|
||||||
`file mismatch after CouchDB sync: ${entry.relativePath}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
caseName: config.caseName,
|
|
||||||
mode: "couchdb-cli-benchmark",
|
mode: "couchdb-cli-benchmark",
|
||||||
couchdbBackendUri: config.couchdbBackendUri,
|
couchdbBackendUri: config.couchdbBackendUri,
|
||||||
couchdbProxyUri: config.couchdbProxyUri,
|
couchdbProxyUri: config.couchdbProxyUri,
|
||||||
couchdbDbname: config.couchdbDbname,
|
couchdbDbname: config.couchdbDbname,
|
||||||
managedCouchdb: config.managedCouchdb,
|
|
||||||
simulationTier: config.simulationTier,
|
|
||||||
networkProfile: config.networkProfile,
|
|
||||||
networkModel: config.networkModel,
|
|
||||||
measurementScope: config.measurementScope,
|
|
||||||
limitations: config.limitations,
|
|
||||||
repeatIndex: config.repeatIndex,
|
|
||||||
repeatCount: config.repeatCount,
|
|
||||||
rttRequestedMs: config.requestedRttMs,
|
rttRequestedMs: config.requestedRttMs,
|
||||||
proxyApplied: proxy.applied,
|
proxyApplied: proxy.applied,
|
||||||
proxyNote: proxy.note,
|
proxyNote: proxy.note,
|
||||||
proxyDirectionalDelayMs: proxy.directionalDelayMs,
|
|
||||||
proxyConfiguredRttMs: proxy.directionalDelayMs * 2,
|
|
||||||
proxyDelayApplication: "request-and-response",
|
|
||||||
datasetSeed: config.datasetSeed,
|
datasetSeed: config.datasetSeed,
|
||||||
datasetDirName: config.datasetDirName,
|
datasetDirName: config.datasetDirName,
|
||||||
totalFiles: seedFiles.totalFiles,
|
totalFiles: seedFiles.totalFiles,
|
||||||
totalBytes: seedFiles.totalBytes,
|
totalBytes: seedFiles.totalBytes,
|
||||||
mdFileCount: seedFiles.mdCount,
|
mdFileCount: seedFiles.mdCount,
|
||||||
binFileCount: seedFiles.binCount,
|
binFileCount: seedFiles.binCount,
|
||||||
...verification,
|
|
||||||
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
|
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
|
||||||
syncAElapsedMs: Number(syncAElapsed.toFixed(1)),
|
syncAElapsedMs: Number(syncAElapsed.toFixed(1)),
|
||||||
syncBElapsedMs: Number(syncBElapsed.toFixed(1)),
|
syncBElapsedMs: Number(syncBElapsed.toFixed(1)),
|
||||||
totalSyncElapsedMs: Number(
|
totalSyncElapsedMs: Number((syncAElapsed + syncBElapsed).toFixed(1)),
|
||||||
(syncAElapsed + syncBElapsed).toFixed(1),
|
throughputBytesPerSec: Number((seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000)).toFixed(2)),
|
||||||
),
|
|
||||||
throughputBytesPerSec: Number(
|
|
||||||
(seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000))
|
|
||||||
.toFixed(
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
throughputMiBPerSec: Number(
|
throughputMiBPerSec: Number(
|
||||||
(seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000) /
|
(seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000) / 1024 / 1024).toFixed(4)
|
||||||
1024 /
|
|
||||||
1024).toFixed(4),
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (resultPath) {
|
if (resultPath) {
|
||||||
await Deno.writeTextFile(
|
await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2));
|
||||||
resultPath,
|
|
||||||
JSON.stringify(result, null, 2),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
console.error(
|
console.error(
|
||||||
`[Benchmark] couchdb mirrored ${seedFiles.totalFiles} files (${
|
`[Benchmark] couchdb mirrored ${seedFiles.totalFiles} files (${formatBytes(seedFiles.totalBytes)}) in ${formatMs(
|
||||||
formatBytes(seedFiles.totalBytes)
|
mirrorElapsed
|
||||||
}) in ${
|
)}, synced in ${formatMs(syncAElapsed + syncBElapsed)} (${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`
|
||||||
formatMs(
|
|
||||||
mirrorElapsed,
|
|
||||||
)
|
|
||||||
}, synced in ${
|
|
||||||
formatMs(syncAElapsed + syncBElapsed)
|
|
||||||
} (${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`,
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await proxy.stop();
|
await proxy.stop();
|
||||||
if (config.managedCouchdb) {
|
await stopCouchdb().catch(() => {});
|
||||||
await stopCouchdb().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
type SweepResult = {
|
|
||||||
name: string;
|
|
||||||
runner: "p2p" | "couchdb";
|
|
||||||
rttMs?: number;
|
|
||||||
repeatIndex: number;
|
|
||||||
repeatCount: number;
|
|
||||||
result: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function readEnvString(name: string, fallback: string): string {
|
|
||||||
const value = Deno.env.get(name)?.trim();
|
|
||||||
return value && value.length > 0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function timestamp(): string {
|
|
||||||
const d = new Date();
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return (
|
|
||||||
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-` +
|
|
||||||
`${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readEnvInteger(name: string, fallback: number): number {
|
|
||||||
const raw = readEnvString(name, String(fallback));
|
|
||||||
const parsed = Number(raw);
|
|
||||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
||||||
throw new Error(`${name} must be a positive integer, got '${raw}'`);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRttList(raw: string): number[] {
|
|
||||||
const values = raw
|
|
||||||
.split(",")
|
|
||||||
.map((value) => Number(value.trim()))
|
|
||||||
.filter((value) => Number.isFinite(value) && value > 0)
|
|
||||||
.map((value) => Math.floor(value));
|
|
||||||
if (values.length === 0) {
|
|
||||||
throw new Error(`BENCH_SWEEP_RTT_MS must contain at least one positive number, got '${raw}'`);
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBaseEnv(): Record<string, string> {
|
|
||||||
return {
|
|
||||||
BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"),
|
|
||||||
BENCH_MD_MIN_SIZE_BYTES: readEnvString("BENCH_MD_MIN_SIZE_BYTES", "512"),
|
|
||||||
BENCH_MD_MAX_SIZE_BYTES: readEnvString("BENCH_MD_MAX_SIZE_BYTES", "2048"),
|
|
||||||
BENCH_BIN_FILE_COUNT: readEnvString("BENCH_BIN_FILE_COUNT", "5"),
|
|
||||||
BENCH_BIN_SIZE_BYTES: readEnvString("BENCH_BIN_SIZE_BYTES", "8192"),
|
|
||||||
BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"),
|
|
||||||
BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"),
|
|
||||||
BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
|
||||||
BENCH_VERIFY_MODE: readEnvString("BENCH_VERIFY_MODE", "all"),
|
|
||||||
LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBenchmark(options: {
|
|
||||||
taskName: "bench:p2p" | "bench:couchdb";
|
|
||||||
name: string;
|
|
||||||
outputDir: string;
|
|
||||||
env: Record<string, string>;
|
|
||||||
repeatIndex: number;
|
|
||||||
repeatCount: number;
|
|
||||||
}): Promise<Record<string, unknown>> {
|
|
||||||
const suffix = options.repeatCount > 1
|
|
||||||
? `-r${String(options.repeatIndex).padStart(2, "0")}`
|
|
||||||
: "";
|
|
||||||
const resultPath = `${options.outputDir}/${options.name}${suffix}.json`;
|
|
||||||
const env = {
|
|
||||||
...Deno.env.toObject(),
|
|
||||||
...options.env,
|
|
||||||
BENCH_RESULT_JSON: resultPath,
|
|
||||||
BENCH_REPEAT_INDEX: String(options.repeatIndex),
|
|
||||||
BENCH_REPEAT_COUNT: String(options.repeatCount),
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`[latency-sweep] running ${options.name}`);
|
|
||||||
const child = new Deno.Command("deno", {
|
|
||||||
args: ["task", options.taskName],
|
|
||||||
cwd: import.meta.dirname,
|
|
||||||
env,
|
|
||||||
stdin: "null",
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
}).spawn();
|
|
||||||
const status = await child.status;
|
|
||||||
if (status.code !== 0) {
|
|
||||||
throw new Error(`benchmark failed: ${options.name} (exit ${status.code})`);
|
|
||||||
}
|
|
||||||
return JSON.parse(await Deno.readTextFile(resultPath)) as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const outRoot = readEnvString("BENCH_SWEEP_ROOT", `${import.meta.dirname}/bench-results`);
|
|
||||||
const outputDir = `${outRoot}/latency-sweep-${timestamp()}`;
|
|
||||||
const rtts = parseRttList(readEnvString("BENCH_SWEEP_RTT_MS", "20,50,100,150,300"));
|
|
||||||
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
|
|
||||||
const base = buildBaseEnv();
|
|
||||||
|
|
||||||
await Deno.mkdir(outputDir, { recursive: true });
|
|
||||||
|
|
||||||
const results: SweepResult[] = [];
|
|
||||||
if (readEnvString("BENCH_SWEEP_INCLUDE_P2P", "true") !== "false") {
|
|
||||||
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
|
|
||||||
const p2pResult = await runBenchmark({
|
|
||||||
taskName: "bench:p2p",
|
|
||||||
name: "p2p-direct-local",
|
|
||||||
outputDir,
|
|
||||||
repeatIndex,
|
|
||||||
repeatCount,
|
|
||||||
env: {
|
|
||||||
...base,
|
|
||||||
BENCH_CASE: "p2p-direct-local",
|
|
||||||
BENCH_TURN_SERVERS: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
results.push({
|
|
||||||
name: "p2p-direct-local",
|
|
||||||
runner: "p2p",
|
|
||||||
repeatIndex,
|
|
||||||
repeatCount,
|
|
||||||
result: p2pResult,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const rtt of rtts) {
|
|
||||||
const name = `couchdb-rtt-${rtt}ms`;
|
|
||||||
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
|
|
||||||
const couchdbResult = await runBenchmark({
|
|
||||||
taskName: "bench:couchdb",
|
|
||||||
name,
|
|
||||||
outputDir,
|
|
||||||
repeatIndex,
|
|
||||||
repeatCount,
|
|
||||||
env: {
|
|
||||||
...base,
|
|
||||||
BENCH_CASE: name,
|
|
||||||
BENCH_COUCHDB_RTT_MS: String(rtt),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
results.push({
|
|
||||||
name,
|
|
||||||
runner: "couchdb",
|
|
||||||
rttMs: rtt,
|
|
||||||
repeatIndex,
|
|
||||||
repeatCount,
|
|
||||||
result: couchdbResult,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = {
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
outputDir,
|
|
||||||
note:
|
|
||||||
"This sweep applies half of each requested CouchDB RTT before forwarding requests and half before returning responses. It is not a full netem model of jitter, loss, MTU, bandwidth, or VPN encapsulation.",
|
|
||||||
rtts,
|
|
||||||
repeatCount,
|
|
||||||
results,
|
|
||||||
};
|
|
||||||
await Deno.writeTextFile(`${outputDir}/summary.json`, JSON.stringify(summary, null, 2));
|
|
||||||
console.log(JSON.stringify(summary, null, 2));
|
|
||||||
console.log(`[latency-sweep] result directory: ${outputDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error("[Fatal Error]", error);
|
|
||||||
Deno.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
export type BenchmarkCase = {
|
|
||||||
name: string;
|
|
||||||
runner: "p2p" | "couchdb";
|
|
||||||
description: string;
|
|
||||||
dataPath: string;
|
|
||||||
trustBoundary: string;
|
|
||||||
measurementScope: string;
|
|
||||||
limitations: string[];
|
|
||||||
env: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function readEnvString(name: string, fallback: string): string {
|
|
||||||
const value = Deno.env.get(name)?.trim();
|
|
||||||
return value && value.length > 0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readEnvInteger(name: string, fallback: number): number {
|
|
||||||
const value = readEnvString(name, String(fallback));
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
||||||
throw new Error(`${name} must be a positive integer, got '${value}'`);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function timestamp(): string {
|
|
||||||
const d = new Date();
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return (
|
|
||||||
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${
|
|
||||||
pad(d.getUTCDate())
|
|
||||||
}-` +
|
|
||||||
`${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${
|
|
||||||
pad(d.getUTCSeconds())
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBaseEnv(): Record<string, string> {
|
|
||||||
return {
|
|
||||||
BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"),
|
|
||||||
BENCH_MD_MIN_SIZE_BYTES: readEnvString(
|
|
||||||
"BENCH_MD_MIN_SIZE_BYTES",
|
|
||||||
"512",
|
|
||||||
),
|
|
||||||
BENCH_MD_MAX_SIZE_BYTES: readEnvString(
|
|
||||||
"BENCH_MD_MAX_SIZE_BYTES",
|
|
||||||
"2048",
|
|
||||||
),
|
|
||||||
BENCH_BIN_FILE_COUNT: readEnvString("BENCH_BIN_FILE_COUNT", "5"),
|
|
||||||
BENCH_BIN_SIZE_BYTES: readEnvString("BENCH_BIN_SIZE_BYTES", "8192"),
|
|
||||||
BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"),
|
|
||||||
BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"),
|
|
||||||
BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
|
||||||
BENCH_VERIFY_MODE: readEnvString("BENCH_VERIFY_MODE", "all"),
|
|
||||||
LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function withScopeEnv(
|
|
||||||
env: Record<string, string>,
|
|
||||||
options: Pick<BenchmarkCase, "measurementScope" | "limitations">,
|
|
||||||
): Record<string, string> {
|
|
||||||
return {
|
|
||||||
...env,
|
|
||||||
BENCH_MEASUREMENT_SCOPE: options.measurementScope,
|
|
||||||
BENCH_LIMITATIONS_JSON: JSON.stringify(options.limitations),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function defineCase(testCase: BenchmarkCase): BenchmarkCase {
|
|
||||||
return {
|
|
||||||
...testCase,
|
|
||||||
env: withScopeEnv(testCase.env, testCase),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildCases(): BenchmarkCase[] {
|
|
||||||
const base = buildBaseEnv();
|
|
||||||
const couchdbRtt = readEnvString("BENCH_COUCHDB_RTT_MS", "20");
|
|
||||||
const tetheringVpnRtt = readEnvString("BENCH_TETHERING_VPN_RTT_MS", "120");
|
|
||||||
const localTurnServers = readEnvString(
|
|
||||||
"BENCH_LOCAL_TURN_SERVERS",
|
|
||||||
"turn:127.0.0.1:3478",
|
|
||||||
);
|
|
||||||
const shimCouchdbUri = readEnvString(
|
|
||||||
"BENCH_SHIM_COUCHDB_URI",
|
|
||||||
"http://couchdb-shim:5984",
|
|
||||||
);
|
|
||||||
const signallingShimRelay = readEnvString(
|
|
||||||
"BENCH_SIGNAL_SHIM_RELAY",
|
|
||||||
"ws://p2p-signalling-shim:7777/",
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
defineCase({
|
|
||||||
name: "couchdb-baseline",
|
|
||||||
runner: "couchdb",
|
|
||||||
description:
|
|
||||||
"Standard self-hosted CouchDB path through a local latency proxy.",
|
|
||||||
dataPath: "Device A -> CouchDB -> Device B",
|
|
||||||
trustBoundary: "CouchDB operator and network path",
|
|
||||||
measurementScope:
|
|
||||||
"Two one-shot synchronisation phases through a CouchDB-compatible remote-store path with a local HTTP latency proxy.",
|
|
||||||
limitations: [
|
|
||||||
"This is not a full netem model of packet loss, jitter, MTU, bandwidth limits, or VPN encapsulation.",
|
|
||||||
"This result should be compared with P2P only as a remote-store baseline under the same deterministic dataset.",
|
|
||||||
],
|
|
||||||
env: {
|
|
||||||
...base,
|
|
||||||
BENCH_CASE: "couchdb-baseline",
|
|
||||||
BENCH_COUCHDB_RTT_MS: couchdbRtt,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defineCase({
|
|
||||||
name: "p2p-direct-local",
|
|
||||||
runner: "p2p",
|
|
||||||
description:
|
|
||||||
"Preferred direct WebRTC P2P path with Nostr signalling and TURN disabled.",
|
|
||||||
dataPath: "Device A -> Device B",
|
|
||||||
trustBoundary: "Nostr relay for signalling metadata; no TURN relay",
|
|
||||||
measurementScope:
|
|
||||||
"One fresh CLI p2p-sync command, including process start-up and WebRTC connection establishment, with TURN disabled; the earlier peer-list observation command is excluded.",
|
|
||||||
limitations: [
|
|
||||||
"The timed command includes its own signalling and connection establishment, but not the earlier peer-list observation window.",
|
|
||||||
"This does not measure public relay operation, mobile carrier behaviour, or TURN-relayed throughput.",
|
|
||||||
"This small-dataset run should not be treated as a WAN, VPN, or large binary initial synchronisation measurement.",
|
|
||||||
],
|
|
||||||
env: {
|
|
||||||
...base,
|
|
||||||
BENCH_CASE: "p2p-direct-local",
|
|
||||||
BENCH_TURN_SERVERS: "",
|
|
||||||
BENCH_SIMULATION_TIER: "1",
|
|
||||||
BENCH_NETWORK_PROFILE: "local-direct",
|
|
||||||
BENCH_NETWORK_MODEL: "local-runner-webrtc",
|
|
||||||
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
|
|
||||||
"turn-disabled-but-selected-ice-pair-not-collected",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defineCase({
|
|
||||||
name: "couchdb-tethering-vpn-proxy",
|
|
||||||
runner: "couchdb",
|
|
||||||
description:
|
|
||||||
"Approximate smartphone tethering/VPN remote-database path using an HTTP latency proxy. This does not model loss, jitter, MTU, or VPN encapsulation.",
|
|
||||||
dataPath:
|
|
||||||
"Device A -> VPN/network path -> CouchDB -> VPN/network path -> Device B",
|
|
||||||
trustBoundary: "VPN/network path and CouchDB operator",
|
|
||||||
measurementScope:
|
|
||||||
"Two one-shot CouchDB synchronisation phases with additional requested RTT through the local HTTP proxy.",
|
|
||||||
limitations: [
|
|
||||||
"This approximates request latency only and does not model loss, jitter, MTU, bandwidth limits, carrier NAT, or VPN encapsulation.",
|
|
||||||
"Use the Tier 2 netem shim cases for a stronger constrained-network fixture.",
|
|
||||||
],
|
|
||||||
env: {
|
|
||||||
...base,
|
|
||||||
BENCH_CASE: "couchdb-tethering-vpn-proxy",
|
|
||||||
BENCH_COUCHDB_RTT_MS: tetheringVpnRtt,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defineCase({
|
|
||||||
name: "couchdb-netem-home-wifi",
|
|
||||||
runner: "couchdb",
|
|
||||||
description:
|
|
||||||
"Tier 2 CouchDB path through the Compose netem TCP shim using the home-wifi profile.",
|
|
||||||
dataPath:
|
|
||||||
"Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B",
|
|
||||||
trustBoundary: "CouchDB operator and constrained network shim",
|
|
||||||
measurementScope:
|
|
||||||
"Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the home-wifi netem profile.",
|
|
||||||
limitations: [
|
|
||||||
"This shapes the CouchDB TCP path, not the WebRTC P2P data path.",
|
|
||||||
"The fixture remains a reproducible network emulation, not a field measurement on a real home network.",
|
|
||||||
],
|
|
||||||
env: {
|
|
||||||
...base,
|
|
||||||
BENCH_CASE: "couchdb-netem-home-wifi",
|
|
||||||
BENCH_COUCHDB_BACKEND_URI: shimCouchdbUri,
|
|
||||||
BENCH_COUCHDB_RTT_MS: "1",
|
|
||||||
BENCH_SIMULATION_TIER: "2",
|
|
||||||
BENCH_NETWORK_PROFILE: "home-wifi",
|
|
||||||
BENCH_NETWORK_MODEL: "compose-netem-tcp-shim",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defineCase({
|
|
||||||
name: "couchdb-netem-tethering-vpn",
|
|
||||||
runner: "couchdb",
|
|
||||||
description:
|
|
||||||
"Tier 2 CouchDB path through the Compose netem TCP shim using a tethering-vpn profile.",
|
|
||||||
dataPath:
|
|
||||||
"Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B",
|
|
||||||
trustBoundary:
|
|
||||||
"CouchDB operator and constrained smartphone/VPN-like network shim",
|
|
||||||
measurementScope:
|
|
||||||
"Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the tethering-vpn netem profile.",
|
|
||||||
limitations: [
|
|
||||||
"This shapes the CouchDB TCP path, not the WebRTC P2P data path.",
|
|
||||||
"The profile approximates smartphone/VPN constraints but is not a field measurement on a real tethered VPN connection.",
|
|
||||||
],
|
|
||||||
env: {
|
|
||||||
...base,
|
|
||||||
BENCH_CASE: "couchdb-netem-tethering-vpn",
|
|
||||||
BENCH_COUCHDB_BACKEND_URI: shimCouchdbUri,
|
|
||||||
BENCH_COUCHDB_RTT_MS: "1",
|
|
||||||
BENCH_SIMULATION_TIER: "2",
|
|
||||||
BENCH_NETWORK_PROFILE: "tethering-vpn",
|
|
||||||
BENCH_NETWORK_MODEL: "compose-netem-tcp-shim",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defineCase({
|
|
||||||
name: "p2p-smartphone-vpn-direct",
|
|
||||||
runner: "p2p",
|
|
||||||
description:
|
|
||||||
"Direct P2P case name for smartphone tethering/VPN measurements. In this local runner it is unshaped and should be treated as a wiring check unless executed on that network.",
|
|
||||||
dataPath:
|
|
||||||
"Device A -> Device B when WebRTC direct connectivity succeeds",
|
|
||||||
trustBoundary:
|
|
||||||
"Smartphone/VPN routing policy plus Nostr signalling metadata",
|
|
||||||
measurementScope:
|
|
||||||
"Structural placeholder for direct P2P measurements on a real smartphone tethering/VPN path.",
|
|
||||||
limitations: [
|
|
||||||
"In the local runner this is unshaped and must not be reported as smartphone, VPN, WAN, or Tier 2 evidence.",
|
|
||||||
"Use only when the command is executed on the intended real network path and the selected ICE candidate pair is recorded.",
|
|
||||||
],
|
|
||||||
env: {
|
|
||||||
...base,
|
|
||||||
BENCH_CASE: "p2p-smartphone-vpn-direct",
|
|
||||||
BENCH_TURN_SERVERS: "",
|
|
||||||
BENCH_SIMULATION_TIER: "unmeasured",
|
|
||||||
BENCH_NETWORK_PROFILE: "smartphone-vpn-direct-placeholder",
|
|
||||||
BENCH_NETWORK_MODEL: "local-runner-no-netem",
|
|
||||||
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
|
|
||||||
"structural-placeholder-only; selected ICE pair may be collected, but the path is not shaped",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defineCase({
|
|
||||||
name: "p2p-signalling-netem-home-wifi",
|
|
||||||
runner: "p2p",
|
|
||||||
description:
|
|
||||||
"Tier 2 P2P path with only the Nostr signalling relay accessed through the home-wifi netem shim.",
|
|
||||||
dataPath:
|
|
||||||
"Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim",
|
|
||||||
trustBoundary:
|
|
||||||
"Nostr signalling metadata through constrained network shim; no TURN relay",
|
|
||||||
measurementScope:
|
|
||||||
"One fresh CLI p2p-sync command where only Nostr signalling access is shaped by the home-wifi netem profile; the selected WebRTC note-data path is unshaped.",
|
|
||||||
limitations: [
|
|
||||||
"The timed p2p-sync command includes signalling and WebRTC connection establishment.",
|
|
||||||
"This does not shape the selected WebRTC DataChannel note-data path.",
|
|
||||||
"This supports only the claim that constrained signalling access does not place note data on the relay path when a non-relayed ICE path is selected.",
|
|
||||||
],
|
|
||||||
env: {
|
|
||||||
...base,
|
|
||||||
BENCH_CASE: "p2p-signalling-netem-home-wifi",
|
|
||||||
BENCH_RELAY: signallingShimRelay,
|
|
||||||
BENCH_TURN_SERVERS: "",
|
|
||||||
BENCH_SIMULATION_TIER: "2",
|
|
||||||
BENCH_NETWORK_PROFILE: "home-wifi",
|
|
||||||
BENCH_NETWORK_MODEL: "compose-netem-signalling-shim",
|
|
||||||
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
|
|
||||||
"selected ICE pair collected; only Nostr signalling path is shaped",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defineCase({
|
|
||||||
name: "p2p-signalling-netem-tethering-vpn",
|
|
||||||
runner: "p2p",
|
|
||||||
description:
|
|
||||||
"Tier 2 P2P path with only the Nostr signalling relay accessed through the tethering-vpn netem shim.",
|
|
||||||
dataPath:
|
|
||||||
"Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim",
|
|
||||||
trustBoundary:
|
|
||||||
"Nostr signalling metadata through constrained smartphone/VPN-like network shim; no TURN relay",
|
|
||||||
measurementScope:
|
|
||||||
"One fresh CLI p2p-sync command where only Nostr signalling access is shaped by the tethering-vpn netem profile; the selected WebRTC note-data path is unshaped.",
|
|
||||||
limitations: [
|
|
||||||
"The timed p2p-sync command includes signalling and WebRTC connection establishment.",
|
|
||||||
"This does not shape the selected WebRTC DataChannel note-data path.",
|
|
||||||
"The profile approximates constrained relay access and is not a field measurement on a real tethered VPN connection.",
|
|
||||||
],
|
|
||||||
env: {
|
|
||||||
...base,
|
|
||||||
BENCH_CASE: "p2p-signalling-netem-tethering-vpn",
|
|
||||||
BENCH_RELAY: signallingShimRelay,
|
|
||||||
BENCH_TURN_SERVERS: "",
|
|
||||||
BENCH_SIMULATION_TIER: "2",
|
|
||||||
BENCH_NETWORK_PROFILE: "tethering-vpn",
|
|
||||||
BENCH_NETWORK_MODEL: "compose-netem-signalling-shim",
|
|
||||||
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
|
|
||||||
"selected ICE pair collected; only Nostr signalling path is shaped",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defineCase({
|
|
||||||
name: "p2p-user-turn",
|
|
||||||
runner: "p2p",
|
|
||||||
description:
|
|
||||||
"Optional fallback path through a local user-controlled TURN server.",
|
|
||||||
dataPath: "Device A -> user-controlled TURN -> Device B",
|
|
||||||
trustBoundary: "User-controlled TURN server",
|
|
||||||
measurementScope:
|
|
||||||
"Optional local TURN fallback wiring check with a user-controlled TURN server configured.",
|
|
||||||
limitations: [
|
|
||||||
"TURN configuration does not prove that the selected ICE path was relayed; interpret the recorded candidate pair.",
|
|
||||||
"This is not evidence for public TURN relay privacy, throughput, or availability.",
|
|
||||||
],
|
|
||||||
env: {
|
|
||||||
...base,
|
|
||||||
BENCH_CASE: "p2p-user-turn",
|
|
||||||
BENCH_TURN_SERVERS: localTurnServers,
|
|
||||||
BENCH_SIMULATION_TIER: "1",
|
|
||||||
BENCH_NETWORK_PROFILE: "local-turn-fallback",
|
|
||||||
BENCH_NETWORK_MODEL: "local-runner-webrtc-turn-configured",
|
|
||||||
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
|
|
||||||
"turn-configured; selected ICE pair may still be direct or relayed, so interpret the recorded candidate types",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCase(
|
|
||||||
testCase: BenchmarkCase,
|
|
||||||
outputDir: string,
|
|
||||||
repeatIndex: number,
|
|
||||||
repeatCount: number,
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
const suffix = repeatCount > 1
|
|
||||||
? `-r${String(repeatIndex).padStart(2, "0")}`
|
|
||||||
: "";
|
|
||||||
const resultPath = `${outputDir}/${testCase.name}${suffix}.json`;
|
|
||||||
const taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb";
|
|
||||||
const env = {
|
|
||||||
...Deno.env.toObject(),
|
|
||||||
...testCase.env,
|
|
||||||
BENCH_RESULT_JSON: resultPath,
|
|
||||||
BENCH_REPEAT_INDEX: String(repeatIndex),
|
|
||||||
BENCH_REPEAT_COUNT: String(repeatCount),
|
|
||||||
};
|
|
||||||
|
|
||||||
const repeatLabel = repeatCount > 1
|
|
||||||
? ` (${repeatIndex}/${repeatCount})`
|
|
||||||
: "";
|
|
||||||
console.log(
|
|
||||||
`[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`,
|
|
||||||
);
|
|
||||||
const command = new Deno.Command("deno", {
|
|
||||||
args: ["task", taskName],
|
|
||||||
cwd: import.meta.dirname,
|
|
||||||
env,
|
|
||||||
stdin: "null",
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
});
|
|
||||||
|
|
||||||
const child = command.spawn();
|
|
||||||
const status = await child.status;
|
|
||||||
if (status.code !== 0) {
|
|
||||||
throw new Error(`case failed: ${testCase.name} (exit ${status.code})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = JSON.parse(await Deno.readTextFile(resultPath)) as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
return {
|
|
||||||
...testCase,
|
|
||||||
repeatIndex,
|
|
||||||
repeatCount,
|
|
||||||
resultPath,
|
|
||||||
result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] {
|
|
||||||
const requested = readEnvString(
|
|
||||||
"BENCH_CASES",
|
|
||||||
"couchdb-baseline,p2p-direct-local",
|
|
||||||
);
|
|
||||||
const names = requested
|
|
||||||
.split(",")
|
|
||||||
.map((v) => v.trim())
|
|
||||||
.filter((v) => v.length > 0);
|
|
||||||
const byName = new Map(allCases.map((c) => [c.name, c]));
|
|
||||||
return names.map((name) => {
|
|
||||||
const found = byName.get(name);
|
|
||||||
if (!found) {
|
|
||||||
throw new Error(
|
|
||||||
`Unknown BENCH_CASES entry '${name}'. Available: ${
|
|
||||||
allCases.map((c) => c.name).join(", ")
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const outRoot = readEnvString(
|
|
||||||
"BENCH_CASES_ROOT",
|
|
||||||
`${import.meta.dirname}/bench-results`,
|
|
||||||
);
|
|
||||||
const outputDir = `${outRoot}/cases-${timestamp()}`;
|
|
||||||
await Deno.mkdir(outputDir, { recursive: true });
|
|
||||||
|
|
||||||
const allCases = buildCases();
|
|
||||||
const cases = selectCases(allCases);
|
|
||||||
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
|
|
||||||
await Deno.writeTextFile(
|
|
||||||
`${outputDir}/case-manifest.json`,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
repeatCount,
|
|
||||||
selectedCases: cases,
|
|
||||||
availableCases: allCases,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results: Record<string, unknown>[] = [];
|
|
||||||
for (const testCase of cases) {
|
|
||||||
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
|
|
||||||
results.push(
|
|
||||||
await runCase(testCase, outputDir, repeatIndex, repeatCount),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = {
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
outputDir,
|
|
||||||
repeatCount,
|
|
||||||
results,
|
|
||||||
};
|
|
||||||
await Deno.writeTextFile(
|
|
||||||
`${outputDir}/summary.json`,
|
|
||||||
JSON.stringify(summary, null, 2),
|
|
||||||
);
|
|
||||||
console.log(JSON.stringify(summary, null, 2));
|
|
||||||
console.log(`[bench-cases] result directory: ${outputDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error("[Fatal Error]", error);
|
|
||||||
Deno.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
import { join } from "@std/path";
|
|
||||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
|
||||||
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
|
||||||
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
|
|
||||||
import { discoverPeer } from "./helpers/p2p.ts";
|
|
||||||
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
|
||||||
|
|
||||||
type Role = "host" | "client";
|
|
||||||
|
|
||||||
type NetemSummary = {
|
|
||||||
enabled: boolean;
|
|
||||||
profile: string;
|
|
||||||
interface: string;
|
|
||||||
delayMs: number;
|
|
||||||
jitterMs: number;
|
|
||||||
lossPercent: number;
|
|
||||||
bandwidthMbit: number;
|
|
||||||
mtu: number;
|
|
||||||
tcQdisc?: string;
|
|
||||||
ipAddr?: string;
|
|
||||||
ipRoute?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HostReady = {
|
|
||||||
generatedAt: string;
|
|
||||||
totalFiles: number;
|
|
||||||
totalBytes: number;
|
|
||||||
mdFileCount: number;
|
|
||||||
binFileCount: number;
|
|
||||||
mirrorElapsedMs: number;
|
|
||||||
netem: NetemSummary;
|
|
||||||
};
|
|
||||||
|
|
||||||
type P2PConnectionStats = {
|
|
||||||
candidatePathCollected: boolean;
|
|
||||||
selectedPath: string;
|
|
||||||
localCandidate?: { candidateType: string; protocol: string; relayProtocol: string };
|
|
||||||
remoteCandidate?: { candidateType: string; protocol: string; relayProtocol: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
function errorToRecord(error: unknown): Record<string, unknown> {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return {
|
|
||||||
name: error.name,
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: "UnknownError",
|
|
||||||
message: String(error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readEnvString(name: string, fallback: string): string {
|
|
||||||
const value = Deno.env.get(name)?.trim();
|
|
||||||
return value && value.length > 0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readEnvNumber(name: string, fallback: number): number {
|
|
||||||
const raw = Deno.env.get(name);
|
|
||||||
if (raw === undefined || raw.trim() === "") {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
const parsed = Number(raw);
|
|
||||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
||||||
throw new Error(`${name} must be a non-negative number, got '${raw}'`);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowMs(): number {
|
|
||||||
return performance.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commandOutput(command: string, args: string[]): Promise<string> {
|
|
||||||
const output = await new Deno.Command(command, {
|
|
||||||
args,
|
|
||||||
stdin: "null",
|
|
||||||
stdout: "piped",
|
|
||||||
stderr: "piped",
|
|
||||||
}).output();
|
|
||||||
const stdout = new TextDecoder().decode(output.stdout);
|
|
||||||
const stderr = new TextDecoder().decode(output.stderr);
|
|
||||||
if (!output.success) {
|
|
||||||
throw new Error(`${command} ${args.join(" ")} failed\nstdout: ${stdout}\nstderr: ${stderr}`);
|
|
||||||
}
|
|
||||||
return stdout.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commandOk(command: string, args: string[]): Promise<void> {
|
|
||||||
await commandOutput(command, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyNetemIfRequested(): Promise<NetemSummary> {
|
|
||||||
const enabled = readEnvString("BENCH_NETEM_ENABLED", "0") === "1";
|
|
||||||
const profile = readEnvString("NETEM_PROFILE", "home-wifi");
|
|
||||||
const iface = readEnvString("NETEM_INTERFACE", "eth0");
|
|
||||||
const delayMs = readEnvNumber("NETEM_DELAY_MS", 20);
|
|
||||||
const jitterMs = readEnvNumber("NETEM_JITTER_MS", 5);
|
|
||||||
const lossPercent = readEnvNumber("NETEM_LOSS_PERCENT", 0.1);
|
|
||||||
const bandwidthMbit = readEnvNumber("NETEM_BANDWIDTH_MBIT", 100);
|
|
||||||
const mtu = readEnvNumber("NETEM_MTU", 1500);
|
|
||||||
|
|
||||||
const summary: NetemSummary = {
|
|
||||||
enabled,
|
|
||||||
profile,
|
|
||||||
interface: iface,
|
|
||||||
delayMs,
|
|
||||||
jitterMs,
|
|
||||||
lossPercent,
|
|
||||||
bandwidthMbit,
|
|
||||||
mtu,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!enabled) {
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
await commandOk("ip", ["link", "set", "dev", iface, "mtu", String(mtu)]);
|
|
||||||
await new Deno.Command("tc", { args: ["qdisc", "del", "dev", iface, "root"] }).output();
|
|
||||||
await commandOk("tc", [
|
|
||||||
"qdisc",
|
|
||||||
"add",
|
|
||||||
"dev",
|
|
||||||
iface,
|
|
||||||
"root",
|
|
||||||
"netem",
|
|
||||||
"delay",
|
|
||||||
`${delayMs}ms`,
|
|
||||||
`${jitterMs}ms`,
|
|
||||||
"loss",
|
|
||||||
`${lossPercent}%`,
|
|
||||||
"rate",
|
|
||||||
`${bandwidthMbit}mbit`,
|
|
||||||
]);
|
|
||||||
summary.tcQdisc = await commandOutput("tc", ["qdisc", "show", "dev", iface]);
|
|
||||||
summary.ipAddr = await commandOutput("ip", ["addr", "show", iface]);
|
|
||||||
summary.ipRoute = await commandOutput("ip", ["route"]);
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForFile(path: string, timeoutMs: number): Promise<void> {
|
|
||||||
const started = Date.now();
|
|
||||||
while (Date.now() - started < timeoutMs) {
|
|
||||||
try {
|
|
||||||
const stat = await Deno.stat(path);
|
|
||||||
if (stat.isFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// wait
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
}
|
|
||||||
throw new Error(`Timed out waiting for ${path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJsonFile<T>(path: string): Promise<T> {
|
|
||||||
return JSON.parse(await Deno.readTextFile(path)) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
|
|
||||||
const unique = new Map<string, DatasetEntry>();
|
|
||||||
for (const entry of [entries.find((e) => e.kind === "md"), entries.find((e) => e.kind === "bin"), entries.at(-1)]) {
|
|
||||||
if (entry) {
|
|
||||||
unique.set(entry.relativePath, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...unique.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readLatestP2PConnectionStats(path: string): Promise<P2PConnectionStats | undefined> {
|
|
||||||
try {
|
|
||||||
const lines = (await Deno.readTextFile(path))
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0);
|
|
||||||
return lines.length === 0 ? undefined : (JSON.parse(lines.at(-1)!) as P2PConnectionStats);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCommonConfig() {
|
|
||||||
const runId = readEnvString("BENCH_SPLIT_RUN_ID", readEnvString("BENCH_ROOM_ID", "bench-split-run"));
|
|
||||||
const baseWorkRoot = readEnvString("BENCH_SPLIT_WORK_ROOT", "/p2p-work");
|
|
||||||
return {
|
|
||||||
runId,
|
|
||||||
workRoot: join(baseWorkRoot, runId),
|
|
||||||
resultRoot: readEnvString("BENCH_SPLIT_RESULT_ROOT", "/workspace/src/apps/cli/testdeno/bench-results"),
|
|
||||||
relay: readEnvString("BENCH_RELAY", "ws://nostr-relay:7777/"),
|
|
||||||
appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"),
|
|
||||||
roomId: readEnvString("BENCH_ROOM_ID", "bench-split-room"),
|
|
||||||
passphrase: readEnvString("BENCH_PASSPHRASE", "bench-split-passphrase"),
|
|
||||||
turnServers: readEnvString("BENCH_TURN_SERVERS", ""),
|
|
||||||
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
|
|
||||||
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
|
||||||
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 20)),
|
|
||||||
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 512)),
|
|
||||||
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 2048)),
|
|
||||||
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 5)),
|
|
||||||
binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 8192)),
|
|
||||||
peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 60),
|
|
||||||
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 300),
|
|
||||||
nodeTimeoutMs: readEnvNumber("BENCH_SPLIT_NODE_TIMEOUT_MS", 360_000),
|
|
||||||
profile: readEnvString("BENCH_NETWORK_PROFILE", readEnvString("NETEM_PROFILE", "split-compose")),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareP2PSettings(
|
|
||||||
settingsPath: string,
|
|
||||||
peerName: string,
|
|
||||||
config: ReturnType<typeof buildCommonConfig>
|
|
||||||
) {
|
|
||||||
await initSettingsFile(settingsPath);
|
|
||||||
await applyP2pSettings(
|
|
||||||
settingsPath,
|
|
||||||
config.roomId,
|
|
||||||
config.passphrase,
|
|
||||||
config.appId,
|
|
||||||
config.relay,
|
|
||||||
"~.*",
|
|
||||||
config.turnServers
|
|
||||||
);
|
|
||||||
await applyP2pTestTweaks(settingsPath, peerName, config.passphrase);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runHost(): Promise<void> {
|
|
||||||
const config = buildCommonConfig();
|
|
||||||
const netem = await applyNetemIfRequested();
|
|
||||||
await Deno.mkdir(config.workRoot, { recursive: true });
|
|
||||||
await Deno.mkdir(config.resultRoot, { recursive: true });
|
|
||||||
|
|
||||||
const hostVault = join(config.workRoot, "vault-host");
|
|
||||||
const hostSettings = join(config.workRoot, "settings-host.json");
|
|
||||||
await Deno.mkdir(hostVault, { recursive: true });
|
|
||||||
await prepareP2PSettings(hostSettings, "p2p-split-host", config);
|
|
||||||
|
|
||||||
const seedFiles = await createDeterministicDataset({
|
|
||||||
rootDir: hostVault,
|
|
||||||
datasetDirName: config.datasetDirName,
|
|
||||||
seed: config.datasetSeed,
|
|
||||||
mdCount: config.mdFileCount,
|
|
||||||
mdMinSizeBytes: config.mdMinSizeBytes,
|
|
||||||
mdMaxSizeBytes: config.mdMaxSizeBytes,
|
|
||||||
binCount: config.binFileCount,
|
|
||||||
binSizeBytes: config.binSizeBytes,
|
|
||||||
});
|
|
||||||
await Deno.writeTextFile(
|
|
||||||
join(config.workRoot, "sample-files.json"),
|
|
||||||
JSON.stringify(pickSampleFiles(seedFiles.entries), null, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
const mirrorStart = nowMs();
|
|
||||||
await runCliOrFail(hostVault, "--settings", hostSettings, "mirror");
|
|
||||||
const mirrorElapsedMs = Number((nowMs() - mirrorStart).toFixed(1));
|
|
||||||
const hostReady: HostReady = {
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
totalFiles: seedFiles.totalFiles,
|
|
||||||
totalBytes: seedFiles.totalBytes,
|
|
||||||
mdFileCount: seedFiles.mdCount,
|
|
||||||
binFileCount: seedFiles.binCount,
|
|
||||||
mirrorElapsedMs,
|
|
||||||
netem,
|
|
||||||
};
|
|
||||||
await Deno.writeTextFile(join(config.workRoot, "host-ready.json"), JSON.stringify(hostReady, null, 2));
|
|
||||||
|
|
||||||
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
|
|
||||||
try {
|
|
||||||
await host.waitUntilContains("P2P host is running", 20_000);
|
|
||||||
await Deno.writeTextFile(
|
|
||||||
join(config.workRoot, "p2p-host-ready.json"),
|
|
||||||
JSON.stringify({ generatedAt: new Date().toISOString() })
|
|
||||||
);
|
|
||||||
await waitForFile(join(config.workRoot, "client-done.json"), config.nodeTimeoutMs);
|
|
||||||
} finally {
|
|
||||||
await host.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runClient(): Promise<void> {
|
|
||||||
const config = buildCommonConfig();
|
|
||||||
const netem = await applyNetemIfRequested();
|
|
||||||
await Deno.mkdir(config.resultRoot, { recursive: true });
|
|
||||||
await waitForFile(join(config.workRoot, "host-ready.json"), config.nodeTimeoutMs);
|
|
||||||
await waitForFile(join(config.workRoot, "p2p-host-ready.json"), config.nodeTimeoutMs);
|
|
||||||
|
|
||||||
const clientVault = join(config.workRoot, "vault-client");
|
|
||||||
const clientSettings = join(config.workRoot, "settings-client.json");
|
|
||||||
const statsPath = join(config.workRoot, "p2p-connection-stats.jsonl");
|
|
||||||
await Deno.mkdir(clientVault, { recursive: true });
|
|
||||||
await prepareP2PSettings(clientSettings, "p2p-split-client", config);
|
|
||||||
|
|
||||||
const hostReady = await readJsonFile<HostReady>(join(config.workRoot, "host-ready.json"));
|
|
||||||
const timestamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
|
|
||||||
const outputDir = join(config.resultRoot, `p2p-split-${config.profile}-${timestamp}`);
|
|
||||||
await Deno.mkdir(outputDir, { recursive: true });
|
|
||||||
|
|
||||||
const previousStatsPath = Deno.env.get("LIVESYNC_P2P_STATS_JSONL");
|
|
||||||
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", statsPath);
|
|
||||||
let stage = "peer-discovery";
|
|
||||||
let peerDiscoveryCommandElapsedMs: number | undefined;
|
|
||||||
let syncElapsedMs: number | undefined;
|
|
||||||
try {
|
|
||||||
const peerDiscoveryCommandStart = nowMs();
|
|
||||||
const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds);
|
|
||||||
peerDiscoveryCommandElapsedMs = Number((nowMs() - peerDiscoveryCommandStart).toFixed(1));
|
|
||||||
|
|
||||||
stage = "p2p-sync";
|
|
||||||
const syncStart = nowMs();
|
|
||||||
await runCliOrFail(
|
|
||||||
clientVault,
|
|
||||||
"--settings",
|
|
||||||
clientSettings,
|
|
||||||
"p2p-sync",
|
|
||||||
peer.id,
|
|
||||||
String(config.syncTimeoutSeconds)
|
|
||||||
);
|
|
||||||
syncElapsedMs = Number((nowMs() - syncStart).toFixed(1));
|
|
||||||
|
|
||||||
stage = "sample-verification";
|
|
||||||
const samples = await readJsonFile<DatasetEntry[]>(join(config.workRoot, "sample-files.json"));
|
|
||||||
for (const sample of samples) {
|
|
||||||
const pulledPath = join(config.workRoot, `pulled-${sample.relativePath.replaceAll("/", "_")}`);
|
|
||||||
await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath);
|
|
||||||
await assertFilesEqual(
|
|
||||||
sample.absolutePath,
|
|
||||||
pulledPath,
|
|
||||||
`sample file mismatch after split sync: ${sample.relativePath}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const p2pConnectionStats = await readLatestP2PConnectionStats(statsPath);
|
|
||||||
const result = {
|
|
||||||
ok: true,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
caseName: "p2p-split-compose",
|
|
||||||
mode: "p2p-split-compose-benchmark",
|
|
||||||
runId: config.runId,
|
|
||||||
simulationTier: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1",
|
|
||||||
networkProfile: config.profile,
|
|
||||||
networkModel:
|
|
||||||
Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "split-compose-egress-netem" : "split-compose-no-netem",
|
|
||||||
relay: config.relay,
|
|
||||||
turnServers: config.turnServers,
|
|
||||||
turnEnabled: config.turnServers.trim().length > 0,
|
|
||||||
p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true,
|
|
||||||
p2pConnectionStats,
|
|
||||||
hostNetem: hostReady.netem,
|
|
||||||
clientNetem: netem,
|
|
||||||
totalFiles: hostReady.totalFiles,
|
|
||||||
totalBytes: hostReady.totalBytes,
|
|
||||||
mdFileCount: hostReady.mdFileCount,
|
|
||||||
binFileCount: hostReady.binFileCount,
|
|
||||||
mirrorElapsedMs: hostReady.mirrorElapsedMs,
|
|
||||||
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
|
|
||||||
peerDiscoveryCommandElapsedMs,
|
|
||||||
syncElapsedMs,
|
|
||||||
throughputBytesPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000)).toFixed(2)),
|
|
||||||
throughputMiBPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000) / 1024 / 1024).toFixed(4)),
|
|
||||||
};
|
|
||||||
await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
|
|
||||||
await Deno.writeTextFile(
|
|
||||||
join(config.workRoot, "client-done.json"),
|
|
||||||
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: true })
|
|
||||||
);
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
|
||||||
} catch (error) {
|
|
||||||
const p2pConnectionStats = await readLatestP2PConnectionStats(statsPath);
|
|
||||||
const result = {
|
|
||||||
ok: false,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
caseName: "p2p-split-compose",
|
|
||||||
mode: "p2p-split-compose-benchmark",
|
|
||||||
runId: config.runId,
|
|
||||||
simulationTier: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1",
|
|
||||||
networkProfile: config.profile,
|
|
||||||
networkModel:
|
|
||||||
Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "split-compose-egress-netem" : "split-compose-no-netem",
|
|
||||||
relay: config.relay,
|
|
||||||
turnServers: config.turnServers,
|
|
||||||
turnEnabled: config.turnServers.trim().length > 0,
|
|
||||||
p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true,
|
|
||||||
p2pConnectionStats,
|
|
||||||
hostNetem: hostReady.netem,
|
|
||||||
clientNetem: netem,
|
|
||||||
totalFiles: hostReady.totalFiles,
|
|
||||||
totalBytes: hostReady.totalBytes,
|
|
||||||
mdFileCount: hostReady.mdFileCount,
|
|
||||||
binFileCount: hostReady.binFileCount,
|
|
||||||
mirrorElapsedMs: hostReady.mirrorElapsedMs,
|
|
||||||
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
|
|
||||||
peerDiscoveryCommandElapsedMs,
|
|
||||||
syncElapsedMs,
|
|
||||||
failure: {
|
|
||||||
stage,
|
|
||||||
...errorToRecord(error),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
|
|
||||||
await Deno.writeTextFile(
|
|
||||||
join(config.workRoot, "client-done.json"),
|
|
||||||
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: false })
|
|
||||||
);
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
if (previousStatsPath === undefined) {
|
|
||||||
Deno.env.delete("LIVESYNC_P2P_STATS_JSONL");
|
|
||||||
} else {
|
|
||||||
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const role = readEnvString("BENCH_P2P_SPLIT_ROLE", "") as Role;
|
|
||||||
if (role === "host") {
|
|
||||||
await runHost();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (role === "client") {
|
|
||||||
await runClient();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error("BENCH_P2P_SPLIT_ROLE must be 'host' or 'client'");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error("[Fatal Error]", error);
|
|
||||||
Deno.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+109
-331
@@ -1,34 +1,15 @@
|
|||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import {
|
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||||
applyP2pSettings,
|
|
||||||
applyP2pTestTweaks,
|
|
||||||
initSettingsFile,
|
|
||||||
} from "./helpers/settings.ts";
|
|
||||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||||
import {
|
import { discoverPeer, maybeStartLocalRelay, stopLocalRelayIfStarted } from "./helpers/p2p.ts";
|
||||||
discoverPeer,
|
|
||||||
maybeStartCoturn,
|
|
||||||
maybeStartLocalRelay,
|
|
||||||
stopCoturnIfStarted,
|
|
||||||
stopLocalRelayIfStarted,
|
|
||||||
} from "./helpers/p2p.ts";
|
|
||||||
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
||||||
import {
|
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
|
||||||
createDeterministicDataset,
|
|
||||||
} from "./helpers/dataset.ts";
|
|
||||||
import {
|
|
||||||
type BenchmarkVerificationMode,
|
|
||||||
parseBenchmarkVerificationMode,
|
|
||||||
verifyBenchmarkDataset,
|
|
||||||
} from "./helpers/benchmarkVerification.ts";
|
|
||||||
|
|
||||||
type BenchmarkConfig = {
|
type BenchmarkConfig = {
|
||||||
caseName: string;
|
|
||||||
relay: string;
|
relay: string;
|
||||||
appId: string;
|
appId: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
passphrase: string;
|
passphrase: string;
|
||||||
turnServers: string;
|
|
||||||
datasetDirName: string;
|
datasetDirName: string;
|
||||||
datasetSeed: string;
|
datasetSeed: string;
|
||||||
mdFileCount: number;
|
mdFileCount: number;
|
||||||
@@ -38,47 +19,6 @@ type BenchmarkConfig = {
|
|||||||
binSizeBytes: number;
|
binSizeBytes: number;
|
||||||
peersTimeoutSeconds: number;
|
peersTimeoutSeconds: number;
|
||||||
syncTimeoutSeconds: number;
|
syncTimeoutSeconds: number;
|
||||||
simulationTier: string;
|
|
||||||
networkProfile: string;
|
|
||||||
networkModel: string;
|
|
||||||
candidatePathVerification: string;
|
|
||||||
measurementScope: string;
|
|
||||||
limitations: string[];
|
|
||||||
verificationMode: BenchmarkVerificationMode;
|
|
||||||
repeatIndex: number;
|
|
||||||
repeatCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type P2PConnectionStats = {
|
|
||||||
generatedAt: string;
|
|
||||||
command: string;
|
|
||||||
peerId: string;
|
|
||||||
peerName: string;
|
|
||||||
candidatePathCollected: boolean;
|
|
||||||
selectedPath: string;
|
|
||||||
selectedPair?: {
|
|
||||||
id: string;
|
|
||||||
state: string;
|
|
||||||
currentRoundTripTime: number | "unknown";
|
|
||||||
totalRoundTripTime: number | "unknown";
|
|
||||||
requestsSent: number | "unknown";
|
|
||||||
responsesReceived: number | "unknown";
|
|
||||||
packetsDiscardedOnSend: number | "unknown";
|
|
||||||
bytesSent: number | "unknown";
|
|
||||||
bytesReceived: number | "unknown";
|
|
||||||
};
|
|
||||||
localCandidate?: {
|
|
||||||
id: string;
|
|
||||||
candidateType: string;
|
|
||||||
protocol: string;
|
|
||||||
relayProtocol: string;
|
|
||||||
};
|
|
||||||
remoteCandidate?: {
|
|
||||||
id: string;
|
|
||||||
candidateType: string;
|
|
||||||
protocol: string;
|
|
||||||
relayProtocol: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function readEnvString(name: string, fallback: string): string {
|
function readEnvString(name: string, fallback: string): string {
|
||||||
@@ -99,30 +39,6 @@ function readEnvNumber(name: string, fallback: number): number {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readEnvStringArray(name: string, fallback: string[]): string[] {
|
|
||||||
const raw = Deno.env.get(name)?.trim();
|
|
||||||
if (!raw) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (
|
|
||||||
Array.isArray(parsed) &&
|
|
||||||
parsed.every((item) => typeof item === "string")
|
|
||||||
) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to comma-separated parsing for hand-written invocations.
|
|
||||||
}
|
|
||||||
|
|
||||||
return raw
|
|
||||||
.split("|")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter((item) => item.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowMs(): number {
|
function nowMs(): number {
|
||||||
return performance.now();
|
return performance.now();
|
||||||
}
|
}
|
||||||
@@ -145,52 +61,19 @@ function formatBytes(value: number): string {
|
|||||||
|
|
||||||
function buildConfig(): BenchmarkConfig {
|
function buildConfig(): BenchmarkConfig {
|
||||||
return {
|
return {
|
||||||
caseName: readEnvString("BENCH_CASE", "p2p-direct-local"),
|
|
||||||
relay: readEnvString("BENCH_RELAY", "ws://localhost:4000/"),
|
relay: readEnvString("BENCH_RELAY", "ws://localhost:4000/"),
|
||||||
appId: readEnvString(
|
appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"),
|
||||||
"BENCH_APP_ID",
|
|
||||||
"self-hosted-livesync-cli-benchmark",
|
|
||||||
),
|
|
||||||
roomId: readEnvString("BENCH_ROOM_ID", `bench-room-${Date.now()}`),
|
roomId: readEnvString("BENCH_ROOM_ID", `bench-room-${Date.now()}`),
|
||||||
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
|
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
|
||||||
turnServers: readEnvString("BENCH_TURN_SERVERS", ""),
|
|
||||||
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
|
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
|
||||||
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
||||||
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
|
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
|
||||||
mdMinSizeBytes: Math.floor(
|
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)),
|
||||||
readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024),
|
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)),
|
||||||
),
|
|
||||||
mdMaxSizeBytes: Math.floor(
|
|
||||||
readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024),
|
|
||||||
),
|
|
||||||
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)),
|
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)),
|
||||||
binSizeBytes: Math.floor(
|
binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024)),
|
||||||
readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024),
|
|
||||||
),
|
|
||||||
peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 20),
|
peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 20),
|
||||||
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
|
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
|
||||||
simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"),
|
|
||||||
networkProfile: readEnvString("BENCH_NETWORK_PROFILE", "local-direct"),
|
|
||||||
networkModel: readEnvString(
|
|
||||||
"BENCH_NETWORK_MODEL",
|
|
||||||
"local-runner-webrtc",
|
|
||||||
),
|
|
||||||
candidatePathVerification: readEnvString(
|
|
||||||
"BENCH_P2P_CANDIDATE_PATH_VERIFICATION",
|
|
||||||
"not-collected",
|
|
||||||
),
|
|
||||||
measurementScope: readEnvString(
|
|
||||||
"BENCH_MEASUREMENT_SCOPE",
|
|
||||||
"One fresh CLI p2p-sync command, including process start-up and WebRTC connection establishment; the earlier peer-list observation command is excluded.",
|
|
||||||
),
|
|
||||||
limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [
|
|
||||||
"This benchmark result is scoped to the configured dataset, network model, and selected ICE path.",
|
|
||||||
]),
|
|
||||||
verificationMode: parseBenchmarkVerificationMode(
|
|
||||||
Deno.env.get("BENCH_VERIFY_MODE"),
|
|
||||||
),
|
|
||||||
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
|
|
||||||
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,22 +85,21 @@ function readOptionalResultPath(): string | undefined {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readLatestP2PConnectionStats(
|
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
|
||||||
statsPath: string,
|
if (entries.length === 0) {
|
||||||
): Promise<P2PConnectionStats | undefined> {
|
return [];
|
||||||
try {
|
|
||||||
const text = await Deno.readTextFile(statsPath);
|
|
||||||
const lines = text
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0);
|
|
||||||
if (lines.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return JSON.parse(lines[lines.length - 1]) as P2PConnectionStats;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
const md = entries.find((e) => e.kind === "md");
|
||||||
|
const bin = entries.find((e) => e.kind === "bin");
|
||||||
|
const middle = entries[Math.floor(entries.length / 2)];
|
||||||
|
const last = entries[entries.length - 1];
|
||||||
|
const unique = new Map<string, DatasetEntry>();
|
||||||
|
for (const entry of [md, bin, middle, last]) {
|
||||||
|
if (entry) {
|
||||||
|
unique.set(entry.relativePath, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...unique.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -225,214 +107,110 @@ async function main(): Promise<void> {
|
|||||||
const resultPath = readOptionalResultPath();
|
const resultPath = readOptionalResultPath();
|
||||||
|
|
||||||
const relayStarted = await maybeStartLocalRelay(config.relay);
|
const relayStarted = await maybeStartLocalRelay(config.relay);
|
||||||
const coturnStarted = await maybeStartCoturn(config.turnServers);
|
|
||||||
await using workDir = await TempDir.create("livesync-cli-p2p-bench");
|
await using workDir = await TempDir.create("livesync-cli-p2p-bench");
|
||||||
|
|
||||||
const hostVault = workDir.join("vault-host");
|
const hostVault = workDir.join("vault-host");
|
||||||
const clientVault = workDir.join("vault-client");
|
const clientVault = workDir.join("vault-client");
|
||||||
const hostSettings = workDir.join("settings-host.json");
|
const hostSettings = workDir.join("settings-host.json");
|
||||||
const clientSettings = workDir.join("settings-client.json");
|
const clientSettings = workDir.join("settings-client.json");
|
||||||
const p2pStatsPath = workDir.join("p2p-connection-stats.jsonl");
|
|
||||||
const previousStatsPath = Deno.env.get("LIVESYNC_P2P_STATS_JSONL");
|
|
||||||
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", p2pStatsPath);
|
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
Deno.mkdir(hostVault, { recursive: true }),
|
||||||
|
Deno.mkdir(clientVault, { recursive: true }),
|
||||||
|
initSettingsFile(hostSettings),
|
||||||
|
initSettingsFile(clientSettings),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
applyP2pSettings(hostSettings, config.roomId, config.passphrase, config.appId, config.relay, "~.*"),
|
||||||
|
applyP2pSettings(clientSettings, config.roomId, config.passphrase, config.appId, config.relay, "~.*"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
applyP2pTestTweaks(hostSettings, "p2p-bench-host", config.passphrase),
|
||||||
|
applyP2pTestTweaks(clientSettings, "p2p-bench-client", config.passphrase),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const seedFiles = await createDeterministicDataset({
|
||||||
|
rootDir: hostVault,
|
||||||
|
datasetDirName: config.datasetDirName,
|
||||||
|
seed: config.datasetSeed,
|
||||||
|
mdCount: config.mdFileCount,
|
||||||
|
mdMinSizeBytes: config.mdMinSizeBytes,
|
||||||
|
mdMaxSizeBytes: config.mdMaxSizeBytes,
|
||||||
|
binCount: config.binFileCount,
|
||||||
|
binSizeBytes: config.binSizeBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mirrorStart = nowMs();
|
||||||
|
await runCliOrFail(hostVault, "--settings", hostSettings, "mirror");
|
||||||
|
const mirrorElapsed = nowMs() - mirrorStart;
|
||||||
|
|
||||||
|
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
const hostReadyStart = nowMs();
|
||||||
Deno.mkdir(hostVault, { recursive: true }),
|
await host.waitUntilContains("P2P host is running", 20000);
|
||||||
Deno.mkdir(clientVault, { recursive: true }),
|
const hostReadyElapsed = nowMs() - hostReadyStart;
|
||||||
initSettingsFile(hostSettings),
|
|
||||||
initSettingsFile(clientSettings),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await Promise.all([
|
const peerDiscoveryStart = nowMs();
|
||||||
applyP2pSettings(
|
const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds);
|
||||||
hostSettings,
|
const peerDiscoveryElapsed = nowMs() - peerDiscoveryStart;
|
||||||
config.roomId,
|
|
||||||
config.passphrase,
|
|
||||||
config.appId,
|
|
||||||
config.relay,
|
|
||||||
"~.*",
|
|
||||||
config.turnServers,
|
|
||||||
),
|
|
||||||
applyP2pSettings(
|
|
||||||
clientSettings,
|
|
||||||
config.roomId,
|
|
||||||
config.passphrase,
|
|
||||||
config.appId,
|
|
||||||
config.relay,
|
|
||||||
"~.*",
|
|
||||||
config.turnServers,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await Promise.all([
|
const syncStart = nowMs();
|
||||||
applyP2pTestTweaks(
|
await runCliOrFail(
|
||||||
hostSettings,
|
clientVault,
|
||||||
"p2p-bench-host",
|
|
||||||
config.passphrase,
|
|
||||||
),
|
|
||||||
applyP2pTestTweaks(
|
|
||||||
clientSettings,
|
|
||||||
"p2p-bench-client",
|
|
||||||
config.passphrase,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const seedFiles = await createDeterministicDataset({
|
|
||||||
rootDir: hostVault,
|
|
||||||
datasetDirName: config.datasetDirName,
|
|
||||||
seed: config.datasetSeed,
|
|
||||||
mdCount: config.mdFileCount,
|
|
||||||
mdMinSizeBytes: config.mdMinSizeBytes,
|
|
||||||
mdMaxSizeBytes: config.mdMaxSizeBytes,
|
|
||||||
binCount: config.binFileCount,
|
|
||||||
binSizeBytes: config.binSizeBytes,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mirrorStart = nowMs();
|
|
||||||
await runCliOrFail(hostVault, "--settings", hostSettings, "mirror");
|
|
||||||
const mirrorElapsed = nowMs() - mirrorStart;
|
|
||||||
|
|
||||||
const host = startCliInBackground(
|
|
||||||
hostVault,
|
|
||||||
"--settings",
|
"--settings",
|
||||||
hostSettings,
|
clientSettings,
|
||||||
"p2p-host",
|
"p2p-sync",
|
||||||
|
peer.id,
|
||||||
|
String(config.syncTimeoutSeconds)
|
||||||
);
|
);
|
||||||
try {
|
const syncElapsed = nowMs() - syncStart;
|
||||||
const hostReadyStart = nowMs();
|
|
||||||
await host.waitUntilContains("P2P host is running", 20000);
|
|
||||||
const hostReadyElapsed = nowMs() - hostReadyStart;
|
|
||||||
|
|
||||||
const peerDiscoveryCommandStart = nowMs();
|
const sampleFiles = pickSampleFiles(seedFiles.entries);
|
||||||
const peer = await discoverPeer(
|
for (const sample of sampleFiles) {
|
||||||
clientVault,
|
const pulledPath = workDir.join(`pulled-${sample.relativePath.replaceAll("/", "_")}`);
|
||||||
clientSettings,
|
await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath);
|
||||||
config.peersTimeoutSeconds,
|
await assertFilesEqual(
|
||||||
|
sample.absolutePath,
|
||||||
|
pulledPath,
|
||||||
|
`sample file mismatch after sync: ${sample.relativePath}`
|
||||||
);
|
);
|
||||||
const peerDiscoveryCommandElapsed = nowMs() -
|
|
||||||
peerDiscoveryCommandStart;
|
|
||||||
|
|
||||||
const syncStart = nowMs();
|
|
||||||
await runCliOrFail(
|
|
||||||
clientVault,
|
|
||||||
"--settings",
|
|
||||||
clientSettings,
|
|
||||||
"p2p-sync",
|
|
||||||
peer.id,
|
|
||||||
String(config.syncTimeoutSeconds),
|
|
||||||
);
|
|
||||||
const syncElapsed = nowMs() - syncStart;
|
|
||||||
|
|
||||||
const verification = await verifyBenchmarkDataset(
|
|
||||||
seedFiles.entries,
|
|
||||||
config.verificationMode,
|
|
||||||
async (entry) => {
|
|
||||||
const pulledPath = workDir.join(
|
|
||||||
`pulled-${entry.relativePath.replaceAll("/", "_")}`,
|
|
||||||
);
|
|
||||||
await runCliOrFail(
|
|
||||||
clientVault,
|
|
||||||
"--settings",
|
|
||||||
clientSettings,
|
|
||||||
"pull",
|
|
||||||
entry.relativePath,
|
|
||||||
pulledPath,
|
|
||||||
);
|
|
||||||
await assertFilesEqual(
|
|
||||||
entry.absolutePath,
|
|
||||||
pulledPath,
|
|
||||||
`file mismatch after P2P sync: ${entry.relativePath}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const p2pConnectionStats = await readLatestP2PConnectionStats(
|
|
||||||
p2pStatsPath,
|
|
||||||
);
|
|
||||||
const result = {
|
|
||||||
caseName: config.caseName,
|
|
||||||
mode: "p2p-cli-benchmark",
|
|
||||||
relay: config.relay,
|
|
||||||
turnServers: config.turnServers,
|
|
||||||
turnEnabled: config.turnServers.trim().length > 0,
|
|
||||||
simulationTier: config.simulationTier,
|
|
||||||
networkProfile: config.networkProfile,
|
|
||||||
networkModel: config.networkModel,
|
|
||||||
measurementScope: config.measurementScope,
|
|
||||||
limitations: config.limitations,
|
|
||||||
repeatIndex: config.repeatIndex,
|
|
||||||
repeatCount: config.repeatCount,
|
|
||||||
p2pCandidatePathVerified:
|
|
||||||
p2pConnectionStats?.candidatePathCollected === true,
|
|
||||||
p2pCandidatePathVerification:
|
|
||||||
p2pConnectionStats?.candidatePathCollected
|
|
||||||
? "selected ICE candidate pair collected from RTCPeerConnection.getStats"
|
|
||||||
: config.candidatePathVerification,
|
|
||||||
p2pCandidatePathNote: p2pConnectionStats?.candidatePathCollected
|
|
||||||
? "The selected ICE candidate pair was collected by the CLI benchmark. Interpret the path from the candidate types; do not infer TURN use from configuration alone."
|
|
||||||
: config.turnServers.trim().length > 0
|
|
||||||
? "TURN is configured, so the selected WebRTC path may be direct, server-reflexive, or relayed. The selected ICE candidate pair was not exported by this run."
|
|
||||||
: "TURN is disabled, so a TURN-relayed path is not expected. The selected ICE candidate pair was not exported by this run.",
|
|
||||||
p2pConnectionStats,
|
|
||||||
appId: config.appId,
|
|
||||||
roomId: config.roomId,
|
|
||||||
datasetSeed: config.datasetSeed,
|
|
||||||
datasetDirName: config.datasetDirName,
|
|
||||||
peerId: peer.id,
|
|
||||||
peerName: peer.name,
|
|
||||||
totalFiles: seedFiles.totalFiles,
|
|
||||||
totalBytes: seedFiles.totalBytes,
|
|
||||||
mdFileCount: seedFiles.mdCount,
|
|
||||||
binFileCount: seedFiles.binCount,
|
|
||||||
...verification,
|
|
||||||
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
|
|
||||||
hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)),
|
|
||||||
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
|
|
||||||
peerDiscoveryCommandElapsedMs: Number(
|
|
||||||
peerDiscoveryCommandElapsed.toFixed(1),
|
|
||||||
),
|
|
||||||
peerDiscoveryNote:
|
|
||||||
"p2p-peers waits for the requested timeout before printing discovered peers, so this is command duration, not first-peer latency.",
|
|
||||||
syncElapsedMs: Number(syncElapsed.toFixed(1)),
|
|
||||||
throughputBytesPerSec: Number(
|
|
||||||
(seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2),
|
|
||||||
),
|
|
||||||
throughputMiBPerSec: Number(
|
|
||||||
(seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024)
|
|
||||||
.toFixed(
|
|
||||||
4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (resultPath) {
|
|
||||||
await Deno.writeTextFile(
|
|
||||||
resultPath,
|
|
||||||
JSON.stringify(result, null, 2),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
|
||||||
console.error(
|
|
||||||
`[Benchmark] mirrored ${seedFiles.totalFiles} files (${
|
|
||||||
formatBytes(
|
|
||||||
seedFiles.totalBytes,
|
|
||||||
)
|
|
||||||
}) in ${formatMs(mirrorElapsed)}, ` +
|
|
||||||
`synced in ${formatMs(syncElapsed)} ` +
|
|
||||||
`(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await host.stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
mode: "p2p-cli-benchmark",
|
||||||
|
relay: config.relay,
|
||||||
|
appId: config.appId,
|
||||||
|
roomId: config.roomId,
|
||||||
|
datasetSeed: config.datasetSeed,
|
||||||
|
datasetDirName: config.datasetDirName,
|
||||||
|
peerId: peer.id,
|
||||||
|
peerName: peer.name,
|
||||||
|
totalFiles: seedFiles.totalFiles,
|
||||||
|
totalBytes: seedFiles.totalBytes,
|
||||||
|
mdFileCount: seedFiles.mdCount,
|
||||||
|
binFileCount: seedFiles.binCount,
|
||||||
|
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
|
||||||
|
hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)),
|
||||||
|
peerDiscoveryElapsedMs: Number(peerDiscoveryElapsed.toFixed(1)),
|
||||||
|
syncElapsedMs: Number(syncElapsed.toFixed(1)),
|
||||||
|
throughputBytesPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2)),
|
||||||
|
throughputMiBPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024).toFixed(4)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resultPath) {
|
||||||
|
await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
console.error(
|
||||||
|
`[Benchmark] mirrored ${seedFiles.totalFiles} files (${formatBytes(seedFiles.totalBytes)}) in ${formatMs(mirrorElapsed)}, ` +
|
||||||
|
`synced in ${formatMs(syncElapsed)} ` +
|
||||||
|
`(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (previousStatsPath === undefined) {
|
await host.stop();
|
||||||
Deno.env.delete("LIVESYNC_P2P_STATS_JSONL");
|
|
||||||
} else {
|
|
||||||
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath);
|
|
||||||
}
|
|
||||||
await stopCoturnIfStarted(coturnStarted);
|
|
||||||
await stopLocalRelayIfStarted(relayStarted);
|
await stopLocalRelayIfStarted(relayStarted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,8 @@
|
|||||||
"test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts",
|
"test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts",
|
||||||
"test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts",
|
"test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts",
|
||||||
"test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts",
|
"test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts",
|
||||||
"test:benchmark-contract": "deno test --env-file=.test.env -A --no-check test-benchmark-contract.ts",
|
|
||||||
"bench:p2p": "deno run --env-file=.test.env -A --no-check bench-p2p.ts",
|
"bench:p2p": "deno run --env-file=.test.env -A --no-check bench-p2p.ts",
|
||||||
"bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts",
|
"bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts",
|
||||||
"bench:cases": "deno run --env-file=.test.env -A --no-check bench-network-cases.ts",
|
|
||||||
"bench:latency-sweep": "deno run --env-file=.test.env -A --no-check bench-latency-sweep.ts",
|
|
||||||
"bench:p2p-split-node": "deno run --env-file=.test.env -A --no-check bench-p2p-split-node.ts",
|
|
||||||
"bench:item1": "bash ./bench-run-item1.sh",
|
"bench:item1": "bash ./bench-run-item1.sh",
|
||||||
"bench:item1:full": "BENCH_MD_FILE_COUNT=1500 BENCH_MD_MIN_SIZE_BYTES=1024 BENCH_MD_MAX_SIZE_BYTES=20480 BENCH_BIN_FILE_COUNT=500 BENCH_BIN_SIZE_BYTES=102400 BENCH_COUCHDB_RTT_MS=50 bash ./bench-run-item1.sh",
|
"bench:item1:full": "BENCH_MD_FILE_COUNT=1500 BENCH_MD_MIN_SIZE_BYTES=1024 BENCH_MD_MAX_SIZE_BYTES=20480 BENCH_BIN_FILE_COUNT=500 BENCH_BIN_SIZE_BYTES=102400 BENCH_COUCHDB_RTT_MS=50 bash ./bench-run-item1.sh",
|
||||||
"test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts",
|
"test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts",
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import type { DatasetEntry } from "./dataset.ts";
|
|
||||||
|
|
||||||
export type BenchmarkVerificationMode = "all" | "sample";
|
|
||||||
|
|
||||||
export type BenchmarkVerificationResult = {
|
|
||||||
verificationMode: BenchmarkVerificationMode;
|
|
||||||
verifiedFiles: number;
|
|
||||||
verificationComplete: boolean;
|
|
||||||
datasetDigestSha256: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function toHex(bytes: ArrayBuffer): string {
|
|
||||||
return [...new Uint8Array(bytes)]
|
|
||||||
.map((value) => value.toString(16).padStart(2, "0"))
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sha256(bytes: Uint8Array): Promise<string> {
|
|
||||||
const input = new ArrayBuffer(bytes.byteLength);
|
|
||||||
new Uint8Array(input).set(bytes);
|
|
||||||
return toHex(await crypto.subtle.digest("SHA-256", input));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseBenchmarkVerificationMode(
|
|
||||||
raw: string | undefined,
|
|
||||||
fallback: BenchmarkVerificationMode = "sample",
|
|
||||||
): BenchmarkVerificationMode {
|
|
||||||
const value = raw?.trim().toLowerCase();
|
|
||||||
if (!value) return fallback;
|
|
||||||
if (value === "all" || value === "sample") return value;
|
|
||||||
throw new Error(`BENCH_VERIFY_MODE must be 'all' or 'sample', got '${raw}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectVerificationEntries(
|
|
||||||
entries: DatasetEntry[],
|
|
||||||
mode: BenchmarkVerificationMode,
|
|
||||||
): DatasetEntry[] {
|
|
||||||
if (mode === "all" || entries.length === 0) return [...entries];
|
|
||||||
|
|
||||||
const md = entries.find((entry) => entry.kind === "md");
|
|
||||||
const bin = entries.find((entry) => entry.kind === "bin");
|
|
||||||
const middle = entries[Math.floor(entries.length / 2)];
|
|
||||||
const last = entries[entries.length - 1];
|
|
||||||
const selected = new Map<string, DatasetEntry>();
|
|
||||||
for (const entry of [md, bin, middle, last]) {
|
|
||||||
if (entry) selected.set(entry.relativePath, entry);
|
|
||||||
}
|
|
||||||
return [...selected.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function computeDatasetDigestSha256(
|
|
||||||
entries: DatasetEntry[],
|
|
||||||
): Promise<string> {
|
|
||||||
const manifest: string[] = [];
|
|
||||||
for (const entry of entries) {
|
|
||||||
const contentDigest = await sha256(await Deno.readFile(entry.absolutePath));
|
|
||||||
manifest.push(
|
|
||||||
`${entry.kind}\t${entry.relativePath}\t${entry.size}\t${contentDigest}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return await sha256(new TextEncoder().encode(manifest.join("\n")));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyBenchmarkDataset(
|
|
||||||
entries: DatasetEntry[],
|
|
||||||
mode: BenchmarkVerificationMode,
|
|
||||||
verifyEntry: (entry: DatasetEntry) => Promise<void>,
|
|
||||||
): Promise<BenchmarkVerificationResult> {
|
|
||||||
const selected = selectVerificationEntries(entries, mode);
|
|
||||||
for (const entry of selected) {
|
|
||||||
await verifyEntry(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
verificationMode: mode,
|
|
||||||
verifiedFiles: selected.length,
|
|
||||||
verificationComplete: selected.length === entries.length,
|
|
||||||
datasetDigestSha256: await computeDatasetDigestSha256(entries),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ function sleep(ms: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise<void> {
|
async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise<void> {
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: number | undefined;
|
||||||
try {
|
try {
|
||||||
const connPromise = Deno.connect({ hostname, port });
|
const connPromise = Deno.connect({ hostname, port });
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
|||||||
@@ -76,10 +76,8 @@ export async function discoverPeer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function maybeStartLocalRelay(relay: string): Promise<boolean> {
|
export async function maybeStartLocalRelay(relay: string): Promise<boolean> {
|
||||||
const shouldStart = isLocalP2pRelay(relay);
|
if (!isLocalP2pRelay(relay)) return false;
|
||||||
if (shouldStart) {
|
await startP2pRelay();
|
||||||
await startP2pRelay();
|
|
||||||
}
|
|
||||||
const endpoint = parseRelayEndpoint(relay);
|
const endpoint = parseRelayEndpoint(relay);
|
||||||
await waitForPort(endpoint.hostname, endpoint.port, {
|
await waitForPort(endpoint.hostname, endpoint.port, {
|
||||||
timeoutMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "15000"),
|
timeoutMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "15000"),
|
||||||
@@ -88,10 +86,8 @@ export async function maybeStartLocalRelay(relay: string): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
// Docker proxy accepts TCP connections instantly before the container's internal process is fully ready.
|
// Docker proxy accepts TCP connections instantly before the container's internal process is fully ready.
|
||||||
// Wait an additional few seconds to ensure strfry is actually accepting WebSockets.
|
// Wait an additional few seconds to ensure strfry is actually accepting WebSockets.
|
||||||
if (shouldStart) {
|
await sleep(3000);
|
||||||
await sleep(3000);
|
return true;
|
||||||
}
|
|
||||||
return shouldStart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopLocalRelayIfStarted(started: boolean): Promise<void> {
|
export async function stopLocalRelayIfStarted(started: boolean): Promise<void> {
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
TASK="${CLI_E2E_TASK:-test:p2p-sync}"
|
|
||||||
|
|
||||||
case "$TASK" in
|
|
||||||
test:p2p-host|test:p2p-peers|test:p2p-sync|test:p2p-three-nodes|test:p2p-upload-download)
|
|
||||||
exec deno task "$TASK"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown CLI_E2E_TASK: $TASK" >&2
|
|
||||||
echo "Expected one of: test:p2p-host, test:p2p-peers, test:p2p-sync, test:p2p-three-nodes, test:p2p-upload-download" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import { assert, assertEquals, assertStringIncludes } from "@std/assert";
|
|
||||||
import { type BenchmarkCase, buildCases } from "./bench-network-cases.ts";
|
|
||||||
import { startCouchdbProxy } from "./bench-couchdb.ts";
|
|
||||||
import {
|
|
||||||
parseBenchmarkVerificationMode,
|
|
||||||
selectVerificationEntries,
|
|
||||||
} from "./helpers/benchmarkVerification.ts";
|
|
||||||
import type { DatasetEntry } from "./helpers/dataset.ts";
|
|
||||||
|
|
||||||
function getFreePort(): number {
|
|
||||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
|
||||||
try {
|
|
||||||
return (listener.addr as Deno.NetAddr).port;
|
|
||||||
} finally {
|
|
||||||
listener.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCase(cases: BenchmarkCase[], name: string): BenchmarkCase {
|
|
||||||
const found = cases.find((testCase) => testCase.name === name);
|
|
||||||
assert(found, `missing benchmark case: ${name}`);
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsedLimitations(testCase: BenchmarkCase): string[] {
|
|
||||||
const raw = testCase.env.BENCH_LIMITATIONS_JSON;
|
|
||||||
assert(
|
|
||||||
raw,
|
|
||||||
`${testCase.name} must pass BENCH_LIMITATIONS_JSON to benchmark result output`,
|
|
||||||
);
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
assert(
|
|
||||||
Array.isArray(parsed),
|
|
||||||
`${testCase.name} limitations must be an array`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
parsed.every((item) =>
|
|
||||||
typeof item === "string" && item.trim().length > 0
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.test("benchmark cases record scope and limitations for paper use", () => {
|
|
||||||
const cases = buildCases();
|
|
||||||
assert(cases.length > 0);
|
|
||||||
|
|
||||||
for (const testCase of cases) {
|
|
||||||
assert(
|
|
||||||
testCase.description.trim().length > 0,
|
|
||||||
`${testCase.name} must describe the case`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
testCase.dataPath.trim().length > 0,
|
|
||||||
`${testCase.name} must describe the data path`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
testCase.trustBoundary.trim().length > 0,
|
|
||||||
`${testCase.name} must describe the trust boundary`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
testCase.measurementScope.trim().length > 0,
|
|
||||||
`${testCase.name} must describe the measurement scope`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
testCase.limitations.length > 0,
|
|
||||||
`${testCase.name} must list limitations`,
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
testCase.env.BENCH_MEASUREMENT_SCOPE,
|
|
||||||
testCase.measurementScope,
|
|
||||||
);
|
|
||||||
assertEquals(parsedLimitations(testCase), testCase.limitations);
|
|
||||||
assertEquals(
|
|
||||||
testCase.env.BENCH_VERIFY_MODE,
|
|
||||||
"all",
|
|
||||||
`${testCase.name} must verify the complete dataset`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
|
|
||||||
const backendPort = getFreePort();
|
|
||||||
const proxyPort = getFreePort();
|
|
||||||
const delays: number[] = [];
|
|
||||||
const backend = Deno.serve(
|
|
||||||
{
|
|
||||||
hostname: "127.0.0.1",
|
|
||||||
port: backendPort,
|
|
||||||
onListen() {},
|
|
||||||
},
|
|
||||||
() => new Response("ok"),
|
|
||||||
);
|
|
||||||
const proxy = startCouchdbProxy({
|
|
||||||
backendUri: `http://127.0.0.1:${backendPort}`,
|
|
||||||
proxyUri: `http://127.0.0.1:${proxyPort}`,
|
|
||||||
requestedRttMs: 20,
|
|
||||||
delay: (milliseconds) => {
|
|
||||||
delays.push(milliseconds);
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/probe`);
|
|
||||||
assertEquals(await response.text(), "ok");
|
|
||||||
assertEquals(proxy.directionalDelayMs, 10);
|
|
||||||
assertEquals(delays, [10, 10]);
|
|
||||||
} finally {
|
|
||||||
await proxy.stop();
|
|
||||||
await backend.shutdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
const halfMillisecondProxy = startCouchdbProxy({
|
|
||||||
backendUri: "http://127.0.0.1:1",
|
|
||||||
proxyUri: `http://127.0.0.1:${getFreePort()}`,
|
|
||||||
requestedRttMs: 1,
|
|
||||||
delay: () => Promise.resolve(),
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
assertEquals(halfMillisecondProxy.directionalDelayMs, 0.5);
|
|
||||||
} finally {
|
|
||||||
await halfMillisecondProxy.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("benchmark verification mode selects either all files or a labelled sample", () => {
|
|
||||||
const entries: DatasetEntry[] = [
|
|
||||||
{ kind: "md", relativePath: "a.md", absolutePath: "/a", size: 1 },
|
|
||||||
{ kind: "md", relativePath: "b.md", absolutePath: "/b", size: 1 },
|
|
||||||
{ kind: "bin", relativePath: "c.bin", absolutePath: "/c", size: 1 },
|
|
||||||
{ kind: "md", relativePath: "d.md", absolutePath: "/d", size: 1 },
|
|
||||||
{ kind: "bin", relativePath: "e.bin", absolutePath: "/e", size: 1 },
|
|
||||||
];
|
|
||||||
|
|
||||||
assertEquals(parseBenchmarkVerificationMode("ALL"), "all");
|
|
||||||
assertEquals(selectVerificationEntries(entries, "all").length, entries.length);
|
|
||||||
const sample = selectVerificationEntries(entries, "sample");
|
|
||||||
assert(sample.length > 0 && sample.length < entries.length);
|
|
||||||
assert(sample.some((entry) => entry.kind === "md"));
|
|
||||||
assert(sample.some((entry) => entry.kind === "bin"));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("P2P signalling-shim cases do not claim to shape the note-data path", () => {
|
|
||||||
const cases = buildCases();
|
|
||||||
for (
|
|
||||||
const name of [
|
|
||||||
"p2p-signalling-netem-home-wifi",
|
|
||||||
"p2p-signalling-netem-tethering-vpn",
|
|
||||||
]
|
|
||||||
) {
|
|
||||||
const testCase = getCase(cases, name);
|
|
||||||
assertEquals(testCase.runner, "p2p");
|
|
||||||
assertEquals(testCase.env.BENCH_TURN_SERVERS, "");
|
|
||||||
assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2");
|
|
||||||
assertEquals(
|
|
||||||
testCase.env.BENCH_NETWORK_MODEL,
|
|
||||||
"compose-netem-signalling-shim",
|
|
||||||
);
|
|
||||||
assertStringIncludes(testCase.dataPath, "WebRTC DataChannel");
|
|
||||||
assertStringIncludes(testCase.dataPath, "Nostr signalling");
|
|
||||||
assertStringIncludes(testCase.measurementScope, "fresh CLI p2p-sync");
|
|
||||||
assert(
|
|
||||||
testCase.limitations.some((limitation) =>
|
|
||||||
limitation.includes("connection establishment")
|
|
||||||
),
|
|
||||||
`${name} must state that connection establishment is timed`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
testCase.limitations.some((limitation) =>
|
|
||||||
limitation.includes("does not shape the selected WebRTC")
|
|
||||||
),
|
|
||||||
`${name} must avoid claiming that the P2P note-data path was shaped`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("placeholder and TURN cases are clearly non-evidence for broad P2P performance", () => {
|
|
||||||
const cases = buildCases();
|
|
||||||
|
|
||||||
const smartphone = getCase(cases, "p2p-smartphone-vpn-direct");
|
|
||||||
assertEquals(smartphone.env.BENCH_SIMULATION_TIER, "unmeasured");
|
|
||||||
assertEquals(smartphone.env.BENCH_NETWORK_MODEL, "local-runner-no-netem");
|
|
||||||
assert(
|
|
||||||
smartphone.limitations.some((limitation) =>
|
|
||||||
limitation.includes("must not be reported as smartphone")
|
|
||||||
),
|
|
||||||
"smartphone/VPN placeholder must not be usable as field evidence by accident",
|
|
||||||
);
|
|
||||||
|
|
||||||
const turn = getCase(cases, "p2p-user-turn");
|
|
||||||
assertStringIncludes(turn.env.BENCH_TURN_SERVERS, "turn:");
|
|
||||||
assert(
|
|
||||||
turn.limitations.some((limitation) =>
|
|
||||||
limitation.includes(
|
|
||||||
"does not prove that the selected ICE path was relayed",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"TURN case must require selected ICE candidate interpretation",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("CouchDB netem cases are marked as remote-store baselines", () => {
|
|
||||||
const cases = buildCases();
|
|
||||||
for (
|
|
||||||
const name of ["couchdb-netem-home-wifi", "couchdb-netem-tethering-vpn"]
|
|
||||||
) {
|
|
||||||
const testCase = getCase(cases, name);
|
|
||||||
assertEquals(testCase.runner, "couchdb");
|
|
||||||
assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2");
|
|
||||||
assertEquals(
|
|
||||||
testCase.env.BENCH_NETWORK_MODEL,
|
|
||||||
"compose-netem-tcp-shim",
|
|
||||||
);
|
|
||||||
assertStringIncludes(testCase.measurementScope, "CouchDB");
|
|
||||||
assert(
|
|
||||||
testCase.limitations.some((limitation) =>
|
|
||||||
limitation.includes("not the WebRTC P2P data path")
|
|
||||||
),
|
|
||||||
`${name} must remain scoped to the CouchDB remote-store path`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Validate the platform-neutral path vocabulary used by rooted storage adapters.
|
|
||||||
*
|
|
||||||
* Paths are slash-separated and relative to the root bound to the adapter. Root
|
|
||||||
* selection and authorisation happen before the adapter is constructed.
|
|
||||||
*/
|
|
||||||
export function validateStoragePath(storagePath: string, allowRoot: boolean = true): string {
|
|
||||||
if (storagePath === "") {
|
|
||||||
if (allowRoot) return storagePath;
|
|
||||||
throw new Error("The storage root is not a valid entry path");
|
|
||||||
}
|
|
||||||
|
|
||||||
// LiveSync normally rejects ':' in portable filenames before paths reach storage. A leading letter and colon
|
|
||||||
// therefore represents drive-qualified input or a leaked non-storage namespace, not a supported physical path.
|
|
||||||
if (storagePath.startsWith("/") || storagePath.startsWith("\\") || /^[A-Za-z]:/.test(storagePath)) {
|
|
||||||
throw new Error(`Storage paths must be relative to the configured root: ${storagePath}`);
|
|
||||||
}
|
|
||||||
if (storagePath.includes("\\")) {
|
|
||||||
throw new Error(`Storage paths must use forward slashes: ${storagePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments = storagePath.split("/");
|
|
||||||
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
||||||
throw new Error(`Storage paths must not contain traversal segments: ${storagePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return storagePath;
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import type { UXDataWriteOptions } from "@lib/common/types";
|
import type { UXDataWriteOptions } from "@lib/common/types";
|
||||||
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||||
import type { FSAPIStat } from "./FSAPITypes";
|
import type { FSAPIStat } from "./FSAPITypes";
|
||||||
import { validateStoragePath } from "@/apps/storagePath";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage adapter implementation for FileSystem API
|
* Storage adapter implementation for FileSystem API
|
||||||
*/
|
*/
|
||||||
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||||
constructor(private readonly rootHandle: FileSystemDirectoryHandle) {}
|
constructor(private rootHandle: FileSystemDirectoryHandle) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a path to directory and file handles
|
* Resolve a path to directory and file handles
|
||||||
@@ -16,9 +15,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
dirHandle: FileSystemDirectoryHandle;
|
dirHandle: FileSystemDirectoryHandle;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
validateStoragePath(p, false);
|
|
||||||
try {
|
try {
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let currentHandle = this.rootHandle;
|
let currentHandle = this.rootHandle;
|
||||||
const fileName = parts[parts.length - 1];
|
const fileName = parts[parts.length - 1];
|
||||||
@@ -38,8 +39,6 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
* Get file handle for a given path
|
* Get file handle for a given path
|
||||||
*/
|
*/
|
||||||
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
|
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
|
||||||
validateStoragePath(p);
|
|
||||||
if (p === "") return null;
|
|
||||||
const resolved = await this.resolvePath(p);
|
const resolved = await this.resolvePath(p);
|
||||||
if (!resolved) return null;
|
if (!resolved) return null;
|
||||||
|
|
||||||
@@ -54,7 +53,6 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
* Get directory handle for a given path
|
* Get directory handle for a given path
|
||||||
*/
|
*/
|
||||||
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
|
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
|
||||||
validateStoragePath(p);
|
|
||||||
try {
|
try {
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
@@ -72,20 +70,6 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a writable file path after creating its parent directories. */
|
|
||||||
private async resolveWritablePath(p: string): Promise<{
|
|
||||||
dirHandle: FileSystemDirectoryHandle;
|
|
||||||
fileName: string;
|
|
||||||
} | null> {
|
|
||||||
validateStoragePath(p, false);
|
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
|
||||||
const fileName = parts.pop()!;
|
|
||||||
const parentPath = parts.join("/");
|
|
||||||
await this.mkdir(parentPath);
|
|
||||||
const dirHandle = await this.getDirectoryHandle(parentPath);
|
|
||||||
return dirHandle ? { dirHandle, fileName } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async exists(p: string): Promise<boolean> {
|
async exists(p: string): Promise<boolean> {
|
||||||
const fileHandle = await this.getFileHandle(p);
|
const fileHandle = await this.getFileHandle(p);
|
||||||
if (fileHandle) return true;
|
if (fileHandle) return true;
|
||||||
@@ -126,7 +110,6 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async mkdir(p: string): Promise<void> {
|
async mkdir(p: string): Promise<void> {
|
||||||
validateStoragePath(p);
|
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
let currentHandle = this.rootHandle;
|
let currentHandle = this.rootHandle;
|
||||||
|
|
||||||
@@ -163,11 +146,14 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const resolved = await this.resolveWritablePath(p);
|
const resolved = await this.resolvePath(p);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
throw new Error(`Invalid path: ${p}`);
|
throw new Error(`Invalid path: ${p}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
await this.mkdir(p.split("/").slice(0, -1).join("/"));
|
||||||
|
|
||||||
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
||||||
const writable = await fileHandle.createWritable();
|
const writable = await fileHandle.createWritable();
|
||||||
await writable.write(data);
|
await writable.write(data);
|
||||||
@@ -175,11 +161,14 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const resolved = await this.resolveWritablePath(p);
|
const resolved = await this.resolvePath(p);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
throw new Error(`Invalid path: ${p}`);
|
throw new Error(`Invalid path: ${p}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
await this.mkdir(p.split("/").slice(0, -1).join("/"));
|
||||||
|
|
||||||
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
||||||
const writable = await fileHandle.createWritable();
|
const writable = await fileHandle.createWritable();
|
||||||
await writable.write(data);
|
await writable.write(data);
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { describe, it } from "vitest";
|
|
||||||
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
|
|
||||||
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
|
|
||||||
|
|
||||||
class MemoryFileHandle {
|
|
||||||
readonly kind = "file";
|
|
||||||
private data = new Uint8Array();
|
|
||||||
|
|
||||||
constructor(readonly name: string) {}
|
|
||||||
|
|
||||||
async getFile(): Promise<File> {
|
|
||||||
return new File([this.data], this.name, { lastModified: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async createWritable(): Promise<FileSystemWritableFileStream> {
|
|
||||||
const handle = this;
|
|
||||||
return {
|
|
||||||
async write(data: FileSystemWriteChunkType) {
|
|
||||||
if (typeof data === "string") {
|
|
||||||
handle.data = new TextEncoder().encode(data);
|
|
||||||
} else if (data instanceof ArrayBuffer) {
|
|
||||||
handle.data = new Uint8Array(data.slice(0));
|
|
||||||
} else if (ArrayBuffer.isView(data)) {
|
|
||||||
handle.data = new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
|
|
||||||
} else {
|
|
||||||
throw new TypeError("Unsupported in-memory write type");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async close() {},
|
|
||||||
} as FileSystemWritableFileStream;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MemoryDirectoryHandle {
|
|
||||||
readonly kind = "directory";
|
|
||||||
private readonly children = new Map<string, MemoryDirectoryHandle | MemoryFileHandle>();
|
|
||||||
|
|
||||||
constructor(readonly name: string) {}
|
|
||||||
|
|
||||||
async getDirectoryHandle(name: string, options?: FileSystemGetDirectoryOptions): Promise<FileSystemDirectoryHandle> {
|
|
||||||
const existing = this.children.get(name);
|
|
||||||
if (existing instanceof MemoryDirectoryHandle) return existing as unknown as FileSystemDirectoryHandle;
|
|
||||||
if (existing !== undefined || !options?.create) throw new DOMException("Directory not found", "NotFoundError");
|
|
||||||
const directory = new MemoryDirectoryHandle(name);
|
|
||||||
this.children.set(name, directory);
|
|
||||||
return directory as unknown as FileSystemDirectoryHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle> {
|
|
||||||
const existing = this.children.get(name);
|
|
||||||
if (existing instanceof MemoryFileHandle) return existing as unknown as FileSystemFileHandle;
|
|
||||||
if (existing !== undefined || !options?.create) throw new DOMException("File not found", "NotFoundError");
|
|
||||||
const file = new MemoryFileHandle(name);
|
|
||||||
this.children.set(name, file);
|
|
||||||
return file as unknown as FileSystemFileHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<void> {
|
|
||||||
const existing = this.children.get(name);
|
|
||||||
if (existing === undefined) throw new DOMException("Entry not found", "NotFoundError");
|
|
||||||
if (existing instanceof MemoryDirectoryHandle && !options?.recursive && existing.children.size > 0) {
|
|
||||||
throw new DOMException("Directory is not empty", "InvalidModificationError");
|
|
||||||
}
|
|
||||||
this.children.delete(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
|
|
||||||
for (const [name, entry] of this.children) {
|
|
||||||
yield [name, entry as unknown as FileSystemHandle];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("FSAPIStorageAdapter", () => {
|
|
||||||
for (const contractCase of storageAdapterContractCases) {
|
|
||||||
it(contractCase.name, async () => {
|
|
||||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
|
||||||
await contractCase.run(new FSAPIStorageAdapter(root));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
+1
-1
Submodule src/lib updated: 1cb156d463...a0efb7274e
@@ -1 +0,0 @@
|
|||||||
bench-results/
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
FROM alpine:3.22
|
|
||||||
|
|
||||||
RUN apk add --no-cache iproute2
|
|
||||||
|
|
||||||
COPY test/bench-network/netem-smoke.sh /usr/local/bin/livesync-netem-smoke
|
|
||||||
RUN chmod +x /usr/local/bin/livesync-netem-smoke
|
|
||||||
|
|
||||||
CMD ["livesync-netem-smoke"]
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
FROM node:24-slim
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ iproute2 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
ENV DENO_INSTALL=/usr/local
|
|
||||||
RUN curl -fsSL https://deno.land/install.sh | sh
|
|
||||||
|
|
||||||
WORKDIR /workspace
|
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
COPY src/apps/cli/package.json ./src/apps/cli/package.json
|
|
||||||
COPY src/apps/webapp/package.json ./src/apps/webapp/package.json
|
|
||||||
COPY src/apps/webpeer/package.json ./src/apps/webpeer/package.json
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build -w self-hosted-livesync-cli
|
|
||||||
|
|
||||||
WORKDIR /workspace/src/apps/cli/testdeno
|
|
||||||
|
|
||||||
RUN deno cache --lock=deno.lock \
|
|
||||||
bench-network-cases.ts \
|
|
||||||
bench-latency-sweep.ts \
|
|
||||||
bench-p2p-split-node.ts \
|
|
||||||
bench-p2p.ts \
|
|
||||||
bench-couchdb.ts \
|
|
||||||
test-p2p-sync.ts
|
|
||||||
|
|
||||||
COPY test/bench-network/run-bench.sh /usr/local/bin/run-livesync-bench
|
|
||||||
COPY src/apps/cli/testdeno/run-cli-e2e.sh /usr/local/bin/run-livesync-cli-e2e
|
|
||||||
RUN chmod +x /usr/local/bin/run-livesync-bench /usr/local/bin/run-livesync-cli-e2e
|
|
||||||
|
|
||||||
CMD ["run-livesync-bench"]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
FROM alpine:3.22
|
|
||||||
|
|
||||||
RUN apk add --no-cache iproute2 socat
|
|
||||||
|
|
||||||
COPY test/bench-network/netem-tcp-shim.sh /usr/local/bin/livesync-netem-tcp-shim
|
|
||||||
RUN chmod +x /usr/local/bin/livesync-netem-tcp-shim
|
|
||||||
|
|
||||||
CMD ["livesync-netem-tcp-shim"]
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
# Network benchmark package
|
|
||||||
|
|
||||||
This directory packages the CLI benchmark cases with Docker Compose. It is
|
|
||||||
intended for reproducible local benchmark runs where CouchDB, the Nostr
|
|
||||||
signalling relay, optional TURN, and the benchmark runner are fixed by the
|
|
||||||
Compose file.
|
|
||||||
|
|
||||||
## Quick smoke run
|
|
||||||
|
|
||||||
From the repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f test/bench-network/compose.yml run --rm bench-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
By default this runs:
|
|
||||||
|
|
||||||
- `couchdb-baseline`
|
|
||||||
- `p2p-direct-local`
|
|
||||||
|
|
||||||
The dataset is intentionally small by default. Results are written to
|
|
||||||
`test/bench-network/bench-results/`.
|
|
||||||
|
|
||||||
## GitHub Actions smoke run
|
|
||||||
|
|
||||||
`.github/workflows/cli-p2p-compose-smoke.yml` provides a manual
|
|
||||||
`workflow_dispatch` smoke run for the same Compose package. It is intentionally
|
|
||||||
not a required check yet, because WebRTC peer discovery can still be slow or
|
|
||||||
environment-sensitive on GitHub-hosted runners. Keep the dataset small and use
|
|
||||||
the uploaded JSON artefact to inspect whether failures are caused by peer
|
|
||||||
discovery, synchronisation, CouchDB startup, or Docker networking.
|
|
||||||
|
|
||||||
## Select cases
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BENCH_CASES=couchdb-baseline,p2p-direct-local,p2p-user-turn \
|
|
||||||
docker compose -f test/bench-network/compose.yml --profile turn run --rm bench-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
Available local cases:
|
|
||||||
|
|
||||||
- `couchdb-baseline`
|
|
||||||
- `p2p-direct-local`
|
|
||||||
- `couchdb-tethering-vpn-proxy`
|
|
||||||
- `couchdb-netem-home-wifi`
|
|
||||||
- `couchdb-netem-tethering-vpn`
|
|
||||||
- `p2p-smartphone-vpn-direct`
|
|
||||||
- `p2p-user-turn`
|
|
||||||
|
|
||||||
Set `BENCH_REPEAT_COUNT` to run each selected case more than once. Repeated
|
|
||||||
results are written with suffixes such as `-r01`, `-r02`, and `-r03`, and the
|
|
||||||
summary records the repeat index for each run.
|
|
||||||
|
|
||||||
`p2p-smartphone-vpn-direct` is a structural case name. When it is run inside
|
|
||||||
this Compose package it is not a real smartphone tethering/VPN measurement; it
|
|
||||||
uses the local Compose network. Use it only for wiring checks unless the runner
|
|
||||||
is executed in an actual tethered/VPN environment.
|
|
||||||
|
|
||||||
## Comparison model
|
|
||||||
|
|
||||||
The primary local comparison is between a remote-database path and a direct P2P
|
|
||||||
path:
|
|
||||||
|
|
||||||
| Case | Data path | What is measured | What is not measured |
|
|
||||||
| ------------------ | ------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `couchdb-baseline` | Device A -> CouchDB -> Device B | Two one-shot CLI synchronisation commands through a local HTTP latency proxy | Real WAN jitter, packet loss, bandwidth limits, VPN encapsulation, and server contention |
|
|
||||||
| `p2p-direct-local` | Device A -> Device B using Nostr signalling | One fresh CLI `p2p-sync` command, including process start-up and WebRTC connection establishment, with TURN disabled | Public relay operation, mobile carrier behaviour, and TURN relay throughput |
|
|
||||||
|
|
||||||
Use the CouchDB result as the remote-store baseline and the P2P result as the
|
|
||||||
direct-transfer comparison. The Nostr relay is used for signalling in the P2P
|
|
||||||
case, but synchronised note content is transferred over the WebRTC DataChannel.
|
|
||||||
The earlier `p2p-peers` observation command is excluded from the P2P timing,
|
|
||||||
but the timed `p2p-sync` command performs its own signalling and connection
|
|
||||||
establishment. The P2P result JSON records the selected WebRTC ICE candidate pair when the CLI
|
|
||||||
can collect it from `RTCPeerConnection.getStats()`. Interpret P2P paths from
|
|
||||||
the recorded candidate types rather than from TURN configuration alone. Do not
|
|
||||||
report a signalling-only Tier 2 run as though the selected note-data path were
|
|
||||||
also shaped.
|
|
||||||
|
|
||||||
Benchmark cases use `BENCH_VERIFY_MODE=all` by default. After the timed phase,
|
|
||||||
the runner retrieves and compares every generated file and records the verified
|
|
||||||
file count, whether verification was complete, and a SHA-256 digest of the
|
|
||||||
deterministic dataset. Set `BENCH_VERIFY_MODE=sample` only for exploratory
|
|
||||||
large-dataset runs where the additional verification time is impractical.
|
|
||||||
|
|
||||||
## Dataset and latency controls
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BENCH_MD_FILE_COUNT=100 \
|
|
||||||
BENCH_MD_MIN_SIZE_BYTES=512 \
|
|
||||||
BENCH_MD_MAX_SIZE_BYTES=2048 \
|
|
||||||
BENCH_BIN_FILE_COUNT=25 \
|
|
||||||
BENCH_BIN_SIZE_BYTES=8192 \
|
|
||||||
BENCH_COUCHDB_RTT_MS=20 \
|
|
||||||
BENCH_PEERS_TIMEOUT=60 \
|
|
||||||
docker compose -f test/bench-network/compose.yml run --rm bench-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
The CouchDB latency model is the HTTP proxy inside `bench-couchdb.ts`. It adds
|
|
||||||
half of the requested RTT before forwarding each request and the other half
|
|
||||||
before returning its response. It does not model packet loss, jitter, MTU,
|
|
||||||
bandwidth limits, bufferbloat, or VPN encapsulation.
|
|
||||||
|
|
||||||
For P2P runs, `BENCH_PEERS_TIMEOUT` is passed to `p2p-peers`. That command waits
|
|
||||||
for the requested observation window before printing discovered peers, so the
|
|
||||||
reported peer discovery command time should not be read as first-peer latency.
|
|
||||||
|
|
||||||
## Latency sweep
|
|
||||||
|
|
||||||
To run P2P once and CouchDB at several requested RTT values:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BENCH_COMMAND=latency-sweep \
|
|
||||||
BENCH_SWEEP_RTT_MS=20,50,100,150,300 \
|
|
||||||
BENCH_REPEAT_COUNT=3 \
|
|
||||||
BENCH_MD_FILE_COUNT=100 \
|
|
||||||
BENCH_MD_MIN_SIZE_BYTES=512 \
|
|
||||||
BENCH_MD_MAX_SIZE_BYTES=2048 \
|
|
||||||
BENCH_BIN_FILE_COUNT=25 \
|
|
||||||
BENCH_BIN_SIZE_BYTES=8192 \
|
|
||||||
BENCH_SYNC_TIMEOUT=300 \
|
|
||||||
BENCH_PEERS_TIMEOUT=60 \
|
|
||||||
docker compose -f test/bench-network/compose.yml run --rm bench-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
This sweep is useful for finding where the remote CouchDB path falls behind the
|
|
||||||
local direct P2P path in the current HTTP-proxy latency model. It should not be
|
|
||||||
presented as a full smartphone/VPN model.
|
|
||||||
|
|
||||||
## Network emulation smoke
|
|
||||||
|
|
||||||
The optional `netem` profile checks whether a Linux runner can apply traffic
|
|
||||||
shaping inside a Compose-managed container. This is a fixture smoke test for a
|
|
||||||
second-tier simulation design; it does not produce synchronisation performance
|
|
||||||
results by itself.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f test/bench-network/compose.yml --profile netem run --rm netem-smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
The smoke writes `tc qdisc`, route, and interface details under
|
|
||||||
`test/bench-network/bench-results/`. Profile parameters can be overridden:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NETEM_PROFILE=tethering-vpn \
|
|
||||||
NETEM_DELAY_MS=140 \
|
|
||||||
NETEM_JITTER_MS=50 \
|
|
||||||
NETEM_LOSS_PERCENT=1.0 \
|
|
||||||
NETEM_BANDWIDTH_MBIT=10 \
|
|
||||||
NETEM_MTU=1380 \
|
|
||||||
docker compose -f test/bench-network/compose.yml --profile netem run --rm netem-smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
## Split-container P2P emulation
|
|
||||||
|
|
||||||
The optional `p2p-split` profile runs the P2P host and client in separate
|
|
||||||
Compose services. Each service can apply `tc netem` to its own egress interface
|
|
||||||
and the client result records the selected WebRTC ICE candidate pair.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BENCH_MD_FILE_COUNT=2 \
|
|
||||||
BENCH_BIN_FILE_COUNT=1 \
|
|
||||||
BENCH_PEERS_TIMEOUT=10 \
|
|
||||||
BENCH_SPLIT_RUN_ID="$(date -u +%Y%m%d%H%M%S)" \
|
|
||||||
docker compose -f test/bench-network/compose.yml --profile p2p-split up \
|
|
||||||
--abort-on-container-exit --exit-code-from p2p-split-client \
|
|
||||||
p2p-split-host p2p-split-client
|
|
||||||
```
|
|
||||||
|
|
||||||
By default this uses the `home-wifi` profile (`20 ms` delay, `5 ms` jitter,
|
|
||||||
`0.1%` loss, `100 Mbit`, and `1500` MTU) on both P2P containers. Override the
|
|
||||||
same `NETEM_*` variables used by the TCP shim to model a stricter profile.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BENCH_MD_FILE_COUNT=100 \
|
|
||||||
BENCH_MD_MIN_SIZE_BYTES=512 \
|
|
||||||
BENCH_MD_MAX_SIZE_BYTES=2048 \
|
|
||||||
BENCH_BIN_FILE_COUNT=25 \
|
|
||||||
BENCH_BIN_SIZE_BYTES=8192 \
|
|
||||||
BENCH_PEERS_TIMEOUT=60 \
|
|
||||||
BENCH_SYNC_TIMEOUT=420 \
|
|
||||||
BENCH_SPLIT_RUN_ID="$(date -u +%Y%m%d%H%M%S)" \
|
|
||||||
BENCH_NETWORK_PROFILE=tethering-vpn \
|
|
||||||
NETEM_PROFILE=tethering-vpn \
|
|
||||||
NETEM_DELAY_MS=140 \
|
|
||||||
NETEM_JITTER_MS=50 \
|
|
||||||
NETEM_LOSS_PERCENT=1.0 \
|
|
||||||
NETEM_BANDWIDTH_MBIT=10 \
|
|
||||||
NETEM_MTU=1380 \
|
|
||||||
docker compose -f test/bench-network/compose.yml --profile p2p-split up \
|
|
||||||
--abort-on-container-exit --exit-code-from p2p-split-client \
|
|
||||||
p2p-split-host p2p-split-client
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a Linux-only manual benchmark fixture, not a required pull-request CI
|
|
||||||
job. It shapes each P2P container's egress path, including signalling traffic,
|
|
||||||
and should be reported separately from the CouchDB TCP-shim measurements. The
|
|
||||||
result JSON includes `ok: true` for completed runs; failed runs still write a
|
|
||||||
summary with `ok: false` and a `failure` object before returning a non-zero
|
|
||||||
exit code.
|
|
||||||
|
|
||||||
Remove the shared work volume between repeated manual runs when you do not use
|
|
||||||
a unique `BENCH_SPLIT_RUN_ID`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f test/bench-network/compose.yml --profile p2p-split down --volumes
|
|
||||||
```
|
|
||||||
|
|
||||||
## P2P Signalling-Only Emulation
|
|
||||||
|
|
||||||
The optional `signalling-shim` profile shapes only the Nostr signalling relay
|
|
||||||
path. The P2P host and client run in the benchmark runner as usual, and the
|
|
||||||
configured relay URL points at a TCP netem shim in front of `nostr-relay`.
|
|
||||||
This is the preferred fixture when evaluating the hypothesis that P2P avoids a
|
|
||||||
constrained remote database data path while still depending on a signalling
|
|
||||||
server for rendezvous.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BENCH_CASES=p2p-signalling-netem-home-wifi \
|
|
||||||
docker compose -f test/bench-network/compose.yml --profile signalling-shim run --rm \
|
|
||||||
bench-runner-signalling-shim
|
|
||||||
```
|
|
||||||
|
|
||||||
For a stricter signalling path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NETEM_PROFILE=tethering-vpn \
|
|
||||||
NETEM_DELAY_MS=140 \
|
|
||||||
NETEM_JITTER_MS=50 \
|
|
||||||
NETEM_LOSS_PERCENT=1.0 \
|
|
||||||
NETEM_BANDWIDTH_MBIT=10 \
|
|
||||||
NETEM_MTU=1380 \
|
|
||||||
BENCH_CASES=p2p-signalling-netem-tethering-vpn \
|
|
||||||
docker compose -f test/bench-network/compose.yml --profile signalling-shim run --rm \
|
|
||||||
bench-runner-signalling-shim
|
|
||||||
```
|
|
||||||
|
|
||||||
Use this separately from `p2p-split`. The `p2p-split` profile shapes each peer's
|
|
||||||
egress path, so it constrains both signalling and the selected WebRTC data
|
|
||||||
path. The `signalling-shim` profile constrains only relay access, which keeps
|
|
||||||
it focused on peer-to-signalling-server reachability rather than peer-to-peer
|
|
||||||
note-data transfer.
|
|
||||||
|
|
||||||
## Shimmed CouchDB benchmark
|
|
||||||
|
|
||||||
The optional `shim` profile runs a CouchDB benchmark through a TCP forwarding
|
|
||||||
container that applies `tc netem`. This is a manual Tier 2 synchronisation
|
|
||||||
measurement path; it is intentionally separate from required pull-request CI.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f test/bench-network/compose.yml --profile shim run --rm bench-runner-shim
|
|
||||||
```
|
|
||||||
|
|
||||||
The default profile is `home-wifi`. A smartphone/VPN-like profile can be
|
|
||||||
requested by overriding both the shim parameters and the benchmark case:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NETEM_PROFILE=tethering-vpn \
|
|
||||||
NETEM_DELAY_MS=140 \
|
|
||||||
NETEM_JITTER_MS=50 \
|
|
||||||
NETEM_LOSS_PERCENT=1.0 \
|
|
||||||
NETEM_BANDWIDTH_MBIT=10 \
|
|
||||||
NETEM_MTU=1380 \
|
|
||||||
BENCH_CASES=couchdb-netem-tethering-vpn \
|
|
||||||
docker compose -f test/bench-network/compose.yml --profile shim run --rm bench-runner-shim
|
|
||||||
```
|
|
||||||
|
|
||||||
The benchmark result records `simulationTier`, `networkProfile`, and
|
|
||||||
`networkModel`. The shim also writes its applied `tc qdisc`, route, and
|
|
||||||
interface state under `test/bench-network/bench-results/`.
|
|
||||||
This shim currently measures the CouchDB path only. It does not shape or verify
|
|
||||||
the WebRTC P2P data path.
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
services:
|
|
||||||
couchdb:
|
|
||||||
image: couchdb:3.5.0
|
|
||||||
environment:
|
|
||||||
COUCHDB_USER: ${BENCH_COUCHDB_USER:-admin}
|
|
||||||
COUCHDB_PASSWORD: ${BENCH_COUCHDB_PASSWORD:-testpassword}
|
|
||||||
COUCHDB_SINGLE_NODE: "true"
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
[
|
|
||||||
"CMD-SHELL",
|
|
||||||
"curl -fsS -u ${BENCH_COUCHDB_USER:-admin}:${BENCH_COUCHDB_PASSWORD:-testpassword} http://127.0.0.1:5984/_up >/dev/null",
|
|
||||||
]
|
|
||||||
interval: 2s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
|
|
||||||
nostr-relay:
|
|
||||||
image: ghcr.io/hoytech/strfry:latest
|
|
||||||
entrypoint: sh
|
|
||||||
command:
|
|
||||||
- -lc
|
|
||||||
- |
|
|
||||||
cat > /tmp/strfry.conf <<'EOF'
|
|
||||||
db = "./strfry-db/"
|
|
||||||
|
|
||||||
relay {
|
|
||||||
bind = "0.0.0.0"
|
|
||||||
port = 7777
|
|
||||||
nofiles = 65536
|
|
||||||
|
|
||||||
info {
|
|
||||||
name = "livesync bench relay"
|
|
||||||
description = "local relay for livesync compose benchmarks"
|
|
||||||
}
|
|
||||||
|
|
||||||
maxWebsocketPayloadSize = 131072
|
|
||||||
autoPingSeconds = 55
|
|
||||||
|
|
||||||
writePolicy {
|
|
||||||
plugin = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
exec /app/strfry --config /tmp/strfry.conf relay
|
|
||||||
tmpfs:
|
|
||||||
- /app/strfry-db:rw,size=256m
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "nc -z 127.0.0.1 7777"]
|
|
||||||
interval: 2s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
|
|
||||||
coturn:
|
|
||||||
image: coturn/coturn:latest
|
|
||||||
command:
|
|
||||||
- --log-file=stdout
|
|
||||||
- --listening-port=3478
|
|
||||||
- --user=${BENCH_TURN_USERNAME:-testuser}:${BENCH_TURN_CREDENTIAL:-testpass}
|
|
||||||
- --realm=${BENCH_TURN_REALM:-livesync.test}
|
|
||||||
profiles:
|
|
||||||
- turn
|
|
||||||
|
|
||||||
bench-runner:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: test/bench-network/Dockerfile.runner
|
|
||||||
depends_on:
|
|
||||||
couchdb:
|
|
||||||
condition: service_healthy
|
|
||||||
nostr-relay:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
|
||||||
BENCH_CASES: ${BENCH_CASES:-couchdb-baseline,p2p-direct-local}
|
|
||||||
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
|
|
||||||
BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
BENCH_SWEEP_RTT_MS: ${BENCH_SWEEP_RTT_MS:-20,50,100,150,300}
|
|
||||||
BENCH_SWEEP_INCLUDE_P2P: ${BENCH_SWEEP_INCLUDE_P2P:-true}
|
|
||||||
BENCH_COUCHDB_MANAGED: "false"
|
|
||||||
BENCH_COUCHDB_BACKEND_URI: http://couchdb:5984
|
|
||||||
BENCH_COUCHDB_URI: http://127.0.0.1:15989
|
|
||||||
BENCH_COUCHDB_USER: ${BENCH_COUCHDB_USER:-admin}
|
|
||||||
BENCH_COUCHDB_PASSWORD: ${BENCH_COUCHDB_PASSWORD:-testpassword}
|
|
||||||
BENCH_RELAY: ws://nostr-relay:7777/
|
|
||||||
BENCH_LOCAL_TURN_SERVERS: turn:coturn:3478
|
|
||||||
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
|
|
||||||
BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512}
|
|
||||||
BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048}
|
|
||||||
BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5}
|
|
||||||
BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192}
|
|
||||||
BENCH_VERIFY_MODE: ${BENCH_VERIFY_MODE:-all}
|
|
||||||
BENCH_COUCHDB_RTT_MS: ${BENCH_COUCHDB_RTT_MS:-20}
|
|
||||||
BENCH_TETHERING_VPN_RTT_MS: ${BENCH_TETHERING_VPN_RTT_MS:-120}
|
|
||||||
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
|
|
||||||
BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60}
|
|
||||||
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
|
|
||||||
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
|
||||||
CLI_E2E_TASK: ${CLI_E2E_TASK:-test:p2p-sync}
|
|
||||||
RELAY: ${RELAY:-ws://nostr-relay:7777/}
|
|
||||||
PEERS_TIMEOUT: ${PEERS_TIMEOUT:-20}
|
|
||||||
SYNC_TIMEOUT: ${SYNC_TIMEOUT:-60}
|
|
||||||
LIVESYNC_USE_COTURN: ${LIVESYNC_USE_COTURN:-0}
|
|
||||||
TURN_SERVERS: ${TURN_SERVERS:-none}
|
|
||||||
LIVESYNC_P2P_PEERS_RETRY: ${LIVESYNC_P2P_PEERS_RETRY:-1}
|
|
||||||
volumes:
|
|
||||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
|
|
||||||
couchdb-shim:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: test/bench-network/Dockerfile.shim
|
|
||||||
profiles:
|
|
||||||
- shim
|
|
||||||
depends_on:
|
|
||||||
couchdb:
|
|
||||||
condition: service_healthy
|
|
||||||
cap_add:
|
|
||||||
- NET_ADMIN
|
|
||||||
environment:
|
|
||||||
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
|
|
||||||
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
|
|
||||||
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
|
|
||||||
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
|
|
||||||
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
|
|
||||||
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
|
|
||||||
NETEM_MTU: ${NETEM_MTU:-1500}
|
|
||||||
NETEM_RESULT_ROOT: /bench-results
|
|
||||||
SHIM_LISTEN_PORT: 5984
|
|
||||||
SHIM_TARGET_HOST: couchdb
|
|
||||||
SHIM_TARGET_PORT: 5984
|
|
||||||
volumes:
|
|
||||||
- ./bench-results:/bench-results
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "nc -z 127.0.0.1 5984"]
|
|
||||||
interval: 2s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
|
|
||||||
bench-runner-shim:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: test/bench-network/Dockerfile.runner
|
|
||||||
profiles:
|
|
||||||
- shim
|
|
||||||
depends_on:
|
|
||||||
couchdb-shim:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
|
||||||
BENCH_CASES: ${BENCH_CASES:-couchdb-netem-home-wifi}
|
|
||||||
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
|
|
||||||
BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
BENCH_COUCHDB_MANAGED: "false"
|
|
||||||
BENCH_COUCHDB_BACKEND_URI: http://couchdb-shim:5984
|
|
||||||
BENCH_SHIM_COUCHDB_URI: http://couchdb-shim:5984
|
|
||||||
BENCH_COUCHDB_URI: http://127.0.0.1:15989
|
|
||||||
BENCH_COUCHDB_USER: ${BENCH_COUCHDB_USER:-admin}
|
|
||||||
BENCH_COUCHDB_PASSWORD: ${BENCH_COUCHDB_PASSWORD:-testpassword}
|
|
||||||
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
|
|
||||||
BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512}
|
|
||||||
BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048}
|
|
||||||
BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5}
|
|
||||||
BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192}
|
|
||||||
BENCH_COUCHDB_RTT_MS: ${BENCH_COUCHDB_RTT_MS:-1}
|
|
||||||
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
|
|
||||||
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
|
||||||
volumes:
|
|
||||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
|
|
||||||
p2p-signalling-shim:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: test/bench-network/Dockerfile.shim
|
|
||||||
profiles:
|
|
||||||
- signalling-shim
|
|
||||||
depends_on:
|
|
||||||
nostr-relay:
|
|
||||||
condition: service_healthy
|
|
||||||
cap_add:
|
|
||||||
- NET_ADMIN
|
|
||||||
environment:
|
|
||||||
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
|
|
||||||
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
|
|
||||||
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
|
|
||||||
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
|
|
||||||
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
|
|
||||||
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
|
|
||||||
NETEM_MTU: ${NETEM_MTU:-1500}
|
|
||||||
NETEM_RESULT_ROOT: /bench-results
|
|
||||||
SHIM_LISTEN_PORT: 7777
|
|
||||||
SHIM_TARGET_HOST: nostr-relay
|
|
||||||
SHIM_TARGET_PORT: 7777
|
|
||||||
volumes:
|
|
||||||
- ./bench-results:/bench-results
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "nc -z 127.0.0.1 7777"]
|
|
||||||
interval: 2s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
|
|
||||||
bench-runner-signalling-shim:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: test/bench-network/Dockerfile.runner
|
|
||||||
profiles:
|
|
||||||
- signalling-shim
|
|
||||||
depends_on:
|
|
||||||
p2p-signalling-shim:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
|
||||||
BENCH_CASES: ${BENCH_CASES:-p2p-signalling-netem-home-wifi}
|
|
||||||
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
|
|
||||||
BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
BENCH_SIGNAL_SHIM_RELAY: ws://p2p-signalling-shim:7777/
|
|
||||||
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
|
|
||||||
BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512}
|
|
||||||
BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048}
|
|
||||||
BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5}
|
|
||||||
BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192}
|
|
||||||
BENCH_VERIFY_MODE: ${BENCH_VERIFY_MODE:-all}
|
|
||||||
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
|
|
||||||
BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60}
|
|
||||||
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
|
|
||||||
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
|
||||||
volumes:
|
|
||||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
|
|
||||||
p2p-split-host:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: test/bench-network/Dockerfile.runner
|
|
||||||
profiles:
|
|
||||||
- p2p-split
|
|
||||||
depends_on:
|
|
||||||
nostr-relay:
|
|
||||||
condition: service_healthy
|
|
||||||
cap_add:
|
|
||||||
- NET_ADMIN
|
|
||||||
environment:
|
|
||||||
BENCH_COMMAND: p2p-split-node
|
|
||||||
BENCH_P2P_SPLIT_ROLE: host
|
|
||||||
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
|
|
||||||
BENCH_SPLIT_WORK_ROOT: /p2p-work
|
|
||||||
BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
BENCH_RELAY: ws://nostr-relay:7777/
|
|
||||||
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
|
|
||||||
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
|
|
||||||
BENCH_PASSPHRASE: ${BENCH_PASSPHRASE:-bench-split-passphrase}
|
|
||||||
BENCH_TURN_SERVERS: ${BENCH_TURN_SERVERS:-}
|
|
||||||
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
|
|
||||||
BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512}
|
|
||||||
BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048}
|
|
||||||
BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5}
|
|
||||||
BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192}
|
|
||||||
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
|
|
||||||
BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60}
|
|
||||||
BENCH_NETEM_ENABLED: ${BENCH_NETEM_ENABLED:-1}
|
|
||||||
BENCH_NETWORK_PROFILE: ${BENCH_NETWORK_PROFILE:-home-wifi}
|
|
||||||
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
|
|
||||||
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
|
|
||||||
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
|
|
||||||
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
|
|
||||||
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
|
|
||||||
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
|
|
||||||
NETEM_MTU: ${NETEM_MTU:-1500}
|
|
||||||
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
|
|
||||||
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
|
||||||
volumes:
|
|
||||||
- p2p-split-work:/p2p-work
|
|
||||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
|
|
||||||
p2p-split-client:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: test/bench-network/Dockerfile.runner
|
|
||||||
profiles:
|
|
||||||
- p2p-split
|
|
||||||
depends_on:
|
|
||||||
nostr-relay:
|
|
||||||
condition: service_healthy
|
|
||||||
p2p-split-host:
|
|
||||||
condition: service_started
|
|
||||||
cap_add:
|
|
||||||
- NET_ADMIN
|
|
||||||
environment:
|
|
||||||
BENCH_COMMAND: p2p-split-node
|
|
||||||
BENCH_P2P_SPLIT_ROLE: client
|
|
||||||
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
|
|
||||||
BENCH_SPLIT_WORK_ROOT: /p2p-work
|
|
||||||
BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
BENCH_RELAY: ws://nostr-relay:7777/
|
|
||||||
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
|
|
||||||
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
|
|
||||||
BENCH_PASSPHRASE: ${BENCH_PASSPHRASE:-bench-split-passphrase}
|
|
||||||
BENCH_TURN_SERVERS: ${BENCH_TURN_SERVERS:-}
|
|
||||||
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
|
|
||||||
BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60}
|
|
||||||
BENCH_NETEM_ENABLED: ${BENCH_NETEM_ENABLED:-1}
|
|
||||||
BENCH_NETWORK_PROFILE: ${BENCH_NETWORK_PROFILE:-home-wifi}
|
|
||||||
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
|
|
||||||
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
|
|
||||||
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
|
|
||||||
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
|
|
||||||
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
|
|
||||||
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
|
|
||||||
NETEM_MTU: ${NETEM_MTU:-1500}
|
|
||||||
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
|
|
||||||
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
|
||||||
volumes:
|
|
||||||
- p2p-split-work:/p2p-work
|
|
||||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
|
||||||
|
|
||||||
netem-smoke:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: test/bench-network/Dockerfile.netem
|
|
||||||
profiles:
|
|
||||||
- netem
|
|
||||||
cap_add:
|
|
||||||
- NET_ADMIN
|
|
||||||
environment:
|
|
||||||
NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi}
|
|
||||||
NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0}
|
|
||||||
NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20}
|
|
||||||
NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5}
|
|
||||||
NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1}
|
|
||||||
NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100}
|
|
||||||
NETEM_MTU: ${NETEM_MTU:-1500}
|
|
||||||
NETEM_RESULT_ROOT: /bench-results
|
|
||||||
volumes:
|
|
||||||
- ./bench-results:/bench-results
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
p2p-split-work:
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
profile="${NETEM_PROFILE:-home-wifi}"
|
|
||||||
iface="${NETEM_INTERFACE:-eth0}"
|
|
||||||
delay_ms="${NETEM_DELAY_MS:-20}"
|
|
||||||
jitter_ms="${NETEM_JITTER_MS:-5}"
|
|
||||||
loss_percent="${NETEM_LOSS_PERCENT:-0.1}"
|
|
||||||
bandwidth_mbit="${NETEM_BANDWIDTH_MBIT:-100}"
|
|
||||||
mtu="${NETEM_MTU:-1500}"
|
|
||||||
out_root="${NETEM_RESULT_ROOT:-/bench-results}"
|
|
||||||
timestamp="$(date -u +%Y%m%d-%H%M%S)"
|
|
||||||
out_dir="${out_root}/netem-smoke-${timestamp}"
|
|
||||||
out_file="${out_dir}/summary.json"
|
|
||||||
|
|
||||||
mkdir -p "$out_dir"
|
|
||||||
|
|
||||||
if ! ip link show "$iface" >/dev/null 2>&1; then
|
|
||||||
echo "Network interface '$iface' was not found" >&2
|
|
||||||
ip addr >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
ip link set dev "$iface" mtu "$mtu"
|
|
||||||
tc qdisc del dev "$iface" root >/dev/null 2>&1 || true
|
|
||||||
tc qdisc add dev "$iface" root netem \
|
|
||||||
delay "${delay_ms}ms" "${jitter_ms}ms" \
|
|
||||||
loss "${loss_percent}%" \
|
|
||||||
rate "${bandwidth_mbit}mbit"
|
|
||||||
|
|
||||||
json_lines() {
|
|
||||||
awk '
|
|
||||||
{
|
|
||||||
gsub(/\\/, "\\\\");
|
|
||||||
gsub(/"/, "\\\"");
|
|
||||||
printf "%s \"%s\"", (NR == 1 ? "" : ",\n"), $0;
|
|
||||||
}
|
|
||||||
'
|
|
||||||
}
|
|
||||||
|
|
||||||
ip_addr="$(ip addr show "$iface" | json_lines)"
|
|
||||||
ip_route="$(ip route | json_lines)"
|
|
||||||
tc_qdisc="$(tc qdisc show dev "$iface" | json_lines)"
|
|
||||||
|
|
||||||
cat > "$out_file" <<EOF
|
|
||||||
{
|
|
||||||
"simulationTier": 2,
|
|
||||||
"mode": "netem-smoke",
|
|
||||||
"profile": "$profile",
|
|
||||||
"interface": "$iface",
|
|
||||||
"netem": {
|
|
||||||
"delayMs": $delay_ms,
|
|
||||||
"jitterMs": $jitter_ms,
|
|
||||||
"lossPercent": $loss_percent,
|
|
||||||
"bandwidthMbit": $bandwidth_mbit,
|
|
||||||
"mtu": $mtu
|
|
||||||
},
|
|
||||||
"ipAddr": [
|
|
||||||
$ip_addr
|
|
||||||
],
|
|
||||||
"ipRoute": [
|
|
||||||
$ip_route
|
|
||||||
],
|
|
||||||
"tcQdisc": [
|
|
||||||
$tc_qdisc
|
|
||||||
]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
cat "$out_file"
|
|
||||||
echo "[netem-smoke] result file: $out_file"
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
profile="${NETEM_PROFILE:-home-wifi}"
|
|
||||||
iface="${NETEM_INTERFACE:-eth0}"
|
|
||||||
delay_ms="${NETEM_DELAY_MS:-20}"
|
|
||||||
jitter_ms="${NETEM_JITTER_MS:-5}"
|
|
||||||
loss_percent="${NETEM_LOSS_PERCENT:-0.1}"
|
|
||||||
bandwidth_mbit="${NETEM_BANDWIDTH_MBIT:-100}"
|
|
||||||
mtu="${NETEM_MTU:-1500}"
|
|
||||||
listen_port="${SHIM_LISTEN_PORT:-5984}"
|
|
||||||
target_host="${SHIM_TARGET_HOST:-couchdb}"
|
|
||||||
target_port="${SHIM_TARGET_PORT:-5984}"
|
|
||||||
out_root="${NETEM_RESULT_ROOT:-/bench-results}"
|
|
||||||
timestamp="$(date -u +%Y%m%d-%H%M%S)"
|
|
||||||
out_dir="${out_root}/netem-shim-${profile}-${timestamp}"
|
|
||||||
out_file="${out_dir}/summary.json"
|
|
||||||
|
|
||||||
json_lines() {
|
|
||||||
awk '
|
|
||||||
{
|
|
||||||
gsub(/\\/, "\\\\");
|
|
||||||
gsub(/"/, "\\\"");
|
|
||||||
printf "%s \"%s\"", (NR == 1 ? "" : ",\n"), $0;
|
|
||||||
}
|
|
||||||
'
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdir -p "$out_dir"
|
|
||||||
|
|
||||||
if ! ip link show "$iface" >/dev/null 2>&1; then
|
|
||||||
echo "Network interface '$iface' was not found" >&2
|
|
||||||
ip addr >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
ip link set dev "$iface" mtu "$mtu"
|
|
||||||
tc qdisc del dev "$iface" root >/dev/null 2>&1 || true
|
|
||||||
tc qdisc add dev "$iface" root netem \
|
|
||||||
delay "${delay_ms}ms" "${jitter_ms}ms" \
|
|
||||||
loss "${loss_percent}%" \
|
|
||||||
rate "${bandwidth_mbit}mbit"
|
|
||||||
|
|
||||||
ip_addr="$(ip addr show "$iface" | json_lines)"
|
|
||||||
ip_route="$(ip route | json_lines)"
|
|
||||||
tc_qdisc="$(tc qdisc show dev "$iface" | json_lines)"
|
|
||||||
|
|
||||||
cat > "$out_file" <<EOF
|
|
||||||
{
|
|
||||||
"simulationTier": 2,
|
|
||||||
"mode": "netem-tcp-shim",
|
|
||||||
"profile": "$profile",
|
|
||||||
"interface": "$iface",
|
|
||||||
"listenPort": $listen_port,
|
|
||||||
"targetHost": "$target_host",
|
|
||||||
"targetPort": $target_port,
|
|
||||||
"netem": {
|
|
||||||
"delayMs": $delay_ms,
|
|
||||||
"jitterMs": $jitter_ms,
|
|
||||||
"lossPercent": $loss_percent,
|
|
||||||
"bandwidthMbit": $bandwidth_mbit,
|
|
||||||
"mtu": $mtu
|
|
||||||
},
|
|
||||||
"ipAddr": [
|
|
||||||
$ip_addr
|
|
||||||
],
|
|
||||||
"ipRoute": [
|
|
||||||
$ip_route
|
|
||||||
],
|
|
||||||
"tcQdisc": [
|
|
||||||
$tc_qdisc
|
|
||||||
]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
cat "$out_file"
|
|
||||||
echo "[netem-shim] forwarding 0.0.0.0:${listen_port} to ${target_host}:${target_port}"
|
|
||||||
echo "[netem-shim] result file: $out_file"
|
|
||||||
|
|
||||||
exec socat "TCP-LISTEN:${listen_port},fork,reuseaddr" "TCP:${target_host}:${target_port}"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
case "${BENCH_COMMAND:-cases}" in
|
|
||||||
cases)
|
|
||||||
exec deno task bench:cases
|
|
||||||
;;
|
|
||||||
latency-sweep)
|
|
||||||
exec deno task bench:latency-sweep
|
|
||||||
;;
|
|
||||||
p2p-split-node)
|
|
||||||
exec deno task bench:p2p-split-node
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2
|
|
||||||
echo "Expected one of: cases, latency-sweep, p2p-split-node" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
This directory contains the experimental real Obsidian end-to-end runner.
|
This directory contains the experimental real Obsidian end-to-end runner.
|
||||||
|
|
||||||
The generic application discovery, isolated-vault, plug-in installation, process lifecycle, CLI, CDP, and readiness implementation comes from `@vrtmrz/obsidian-test-session`. The small modules under `runner/` preserve LiveSync's existing imports and supply its plug-in ID and artefact location. LiveSync-specific fixtures, services, settings, workflows, and assertions remain in this repository.
|
|
||||||
|
|
||||||
The current smoke runner verifies only the launch path:
|
The current smoke runner verifies only the launch path:
|
||||||
|
|
||||||
1. create a temporary vault,
|
1. create a temporary vault,
|
||||||
@@ -20,7 +18,7 @@ The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. R
|
|||||||
|
|
||||||
Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/community-plugins.json`. The smoke runner enables it through `app.plugins.setEnable(true)` after the vault window is available.
|
Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/community-plugins.json`. The smoke runner enables it through `app.plugins.setEnable(true)` after the vault window is available.
|
||||||
|
|
||||||
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here.
|
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence.
|
||||||
|
|
||||||
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
|
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,103 @@
|
|||||||
export {
|
import { spawn } from "node:child_process";
|
||||||
evalObsidianJson,
|
|
||||||
openVaultWithObsidianCli,
|
export type ObsidianCliResult = {
|
||||||
runObsidianCli,
|
code: number | null;
|
||||||
type ObsidianCliResult,
|
signal: NodeJS.Signals | null;
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseEvalJson(stdout: string): unknown {
|
||||||
|
const marker = "=> ";
|
||||||
|
const markerIndex = stdout.indexOf(marker);
|
||||||
|
const text = markerIndex >= 0 ? stdout.slice(markerIndex + marker.length) : stdout;
|
||||||
|
return JSON.parse(text.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runObsidianCli(
|
||||||
|
cliBinary: string,
|
||||||
|
args: string[],
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
timeoutMs = Number(process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ?? 10000)
|
||||||
|
): Promise<ObsidianCliResult> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(cliBinary, args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
reject(new Error(`Obsidian CLI timed out: ${cliBinary} ${args.join(" ")}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("error", (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({ code, signal, stdout, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openVaultWithObsidianCli(
|
||||||
|
cliBinary: string,
|
||||||
|
vaultPath: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await runObsidianCli(cliBinary, [`obsidian://open?path=${encodeURIComponent(vaultPath)}`], env);
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to open Obsidian vault through CLI. code=${result.code}, signal=${result.signal}`,
|
||||||
|
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||||
|
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evalObsidianJson<T>(
|
||||||
|
cliBinary: string,
|
||||||
|
code: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
timeoutMs?: number
|
||||||
|
): Promise<T> {
|
||||||
|
const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env, timeoutMs);
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to evaluate Obsidian JavaScript through CLI. code=${result.code}, signal=${result.signal}`,
|
||||||
|
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||||
|
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parseEvalJson(result.stdout) as T;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to parse Obsidian CLI eval JSON. code=${result.code}, signal=${result.signal}`,
|
||||||
|
error instanceof Error ? `parse error: ${error.message}` : undefined,
|
||||||
|
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||||
|
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,149 @@
|
|||||||
export {
|
import { accessSync, constants, existsSync } from "node:fs";
|
||||||
discoverObsidianBinary,
|
import { resolve } from "node:path";
|
||||||
discoverObsidianCli,
|
import { platform } from "node:process";
|
||||||
requireObsidianBinary,
|
|
||||||
requireObsidianCli,
|
export type ObsidianDiscoveryResult = {
|
||||||
type ObsidianDiscoveryResult,
|
binary?: string;
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
source?: string;
|
||||||
|
checked: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
|
||||||
|
aix: [],
|
||||||
|
android: [],
|
||||||
|
darwin: [
|
||||||
|
"/Applications/Obsidian.app/Contents/MacOS/Obsidian",
|
||||||
|
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
|
||||||
|
],
|
||||||
|
freebsd: [],
|
||||||
|
haiku: [],
|
||||||
|
linux: [
|
||||||
|
"_testdata/obsidian/squashfs-root/obsidian",
|
||||||
|
"_testdata/obsidian/squashfs-root/AppRun",
|
||||||
|
"_testdata/obsidian/Obsidian-1.12.7-arm64.AppImage",
|
||||||
|
"_testdata/obsidian/Obsidian-1.12.7-x86_64.AppImage",
|
||||||
|
"/usr/bin/obsidian",
|
||||||
|
"/usr/local/bin/obsidian",
|
||||||
|
"/snap/bin/obsidian",
|
||||||
|
"/opt/Obsidian/obsidian",
|
||||||
|
"/opt/obsidian/obsidian",
|
||||||
|
"/app/bin/obsidian",
|
||||||
|
],
|
||||||
|
openbsd: [],
|
||||||
|
sunos: [],
|
||||||
|
win32: ["C:\\Program Files\\Obsidian\\Obsidian.exe", "C:\\Program Files (x86)\\Obsidian\\Obsidian.exe"],
|
||||||
|
cygwin: [],
|
||||||
|
netbsd: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultCliCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
|
||||||
|
aix: [],
|
||||||
|
android: [],
|
||||||
|
darwin: [
|
||||||
|
"/Applications/Obsidian.app/Contents/MacOS/obsidian-cli",
|
||||||
|
"/Applications/Obsidian.app/Contents/Resources/obsidian-cli",
|
||||||
|
],
|
||||||
|
freebsd: [],
|
||||||
|
haiku: [],
|
||||||
|
linux: [
|
||||||
|
"_testdata/obsidian/squashfs-root/obsidian-cli",
|
||||||
|
"/usr/bin/obsidian-cli",
|
||||||
|
"/usr/local/bin/obsidian-cli",
|
||||||
|
"/snap/bin/obsidian-cli",
|
||||||
|
"/opt/Obsidian/obsidian-cli",
|
||||||
|
"/opt/obsidian/obsidian-cli",
|
||||||
|
],
|
||||||
|
openbsd: [],
|
||||||
|
sunos: [],
|
||||||
|
win32: ["C:\\Program Files\\Obsidian\\obsidian-cli.exe", "C:\\Program Files (x86)\\Obsidian\\obsidian-cli.exe"],
|
||||||
|
cygwin: [],
|
||||||
|
netbsd: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function isUsableFile(path: string): boolean {
|
||||||
|
const resolvedPath = resolve(path);
|
||||||
|
if (!existsSync(resolvedPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (platform === "win32") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
accessSync(resolvedPath, constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverObsidianBinary(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
|
||||||
|
const checked: string[] = [];
|
||||||
|
const envBinary = env.OBSIDIAN_BINARY?.trim();
|
||||||
|
if (envBinary) {
|
||||||
|
checked.push(envBinary);
|
||||||
|
if (isUsableFile(envBinary)) {
|
||||||
|
return {
|
||||||
|
binary: resolve(envBinary),
|
||||||
|
source: "OBSIDIAN_BINARY",
|
||||||
|
checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = defaultCandidatesByPlatform[platform] ?? [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
checked.push(candidate);
|
||||||
|
if (isUsableFile(candidate)) {
|
||||||
|
return {
|
||||||
|
binary: resolve(candidate),
|
||||||
|
source: "default-path",
|
||||||
|
checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { checked };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireObsidianBinary(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
const result = discoverObsidianBinary(env);
|
||||||
|
if (!result.binary) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
"Could not find an Obsidian executable.",
|
||||||
|
"Set OBSIDIAN_BINARY to the installed Obsidian executable path.",
|
||||||
|
`Checked paths: ${result.checked.length > 0 ? result.checked.join(", ") : "(none)"}`,
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.binary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverObsidianCli(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
|
||||||
|
const checked: string[] = [];
|
||||||
|
const envBinary = env.OBSIDIAN_CLI?.trim();
|
||||||
|
if (envBinary) {
|
||||||
|
checked.push(envBinary);
|
||||||
|
if (isUsableFile(envBinary)) {
|
||||||
|
return {
|
||||||
|
binary: resolve(envBinary),
|
||||||
|
source: "OBSIDIAN_CLI",
|
||||||
|
checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = defaultCliCandidatesByPlatform[platform] ?? [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
checked.push(candidate);
|
||||||
|
if (isUsableFile(candidate)) {
|
||||||
|
return {
|
||||||
|
binary: resolve(candidate),
|
||||||
|
source: "default-path",
|
||||||
|
checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { checked };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,196 @@
|
|||||||
import {
|
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||||||
cleanupStaleObsidianE2EProcesses as cleanupStaleProcesses,
|
import { once } from "node:events";
|
||||||
launchObsidian as launchObsidianSession,
|
import { existsSync } from "node:fs";
|
||||||
type LaunchObsidianOptions,
|
import { dirname } from "node:path";
|
||||||
type ObsidianProcess,
|
import { platform } from "node:process";
|
||||||
type ObsidianProcessOutput,
|
import { promisify } from "node:util";
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
|
||||||
|
|
||||||
export type { LaunchObsidianOptions, ObsidianProcess, ObsidianProcessOutput };
|
export type ObsidianProcess = {
|
||||||
|
process: ChildProcess;
|
||||||
|
output: () => { stdout: string; stderr: string };
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
const STALE_PROCESS_PATTERN = "obsidian-livesync-e2e-state";
|
export type LaunchObsidianOptions = {
|
||||||
|
binary: string;
|
||||||
|
vaultPath: string;
|
||||||
|
homePath?: string;
|
||||||
|
xdgConfigPath?: string;
|
||||||
|
xdgCachePath?: string;
|
||||||
|
xdgDataPath?: string;
|
||||||
|
userDataPath?: string;
|
||||||
|
startupGraceMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
function splitArgs(args: string): string[] {
|
||||||
|
return args.split(" ").filter((arg) => arg.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchArgs(options: LaunchObsidianOptions): string[] {
|
||||||
|
const explicitArgs = process.env.E2E_OBSIDIAN_ARGS;
|
||||||
|
if (explicitArgs) {
|
||||||
|
return splitArgs(explicitArgs);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--disable-software-rasterizer",
|
||||||
|
...(process.env.E2E_OBSIDIAN_USE_USER_DATA_DIR !== "false" && options.userDataPath
|
||||||
|
? [`--user-data-dir=${options.userDataPath}`]
|
||||||
|
: []),
|
||||||
|
...(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT
|
||||||
|
? [`--remote-debugging-port=${process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT}`]
|
||||||
|
: []),
|
||||||
|
`obsidian://open?path=${encodeURIComponent(options.vaultPath)}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseXvfb(): boolean {
|
||||||
|
if (process.env.E2E_OBSIDIAN_USE_XVFB === "false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return platform === "linux" && existsSync("/usr/bin/xvfb-run");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listChildPids(pid: number): Promise<number[]> {
|
||||||
|
if (platform === "win32") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { stdout } = await execFileAsync("ps", ["-o", "pid=", "--ppid", String(pid)]).catch(() => ({
|
||||||
|
stdout: "",
|
||||||
|
}));
|
||||||
|
const directChildren = stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => Number(line.trim()))
|
||||||
|
.filter((childPid) => Number.isInteger(childPid) && childPid > 0);
|
||||||
|
const descendants = await Promise.all(directChildren.map((childPid) => listChildPids(childPid)));
|
||||||
|
return [...directChildren, ...descendants.flat()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function killPids(pids: number[], signal: NodeJS.Signals): Promise<void> {
|
||||||
|
for (const pid of pids) {
|
||||||
|
if (pid === process.pid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
process.kill(pid, signal);
|
||||||
|
} catch {
|
||||||
|
// The process may have exited between discovery and signalling.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExit(exitPromise: Promise<unknown>, timeoutMs: number): Promise<"exited" | "timeout"> {
|
||||||
|
const stopTimer = new Promise<"timeout">((resolve) => {
|
||||||
|
setTimeout(() => resolve("timeout"), timeoutMs);
|
||||||
|
});
|
||||||
|
const stopResult = await Promise.race([exitPromise.then(() => "exited" as const), stopTimer]);
|
||||||
|
return stopResult;
|
||||||
|
}
|
||||||
|
|
||||||
export async function cleanupStaleObsidianE2EProcesses(): Promise<void> {
|
export async function cleanupStaleObsidianE2EProcesses(): Promise<void> {
|
||||||
await cleanupStaleProcesses(STALE_PROCESS_PATTERN);
|
if (process.env.E2E_OBSIDIAN_CLEANUP_STALE_PROCESSES === "false" || platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { stdout } = await execFileAsync("pgrep", ["-f", "obsidian-livesync-e2e-state"]).catch(() => ({
|
||||||
|
stdout: "",
|
||||||
|
}));
|
||||||
|
const pids = stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => Number(line.trim()))
|
||||||
|
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
|
||||||
|
if (pids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await killPids(pids, "SIGTERM");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
await killPids(pids, "SIGKILL");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
|
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
|
||||||
const configuredPort =
|
await cleanupStaleObsidianE2EProcesses();
|
||||||
options.env?.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT;
|
const startupGraceMs = options.startupGraceMs ?? 1000;
|
||||||
return await launchObsidianSession({
|
const args = launchArgs(options);
|
||||||
...options,
|
const useXvfb = shouldUseXvfb();
|
||||||
remoteDebuggingPort:
|
const command = useXvfb ? "/usr/bin/xvfb-run" : options.binary;
|
||||||
options.remoteDebuggingPort ?? (configuredPort === undefined ? undefined : Number(configuredPort)),
|
const commandArgs = useXvfb ? ["-a", options.binary, ...args] : args;
|
||||||
staleProcessPattern: options.staleProcessPattern ?? STALE_PROCESS_PATTERN,
|
const child = spawn(command, commandArgs, {
|
||||||
|
cwd: dirname(options.binary),
|
||||||
|
detached: true,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...(options.homePath ? { HOME: options.homePath } : {}),
|
||||||
|
...(options.xdgConfigPath ? { XDG_CONFIG_HOME: options.xdgConfigPath } : {}),
|
||||||
|
...(options.xdgCachePath ? { XDG_CACHE_HOME: options.xdgCachePath } : {}),
|
||||||
|
...(options.xdgDataPath ? { XDG_DATA_HOME: options.xdgDataPath } : {}),
|
||||||
|
OBSIDIAN_DISABLE_GPU: process.env.OBSIDIAN_DISABLE_GPU ?? "1",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let stderr = "";
|
||||||
|
let stdout = "";
|
||||||
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitPromise = once(child, "exit").then(([code, signal]) => ({ code, signal }));
|
||||||
|
const timer = new Promise<"timeout">((resolve) => {
|
||||||
|
setTimeout(() => resolve("timeout"), startupGraceMs);
|
||||||
|
});
|
||||||
|
const firstResult = await Promise.race([exitPromise, timer]);
|
||||||
|
if (firstResult !== "timeout") {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Obsidian exited before the smoke timeout. code=${firstResult.code}, signal=${firstResult.signal}`,
|
||||||
|
stdout ? `stdout:\n${stdout}` : undefined,
|
||||||
|
stderr ? `stderr:\n${stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
process: child,
|
||||||
|
output: () => ({ stdout, stderr }),
|
||||||
|
stop: async () => {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const descendantPids = child.pid ? await listChildPids(child.pid) : [];
|
||||||
|
if (child.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
await killPids(descendantPids.reverse(), "SIGTERM");
|
||||||
|
const stopResult = await waitForExit(exitPromise, 5000);
|
||||||
|
if (stopResult === "timeout") {
|
||||||
|
if (child.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
await killPids(descendantPids, "SIGKILL");
|
||||||
|
await exitPromise;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
import {
|
import { copyFile, mkdir, writeFile } from "node:fs/promises";
|
||||||
installBuiltPlugin as installGenericBuiltPlugin,
|
import { existsSync } from "node:fs";
|
||||||
type PluginInstallResult,
|
import { join, resolve } from "node:path";
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
|
||||||
|
|
||||||
export type { PluginInstallResult };
|
export type PluginInstallResult = {
|
||||||
|
pluginDir: string;
|
||||||
|
copied: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const pluginId = "obsidian-livesync";
|
||||||
|
|
||||||
export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise<PluginInstallResult> {
|
export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise<PluginInstallResult> {
|
||||||
return await installGenericBuiltPlugin(vaultPath, {
|
const pluginDir = join(vaultPath, ".obsidian", "plugins", pluginId);
|
||||||
pluginId: "obsidian-livesync",
|
const copied: string[] = [];
|
||||||
artifactRoot: rootDir,
|
await mkdir(pluginDir, { recursive: true });
|
||||||
});
|
|
||||||
|
const requiredArtifacts = ["main.js", "manifest.json"];
|
||||||
|
for (const artifact of requiredArtifacts) {
|
||||||
|
const source = resolve(rootDir, artifact);
|
||||||
|
if (!existsSync(source)) {
|
||||||
|
throw new Error(`Required plug-in artifact is missing: ${source}`);
|
||||||
|
}
|
||||||
|
await copyFile(source, join(pluginDir, artifact));
|
||||||
|
copied.push(artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalArtifacts = ["styles.css"];
|
||||||
|
for (const artifact of optionalArtifacts) {
|
||||||
|
const source = resolve(rootDir, artifact);
|
||||||
|
if (!existsSync(source)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await copyFile(source, join(pluginDir, artifact));
|
||||||
|
copied.push(artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(join(vaultPath, ".obsidian", "community-plugins.json"), JSON.stringify([pluginId], null, 4));
|
||||||
|
return { pluginDir, copied };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,41 @@
|
|||||||
export { waitForPluginReady, type PluginReadiness } from "@vrtmrz/obsidian-test-session";
|
import { evalObsidianJson } from "./cli.ts";
|
||||||
|
|
||||||
|
export type PluginReadiness = {
|
||||||
|
status: "ready";
|
||||||
|
pluginId: string;
|
||||||
|
pluginVersion: string;
|
||||||
|
vaultName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function waitForPluginReady(
|
||||||
|
cliBinary: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
timeoutMs = Number(process.env.E2E_OBSIDIAN_READY_TIMEOUT_MS ?? 20000)
|
||||||
|
): Promise<PluginReadiness> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastOutput = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const readiness = await evalObsidianJson<PluginReadiness>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>JSON.stringify({",
|
||||||
|
"status:!!app.plugins.plugins['obsidian-livesync']?'ready':'pending',",
|
||||||
|
"pluginId:'obsidian-livesync',",
|
||||||
|
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version,",
|
||||||
|
"vaultName:app.vault.getName()",
|
||||||
|
"}))()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
if (readiness.status === "ready") {
|
||||||
|
return readiness;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastOutput = error instanceof Error ? error.message : String(error);
|
||||||
|
// Keep polling until Obsidian exposes the vault-side CLI and plug-in state.
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for Self-hosted LiveSync readiness through Obsidian CLI.\n${lastOutput}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session";
|
import { evalObsidianJson, openVaultWithObsidianCli, runObsidianCli } from "./cli.ts";
|
||||||
|
import { launchObsidian, type ObsidianProcess } from "./launch.ts";
|
||||||
|
import { installBuiltPlugin, type PluginInstallResult } from "./pluginInstaller.ts";
|
||||||
|
import { waitForPluginReady, type PluginReadiness } from "./readiness.ts";
|
||||||
import type { TemporaryVault } from "./vault.ts";
|
import type { TemporaryVault } from "./vault.ts";
|
||||||
|
import { obsidianRemoteDebuggingPort, preseedTrustedVaultState, trustVaultIfPrompted } from "./ui.ts";
|
||||||
|
|
||||||
export type ObsidianLiveSyncSession = ObsidianPluginSession;
|
export type ObsidianLiveSyncSession = {
|
||||||
|
app: ObsidianProcess;
|
||||||
|
cliEnv: NodeJS.ProcessEnv;
|
||||||
|
install: PluginInstallResult;
|
||||||
|
readiness: PluginReadiness;
|
||||||
|
};
|
||||||
|
|
||||||
export type StartObsidianLiveSyncSessionOptions = {
|
export type StartObsidianLiveSyncSessionOptions = {
|
||||||
binary: string;
|
binary: string;
|
||||||
@@ -10,15 +19,101 @@ export type StartObsidianLiveSyncSessionOptions = {
|
|||||||
startupGraceMs?: number;
|
startupGraceMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||||
|
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 60000);
|
||||||
|
let lastOutput = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const result = await evalObsidianJson<{ hasLiveSync: boolean }>(
|
||||||
|
cliBinary,
|
||||||
|
["JSON.stringify({", "hasLiveSync:!!app.plugins?.manifests?.['obsidian-livesync']", "})"].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
lastOutput = JSON.stringify(result);
|
||||||
|
if (result.hasLiveSync) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastOutput = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for Obsidian plug-in catalogue through CLI.\n${lastOutput}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableCommunityPlugins(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||||
|
const result = await runObsidianCli(cliBinary, ["eval", "code=(async()=>app.plugins.setEnable(true))()"], env);
|
||||||
|
if (result.code !== 0 || result.stdout.includes("Error:")) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to enable Obsidian community plug-ins through CLI. code=${result.code}, signal=${result.signal}`,
|
||||||
|
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||||
|
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadLiveSyncPlugin(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||||
|
const reload = await runObsidianCli(cliBinary, ["plugin:reload", "id=obsidian-livesync"], env);
|
||||||
|
if (reload.code !== 0 || !reload.stdout.includes("Reloaded: obsidian-livesync")) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to reload Self-hosted LiveSync through Obsidian CLI. code=${reload.code}, signal=${reload.signal}`,
|
||||||
|
reload.stdout ? `stdout:\n${reload.stdout}` : undefined,
|
||||||
|
reload.stderr ? `stderr:\n${reload.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function startObsidianLiveSyncSession(
|
export async function startObsidianLiveSyncSession(
|
||||||
options: StartObsidianLiveSyncSessionOptions
|
options: StartObsidianLiveSyncSessionOptions
|
||||||
): Promise<ObsidianLiveSyncSession> {
|
): Promise<ObsidianLiveSyncSession> {
|
||||||
return await startObsidianPluginSession({
|
const install = await installBuiltPlugin(options.vault.path);
|
||||||
|
const remoteDebuggingPort = obsidianRemoteDebuggingPort();
|
||||||
|
const app = await launchObsidian({
|
||||||
binary: options.binary,
|
binary: options.binary,
|
||||||
cliBinary: options.cliBinary,
|
vaultPath: options.vault.path,
|
||||||
vault: options.vault,
|
homePath: options.vault.homePath,
|
||||||
pluginId: "obsidian-livesync",
|
xdgConfigPath: options.vault.xdgConfigPath,
|
||||||
artifactRoot: process.cwd(),
|
xdgCachePath: options.vault.xdgCachePath,
|
||||||
|
xdgDataPath: options.vault.xdgDataPath,
|
||||||
|
userDataPath: options.vault.userDataPath,
|
||||||
startupGraceMs: options.startupGraceMs,
|
startupGraceMs: options.startupGraceMs,
|
||||||
});
|
});
|
||||||
|
const cliEnv = {
|
||||||
|
...process.env,
|
||||||
|
HOME: options.vault.homePath,
|
||||||
|
XDG_CONFIG_HOME: options.vault.xdgConfigPath,
|
||||||
|
XDG_CACHE_HOME: options.vault.xdgCachePath,
|
||||||
|
XDG_DATA_HOME: options.vault.xdgDataPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await preseedTrustedVaultState(remoteDebuggingPort, options.vault.id);
|
||||||
|
await openVaultWithObsidianCli(options.cliBinary, options.vault.path, cliEnv);
|
||||||
|
await trustVaultIfPrompted(remoteDebuggingPort);
|
||||||
|
await waitForPluginCatalogue(options.cliBinary, cliEnv);
|
||||||
|
await enableCommunityPlugins(options.cliBinary, cliEnv);
|
||||||
|
await reloadLiveSyncPlugin(options.cliBinary, cliEnv);
|
||||||
|
const readiness = await waitForPluginReady(options.cliBinary, cliEnv);
|
||||||
|
return { app, cliEnv, install, readiness };
|
||||||
|
} catch (error) {
|
||||||
|
const output = app.output();
|
||||||
|
await app.stop();
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
output.stdout ? `Obsidian stdout:\n${output.stdout}` : undefined,
|
||||||
|
output.stderr ? `Obsidian stderr:\n${output.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,74 @@
|
|||||||
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
|
import { chromium, type Page } from "playwright";
|
||||||
|
|
||||||
export {
|
export function obsidianRemoteDebuggingPort(): number {
|
||||||
obsidianRemoteDebuggingPort,
|
const port = Number(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? 9222);
|
||||||
preseedTrustedVaultState,
|
process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT = String(port);
|
||||||
trustVaultIfPrompted,
|
return port;
|
||||||
withObsidianPage,
|
}
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
|
||||||
|
async function waitForCdp(port: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CDP_TIMEOUT_MS ?? 30000);
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep polling until Obsidian exposes the debugging endpoint.
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for Obsidian DevTools endpoint on port ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
|
||||||
|
await waitForCdp(port);
|
||||||
|
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
|
||||||
|
try {
|
||||||
|
const context = browser.contexts()[0];
|
||||||
|
const page = context.pages()[0] ?? (await context.waitForEvent("page", { timeout: 10000 }));
|
||||||
|
return await operation(page);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preseedTrustedVaultState(port: number, vaultId: string): Promise<void> {
|
||||||
|
await withObsidianPage(port, async (page) => {
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
localStorage.setItem(`enable-plugin-${id}`, "true");
|
||||||
|
}, vaultId);
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => undefined);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trustVaultIfPrompted(port: number): Promise<void> {
|
||||||
|
await withObsidianPage(port, async (page) => {
|
||||||
|
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_TRUST_PROMPT_TIMEOUT_MS ?? 30000);
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const yesButton = page.getByRole("button", { name: "Yes" });
|
||||||
|
if (await yesButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||||
|
await yesButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trustButton = page.getByText("Trust author and enable plugins");
|
||||||
|
if (await trustButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||||
|
await trustButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = page.locator(".workspace");
|
||||||
|
if (await workspace.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
|
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
|
||||||
await withObsidianPage(port, async (page) => {
|
await withObsidianPage(port, async (page) => {
|
||||||
|
|||||||
@@ -1,14 +1,94 @@
|
|||||||
import {
|
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
createTemporaryVault as createGenericTemporaryVault,
|
import { join } from "node:path";
|
||||||
type TemporaryVault,
|
import { tmpdir } from "node:os";
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
|
||||||
|
|
||||||
export type { TemporaryVault };
|
export type TemporaryVault = {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
homePath: string;
|
||||||
|
xdgConfigPath: string;
|
||||||
|
xdgCachePath: string;
|
||||||
|
xdgDataPath: string;
|
||||||
|
userDataPath: string;
|
||||||
|
dispose: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise<TemporaryVault> {
|
export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise<TemporaryVault> {
|
||||||
return await createGenericTemporaryVault({
|
const vaultPath = await mkdtemp(join(tmpdir(), prefix));
|
||||||
prefix,
|
const statePath = await mkdtemp(join(tmpdir(), `${prefix}state-`));
|
||||||
pluginIds: ["obsidian-livesync"],
|
const name = vaultPath.split(/[\\/]/).pop() ?? "obsidian-livesync-e2e";
|
||||||
idPrefix: "livesync-e2e",
|
await mkdir(join(vaultPath, ".obsidian"), { recursive: true });
|
||||||
});
|
const homePath = join(statePath, "home");
|
||||||
|
const xdgConfigPath = join(statePath, "xdg-config");
|
||||||
|
const xdgCachePath = join(statePath, "xdg-cache");
|
||||||
|
const xdgDataPath = join(statePath, "xdg-data");
|
||||||
|
const userDataPath = join(statePath, "user-data");
|
||||||
|
const id = `livesync-e2e-${Date.now()}`;
|
||||||
|
await mkdir(homePath, { recursive: true });
|
||||||
|
await mkdir(xdgConfigPath, { recursive: true });
|
||||||
|
await mkdir(xdgCachePath, { recursive: true });
|
||||||
|
await mkdir(xdgDataPath, { recursive: true });
|
||||||
|
await mkdir(userDataPath, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(vaultPath, ".obsidian", "app.json"),
|
||||||
|
JSON.stringify({ legacyEditor: false, safeMode: false }, null, 4)
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(vaultPath, ".obsidian", "community-plugins.json"),
|
||||||
|
JSON.stringify(["obsidian-livesync"], null, 4)
|
||||||
|
);
|
||||||
|
await writeObsidianVaultRegistry(id, vaultPath, name, homePath, xdgConfigPath, userDataPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: vaultPath,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
homePath,
|
||||||
|
xdgConfigPath,
|
||||||
|
xdgCachePath,
|
||||||
|
xdgDataPath,
|
||||||
|
userDataPath,
|
||||||
|
dispose: async () => {
|
||||||
|
if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true") {
|
||||||
|
console.log(`Keeping temporary vault: ${vaultPath}`);
|
||||||
|
console.log(`Keeping temporary Obsidian state: ${statePath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
|
||||||
|
rm(statePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeObsidianVaultRegistry(
|
||||||
|
vaultId: string,
|
||||||
|
vaultPath: string,
|
||||||
|
vaultName: string,
|
||||||
|
homePath: string,
|
||||||
|
xdgConfigPath: string,
|
||||||
|
userDataPath: string
|
||||||
|
): Promise<void> {
|
||||||
|
const vaultRecord = {
|
||||||
|
path: vaultPath,
|
||||||
|
ts: Date.now(),
|
||||||
|
open: true,
|
||||||
|
name: vaultName,
|
||||||
|
};
|
||||||
|
const registry = {
|
||||||
|
cli: true,
|
||||||
|
vaults: {
|
||||||
|
[vaultId]: vaultRecord,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const registryText = JSON.stringify(registry, null, 4);
|
||||||
|
for (const configRoot of [join(homePath, ".config"), xdgConfigPath]) {
|
||||||
|
const obsidianConfigDir = join(configRoot, "obsidian");
|
||||||
|
await mkdir(obsidianConfigDir, { recursive: true });
|
||||||
|
await writeFile(join(obsidianConfigDir, "obsidian.json"), registryText);
|
||||||
|
}
|
||||||
|
await writeFile(join(userDataPath, "obsidian.json"), registryText);
|
||||||
|
await writeFile(join(userDataPath, `${vaultId}.json`), JSON.stringify(vaultRecord, null, 4));
|
||||||
}
|
}
|
||||||
|
|||||||
-14
@@ -5,20 +5,6 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Improved (CLI and Webapp)
|
|
||||||
|
|
||||||
- Rooted storage adapters now reject absolute, drive-qualified, backslash-separated, and traversal paths. They also prevent file writes, appends, and removal from targeting the configured root itself.
|
|
||||||
- File System Access API storage can now create files below previously missing parent directories, matching the existing Node behaviour.
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- Added shared Node and File System Access API storage contract coverage for metadata, text and binary operations, append, listing, removal, path containment, and empty-root handling.
|
|
||||||
- Added a Compose-based P2P end-to-end smoke test and repeatable network benchmark cases for local performance investigations.
|
|
||||||
|
|
||||||
### Miscellaneous
|
|
||||||
|
|
||||||
- Split the internal storage adapter contract into focused capability views without changing existing runtime behaviour.
|
|
||||||
|
|
||||||
## 0.25.80
|
## 0.25.80
|
||||||
|
|
||||||
7th July, 2026
|
7th July, 2026
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
|
||||||
|
const updatesPath = "updates.md";
|
||||||
|
|
||||||
|
// Utility used by the release workflows to rotate and validate `updates.md`.
|
||||||
|
// It intentionally keeps the Markdown format simple: top-level `##` headings
|
||||||
|
// are treated as release boundaries, and only one `## Unreleased` section is
|
||||||
|
// allowed.
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(path) {
|
||||||
|
return JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertVersion(version) {
|
||||||
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
|
||||||
|
fail(`Invalid release version: ${version}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReleaseDate(date = new Date()) {
|
||||||
|
const day = date.getUTCDate();
|
||||||
|
const suffix = day % 10 === 1 && day !== 11 ? "st" : day % 10 === 2 && day !== 12 ? "nd" : day % 10 === 3 && day !== 13 ? "rd" : "th";
|
||||||
|
const month = new Intl.DateTimeFormat("en-GB", { month: "long", timeZone: "UTC" }).format(date);
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
return `${day}${suffix} ${month}, ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headingPattern(heading) {
|
||||||
|
return new RegExp(`^## ${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a `##` section body without interpreting lower-level headings.
|
||||||
|
function findSection(markdown, heading) {
|
||||||
|
const pattern = headingPattern(heading);
|
||||||
|
const match = pattern.exec(markdown);
|
||||||
|
if (!match) return undefined;
|
||||||
|
|
||||||
|
const headingStart = match.index;
|
||||||
|
const bodyStart = match.index + match[0].length;
|
||||||
|
const rest = markdown.slice(bodyStart);
|
||||||
|
const next = /^## .+$/m.exec(rest);
|
||||||
|
const end = next ? bodyStart + next.index : markdown.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
headingStart,
|
||||||
|
bodyStart,
|
||||||
|
end,
|
||||||
|
body: markdown.slice(bodyStart, end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSingleUnreleased(markdown) {
|
||||||
|
const matches = markdown.match(/^## Unreleased\s*$/gm) || [];
|
||||||
|
if (matches.length !== 1) {
|
||||||
|
fail(`Expected exactly one '## Unreleased' section in ${updatesPath}, found ${matches.length}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepare(version) {
|
||||||
|
assertVersion(version);
|
||||||
|
const markdown = readFileSync(updatesPath, "utf8");
|
||||||
|
assertSingleUnreleased(markdown);
|
||||||
|
|
||||||
|
if (headingPattern(version).test(markdown)) {
|
||||||
|
fail(`Release notes for ${version} already exist in ${updatesPath}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreleased = findSection(markdown, "Unreleased");
|
||||||
|
if (!unreleased) fail(`Could not find '## Unreleased' in ${updatesPath}.`);
|
||||||
|
|
||||||
|
const allowEmpty = process.env.ALLOW_EMPTY_UPDATES === "true";
|
||||||
|
if (!allowEmpty && unreleased.body.trim() === "") {
|
||||||
|
fail(`The '## Unreleased' section is empty. Set ALLOW_EMPTY_UPDATES=true if this is intentional.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a fresh empty Unreleased section above the newly dated release notes.
|
||||||
|
const releasedBody = `${unreleased.body.trim()}\n\n`;
|
||||||
|
const releaseDate = process.env.RELEASE_DATE || formatReleaseDate();
|
||||||
|
const replacement = `## Unreleased\n\n## ${version}\n\n${releaseDate}\n\n${releasedBody}`;
|
||||||
|
const nextMarkdown = markdown.slice(0, unreleased.headingStart) + replacement + markdown.slice(unreleased.end);
|
||||||
|
writeFileSync(updatesPath, nextMarkdown, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(version) {
|
||||||
|
assertVersion(version);
|
||||||
|
|
||||||
|
const rootPackage = readJson("package.json");
|
||||||
|
const manifest = readJson("manifest.json");
|
||||||
|
if (rootPackage.version !== version) {
|
||||||
|
fail(`package.json version is ${rootPackage.version}, expected ${version}.`);
|
||||||
|
}
|
||||||
|
if (manifest.version !== version) {
|
||||||
|
fail(`manifest.json version is ${manifest.version}, expected ${version}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const workspace of ["cli", "webpeer", "webapp"]) {
|
||||||
|
const workspacePackage = readJson(`src/apps/${workspace}/package.json`);
|
||||||
|
const expected = `${version}-${workspace}`;
|
||||||
|
if (workspacePackage.version !== expected) {
|
||||||
|
fail(`src/apps/${workspace}/package.json version is ${workspacePackage.version}, expected ${expected}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = readJson("versions.json");
|
||||||
|
if (versions[version] !== manifest.minAppVersion) {
|
||||||
|
fail(`versions.json does not map ${version} to manifest minAppVersion ${manifest.minAppVersion}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = readFileSync(updatesPath, "utf8");
|
||||||
|
assertSingleUnreleased(markdown);
|
||||||
|
|
||||||
|
const releaseSection = findSection(markdown, version);
|
||||||
|
if (!releaseSection) {
|
||||||
|
fail(`Could not find '## ${version}' in ${updatesPath}.`);
|
||||||
|
}
|
||||||
|
if (releaseSection.body.trim() === "") {
|
||||||
|
fail(`The release notes for ${version} are empty.`);
|
||||||
|
}
|
||||||
|
if (/\b(?:TODO|WIP)\b/i.test(releaseSection.body)) {
|
||||||
|
fail(`The release notes for ${version} still contain TODO or WIP markers.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [command, version] = process.argv.slice(2);
|
||||||
|
if (!command || !version) {
|
||||||
|
fail("Usage: node utils/release-notes.mjs <prepare|validate> <version>");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "prepare") {
|
||||||
|
prepare(version);
|
||||||
|
} else if (command === "validate") {
|
||||||
|
validate(version);
|
||||||
|
} else {
|
||||||
|
fail(`Unknown command: ${command}`);
|
||||||
|
}
|
||||||
+1
-1
@@ -11,7 +11,7 @@ writeFileSync("manifest.json", JSON.stringify(manifest, null, 4));
|
|||||||
// update versions.json with target version and minAppVersion from manifest.json
|
// update versions.json with target version and minAppVersion from manifest.json
|
||||||
// but only if the target version is not already in versions.json
|
// but only if the target version is not already in versions.json
|
||||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||||
if (!Object.values(versions).includes(minAppVersion)) {
|
if (!(targetVersion in versions)) {
|
||||||
versions[targetVersion] = minAppVersion;
|
versions[targetVersion] = minAppVersion;
|
||||||
writeFileSync('versions.json', JSON.stringify(versions, null, 4));
|
writeFileSync('versions.json', JSON.stringify(versions, null, 4));
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user