Migrate P2P UI and CLI transfers to service views

This commit is contained in:
vorotamoroz
2026-08-31 15:06:39 +00:00
parent 1d2077d3fc
commit 3f76ad796e
8 changed files with 125 additions and 47 deletions
+7
View File
@@ -17,6 +17,7 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => compatGlobal.setTimeout(resolve, ms));
}
/** Parse a CLI timeout expressed as a finite, non-negative number of seconds. */
export function parseTimeoutSeconds(value: string, commandName: string): number {
const timeoutSec = Number(value);
if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
@@ -54,6 +55,7 @@ function getSortedPeers(service: Pick<P2PServiceViews, "peerDirectory">): CLIP2P
.sort((a, b) => a.peerId.localeCompare(b.peerId));
}
/** Connect for a bounded discovery interval, return a stable peer ordering, and disconnect. */
export async function collectPeers(
core: LiveSyncBaseCore<ServiceContext, never>,
p2pService: CLIP2PService | undefined,
@@ -137,6 +139,7 @@ export function createPeerConnectionStatsPayload(
return payload;
}
/** Resolve one peer token, complete pull then push, and disconnect on every settlement. */
export async function syncWithPeer(
core: LiveSyncBaseCore<ServiceContext, never>,
p2pService: CLIP2PService | undefined,
@@ -167,6 +170,9 @@ export async function syncWithPeer(
if (pullResult && "error" in pullResult && pullResult.error) {
throw pullResult.error instanceof Error ? pullResult.error : LiveSyncError.fromError(pullResult.error);
}
if (!pullResult || pullResult.status !== "completed") {
throw LiveSyncError.fromError("P2P sync failed while pulling from peer");
}
const pushResult = await service.targetedTransfer.requestPushToPeer(targetPeer.peerId);
if (!pushResult || pushResult.ok !== true) {
const err: unknown = pushResult && "error" in pushResult ? pushResult.error : undefined;
@@ -182,6 +188,7 @@ export async function syncWithPeer(
}
}
/** Connect the headless P2P host and transfer transport ownership to the caller. */
export async function openP2PHost(
core: LiveSyncBaseCore<ServiceContext, never>,
p2pService: CLIP2PService | undefined
+12 -2
View File
@@ -14,8 +14,8 @@ function createCore() {
function createP2PService() {
const connect = vi.fn(async () => undefined);
const disconnect = vi.fn(async () => undefined);
const pullFromPeer = vi.fn(async () => ({ ok: true }));
const requestPushToPeer = vi.fn(async () => ({ ok: true }));
const pullFromPeer = vi.fn(async () => ({ status: "completed" as const, ok: true as const }));
const requestPushToPeer = vi.fn(async () => ({ status: "completed" as const, ok: true as const }));
return {
service: {
transportLifecycle: { isConnected: false, connect, disconnect },
@@ -72,6 +72,16 @@ describe("p2p command helpers", () => {
expect(requestPushToPeer).toHaveBeenCalledWith("peer-a");
});
it("rejects a cancelled pull without requesting a peer push", async () => {
const { service, disconnect, pullFromPeer, requestPushToPeer } = createP2PService();
pullFromPeer.mockResolvedValue({ status: "cancelled" } as never);
await expect(syncWithPeer(createCore(), service as never, "peer-a", 0)).rejects.toBeDefined();
expect(requestPushToPeer).not.toHaveBeenCalled();
expect(disconnect).toHaveBeenCalledOnce();
});
it("preserves the benchmark diagnostics JSONL contract", () => {
expect(
createPeerConnectionStatsPayload(
@@ -3,9 +3,13 @@ import P2POpenReplicationPane from "./P2POpenReplicationPane.svelte";
import { mount, unmount } from "svelte";
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
/**
* Reports action completion so the pane does not infer success merely from a
* settled Promise.
*/
export type P2POpenReplicationModalCallback = {
onSync: (peerId: string) => Promise<void>;
onSyncAndClose: (peerId: string) => Promise<void>;
onSync: (peerId: string) => Promise<boolean>;
onSyncAndClose: (peerId: string) => Promise<boolean>;
};
export class P2POpenReplicationModal extends Modal {
@@ -35,17 +39,20 @@ export class P2POpenReplicationModal extends Modal {
this.rebuildMode = rebuildMode;
}
async onSync(peerId: string) {
async onSync(peerId: string): Promise<boolean> {
if (this.callback?.onSync) {
await this.callback.onSync(peerId);
return await this.callback.onSync(peerId);
}
return false;
}
async onSyncAndClose(peerId: string) {
async onSyncAndClose(peerId: string): Promise<boolean> {
let completed = false;
if (this.callback?.onSyncAndClose) {
await this.callback.onSyncAndClose(peerId);
completed = await this.callback.onSyncAndClose(peerId);
}
this.close();
return completed;
}
override onOpen() {
@@ -16,8 +16,8 @@
interface Props {
p2p: P2PServiceViews;
onSync: (_peerId: string) => Promise<void>;
onSyncAndClose: (_peerId: string) => Promise<void>;
onSync: (_peerId: string) => Promise<boolean>;
onSyncAndClose: (_peerId: string) => Promise<boolean>;
onClose: () => void;
showResult: boolean;
rebuildMode?: boolean;
@@ -49,8 +49,8 @@
try {
syncingPeerId = peerId;
Logger(`Starting sync with ${peerId}`, logLevel);
await onSync(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
const completed = await onSync(peerId);
if (completed) Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
} finally {
@@ -61,8 +61,8 @@
try {
syncingPeerId = peerId;
Logger(`Starting sync with ${peerId}`, logLevel);
await onSyncAndClose(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
const completed = await onSyncAndClose(peerId);
if (completed) Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
} finally {
@@ -8,7 +8,9 @@ import { P2POpenReplicationModal } from "./P2POpenReplicationModal";
/**
* Creates an openReplicationUI factory for Obsidian environments.
* Returns a per-replicator closure that opens the P2P Replication modal
* and performs bidirectional sync (pull then push on success).
* and performs bidirectional sync (pull then push on success) through the
* stable targeted-transfer view. The compatibility Replicator argument is
* intentionally unused here and remains available only to the rebuild factory.
*
* Usage:
* const factory = createOpenReplicationUI(app);
@@ -17,7 +19,7 @@ import { P2POpenReplicationModal } from "./P2POpenReplicationModal";
export function createOpenReplicationUI(
app: App
): (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) => (showResult: boolean) => Promise<boolean | void> {
return (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) =>
return (_replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) =>
(showResult: boolean): Promise<boolean | void> => {
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
return new Promise<boolean | void>((resolve) => {
@@ -37,20 +39,25 @@ export function createOpenReplicationUI(
activeSynchronisations++;
try {
// Pull first, then push only when the pull succeeds.
const pullResult = await replicator.replicateFrom(peerId, showResult);
if (!pullResult?.ok) {
const pullResult = await p2p.targetedTransfer.pullFromPeer(peerId, {
showNotice: showResult,
});
if (pullResult.status !== "completed" || !pullResult.ok) {
sessionResult = false;
return;
return false;
}
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
sessionResult = pushResult?.ok ?? true;
if (sessionResult && closeConnection) await replicator.close();
const pushResult = await p2p.targetedTransfer.requestPushToPeer(peerId);
const completed = pushResult.status === "completed" && pushResult.ok === true;
sessionResult = completed;
if (completed && closeConnection) await p2p.transportLifecycle.disconnect();
return completed;
} catch (e) {
Logger(
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
logLevel
);
sessionResult = false;
return false;
} finally {
activeSynchronisations--;
settleClosedSession();
@@ -114,12 +121,14 @@ export function createOpenRebuildUI(
Logger(`Rebuilding from peer ${peerId}`, logLevel);
const result = await replicator.replicateFrom(peerId, showResult, true);
sessionResult = result?.ok ?? false;
return sessionResult;
} catch (e) {
Logger(
`Error in rebuild from ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
logLevel
);
sessionResult = false;
return false;
} finally {
try {
replicator.clearOnSetup();
@@ -4,8 +4,8 @@ const modalState = vi.hoisted(() => ({
instances: [] as Array<{
p2p: unknown;
callback: {
onSync: (peerId: string) => Promise<void>;
onSyncAndClose: (peerId: string) => Promise<void>;
onSync: (peerId: string) => Promise<boolean>;
onSyncAndClose: (peerId: string) => Promise<boolean>;
};
onClosed?: () => void;
open: ReturnType<typeof vi.fn>;
@@ -41,8 +41,8 @@ import { createOpenRebuildUI, createOpenReplicationUI } from "./P2PReplicationUI
function createReplicator() {
return {
replicateFrom: vi.fn(async () => ({ ok: true })),
requestSynchroniseToPeer: vi.fn(async () => ({ ok: true })),
replicateFrom: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
requestSynchroniseToPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
close: vi.fn(async () => undefined),
setOnSetup: vi.fn(),
clearOnSetup: vi.fn(),
@@ -50,7 +50,16 @@ function createReplicator() {
}
function createP2PServiceViews() {
return { transportLifecycle: {}, diagnostics: {} } as any;
return {
transportLifecycle: {
disconnect: vi.fn(async () => undefined),
},
targetedTransfer: {
pullFromPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
requestPushToPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
},
diagnostics: {},
} as any;
}
describe("createOpenReplicationUI", () => {
@@ -72,35 +81,53 @@ describe("createOpenReplicationUI", () => {
it("keeps repeated synchronisation inside the session boundary until the modal closes", async () => {
const replicator = createReplicator();
const session = createOpenReplicationUI({} as any)(replicator, createP2PServiceViews())(true);
const p2p = createP2PServiceViews();
const session = createOpenReplicationUI({} as any)(replicator, p2p)(true);
const modal = modalState.instances[0];
let settled = false;
void session.finally(() => {
settled = true;
});
await modal.callback.onSync("peer-a");
await expect(modal.callback.onSync("peer-a")).resolves.toBe(true);
await Promise.resolve();
expect(settled).toBe(false);
await modal.callback.onSync("peer-b");
expect(replicator.replicateFrom).toHaveBeenCalledTimes(2);
expect(replicator.requestSynchroniseToPeer).toHaveBeenCalledTimes(2);
expect(p2p.targetedTransfer.pullFromPeer).toHaveBeenCalledTimes(2);
expect(p2p.targetedTransfer.requestPushToPeer).toHaveBeenCalledTimes(2);
modal.onClosed?.();
await expect(session).resolves.toBe(true);
});
it("waits for an in-flight synchronisation when the modal closes", async () => {
let finishPull!: (value: { ok: boolean }) => void;
it("routes ordinary peer transfer through the stable targeted-transfer view", async () => {
const replicator = createReplicator();
replicator.replicateFrom.mockImplementation(
const p2p = createP2PServiceViews();
const session = createOpenReplicationUI({} as any)(replicator, p2p)(true);
const modal = modalState.instances[0];
await modal.callback.onSync("peer-a");
modal.onClosed?.();
await expect(session).resolves.toBe(true);
expect(p2p.targetedTransfer.pullFromPeer).toHaveBeenCalledWith("peer-a", { showNotice: true });
expect(p2p.targetedTransfer.requestPushToPeer).toHaveBeenCalledWith("peer-a");
expect(replicator.replicateFrom).not.toHaveBeenCalled();
expect(replicator.requestSynchroniseToPeer).not.toHaveBeenCalled();
});
it("waits for an in-flight synchronisation when the modal closes", async () => {
let finishPull!: (value: { status: "completed"; ok: true }) => void;
const replicator = createReplicator();
const p2p = createP2PServiceViews();
p2p.targetedTransfer.pullFromPeer.mockImplementation(
async () =>
await new Promise<{ ok: boolean }>((resolve) => {
await new Promise<{ status: "completed"; ok: true }>((resolve) => {
finishPull = resolve;
})
);
const session = createOpenReplicationUI({} as any)(replicator, createP2PServiceViews())(true);
const session = createOpenReplicationUI({} as any)(replicator, p2p)(true);
const modal = modalState.instances[0];
let settled = false;
void session.finally(() => {
@@ -113,19 +140,21 @@ describe("createOpenReplicationUI", () => {
expect(settled).toBe(false);
finishPull({ ok: true });
finishPull({ status: "completed", ok: true });
await synchronisation;
await expect(session).resolves.toBe(true);
});
it("closes the P2P connection after a successful sync-and-close action", async () => {
const replicator = createReplicator();
const session = createOpenReplicationUI({} as any)(replicator, createP2PServiceViews())(true);
const p2p = createP2PServiceViews();
const session = createOpenReplicationUI({} as any)(replicator, p2p)(true);
const modal = modalState.instances[0];
await modal.callback.onSyncAndClose("peer-a");
expect(replicator.close).toHaveBeenCalledOnce();
expect(p2p.transportLifecycle.disconnect).toHaveBeenCalledOnce();
expect(replicator.close).not.toHaveBeenCalled();
let settled = false;
void session.finally(() => {
settled = true;
@@ -136,6 +165,20 @@ describe("createOpenReplicationUI", () => {
modal.onClosed?.();
await expect(session).resolves.toBe(true);
});
it("returns a cancelled peer push as non-success to the presentation boundary", async () => {
const replicator = createReplicator();
const p2p = createP2PServiceViews();
p2p.targetedTransfer.requestPushToPeer.mockResolvedValue({ status: "cancelled" } as never);
const session = createOpenReplicationUI({} as any)(replicator, p2p)(true);
const modal = modalState.instances[0];
const actionResult = await modal.callback.onSync("peer-a");
modal.onClosed?.();
expect(actionResult).toBe(false);
await expect(session).resolves.toBe(false);
});
});
describe("createOpenRebuildUI", () => {
@@ -144,11 +187,11 @@ describe("createOpenRebuildUI", () => {
});
it("waits for an in-flight rebuild when the modal closes", async () => {
let finishPull!: (value: { ok: boolean }) => void;
let finishPull!: (value: { status: "completed"; ok: true }) => void;
const replicator = createReplicator();
replicator.replicateFrom.mockImplementation(
async () =>
await new Promise<{ ok: boolean }>((resolve) => {
await new Promise<{ status: "completed"; ok: true }>((resolve) => {
finishPull = resolve;
})
);
@@ -165,8 +208,8 @@ describe("createOpenRebuildUI", () => {
expect(settled).toBe(false);
finishPull({ ok: true });
await rebuild;
finishPull({ status: "completed", ok: true });
await expect(rebuild).resolves.toBe(true);
await expect(session).resolves.toBe(true);
expect(replicator.setOnSetup).toHaveBeenCalledOnce();
expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true);
@@ -106,12 +106,12 @@
throw new Error("The P2P Setup connection probe is not available.");
}
const result = await coordinateP2PSetupConnectionProbe(admission, trialRemoteSetting, async () => {
const map = new Map<string, string>();
const map = new Map<string, unknown>();
const store = {
get: (key: string) => {
return Promise.resolve(map.get(key) || null);
},
set: (key: string, value: any) => {
set: (key: string, value: unknown) => {
map.set(key, value);
return Promise.resolve();
},
@@ -125,7 +125,7 @@
get db() {
return Promise.resolve(this);
},
} as SimpleStore<any>;
} as SimpleStore<unknown>;
const dummyPouch = new PouchDB<EntryDoc>("dummy");
let replicator: TrysteroReplicator | undefined;
@@ -134,7 +134,7 @@
events: context.context.events,
translate: context.context.translate,
settings: trialRemoteSetting,
processReplicatedDocs: async (_docs: any[]) => {
processReplicatedDocs: async (_docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => {
return;
},
confirm: context.services.confirm,
@@ -111,6 +111,8 @@ export function useP2PReplicatorUI(
const activeReplicator = replicator.replicator;
if (!activeReplicator) return;
const settings = host.services.setting.currentSettings();
// The deprecated compatibility result only opens the ordinary UI; the
// actual transfer runs through the stable targeted-transfer view.
void host.services.replicator.runFiniteReplicationActivity(
() => activeReplicator.openReplication(settings, false, true, false),
{ label: "replication" }