From 6484a43e2f144aa62d63f8930e6ac9e0a860c436 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 20 Jul 2026 15:06:05 +0000 Subject: [PATCH] Keep P2P consumers on the current transport --- .github/workflows/cli-deno-tests.yml | 2 +- devs.md | 4 +- docs/adr/2026_07_p2p_transport_lifecycle.md | 89 ++++++++++++ docs/settings.md | 4 + package.json | 1 + src/apps/cli/commands/types.ts | 3 + src/apps/cli/main.ts | 23 ++- .../test-support/p2p-lifecycle-entrypoint.ts | 39 ++++++ .../p2p-replicator-replacement.ts | 132 ++++++++++++++++++ src/apps/cli/testdeno/deno.json | 3 + .../cli/testdeno/relay-disconnect-probe.mjs | 82 +++++++++++ src/apps/cli/testdeno/run-cli-e2e.sh | 6 +- src/apps/cli/testdeno/run-compose-p2p.ts | 4 +- .../cli/testdeno/test-p2p-relay-disconnect.ts | 26 ++++ .../test-p2p-replicator-replacement.ts | 117 ++++++++++++++++ src/apps/cli/vite.config.ts | 15 +- .../P2POpenReplicationPane.svelte | 3 +- .../P2PSync/P2PReplicator/P2PReplicationUI.ts | 2 +- .../P2PReplicationUI.unit.spec.ts | 1 + .../P2PReplicator/P2PReplicatorPane.svelte | 15 +- .../P2PReplicator/P2PReplicatorPaneView.ts | 2 +- .../P2PReplicator/P2PServerStatusCard.svelte | 14 +- .../P2PReplicator/P2PServerStatusPane.svelte | 20 +-- .../P2PReplicator/P2PServerStatusPaneView.ts | 2 +- .../P2PReplicator/PeerStatusRow.svelte | 12 +- src/serviceFeatures/useP2PReplicatorUI.ts | 30 ++-- .../useP2PReplicatorUI.unit.spec.ts | 34 +++++ test/bench-network/Dockerfile.runner | 6 +- test/bench-network/compose.yml | 4 +- test/e2e-obsidian/README.md | 6 +- test/e2e-obsidian/runner/ui.ts | 12 +- test/e2e-obsidian/scripts/local-suite.ts | 1 + test/e2e-obsidian/scripts/p2p-pane.ts | 89 ++++++++++++ updates.md | 1 + 34 files changed, 731 insertions(+), 73 deletions(-) create mode 100644 docs/adr/2026_07_p2p_transport_lifecycle.md create mode 100644 src/apps/cli/test-support/p2p-lifecycle-entrypoint.ts create mode 100644 src/apps/cli/test-support/p2p-replicator-replacement.ts create mode 100644 src/apps/cli/testdeno/relay-disconnect-probe.mjs create mode 100644 src/apps/cli/testdeno/test-p2p-relay-disconnect.ts create mode 100644 src/apps/cli/testdeno/test-p2p-replicator-replacement.ts create mode 100644 test/e2e-obsidian/scripts/p2p-pane.ts diff --git a/.github/workflows/cli-deno-tests.yml b/.github/workflows/cli-deno-tests.yml index 93cda742..d249fa00 100644 --- a/.github/workflows/cli-deno-tests.yml +++ b/.github/workflows/cli-deno-tests.yml @@ -160,7 +160,7 @@ jobs: - name: Run Compose CLI P2P E2E env: - CLI_E2E_TASK: test:p2p-sync + CLI_E2E_TASK: test:p2p:ci RELAY: ws://nostr-relay:7777/ PEERS_TIMEOUT: '20' SYNC_TIMEOUT: '60' diff --git a/devs.md b/devs.md index 80b2da28..3f843042 100644 --- a/devs.md +++ b/devs.md @@ -66,7 +66,7 @@ To facilitate development and testing, the build process can automatically copy - **Integration Tests** (`vitest.config.integration.ts`): Tests run in Node.js against a real CouchDB instance. Integration tests should be `*.integration.spec.ts` or `*.integration.test.ts` and placed alongside the implementation file (e.g., `StreamingFetch.integration.spec.ts`). Executed via `npm run test:integration`. - If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you strongly expected to write an integration test to verify the behaviour against a real CouchDB server. - **Commonlib Tests**: Commonlib owns unit and package tests for shared RPC, storage, replication, and platform contracts. LiveSync CI verifies the exact packed dependency as a downstream consumer. -- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows, including the canonical Compose-based P2P scenarios. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation. +- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation. - **Real Obsidian E2E** (`test/e2e-obsidian/`): Local-first scripts that launch real Obsidian with temporary vaults and the built Self-hosted LiveSync plug-in. Use these for boot-up sequence, vault reflection, RedFlag flows, Fast Setup (Simple Fetch), settings dialogues, restart-sensitive workflows, Object Storage regressions, and other behaviour that depends on Obsidian itself. Run focused scripts such as `npm run test:e2e:obsidian:two-vault-sync`, or use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and MinIO fixtures managed by the wrapper. - **Docker Services**: Service-backed tests use CouchDB and MinIO (S3). Canonical P2P validation owns its relay through the CLI Compose runner: @@ -143,6 +143,8 @@ Hence, the new feature should be implemented as follows: - **Service Hub** (`src/modules/services/`): Central service registry using dependency injection - **Common Library** (`@vrtmrz/livesync-commonlib`): Platform-independent synchronisation logic, shared with the CLI, Webapp, WebPeer, and external tools +Commonlib owns the P2P replicator and Trystero transport lifecycle. Host commands, event handlers, and views must retain the Commonlib service-feature result and resolve its current `replicator` at the point of use. They must not snapshot an instance which can be replaced when settings or the local database change, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. + ### Conflict Merge Policy Markdown conflict auto-merge should behave like a conservative three-way merge. The guiding rule is to merge changes when they touch non-overlapping regions, and to keep a manual conflict when the edits overlap semantically. diff --git a/docs/adr/2026_07_p2p_transport_lifecycle.md b/docs/adr/2026_07_p2p_transport_lifecycle.md new file mode 100644 index 00000000..b90dc0eb --- /dev/null +++ b/docs/adr/2026_07_p2p_transport_lifecycle.md @@ -0,0 +1,89 @@ +# Architectural Decision Record: P2P Room and Transport Lifecycle + +## Status + +Accepted — implemented and verified against the locked Commonlib package. + +## Context + +Self-hosted LiveSync uses Trystero's Nostr strategy for P2P discovery, signalling, and WebRTC transport. Three related resources have different owners and lifetimes: + +- a LiveSync P2P service instance owns its commands, RPC sessions, advertisements, and room membership; +- Trystero owns the underlying WebRTC peers and may share one physical peer across more than one room; and +- Trystero's Nostr relay manager owns WebSocket clients shared by relay URL. + +Closing every `RTCPeerConnection` returned by `room.getPeers()` bypasses Trystero's shared-peer manager. The manager may then retain a stale shared peer and prevent a replacement LiveSync replicator from discovering the same remote peer again. + +Room departure and physical transport destruction are not equivalent. `room.leave()` sends the room-leave action, removes that room's actions and callbacks, and detaches its shared-peer binding. Trystero may retain a healthy physical WebRTC peer for later reuse after the last room binding has gone. The retained peer cannot carry actions for the room which has been left. + +Relay WebSockets have a separate lifecycle. LiveSync's explicit disconnect operation must close them and prevent automatic reconnection. A later explicit connect must allow reconnection before joining the room again. + +## Decision + +Normal P2P shutdown delegates physical peer ownership to Trystero: + +1. Stop LiveSync broadcast, replication, watch, client, and RPC state. +2. Leave the active Trystero room through `room.leave()`. +3. Remove LiveSync's room, advertisement, diagnostic-listener, and active-instance references. +4. Pause Trystero relay reconnection and close the current relay WebSockets. + +LiveSync does not call `close()` on the `RTCPeerConnection` values returned by `room.getPeers()` during normal shutdown or from a peer-leave callback. A peer-leave callback removes LiveSync-owned advertisement and client state only. Trystero remains responsible for deciding whether an underlying shared peer is reusable, stale, or ready for idle destruction. + +The explicit disconnect operation therefore has the following contract: + +| Resource | State after the operation | +| --- | --- | +| LiveSync P2P service and RPC room | Closed immediately. | +| Trystero room membership | Left; room actions and advertisements are no longer available. | +| Nostr relay WebSockets | Closed, with automatic reconnection paused. | +| Underlying WebRTC peer | May remain idle under Trystero ownership for reuse, but cannot carry the departed room's traffic. | + +This operation is a logical LiveSync disconnection and a physical signalling-server disconnection. It does not promise that every browser-owned WebRTC object has been destroyed synchronously. + +An explicit connect resumes relay reconnection before opening a new room. Settings application and database lifecycle replacement close the current LiveSync replicator, discard it, construct a new instance from the current settings, and open that current instance when the configured policy requires it. Commands, event handlers, and panes resolve the current service-feature result at the point of use rather than retaining an obsolete replicator. + +Lifecycle operations on one `LiveSyncTrysteroReplicator` are serialised. A close requested while an open is in progress must leave no orphan room serving, and repeated opens must not create parallel rooms. No fixed delay is inserted between close and open: readiness is determined by the actual lifecycle operation and peer discovery. + +Relay sockets retain their Trystero-provided close handlers. LiveSync pauses relay reconnection, closes the sockets, and later resumes reconnection through Trystero's public functions. It does not replace `socket.onclose`, because Trystero uses that handler to retire and recreate shared relay clients correctly. + +## Ownership + +Commonlib owns the LiveSync-specific P2P service, RPC, command, and lifecycle composition. Trystero owns WebRTC peer creation, sharing, reuse, stale detection, and destruction, as well as relay-client reconstruction. The Self-hosted LiveSync host owns the current Commonlib service-feature result and supplies the platform services used by its current replicator. + +Self-hosted LiveSync does not add a separate root Trystero dependency. Tests which must observe relay sockets resolve the exact Trystero generation owned by the locked Commonlib package, avoiding two independent transport singletons in one process. + +## Alternatives rejected + +### Close every value returned by `room.getPeers()` + +This bypasses Trystero's shared-peer manager and can prevent a replacement replicator from rediscovering the same peer. + +### Add a fixed close-to-open delay + +A timing guess does not repair stale ownership and would make ordinary settings application slower. + +### Keep raw close behaviour behind a force command + +Changing the command name does not make the lifecycle safe. A force command which cannot reconnect predictably has no reliable operational value. + +### Override relay `onclose` to suppress reconnection + +This interferes with Trystero's shared relay clients. The public pause and resume functions provide the intended control boundary. + +## Verification + +Commonlib unit tests prove that normal P2P host closure calls `room.leave()` without directly closing Trystero-owned peer connections. Additional package tests cover the action API, replaceable peer-event subscriptions, multiple RPC transport disposers, and serialised open and close operations. + +Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication. + +The canonical Compose P2P suite uses a real local Nostr relay and WebRTC implementation. It covers ordinary two-peer synchronisation, replacement of the active LiveSync replicator followed by discovery and transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. The lifecycle scenario is exposed only through a Docker test build and an injected CLI command runner; it is not part of the public CLI command surface. + +A focused real-Obsidian test mounts the P2P status pane without a relay or remote peer, verifies its principal connection control and horizontal layout, and confirms normal process and fixture teardown. Transport replacement and relay lifecycle remain owned by the package and Compose tests rather than being duplicated in Obsidian. + +## Consequences + +- Replacing a P2P replicator no longer leaves host views or commands bound to an obsolete instance. +- Explicit signalling-server disconnection has a testable socket-level meaning without claiming immediate destruction of idle WebRTC objects. +- Settings which change the relay, room, passphrase, or TURN configuration can replace the whole LiveSync room safely. +- Trystero may reuse healthy peers across room lifecycles, reducing unnecessary renegotiation. +- Strict physical WebRTC teardown remains unavailable until Trystero exposes a safe ownership-aware operation. diff --git a/docs/settings.md b/docs/settings.md index 3a1f60d5..bd7f7da4 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -420,6 +420,10 @@ Setting key: P2P_AutoStart This option is labeled **Auto Start P2P Connection** in the setup dialogue. If enabled, the P2P connection will start automatically when the plug-in launches. +#### Connect and disconnect + +Closing a P2P connection leaves the LiveSync P2P room, stops its replication service, closes the signalling relay sockets, and pauses their automatic reconnection. An idle WebRTC connection may remain temporarily under the transport's ownership so that it can be reused, but it cannot carry traffic for the room which has been left. Connecting again resumes relay reconnection and joins a new LiveSync room. + #### Automatically broadcast changes to connected peers Setting key: P2P_AutoBroadcast diff --git a/package.json b/package.json index 2dd1fda9..a7536e90 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts", "test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts", "test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts", + "test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts", "test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts", "test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts", "test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts", diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts index 504334c2..7a7b13c5 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -1,6 +1,7 @@ import { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { NodeServiceContext } from "@/apps/cli/services/NodeServiceContext"; +import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; export type CLICommand = | "daemon" @@ -48,6 +49,8 @@ export interface CLICommandContext { databasePath: string; vaultPath: string; core: LiveSyncBaseCore; + /** Current-result contract owned by the P2P service feature. */ + p2pReplicator?: UseP2PReplicatorResult; settingsPath: string; originalSyncSettings: Pick< ObsidianLiveSyncSettings, diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index b2126e03..8602bdec 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -19,11 +19,12 @@ import { } from "octagonal-wheels/common/logger"; import { runCommand } from "./commands/runCommand"; import { isCLICommand } from "./commands/types"; -import type { CLICommand, CLIOptions } from "./commands/types"; +import type { CLICommand, CLICommandContext, CLIOptions } from "./commands/types"; import { getPathFromUXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils"; import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; import { IgnoreRules } from "./serviceModules/IgnoreRules"; import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature"; +import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; import { createNodeStandardIo, fsPromises as fs, path, fs as fsSync } from "@vrtmrz/livesync-commonlib/node"; import type { StandardIo } from "@vrtmrz/livesync-commonlib/context"; import { writeStderrLine, writeStdoutLine } from "./cliOutput"; @@ -33,6 +34,9 @@ const SETTINGS_FILE = ".livesync/settings.json"; ensureGlobalNodeLocalStorage(); defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG; +/** Injectable command boundary used by CLI integration probes. */ +export type CliCommandRunner = (options: CLIOptions, context: CLICommandContext) => Promise; + function printHelp(standardIo: StandardIo): void { writeStdoutLine( standardIo, @@ -264,7 +268,10 @@ async function createDefaultSettingsFile(options: CLIOptions, standardIo: Standa writeStdoutLine(standardIo, `[Done] Created settings file: ${targetPath}`); } -export async function main(standardIo: StandardIo = createNodeStandardIo()) { +export async function main( + standardIo: StandardIo = createNodeStandardIo(), + commandRunner: CliCommandRunner = runCommand +) { const options = parseArgs(standardIo); if (options.interval && options.command !== "daemon") { writeStderrLine(standardIo, `Warning: --interval is only used in daemon mode, ignored for '${options.command}'`); @@ -438,6 +445,7 @@ export async function main(standardIo: StandardIo = createNodeStandardIo()) { ); // Create LiveSync core + let p2pReplicator: UseP2PReplicatorResult | undefined; const core = new LiveSyncBaseCore( serviceHubInstance, (core: LiveSyncBaseCore, serviceHub: InjectableServiceHub) => { @@ -447,7 +455,7 @@ export async function main(standardIo: StandardIo = createNodeStandardIo()) { () => [], // No add-ons (core) => { // Register P2P replicator feature. - useP2PReplicatorFeature(core); + p2pReplicator = useP2PReplicatorFeature(core); // Add target filter to prevent internal files are handled core.services.vault.isTargetFile.addHandler(async (target) => { const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target)); @@ -560,7 +568,14 @@ export async function main(standardIo: StandardIo = createNodeStandardIo()) { infoLog(""); } - const result = await runCommand(options, { databasePath, vaultPath, core, settingsPath, originalSyncSettings }); + const result = await commandRunner(options, { + databasePath, + vaultPath, + core, + p2pReplicator, + settingsPath, + originalSyncSettings, + }); if (!result) { writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`); process.exitCode = 1; diff --git a/src/apps/cli/test-support/p2p-lifecycle-entrypoint.ts b/src/apps/cli/test-support/p2p-lifecycle-entrypoint.ts new file mode 100644 index 00000000..8b13a296 --- /dev/null +++ b/src/apps/cli/test-support/p2p-lifecycle-entrypoint.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import { RTCPeerConnection } from "werift"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { createNodeStandardIo } from "@vrtmrz/livesync-commonlib/node"; +import { writeStderrLine } from "@/apps/cli/cliOutput"; +import { main, type CliCommandRunner } from "@/apps/cli/main"; +import { parseTimeoutSeconds } from "@/apps/cli/commands/p2p"; +import { runP2PReplicatorReplacementProbe } from "./p2p-replicator-replacement"; + +if ( + typeof (compatGlobal as unknown as Record).RTCPeerConnection === "undefined" && + typeof RTCPeerConnection === "function" +) { + (compatGlobal as unknown as Record).RTCPeerConnection = RTCPeerConnection; +} + +const standardIo = createNodeStandardIo(); +const runLifecycleProbe: CliCommandRunner = async (options, context) => { + if (options.command !== "p2p-sync" || options.commandArgs.length < 2) { + throw new Error("The P2P lifecycle test entry requires: p2p-sync [note-path] [note-content]"); + } + const peerToken = options.commandArgs[0].trim(); + if (!peerToken) { + throw new Error("The P2P lifecycle test entry requires a non-empty peer"); + } + const timeoutSec = parseTimeoutSeconds(options.commandArgs[1], "P2P lifecycle test entry"); + return await runP2PReplicatorReplacementProbe( + context, + peerToken, + timeoutSec * 1000, + options.commandArgs[2], + options.commandArgs[3] + ); +}; + +main(standardIo, runLifecycleProbe).catch((error) => { + writeStderrLine(standardIo, "[Fatal Error]", error); + process.exit(1); +}); diff --git a/src/apps/cli/test-support/p2p-replicator-replacement.ts b/src/apps/cli/test-support/p2p-replicator-replacement.ts new file mode 100644 index 00000000..18ab6bef --- /dev/null +++ b/src/apps/cli/test-support/p2p-replicator-replacement.ts @@ -0,0 +1,132 @@ +import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import type { CLICommandContext } from "@/apps/cli/commands/types"; +import { openP2PHost } from "@/apps/cli/commands/p2p"; + +const DEFAULT_NOTE_PATH = "p2p-replicator-replacement.md"; +const DEFAULT_NOTE_CONTENT = "Replicated after replacing the active P2P replicator."; + +function delay(ms: number): Promise { + return new Promise((resolve) => compatGlobal.setTimeout(resolve, ms)); +} + +function describeError(value: unknown): string { + return value instanceof Error ? (value.stack ?? value.message) : String(value); +} + +async function waitForServing(replicator: LiveSyncTrysteroReplicator, timeoutMs: number): Promise { + const started = Date.now(); + while (Date.now() - started <= timeoutMs) { + if (replicator.server?.isServing) return; + await delay(200); + } + throw new Error("The replacement P2P replicator did not start serving within the timeout"); +} + +async function waitForPeer( + replicator: LiveSyncTrysteroReplicator, + targetPeer: string, + timeoutMs: number +): Promise<{ peerId: string; name: string }> { + const started = Date.now(); + while (Date.now() - started <= timeoutMs) { + const peer = replicator.knownAdvertisements.find( + (candidate) => candidate.name === targetPeer || candidate.peerId === targetPeer + ); + if (peer) return peer; + await delay(200); + } + const knownPeers = replicator.knownAdvertisements.map((peer) => `${peer.name} (${peer.peerId})`).join(", "); + throw new Error( + `Peer '${targetPeer}' was not discovered within the timeout. Known peers: ${knownPeers || "none"}` + ); +} + +function assertPullSucceeded(result: unknown): void { + if (result && typeof result === "object" && "error" in result && result.error) { + throw new Error(`P2P pull failed: ${describeError(result.error)}`); + } +} + +async function communicateWithPeer( + replicator: LiveSyncTrysteroReplicator, + targetPeer: string, + timeoutMs: number +): Promise<{ peerId: string; name: string }> { + await replicator.open(); + await waitForServing(replicator, timeoutMs); + const peer = await waitForPeer(replicator, targetPeer, timeoutMs); + assertPullSucceeded(await replicator.replicateFrom(peer.peerId, false)); + const pushResult = await replicator.requestSynchroniseToPeer(peer.peerId); + if (!pushResult || pushResult.ok !== true) { + throw new Error(`P2P push failed: ${describeError(pushResult?.error)}`); + } + return peer; +} + +/** Runs the real-transport lifecycle probe used by the Deno and Compose P2P suites. */ +export async function runP2PReplicatorReplacementProbe( + context: CLICommandContext, + targetPeer: string, + timeoutMs: number, + notePath = DEFAULT_NOTE_PATH, + noteContent = DEFAULT_NOTE_CONTENT +): Promise { + const { core, p2pReplicator } = context; + if (!p2pReplicator) { + throw new Error("The CLI did not expose its P2P service-feature result to the integration probe"); + } + + const firstReplicator = await openP2PHost(core); + if (p2pReplicator.replicator !== firstReplicator) { + throw new Error("The P2P service feature did not expose the newly created replicator"); + } + + const firstPeer = await communicateWithPeer(firstReplicator, targetPeer, timeoutMs); + const initialised = await core.services.databaseEvents.initialiseDatabase(false, true, false); + if (!initialised) { + throw new Error("Database reinitialisation failed during the P2P replacement probe"); + } + + const replacementReplicator = p2pReplicator.replicator; + if (core.services.replicator.getActiveReplicator() !== replacementReplicator) { + throw new Error("ReplicatorService did not activate the P2P service feature's replacement replicator"); + } + if (replacementReplicator === firstReplicator) { + throw new Error("Database reinitialisation retained the previous P2P replicator instance"); + } + if (firstReplicator.server !== undefined) { + throw new Error("The previous P2P replicator remained open after replacement"); + } + + const settings = core.services.setting.currentSettings(); + settings.P2P_AutoStart = true; + await core.services.control.applySettings(); + const resumedReplicator = p2pReplicator.replicator; + await waitForServing(resumedReplicator, timeoutMs); + if (firstReplicator.server !== undefined) { + throw new Error("A setting event reopened the previous P2P replicator"); + } + + const encoded = new TextEncoder().encode(noteContent); + const noteBody = encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength); + const timestamp = Date.now(); + await core.serviceModules.storageAccess.writeFileAuto(notePath, noteBody, { + ctime: timestamp, + mtime: timestamp, + }); + await core.serviceModules.fileHandler.storeFileToDB(notePath as FilePathWithPrefix, true); + + const replacementPeer = await communicateWithPeer(resumedReplicator, targetPeer, timeoutMs); + if (replacementPeer.name !== firstPeer.name) { + throw new Error( + `The replacement replicator reached '${replacementPeer.name}' instead of the original peer '${firstPeer.name}'` + ); + } + + core.services.context.standardIo.writeStdout( + `[Probe] P2P replicator replaced, old transport stayed closed, and ${notePath} was sent through the replacement.\n` + ); + return true; +} diff --git a/src/apps/cli/testdeno/deno.json b/src/apps/cli/testdeno/deno.json index fd4727d8..35f3d84a 100644 --- a/src/apps/cli/testdeno/deno.json +++ b/src/apps/cli/testdeno/deno.json @@ -15,6 +15,9 @@ "test:p2p-host": "deno test --env-file=.test.env -A --no-check test-p2p-host.ts", "test:p2p-peers": "deno test --env-file=.test.env -A --no-check test-p2p-peers-local-relay.ts", "test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts", + "test:p2p-replacement": "deno test --env-file=.test.env -A --no-check test-p2p-replicator-replacement.ts", + "test:p2p-relay-disconnect": "deno test --env-file=.test.env -A --no-check test-p2p-relay-disconnect.ts", + "test:p2p:ci": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts test-p2p-replicator-replacement.ts test-p2p-relay-disconnect.ts", "test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts", "test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts", "test:benchmark-contract": "deno test --env-file=.test.env -A --no-check test-benchmark-contract.ts", diff --git a/src/apps/cli/testdeno/relay-disconnect-probe.mjs b/src/apps/cli/testdeno/relay-disconnect-probe.mjs new file mode 100644 index 00000000..2b5f0e42 --- /dev/null +++ b/src/apps/cli/testdeno/relay-disconnect-probe.mjs @@ -0,0 +1,82 @@ +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { RTCPeerConnection } from "werift"; +import { TrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator"; + +const requireFromProbe = createRequire(import.meta.url); +const commonlibEntry = requireFromProbe.resolve("@vrtmrz/livesync-commonlib/context"); +const requireFromCommonlib = createRequire(commonlibEntry); +const nostrEntry = requireFromCommonlib.resolve("@trystero-p2p/nostr"); +const { getRelaySockets, joinRoom, pauseRelayReconnection } = await import(pathToFileURL(nostrEntry).href); + +const relayUrl = process.env.RELAY ?? "ws://nostr-relay:7777/"; +const timeoutMs = Number(process.env.RELAY_TIMEOUT_MS ?? 15_000); + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor(description, predicate) { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (predicate()) return; + await delay(50); + } + throw new Error(`Timed out waiting for ${description}`); +} + +const room = joinRoom( + { + appId: `livesync-relay-disconnect-probe-${Date.now()}`, + password: "local-test-only", + relayConfig: { + urls: [relayUrl], + manualReconnection: true, + }, + rtcPolyfill: RTCPeerConnection, + }, + "disconnect-probe" +); + +try { + await waitFor("the relay WebSocket to open", () => + Object.values(getRelaySockets()).some((socket) => socket.readyState === WebSocket.OPEN) + ); + const originalSockets = Object.values(getRelaySockets()); + + const replicator = new TrysteroReplicator( + {}, + { + close: async () => undefined, + dispatchConnectionStatus: async () => undefined, + } + ); + replicator.disconnectFromServer(); + + await waitFor("all relay WebSockets to close", () => + originalSockets.every((socket) => socket.readyState === WebSocket.CLOSED) + ); + await delay(4_000); + if (!originalSockets.every((socket) => socket.readyState === WebSocket.CLOSED)) { + throw new Error("A relay WebSocket reconnected while reconnection was paused"); + } + + replicator.allowReconnection(); + await waitFor("a replacement relay WebSocket to open", () => + Object.values(getRelaySockets()).some( + (socket) => !originalSockets.includes(socket) && socket.readyState === WebSocket.OPEN + ) + ); + + console.log( + JSON.stringify({ + socketsClosed: originalSockets.length, + stayedDisconnectedWhilePaused: true, + reconnectedAfterResume: true, + }) + ); +} finally { + await room.leave(); + pauseRelayReconnection(); + for (const socket of Object.values(getRelaySockets())) socket.close(); +} diff --git a/src/apps/cli/testdeno/run-cli-e2e.sh b/src/apps/cli/testdeno/run-cli-e2e.sh index bdc9e14e..af5baf0e 100644 --- a/src/apps/cli/testdeno/run-cli-e2e.sh +++ b/src/apps/cli/testdeno/run-cli-e2e.sh @@ -1,15 +1,15 @@ #!/usr/bin/env sh set -eu -TASK="${CLI_E2E_TASK:-test:p2p-sync}" +TASK="${CLI_E2E_TASK:-test:p2p:ci}" case "$TASK" in - test:p2p-host|test:p2p-peers|test:p2p-sync|test:p2p-three-nodes|test:p2p-upload-download) + test:p2p-host|test:p2p-peers|test:p2p-sync|test:p2p-replacement|test:p2p-relay-disconnect|test:p2p:ci|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 + echo "Expected one of: test:p2p-host, test:p2p-peers, test:p2p-sync, test:p2p-replacement, test:p2p-relay-disconnect, test:p2p:ci, test:p2p-three-nodes, test:p2p-upload-download" >&2 exit 2 ;; esac diff --git a/src/apps/cli/testdeno/run-compose-p2p.ts b/src/apps/cli/testdeno/run-compose-p2p.ts index da08939a..66ed898f 100644 --- a/src/apps/cli/testdeno/run-compose-p2p.ts +++ b/src/apps/cli/testdeno/run-compose-p2p.ts @@ -1,7 +1,7 @@ const repositoryRoot = await Deno.realPath(new URL("../../../../", import.meta.url)); const composeArgs = ["compose", "-f", "test/bench-network/compose.yml"]; const p2pEnvironment = { - CLI_E2E_TASK: Deno.env.get("CLI_E2E_TASK") ?? "test:p2p-sync", + CLI_E2E_TASK: Deno.env.get("CLI_E2E_TASK") ?? "test:p2p:ci", RELAY: Deno.env.get("RELAY") ?? "ws://nostr-relay:7777/", PEERS_TIMEOUT: Deno.env.get("PEERS_TIMEOUT") ?? "20", SYNC_TIMEOUT: Deno.env.get("SYNC_TIMEOUT") ?? "60", @@ -10,6 +10,8 @@ const p2pEnvironment = { LIVESYNC_P2P_PEERS_RETRY: Deno.env.get("LIVESYNC_P2P_PEERS_RETRY") ?? "1", LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "60000", BENCH_LIVESYNC_TEST_TEE: Deno.env.get("BENCH_LIVESYNC_TEST_TEE") ?? "0", + LIVESYNC_CLI_DEBUG: Deno.env.get("LIVESYNC_CLI_DEBUG") ?? "0", + LIVESYNC_CLI_VERBOSE: Deno.env.get("LIVESYNC_CLI_VERBOSE") ?? "0", }; async function runDocker(args: string[], env?: Record): Promise { diff --git a/src/apps/cli/testdeno/test-p2p-relay-disconnect.ts b/src/apps/cli/testdeno/test-p2p-relay-disconnect.ts new file mode 100644 index 00000000..1dc00778 --- /dev/null +++ b/src/apps/cli/testdeno/test-p2p-relay-disconnect.ts @@ -0,0 +1,26 @@ +import { assert, assertEquals } from "@std/assert"; + +Deno.test("p2p lifecycle: explicit disconnect closes and pauses relay WebSockets", async () => { + const command = new Deno.Command("node", { + args: [new URL("./relay-disconnect-probe.mjs", import.meta.url).pathname], + env: { + RELAY: Deno.env.get("RELAY") ?? "ws://nostr-relay:7777/", + RELAY_TIMEOUT_MS: Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "15000", + }, + stdout: "piped", + stderr: "piped", + }); + const result = await command.output(); + const stdout = new TextDecoder().decode(result.stdout).trim(); + const stderr = new TextDecoder().decode(result.stderr).trim(); + + assert(result.success, `Relay disconnect probe failed\nstdout: ${stdout}\nstderr: ${stderr}`); + const report = JSON.parse(stdout.split("\n").at(-1) ?? "{}") as { + socketsClosed?: number; + stayedDisconnectedWhilePaused?: boolean; + reconnectedAfterResume?: boolean; + }; + assert((report.socketsClosed ?? 0) > 0, "The probe did not observe an open relay WebSocket"); + assertEquals(report.stayedDisconnectedWhilePaused, true); + assertEquals(report.reconnectedAfterResume, true); +}); diff --git a/src/apps/cli/testdeno/test-p2p-replicator-replacement.ts b/src/apps/cli/testdeno/test-p2p-replicator-replacement.ts new file mode 100644 index 00000000..a6075535 --- /dev/null +++ b/src/apps/cli/testdeno/test-p2p-replicator-replacement.ts @@ -0,0 +1,117 @@ +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { join } from "@std/path"; +import { TempDir } from "./helpers/temp.ts"; +import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts"; +import { startCliInBackground } from "./helpers/backgroundCli.ts"; +import { maybeStartLocalRelay, stopLocalRelayIfStarted, maybeStartCoturn, stopCoturnIfStarted } from "./helpers/p2p.ts"; +import { CLI_DIR, runCli, sanitiseCatStdout } from "./helpers/cli.ts"; + +const NOTE_PATH = "p2p-replicator-replacement.md"; +const NOTE_CONTENT = "Replicated after replacing the active P2P replicator."; + +async function runReplacementProbe( + vaultPath: string, + settingsPath: string, + targetPeer: string, + timeoutMs: number +): Promise<{ code: number; stdout: string; stderr: string }> { + const command = new Deno.Command("node", { + args: [ + join(CLI_DIR, "dist", "p2p-lifecycle-test.cjs"), + vaultPath, + "--settings", + settingsPath, + "p2p-sync", + targetPeer, + String(timeoutMs / 1000), + NOTE_PATH, + NOTE_CONTENT, + ], + cwd: CLI_DIR, + stdout: "piped", + stderr: "piped", + }); + const result = await command.output(); + return { + code: result.code, + stdout: new TextDecoder().decode(result.stdout), + stderr: new TextDecoder().decode(result.stderr), + }; +} + +Deno.test("p2p lifecycle: replacement keeps real CLI communication on the current replicator", async () => { + const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/"; + const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20"); + const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "60"); + const probeTimeoutMs = Math.max(peersTimeout, syncTimeout) * 1000; + const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; + const roomId = Deno.env.get("ROOM_ID") ?? `replacement-room-${nonce}`; + const passphrase = Deno.env.get("PASSPHRASE") ?? `replacement-pass-${nonce}`; + const appId = "self-hosted-livesync-cli-replacement-test"; + const hostPeerName = `p2p-replacement-host-${nonce}`; + const probePeerName = `p2p-replacement-probe-${nonce}`; + const verifierPeerName = `p2p-replacement-verifier-${nonce}`; + const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0"; + const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? "turn:127.0.0.1:3478" : "none"); + + await using workDir = await TempDir.create("livesync-cli-p2p-replacement"); + const hostVault = workDir.join("vault-host"); + const probeVault = workDir.join("vault-probe"); + const verifierVault = workDir.join("vault-verifier"); + const hostSettings = workDir.join("settings-host.json"); + const probeSettings = workDir.join("settings-probe.json"); + const verifierSettings = workDir.join("settings-verifier.json"); + await Promise.all([ + Deno.mkdir(hostVault, { recursive: true }), + Deno.mkdir(probeVault, { recursive: true }), + Deno.mkdir(verifierVault, { recursive: true }), + ]); + + const relayStarted = await maybeStartLocalRelay(relay); + const coturnStarted = await maybeStartCoturn(turnServers); + try { + for (const settingsPath of [hostSettings, probeSettings, verifierSettings]) { + await initSettingsFile(settingsPath); + await applyP2pSettings(settingsPath, roomId, passphrase, appId, relay, "~.*", turnServers); + } + await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase); + await applyP2pTestTweaks(probeSettings, probePeerName, passphrase); + await applyP2pTestTweaks(verifierSettings, verifierPeerName, passphrase); + + const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host"); + try { + await host.waitUntilContains("P2P host is running", 20000); + const probe = await runReplacementProbe(probeVault, probeSettings, hostPeerName, probeTimeoutMs); + assert( + probe.code === 0, + `P2P replacement probe failed\nstdout: ${probe.stdout}\nstderr: ${probe.stderr}` + ); + assertStringIncludes(probe.stdout, "[Probe] P2P replicator replaced"); + + const syncResult = await runCli( + verifierVault, + "--settings", + verifierSettings, + "p2p-sync", + hostPeerName, + String(syncTimeout) + ); + assert( + syncResult.code === 0, + `Verifier P2P sync failed\nstdout: ${syncResult.stdout}\nstderr: ${syncResult.stderr}` + ); + + const catResult = await runCli(verifierVault, "--settings", verifierSettings, "cat", NOTE_PATH); + assert( + catResult.code === 0, + `Verifier could not read ${NOTE_PATH}\nstdout: ${catResult.stdout}\nstderr: ${catResult.stderr}` + ); + assertEquals(sanitiseCatStdout(catResult.stdout).trim(), NOTE_CONTENT); + } finally { + await host.stop(); + } + } finally { + await stopLocalRelayIfStarted(relayStarted); + await stopCoturnIfStarted(coturnStarted); + } +}); diff --git a/src/apps/cli/vite.config.ts b/src/apps/cli/vite.config.ts index 343eefa1..f0431741 100644 --- a/src/apps/cli/vite.config.ts +++ b/src/apps/cli/vite.config.ts @@ -45,7 +45,7 @@ function injectBanner(): import("vite").Plugin { name: "inject-banner", generateBundle(_options, bundle) { for (const chunk of Object.values(bundle)) { - if (chunk.type === "chunk" && chunk.fileName.startsWith("entrypoint")) { + if (chunk.type === "chunk" && chunk.isEntry) { // Insert after the shebang line if present, otherwise at the top. if (chunk.code.startsWith("#!")) { const newline = chunk.code.indexOf("\n"); @@ -60,6 +60,13 @@ function injectBanner(): import("vite").Plugin { }; } +const buildInputs: Record = { + index: resolve(__dirname, "entrypoint.ts"), +}; +if (process.env.LIVESYNC_CLI_TEST_SUPPORT === "1") { + buildInputs["p2p-lifecycle-test"] = resolve(__dirname, "test-support/p2p-lifecycle-entrypoint.ts"); +} + export default defineConfig({ plugins: [svelte(), injectBanner()], resolve: { @@ -83,9 +90,7 @@ export default defineConfig({ emptyOutDir: true, minify: false, rollupOptions: { - input: { - index: resolve(__dirname, "entrypoint.ts"), - }, + input: buildInputs, external: (id) => { if (isBuiltin(id)) return true; if (defaultExternal.includes(id)) return true; @@ -100,7 +105,7 @@ export default defineConfig({ lib: { entry: resolve(__dirname, "entrypoint.ts"), formats: ["cjs"], - fileName: "index", + fileName: (_format, entryName) => `${entryName}.cjs`, }, }, define: { diff --git a/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte b/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte index 395e93ae..aeb1a72e 100644 --- a/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte @@ -23,6 +23,7 @@ } let { onSync, onSyncAndClose, onClose, showResult, liveSyncReplicator, rebuildMode = false }: Props = $props(); + const getLiveSyncReplicator = () => liveSyncReplicator; let serverInfo = $state(undefined); let syncingPeerId = $state(null); @@ -98,7 +99,7 @@
- +

