mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 11:33:21 +00:00
Fix automatic P2P settings restoration in CLI host mode
This commit is contained in:
1 parent
0956663f7b
commit
3e2b4dcb3b
16 files changed
+944
-25
No files matched your search
@@ -0,0 +1,221 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { CLICommandContext, CLIOptions } from "@/apps/cli/commands/types";
|
||||
import { runCommand } from "@/apps/cli/commands/runCommand";
|
||||
|
||||
const FRAME_PREFIX = "@@LIVESYNC_P2P_AUTOMATIC@@";
|
||||
const SEED_PATH_ENV = "LIVESYNC_CLI_P2P_AUTOMATIC_SEED_PATH";
|
||||
const SEED_CONTENT_ENV = "LIVESYNC_CLI_P2P_AUTOMATIC_SEED_CONTENT";
|
||||
|
||||
type FixtureRequest = {
|
||||
id: string;
|
||||
action: "state" | "wait-peer" | "write" | "wait-content" | "stop";
|
||||
peerName?: string;
|
||||
path?: string;
|
||||
content?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function describeError(value: unknown): string {
|
||||
return value instanceof Error ? (value.stack ?? value.message) : String(value);
|
||||
}
|
||||
|
||||
function emitFrame(payload: Record<string, unknown>): void {
|
||||
process.stdout.write(`${FRAME_PREFIX}${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
function requirePath(path: string | undefined): FilePathWithPrefix {
|
||||
if (!path?.trim() || path.includes("\0")) {
|
||||
throw new Error("The P2P automatic-host fixture requires a non-empty database path");
|
||||
}
|
||||
return path as FilePathWithPrefix;
|
||||
}
|
||||
|
||||
async function storeText(
|
||||
context: CLICommandContext,
|
||||
path: string | undefined,
|
||||
content: string | undefined
|
||||
): Promise<void> {
|
||||
if (content === undefined) {
|
||||
throw new Error("The P2P automatic-host fixture write request requires content");
|
||||
}
|
||||
const stored = await context.core.serviceModules.databaseFileAccess.storeContent(requirePath(path), content);
|
||||
if (!stored) {
|
||||
throw new Error(`Could not store '${path}' in the fixture-owned database`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readText(context: CLICommandContext, path: string | undefined): Promise<string | undefined> {
|
||||
const source = await context.core.serviceModules.databaseFileAccess.fetch(requirePath(path), undefined, true);
|
||||
if (!source || source.deleted) return undefined;
|
||||
if (source.body.type === "text/plain") return await source.body.text();
|
||||
return new TextDecoder().decode(await source.body.arrayBuffer());
|
||||
}
|
||||
|
||||
function getPolicy(context: CLICommandContext) {
|
||||
const settings = context.core.services.setting.currentSettings();
|
||||
return {
|
||||
P2P_Enabled: settings.P2P_Enabled,
|
||||
P2P_AutoAccepting: settings.P2P_AutoAccepting,
|
||||
P2P_AutoStart: settings.P2P_AutoStart,
|
||||
P2P_AutoBroadcast: settings.P2P_AutoBroadcast,
|
||||
P2P_AutoSyncPeers: settings.P2P_AutoSyncPeers,
|
||||
P2P_AutoWatchPeers: settings.P2P_AutoWatchPeers,
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs a persistent real P2P service with in-process controls for the E2E test. */
|
||||
export async function runP2PAutomaticHostFixture(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
|
||||
const seedPath = process.env[SEED_PATH_ENV];
|
||||
const seedContent = process.env[SEED_CONTENT_ENV];
|
||||
if (seedPath !== undefined || seedContent !== undefined) {
|
||||
await storeText(context, seedPath, seedContent);
|
||||
}
|
||||
|
||||
const service = context.p2pReplicator;
|
||||
if (!service) {
|
||||
throw new Error("The CLI did not expose its P2P service-feature result to the automatic-host fixture");
|
||||
}
|
||||
const commandOutcome = runCommand(options, context).then(
|
||||
(result) => ({ type: "result" as const, result }),
|
||||
(error) => ({ type: "error" as const, error })
|
||||
);
|
||||
const connectedWaitStarted = Date.now();
|
||||
while (!service.transportLifecycle.isConnected) {
|
||||
const earlyOutcome = await Promise.race([commandOutcome, delay(200).then(() => undefined)]);
|
||||
if (earlyOutcome) {
|
||||
if (earlyOutcome.type === "error") throw earlyOutcome.error;
|
||||
return earlyOutcome.result;
|
||||
}
|
||||
if (Date.now() - connectedWaitStarted > 30000) {
|
||||
throw new Error("The production p2p-host command did not connect within 30000ms");
|
||||
}
|
||||
}
|
||||
const input = createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
emitFrame({
|
||||
type: "ready",
|
||||
connected: service.transportLifecycle.isConnected,
|
||||
policy: getPolicy(context),
|
||||
});
|
||||
|
||||
const controls = (async () => {
|
||||
try {
|
||||
for await (const line of input) {
|
||||
let request: FixtureRequest | undefined;
|
||||
try {
|
||||
request = JSON.parse(line) as FixtureRequest;
|
||||
if (typeof request.id !== "string" || !request.id) {
|
||||
throw new Error("The P2P automatic-host fixture request requires an id");
|
||||
}
|
||||
|
||||
let result: unknown;
|
||||
if (request.action === "state") {
|
||||
result = {
|
||||
connected: service.transportLifecycle.isConnected,
|
||||
peers: service.peerDirectory.getPeers().map((peer) => ({
|
||||
name: peer.name,
|
||||
peerId: peer.peerId,
|
||||
})),
|
||||
policy: getPolicy(context),
|
||||
};
|
||||
} else if (request.action === "wait-peer") {
|
||||
const peerName = request.peerName?.trim();
|
||||
if (!peerName) {
|
||||
throw new Error("The wait-peer request requires peerName");
|
||||
}
|
||||
const timeoutMs = request.timeoutMs ?? 20000;
|
||||
const started = Date.now();
|
||||
let peer = service.peerDirectory.getPeers().find((candidate) => candidate.name === peerName);
|
||||
while (!peer && Date.now() - started < timeoutMs) {
|
||||
await delay(200);
|
||||
peer = service.peerDirectory.getPeers().find((candidate) => candidate.name === peerName);
|
||||
}
|
||||
if (!peer) {
|
||||
const known = service.peerDirectory
|
||||
.getPeers()
|
||||
.map((candidate) => `${candidate.name} (${candidate.peerId})`)
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`Peer '${peerName}' was not discovered within ${timeoutMs}ms. Known peers: ${
|
||||
known || "none"
|
||||
}`
|
||||
);
|
||||
}
|
||||
result = { name: peer.name, peerId: peer.peerId };
|
||||
} else if (request.action === "write") {
|
||||
await storeText(context, request.path, request.content);
|
||||
result = { path: request.path };
|
||||
} else if (request.action === "wait-content") {
|
||||
const path = requirePath(request.path);
|
||||
if (request.content === undefined) {
|
||||
throw new Error("The wait-content request requires content");
|
||||
}
|
||||
const timeoutMs = request.timeoutMs ?? 30000;
|
||||
const started = Date.now();
|
||||
let actual = await readText(context, path);
|
||||
while (actual !== request.content && Date.now() - started < timeoutMs) {
|
||||
await delay(200);
|
||||
actual = await readText(context, path);
|
||||
}
|
||||
if (actual !== request.content) {
|
||||
throw new Error(
|
||||
`Timed out waiting for '${path}' to contain the expected text within ${timeoutMs}ms; ` +
|
||||
`last content: ${actual === undefined ? "missing" : JSON.stringify(actual)}`
|
||||
);
|
||||
}
|
||||
emitFrame({
|
||||
type: "checkpoint",
|
||||
id: request.id,
|
||||
action: request.action,
|
||||
path,
|
||||
content: actual,
|
||||
});
|
||||
result = { path, content: actual };
|
||||
} else if (request.action === "stop") {
|
||||
emitFrame({
|
||||
type: "response",
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { stopping: true },
|
||||
});
|
||||
break;
|
||||
} else {
|
||||
throw new Error(`Unknown P2P automatic-host fixture action: ${String(request.action)}`);
|
||||
}
|
||||
|
||||
emitFrame({
|
||||
type: "response",
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
emitFrame({
|
||||
type: "response",
|
||||
id: request?.id ?? "",
|
||||
ok: false,
|
||||
error: describeError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
input.close();
|
||||
}
|
||||
})();
|
||||
|
||||
const outcome = await Promise.race([
|
||||
commandOutcome.then((result) => ({ type: "command" as const, result })),
|
||||
controls.then(() => ({ type: "controls" as const })),
|
||||
]);
|
||||
input.close();
|
||||
if (outcome.type === "controls") return true;
|
||||
if (outcome.result.type === "error") throw outcome.result.error;
|
||||
return outcome.result.result;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { runP2PAutomaticHostFixture } from "./p2p-automatic-host.test";
|
||||
import { runP2PReplicatorReplacementProbe } from "./p2p-replicator-replacement.test";
|
||||
|
||||
if (
|
||||
@@ -16,8 +17,13 @@ if (
|
||||
|
||||
const standardIo = createNodeStandardIo();
|
||||
const runLifecycleProbe: CliCommandRunner = async (options, context) => {
|
||||
if (options.command === "p2p-host") {
|
||||
return await runP2PAutomaticHostFixture(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]");
|
||||
throw new Error(
|
||||
"The P2P lifecycle test entry requires: p2p-host or p2p-sync <peer> <timeout> [note-path] [note-content]"
|
||||
);
|
||||
}
|
||||
const peerToken = options.commandArgs[0].trim();
|
||||
if (!peerToken) {
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
throw new Error("The CLI did not activate the initial P2P Replicator adapter");
|
||||
}
|
||||
const compatibilityFacade = p2pReplicator.replicator;
|
||||
const p2pService = await openP2PHost(core, p2pReplicator);
|
||||
const p2pService = await openP2PHost(core, p2pReplicator, context.originalP2PSettings);
|
||||
|
||||
const firstPeer = await communicateWithPeer(p2pService, targetPeer, timeoutMs);
|
||||
const initialised = await core.services.databaseEvents.initialiseDatabase(false, true, false);
|
||||
|
||||
Reference in new issue
Block a user