mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
Keep P2P consumers on the current transport
This commit is contained in:
@@ -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<NodeServiceContext, never>;
|
||||
/** Current-result contract owned by the P2P service feature. */
|
||||
p2pReplicator?: UseP2PReplicatorResult;
|
||||
settingsPath: string;
|
||||
originalSyncSettings: Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
|
||||
+19
-4
@@ -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<boolean>;
|
||||
|
||||
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<NodeServiceContext, never>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>).RTCPeerConnection === "undefined" &&
|
||||
typeof RTCPeerConnection === "function"
|
||||
) {
|
||||
(compatGlobal as unknown as Record<string, unknown>).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 <peer> <timeout> [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);
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>): Promise<Deno.CommandStatus> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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: {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
}
|
||||
|
||||
let { onSync, onSyncAndClose, onClose, showResult, liveSyncReplicator, rebuildMode = false }: Props = $props();
|
||||
const getLiveSyncReplicator = () => liveSyncReplicator;
|
||||
|
||||
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||
let syncingPeerId = $state<string | null>(null);
|
||||
@@ -98,7 +99,7 @@
|
||||
</script>
|
||||
|
||||
<div class="p2p-container">
|
||||
<P2PServerStatusCard {liveSyncReplicator} showBroadcastToggle={false} />
|
||||
<P2PServerStatusCard {getLiveSyncReplicator} showBroadcastToggle={false} />
|
||||
|
||||
<div class="peers-section">
|
||||
<h3>Available Peers</h3>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,13 +23,12 @@
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
|
||||
interface Props {
|
||||
cmdSync: P2PReplicatorPaneController;
|
||||
getCmdSync: () => P2PReplicatorPaneController;
|
||||
core: Pick<LiveSyncBaseCore, "services">;
|
||||
}
|
||||
|
||||
let { cmdSync, core }: Props = $props();
|
||||
// const cmdSync = plugin.getAddOn<P2PReplicator>("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`;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<P2PServerInfo | undefined>(undefined);
|
||||
let replicatorStatus = $state<P2PReplicatorStatus | undefined>(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<boolean>(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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<P2PServerInfo | undefined>(undefined);
|
||||
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||
let decidingPeerId = $state<string | null>(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 @@
|
||||
<p class="warning-line">Please select an active P2P remote configuration to change P2P sync targets.</p>
|
||||
{/if}
|
||||
|
||||
<P2PServerStatusCard {liveSyncReplicator} {core} />
|
||||
<P2PServerStatusCard {getLiveSyncReplicator} {core} />
|
||||
|
||||
<div class="peers-section">
|
||||
<div class="peers-header">
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user