Available Peers

diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts index 7e13e1ec..39cf4a62 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts @@ -111,7 +111,7 @@ export function createOpenRebuildUI( try { replicator.setOnSetup(); Logger(`Rebuilding from peer ${peerId}`, logLevel); - const result = await replicator.replicateFrom(peerId, showResult); + const result = await replicator.replicateFrom(peerId, showResult, true); sessionResult = result?.ok ?? false; } catch (e) { Logger( diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts index e94d9ab3..13d51343 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts @@ -160,6 +160,7 @@ describe("createOpenRebuildUI", () => { await rebuild; await expect(session).resolves.toBe(true); expect(replicator.setOnSetup).toHaveBeenCalledOnce(); + expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true); expect(replicator.clearOnSetup).toHaveBeenCalledOnce(); }); }); diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte index 85bf0be3..f2660cb7 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte @@ -23,13 +23,12 @@ import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; interface Props { - cmdSync: P2PReplicatorPaneController; + getCmdSync: () => P2PReplicatorPaneController; core: Pick; } - let { cmdSync, core }: Props = $props(); - // const cmdSync = plugin.getAddOn("P2PReplicator")!; - setContext("getReplicator", () => cmdSync); + let { getCmdSync, core }: Props = $props(); + setContext("getReplicator", () => getCmdSync()); const currentSettings = () => core.services.setting.currentSettings() as P2PSyncSetting; const initialSettings = { ...currentSettings() } as P2PSyncSetting; @@ -223,16 +222,16 @@ } async function openServer() { - await cmdSync.open(); + await getCmdSync().open(); } async function closeServer() { - await cmdSync.close(); + await getCmdSync().close(); } function startBroadcasting() { - void cmdSync.enableBroadcastChanges(); + void getCmdSync().enableBroadcastChanges(); } function stopBroadcasting() { - void cmdSync.disableBroadcastChanges(); + void getCmdSync().disableBroadcastChanges(); } const initialDialogStatusKey = `p2p-dialog-status`; diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts index 1d2d8f96..08626326 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts @@ -187,7 +187,7 @@ And you can also drop the local database to rebuild from the remote device.`, return mount(ReplicatorPaneComponent, { target: target, props: { - cmdSync: this._p2pResult.replicator, + getCmdSync: () => this._p2pResult.replicator, core: this.core, }, }); diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte index f007b7dc..d3ac82e9 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte @@ -15,12 +15,12 @@ import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; interface Props { - liveSyncReplicator: LiveSyncTrysteroReplicator; + getLiveSyncReplicator: () => LiveSyncTrysteroReplicator; showBroadcastToggle?: boolean; core?: LiveSyncBaseCore; } - let { liveSyncReplicator, showBroadcastToggle = true, core }: Props = $props(); + let { getLiveSyncReplicator, showBroadcastToggle = true, core }: Props = $props(); let serverInfo = $state(undefined); let replicatorStatus = $state(undefined); // Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time. @@ -30,25 +30,25 @@ let useDiagRTC = $state(initialSettings?.P2P_useDiagRTC ?? false); async function requestServerStatus() { - await Promise.resolve(liveSyncReplicator.requestStatus()); + await Promise.resolve(getLiveSyncReplicator().requestStatus()); eventHub.emitEvent(EVENT_REQUEST_STATUS); } async function onOpenConnection() { - await liveSyncReplicator.makeSureOpened(); + await getLiveSyncReplicator().makeSureOpened(); await requestServerStatus(); } async function onDisconnect() { - await liveSyncReplicator.close(); + await getLiveSyncReplicator().close(); await requestServerStatus(); } function toggleBroadcast() { if (replicatorStatus?.isBroadcasting) { - liveSyncReplicator.disableBroadcastChanges(); + getLiveSyncReplicator().disableBroadcastChanges(); } else { - liveSyncReplicator.enableBroadcastChanges(); + getLiveSyncReplicator().enableBroadcastChanges(); } } diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte index 962cd727..50f53afb 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte @@ -26,11 +26,11 @@ import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte"; interface Props { - liveSyncReplicator: LiveSyncTrysteroReplicator; + getLiveSyncReplicator: () => LiveSyncTrysteroReplicator; core: LiveSyncBaseCore; } - let { liveSyncReplicator, core }: Props = $props(); + let { getLiveSyncReplicator, core }: Props = $props(); let serverInfo = $state(undefined); let replicatorInfo = $state(undefined); let decidingPeerId = $state(null); @@ -129,7 +129,7 @@ } async function requestServerStatus() { - await liveSyncReplicator.requestStatus(); + await getLiveSyncReplicator().requestStatus(); eventHub.emitEvent(EVENT_REQUEST_STATUS); } @@ -309,7 +309,7 @@ ) { decidingPeerId = peer.peerId; try { - await liveSyncReplicator.makeDecision({ + await getLiveSyncReplicator().makeDecision({ peerId: peer.peerId, name: peer.name, decision, @@ -324,7 +324,7 @@ async function revokeDecision(peer: P2PServerInfo["knownAdvertisements"][number]) { decidingPeerId = peer.peerId; try { - await liveSyncReplicator.revokeDecision({ + await getLiveSyncReplicator().revokeDecision({ peerId: peer.peerId, name: peer.name, }); @@ -337,9 +337,9 @@ async function startReplication(peer: P2PServerInfo["knownAdvertisements"][number]) { replicatingPeerId = peer.peerId; try { - const pullResult = await liveSyncReplicator.replicateFrom(peer.peerId, true); + const pullResult = await getLiveSyncReplicator().replicateFrom(peer.peerId, true); if (pullResult?.ok) { - await liveSyncReplicator.requestSynchroniseToPeer(peer.peerId); + await getLiveSyncReplicator().requestSynchroniseToPeer(peer.peerId); } await requestServerStatus(); } finally { @@ -360,9 +360,9 @@ return; } if (isWatching(peerId)) { - liveSyncReplicator.unwatchPeer(peerId); + getLiveSyncReplicator().unwatchPeer(peerId); } else { - liveSyncReplicator.watchPeer(peerId); + getLiveSyncReplicator().watchPeer(peerId); } } @@ -440,7 +440,7 @@

