mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 04:52: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: {
|
||||
|
||||
Reference in New Issue
Block a user