mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 21:12:59 +00:00
Protect bounded remote activity across app lifecycle
This commit is contained in:
@@ -69,18 +69,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncAndClose(peerId: string) {
|
||||
fireAndForget(async () => {
|
||||
try {
|
||||
Logger(`Starting sync with ${peerId}`, logLevel);
|
||||
await onSync(peerId);
|
||||
Logger(`Sync completed with ${peerId}`, logLevel);
|
||||
} catch (e) {
|
||||
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
async function disconnect() {
|
||||
try {
|
||||
await liveSyncReplicator.close();
|
||||
@@ -142,7 +130,7 @@
|
||||
<button
|
||||
class="btn {rebuildMode ? 'btn-primary' : 'btn-secondary'}"
|
||||
disabled={syncingPeerId !== null}
|
||||
onclick={() => handleSyncAndClose(peer.peerId)}
|
||||
onclick={() => handleSyncThenClose(peer.peerId)}
|
||||
>
|
||||
{syncingPeerId === peer.peerId ? "Syncing..." : "Start Sync & Close"}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { App } from "@/deps.ts";
|
||||
import type { App } from "@/deps.ts";
|
||||
import { Logger } from "@lib/common/logger";
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types";
|
||||
import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
@@ -20,52 +20,54 @@ export function createOpenReplicationUI(
|
||||
(showResult: boolean): Promise<boolean | void> => {
|
||||
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
return new Promise<boolean | void>((resolve) => {
|
||||
let resolved = false;
|
||||
let sessionResult = false;
|
||||
let activeSynchronisations = 0;
|
||||
let closed = false;
|
||||
const safeResolve = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolve(sessionResult);
|
||||
};
|
||||
const settleClosedSession = () => {
|
||||
if (closed && activeSynchronisations === 0) safeResolve();
|
||||
};
|
||||
const synchronise = async (peerId: string, closeConnection: boolean) => {
|
||||
activeSynchronisations++;
|
||||
try {
|
||||
// Pull first, then push only when the pull succeeds.
|
||||
const pullResult = await replicator.replicateFrom(peerId, showResult);
|
||||
if (!pullResult?.ok) {
|
||||
sessionResult = false;
|
||||
return;
|
||||
}
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
|
||||
sessionResult = pushResult?.ok ?? true;
|
||||
if (sessionResult && closeConnection) await replicator.close();
|
||||
} catch (e) {
|
||||
Logger(
|
||||
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
logLevel
|
||||
);
|
||||
sessionResult = false;
|
||||
} finally {
|
||||
activeSynchronisations--;
|
||||
settleClosedSession();
|
||||
}
|
||||
};
|
||||
const modal = new P2POpenReplicationModal(
|
||||
app,
|
||||
replicator,
|
||||
{
|
||||
onSync: async (peerId: string) => {
|
||||
try {
|
||||
// pull (replicateFrom) first; push only on success
|
||||
const pullResult = await replicator.replicateFrom(peerId, showResult);
|
||||
if (pullResult?.ok) {
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
|
||||
resolve(pushResult?.ok ?? true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger(
|
||||
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
logLevel
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
onSyncAndClose: async (peerId: string) => {
|
||||
try {
|
||||
const pullResult = await replicator.replicateFrom(peerId, showResult);
|
||||
if (pullResult?.ok) {
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
|
||||
if (pushResult?.ok ?? true) {
|
||||
await replicator.close();
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger(
|
||||
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
logLevel
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
onSync: (peerId: string) => synchronise(peerId, false),
|
||||
onSyncAndClose: (peerId: string) => synchronise(peerId, true),
|
||||
},
|
||||
showResult
|
||||
showResult,
|
||||
"P2P Replication",
|
||||
() => {
|
||||
closed = true;
|
||||
settleClosedSession();
|
||||
}
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
@@ -89,27 +91,42 @@ export function createOpenRebuildUI(
|
||||
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
return new Promise<boolean | void>((resolve) => {
|
||||
let resolved = false;
|
||||
let activeSynchronisations = 0;
|
||||
let closed = false;
|
||||
let operationCompleted = false;
|
||||
let sessionResult = false;
|
||||
const safeResolve = (val: boolean) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(val);
|
||||
}
|
||||
};
|
||||
const settleSession = () => {
|
||||
if (activeSynchronisations !== 0) return;
|
||||
if (closed || operationCompleted) safeResolve(sessionResult);
|
||||
};
|
||||
|
||||
const doRebuild = async (peerId: string) => {
|
||||
replicator.setOnSetup();
|
||||
activeSynchronisations++;
|
||||
try {
|
||||
replicator.setOnSetup();
|
||||
Logger(`Rebuilding from peer ${peerId}`, logLevel);
|
||||
const result = await replicator.replicateFrom(peerId, showResult);
|
||||
safeResolve(result?.ok ?? false);
|
||||
sessionResult = result?.ok ?? false;
|
||||
} catch (e) {
|
||||
Logger(
|
||||
`Error in rebuild from ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
logLevel
|
||||
);
|
||||
safeResolve(false);
|
||||
sessionResult = false;
|
||||
} finally {
|
||||
replicator.clearOnSetup();
|
||||
try {
|
||||
replicator.clearOnSetup();
|
||||
} finally {
|
||||
operationCompleted = true;
|
||||
activeSynchronisations--;
|
||||
settleSession();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,7 +139,10 @@ export function createOpenRebuildUI(
|
||||
},
|
||||
showResult,
|
||||
"P2P Rebuild",
|
||||
() => safeResolve(false),
|
||||
() => {
|
||||
closed = true;
|
||||
settleSession();
|
||||
},
|
||||
true
|
||||
);
|
||||
modal.open();
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const modalState = vi.hoisted(() => ({
|
||||
instances: [] as Array<{
|
||||
callback: {
|
||||
onSync: (peerId: string) => Promise<void>;
|
||||
onSyncAndClose: (peerId: string) => Promise<void>;
|
||||
};
|
||||
onClosed?: () => void;
|
||||
open: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({ App: class {} }));
|
||||
|
||||
vi.mock("./P2POpenReplicationModal", () => ({
|
||||
P2POpenReplicationModal: class {
|
||||
callback;
|
||||
onClosed;
|
||||
open = vi.fn();
|
||||
|
||||
constructor(
|
||||
_app: unknown,
|
||||
_replicator: unknown,
|
||||
callback: (typeof modalState.instances)[number]["callback"],
|
||||
_showResult: boolean,
|
||||
_title?: string,
|
||||
onClosed?: () => void
|
||||
) {
|
||||
this.callback = callback;
|
||||
this.onClosed = onClosed;
|
||||
modalState.instances.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { createOpenRebuildUI, createOpenReplicationUI } from "./P2PReplicationUI";
|
||||
|
||||
function createReplicator() {
|
||||
return {
|
||||
replicateFrom: vi.fn(async () => ({ ok: true })),
|
||||
requestSynchroniseToPeer: vi.fn(async () => ({ ok: true })),
|
||||
close: vi.fn(async () => undefined),
|
||||
setOnSetup: vi.fn(),
|
||||
clearOnSetup: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("createOpenReplicationUI", () => {
|
||||
beforeEach(() => {
|
||||
modalState.instances.length = 0;
|
||||
});
|
||||
|
||||
it("settles a cancelled peer-selection session when the modal closes", async () => {
|
||||
const session = createOpenReplicationUI({} as any)(createReplicator())(true);
|
||||
const modal = modalState.instances[0];
|
||||
|
||||
expect(modal.onClosed).toBeTypeOf("function");
|
||||
modal.onClosed?.();
|
||||
|
||||
await expect(session).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("keeps repeated synchronisation inside the session boundary until the modal closes", async () => {
|
||||
const replicator = createReplicator();
|
||||
const session = createOpenReplicationUI({} as any)(replicator)(true);
|
||||
const modal = modalState.instances[0];
|
||||
let settled = false;
|
||||
void session.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await modal.callback.onSync("peer-a");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(settled).toBe(false);
|
||||
await modal.callback.onSync("peer-b");
|
||||
expect(replicator.replicateFrom).toHaveBeenCalledTimes(2);
|
||||
expect(replicator.requestSynchroniseToPeer).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;
|
||||
const replicator = createReplicator();
|
||||
replicator.replicateFrom.mockImplementation(
|
||||
async () =>
|
||||
await new Promise<{ ok: boolean }>((resolve) => {
|
||||
finishPull = resolve;
|
||||
})
|
||||
);
|
||||
const session = createOpenReplicationUI({} as any)(replicator)(true);
|
||||
const modal = modalState.instances[0];
|
||||
let settled = false;
|
||||
void session.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
const synchronisation = modal.callback.onSync("peer-a");
|
||||
modal.onClosed?.();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishPull({ 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)(true);
|
||||
const modal = modalState.instances[0];
|
||||
|
||||
await modal.callback.onSyncAndClose("peer-a");
|
||||
|
||||
expect(replicator.close).toHaveBeenCalledOnce();
|
||||
let settled = false;
|
||||
void session.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
modal.onClosed?.();
|
||||
await expect(session).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOpenRebuildUI", () => {
|
||||
beforeEach(() => {
|
||||
modalState.instances.length = 0;
|
||||
});
|
||||
|
||||
it("waits for an in-flight rebuild when the modal closes", async () => {
|
||||
let finishPull!: (value: { ok: boolean }) => void;
|
||||
const replicator = createReplicator();
|
||||
replicator.replicateFrom.mockImplementation(
|
||||
async () =>
|
||||
await new Promise<{ ok: boolean }>((resolve) => {
|
||||
finishPull = resolve;
|
||||
})
|
||||
);
|
||||
const session = createOpenRebuildUI({} as any)(replicator)(true);
|
||||
const modal = modalState.instances[0];
|
||||
let settled = false;
|
||||
void session.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
const rebuild = modal.callback.onSyncAndClose("peer-a");
|
||||
modal.onClosed?.();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishPull({ ok: true });
|
||||
await rebuild;
|
||||
await expect(session).resolves.toBe(true);
|
||||
expect(replicator.setOnSetup).toHaveBeenCalledOnce();
|
||||
expect(replicator.clearOnSetup).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user