Please select an active P2P remote configuration to change P2P sync targets.

{/if} - +
diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts b/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts index c9480715..e10cfad9 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts @@ -35,7 +35,7 @@ export class P2PServerStatusPaneView extends SvelteItemView { return mount(P2PServerStatusPane, { target, props: { - liveSyncReplicator: this._p2pResult.replicator, + getLiveSyncReplicator: () => this._p2pResult.replicator, core: this.core, }, }); diff --git a/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte b/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte index 820c2b98..b9b92a99 100644 --- a/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte +++ b/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte @@ -57,7 +57,7 @@ let isNew = $derived.by(() => peer.accepted === AcceptedStatus.UNKNOWN); function makeDecision(isAccepted: boolean, isTemporary: boolean) { - replicator.makeDecision({ + getReplicator().makeDecision({ peerId: peer.peerId, name: peer.name, decision: isAccepted, @@ -65,12 +65,12 @@ }); } function revokeDecision() { - replicator.revokeDecision({ + getReplicator().revokeDecision({ peerId: peer.peerId, name: peer.name, }); } - const replicator = getContext<() => P2PReplicatorPaneController>("getReplicator")(); + const getReplicator = getContext<() => P2PReplicatorPaneController>("getReplicator"); const peerAttrLabels = $derived.by(() => { const attrs = []; @@ -86,14 +86,14 @@ return attrs; }); function startWatching() { - replicator?.watchPeer(peer.peerId); + getReplicator().watchPeer(peer.peerId); } function stopWatching() { - replicator?.unwatchPeer(peer.peerId); + getReplicator().unwatchPeer(peer.peerId); } function sync() { - void replicator?.sync(peer.peerId, false); + void getReplicator().sync(peer.peerId, false); } function moreMenu(evt: MouseEvent) { diff --git a/src/serviceFeatures/useP2PReplicatorUI.ts b/src/serviceFeatures/useP2PReplicatorUI.ts index 39cca2c0..c254a319 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.ts @@ -13,12 +13,9 @@ import type { WorkspaceLeaf } from "@/deps"; import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; /** - * ServiceFeature: P2P Replicator lifecycle management. - * Binds a LiveSyncTrysteroReplicator to the host's lifecycle events, - * following the same middleware style as useOfflineScanner. - * - * @param viewTypeAndFactory Optional [viewType, factory] pair for registering the P2P pane view. - * When provided, also registers commands and ribbon icon via services.API. + * Obsidian-specific P2P views, commands, status collection, and ribbon wiring. + * Replicator ownership and lifecycle remain in Commonlib's + * `useP2PReplicatorFeature`; this feature only consumes its current result. */ export function useP2PReplicatorUI( @@ -59,22 +56,21 @@ export function useP2PReplicatorUI( p2pLogCollector.p2pReplicationLine.onChanged((line) => { storeP2PStatusLine.value = line.value; }); + const p2pParams = { + get replicator() { + return getReplicator(); + }, + p2pLogCollector, + storeP2PStatusLine, + }; // Register view, commands and ribbon if a view factory is provided const viewType = VIEW_TYPE_P2P; const factory = (leaf: WorkspaceLeaf) => { - return new P2PReplicatorPaneView(leaf, core, { - replicator: getReplicator(), - p2pLogCollector, - storeP2PStatusLine, - }); + return new P2PReplicatorPaneView(leaf, core, p2pParams); }; const statusFactory = (leaf: WorkspaceLeaf) => { - return new P2PServerStatusPaneView(leaf, core, { - replicator: getReplicator(), - p2pLogCollector, - storeP2PStatusLine, - }); + return new P2PServerStatusPaneView(leaf, core, p2pParams); }; const openPane = () => api.showWindow(viewType); const openStatusPane = () => { @@ -173,5 +169,5 @@ export function useP2PReplicatorUI( } return Promise.resolve(true); }); - return { replicator: getReplicator(), p2pLogCollector, storeP2PStatusLine }; + return p2pParams; } diff --git a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts index f860c6f4..d360c655 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts @@ -57,4 +57,38 @@ describe("useP2PReplicatorUI commands", () => { label: "replication", }); }); + + it("keeps the current replicator in the pane parameters after replacement", () => { + const first = { id: "first" }; + const second = { id: "second" }; + let current = first; + const p2p = { + get replicator() { + return current; + }, + } as any; + const host = { + services: { + context: createServiceContext(), + API: { + showWindow: vi.fn(async () => undefined), + registerWindow: vi.fn(), + addCommand: vi.fn(), + addRibbonIcon: vi.fn(), + getPlatform: vi.fn(() => "obsidian"), + }, + appLifecycle: { + onInitialise: { addHandler: vi.fn() }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + + const paneParams = useP2PReplicatorUI(host, {} as any, p2p); + current = second; + + expect(paneParams.replicator).toBe(second); + }); }); diff --git a/test/bench-network/Dockerfile.runner b/test/bench-network/Dockerfile.runner index 04d387c3..ba9d04f9 100644 --- a/test/bench-network/Dockerfile.runner +++ b/test/bench-network/Dockerfile.runner @@ -18,7 +18,7 @@ COPY src/apps/webpeer/package.json ./src/apps/webpeer/package.json RUN npm ci COPY . . -RUN npm run build -w self-hosted-livesync-cli +RUN LIVESYNC_CLI_TEST_SUPPORT=1 npm run build -w self-hosted-livesync-cli WORKDIR /workspace/src/apps/cli/testdeno @@ -28,7 +28,9 @@ RUN deno cache --lock=deno.lock \ bench-p2p-split-node.ts \ bench-p2p.ts \ bench-couchdb.ts \ - test-p2p-sync.ts + test-p2p-sync.ts \ + test-p2p-replicator-replacement.ts \ + test-p2p-relay-disconnect.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 diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index c7d53d36..c5928bc7 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -97,7 +97,9 @@ services: 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} + LIVESYNC_CLI_DEBUG: ${LIVESYNC_CLI_DEBUG:-0} + LIVESYNC_CLI_VERBOSE: ${LIVESYNC_CLI_VERBOSE:-0} + CLI_E2E_TASK: ${CLI_E2E_TASK:-test:p2p:ci} RELAY: ${RELAY:-ws://nostr-relay:7777/} PEERS_TIMEOUT: ${PEERS_TIMEOUT:-20} SYNC_TIMEOUT: ${SYNC_TIMEOUT:-60} diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index ddf7be7f..694939c0 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -64,6 +64,7 @@ npm run test:e2e:obsidian:onboarding-invitation npm run test:e2e:obsidian:dialog-mounts npm run test:e2e:obsidian:settings-ui npm run test:e2e:obsidian:review-harness +npm run test:e2e:obsidian:p2p-pane npm run test:e2e:obsidian:vault-reflection npm run test:e2e:obsidian:couchdb-upload npm run test:e2e:obsidian:cli-to-obsidian-sync @@ -91,7 +92,9 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe `test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the Compose suite, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows. -`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, settings UI, the Review Harness, Vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload, startup scan, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB and MinIO fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. +`test:e2e:obsidian:p2p-pane` opens the P2P status view in a temporary real Obsidian session, verifies its principal connection control and horizontal layout, and captures a screenshot. It deliberately uses no relay or peer: current-replicator replacement is covered by focused unit tests, while the Deno and Compose CLI P2P lifecycle suite owns real peer discovery, replacement, relay disconnection, and transfer behaviour. + +`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload, startup scan, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB and MinIO fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. `test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies the copied-or-restored Vault explanation in the actual compatibility dialogue. It captures the summary and details, resumes explicitly, confirms that the marker was recorded, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents. @@ -157,6 +160,7 @@ Useful environment variables: - `E2E_OBSIDIAN_DIALOG_TIMEOUT_MS`: timeout for a representative Svelte dialogue to mount, expose its principal controls, and close; default is 10 seconds. - `E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS`: timeout for the settings pane and its deletion controls to become visible; default is 10 seconds. - `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds. +- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds. - `E2E_OBSIDIAN_READY_TIMEOUT_MS`: plug-in readiness timeout in milliseconds. - `E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS`: timeout for waiting until the vault-side Obsidian CLI exposes the plug-in catalogue. - `E2E_OBSIDIAN_CLI_TIMEOUT_MS`: timeout for each `obsidian-cli` invocation. diff --git a/test/e2e-obsidian/runner/ui.ts b/test/e2e-obsidian/runner/ui.ts index 4b2cd61a..52f73ba9 100644 --- a/test/e2e-obsidian/runner/ui.ts +++ b/test/e2e-obsidian/runner/ui.ts @@ -10,7 +10,7 @@ export { withObsidianPage, } from "@vrtmrz/obsidian-test-session"; -export async function captureObsidianDialogue( +export async function captureObsidianPage( port: number, filename: string, assertReady: (page: Page) => Promise @@ -25,7 +25,7 @@ export async function captureObsidianDialogue( } catch (error) { const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png"); await page.screenshot({ path: failurePath, fullPage: true }); - console.error(`Dialogue failure screenshot: ${failurePath}`); + console.error(`UI failure screenshot: ${failurePath}`); throw error; } await page.screenshot({ path: screenshotPath, fullPage: true }); @@ -34,6 +34,14 @@ export async function captureObsidianDialogue( return screenshotPath; } +export async function captureObsidianDialogue( + port: number, + filename: string, + assertReady: (page: Page) => Promise +): Promise { + return await captureObsidianPage(port, filename, assertReady); +} + export async function captureJsonResolveDialogue(port: number): Promise { return await captureObsidianDialogue(port, "hidden-file-json-resolve-dialogue.png", async (page) => { const optionAB = page.locator('label:has(input[name="disp"][value="AB"])'); diff --git a/test/e2e-obsidian/scripts/local-suite.ts b/test/e2e-obsidian/scripts/local-suite.ts index 08695bef..b5c14840 100644 --- a/test/e2e-obsidian/scripts/local-suite.ts +++ b/test/e2e-obsidian/scripts/local-suite.ts @@ -17,6 +17,7 @@ const testSteps: Step[] = [ { name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] }, { name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] }, { name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] }, + { name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] }, { name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] }, { name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] }, { diff --git a/test/e2e-obsidian/scripts/p2p-pane.ts b/test/e2e-obsidian/scripts/p2p-pane.ts new file mode 100644 index 00000000..45aed562 --- /dev/null +++ b/test/e2e-obsidian/scripts/p2p-pane.ts @@ -0,0 +1,89 @@ +import { assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000); + +type ObsidianTestApp = { + commands?: { executeCommandById(commandId: string): boolean }; +}; + +type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; + +async function openP2PStatusPane(): Promise { + const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + return await page.evaluate( + (commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:open-p2p-server-status" + ); + }); + if (!opened) { + throw new Error("The P2P status command was not registered or could not be executed."); + } +} + +async function verifyP2PStatusPane(): Promise { + await openP2PStatusPane(); + return await captureObsidianPage(obsidianRemoteDebuggingPort(), "p2p-status-pane.png", async (page) => { + const heading = page.getByRole("heading", { name: "Signalling Status" }).last(); + await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const pane = heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + await pane.getByText("Connection:", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + await pane.getByRole("button", { name: "Open connection" }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await assertNoHorizontalOverflow(page, pane, { label: "P2P status pane" }); + }); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: { + doctorProcessedVersion: "0.25.27", + isConfigured: true, + liveSync: false, + notifyThresholdOfRemoteStorageSize: -1, + periodicReplication: false, + P2P_Enabled: false, + P2P_AutoStart: false, + syncAfterMerge: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncOnSave: false, + syncOnStart: false, + }, + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + const screenshot = await verifyP2PStatusPane(); + console.log(`P2P status pane mounted without network fixtures. Screenshot: ${screenshot}`); + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/updates.md b/updates.md index 20e6939d..21ba45e4 100644 --- a/updates.md +++ b/updates.md @@ -16,6 +16,7 @@ Earlier releases remain available in the [0.25 release history](docs/releases/0. - Wizard-driven new-device and existing-device setup now reserves Rebuild or Fetch before enabling imported settings, preventing ordinary start-up work from running ahead of the selected initialisation. - Manual onboarding now creates and selects CouchDB, Object Storage, and P2P remote profiles directly while preserving existing profiles. Current Setup URIs retain profile names and selections, while older flat settings remain supported through compatibility migration. +- P2P panes and explicit rebuild actions now use the current transport after settings or database replacement. Disconnecting leaves the LiveSync room and closes signalling relay sockets without destroying Trystero-owned shared peers. ## 1.0.0-rc.0