mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-17 10:06:00 +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();
|
||||
});
|
||||
});
|
||||
+1
-1
Submodule src/lib updated: ef1bdf0d07...a58965f9cd
@@ -133,33 +133,41 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
await this.core.rebuilder.$performRebuildDB("localOnly");
|
||||
}
|
||||
if (ret == CHOICE_CLEAN) {
|
||||
const replicator = this.services.replicator.getActiveReplicator();
|
||||
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
||||
this.settings,
|
||||
this.services.API.isMobile(),
|
||||
true
|
||||
);
|
||||
if (typeof remoteDB == "string") {
|
||||
Logger(remoteDB, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
await this.services.replicator.runBoundedRemoteActivity(
|
||||
async () => {
|
||||
const replicator = this.services.replicator.getActiveReplicator();
|
||||
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
||||
this.settings,
|
||||
this.services.API.isMobile(),
|
||||
true
|
||||
);
|
||||
if (typeof remoteDB == "string") {
|
||||
Logger(remoteDB, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.clearCaches();
|
||||
// Perform the synchronisation once.
|
||||
if (await this.core.replicator.openReplication(this.settings, false, showMessage, true)) {
|
||||
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.clearCaches();
|
||||
await this.services.replicator.getActiveReplicator()?.markRemoteResolved(this.settings);
|
||||
Logger("The local database has been cleaned up.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
} else {
|
||||
Logger(
|
||||
"Replication has been cancelled. Please try it again.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.clearCaches();
|
||||
// Perform the synchronisation once.
|
||||
if (await this.core.replicator.openReplication(this.settings, false, showMessage, true)) {
|
||||
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.clearCaches();
|
||||
await this.services.replicator.getActiveReplicator()?.markRemoteResolved(this.settings);
|
||||
Logger(
|
||||
"The local database has been cleaned up.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
} else {
|
||||
Logger(
|
||||
"Replication has been cancelled. Please try it again.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
},
|
||||
{ label: "database-cleanup" }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const chunkMocks = vi.hoisted(() => ({
|
||||
purgeUnreferencedChunks: vi.fn(async (_db: unknown, countOnly: boolean) => (countOnly ? 2 : 0)),
|
||||
balanceChunkPurgedDBs: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@lib/pouchdb/chunks", () => chunkMocks);
|
||||
vi.mock("@lib/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {},
|
||||
}));
|
||||
|
||||
import { LiveSyncCouchDBReplicator } from "@lib/replication/couchdb/LiveSyncReplicator";
|
||||
import { ModuleReplicator } from "./ModuleReplicator";
|
||||
|
||||
describe("ModuleReplicator", () => {
|
||||
@@ -46,3 +58,61 @@ describe("ModuleReplicator", () => {
|
||||
expect(ensurePBKDF2Salt).toHaveBeenCalledWith({}, false, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleReplicator legacy cleanup", () => {
|
||||
it("keeps its finite replication and balancing work inside the shared activity boundary", async () => {
|
||||
const activityFinished = vi.fn();
|
||||
const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => {
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
activityFinished();
|
||||
}
|
||||
});
|
||||
const openReplication = vi.fn(async () => true);
|
||||
const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as any), {
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: {} })),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
});
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
isMobile: vi.fn(() => false),
|
||||
},
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: { addHandler: vi.fn() },
|
||||
},
|
||||
replicator: {
|
||||
getActiveReplicator: vi.fn(() => activeReplicator),
|
||||
runBoundedRemoteActivity,
|
||||
},
|
||||
};
|
||||
const localDatabase = {
|
||||
localDatabase: {},
|
||||
clearCaches: vi.fn(),
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: {},
|
||||
localDatabase,
|
||||
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
|
||||
replicator: { openReplication },
|
||||
} as any;
|
||||
const module = new ModuleReplicator(core);
|
||||
|
||||
await module.cleaned(true);
|
||||
|
||||
expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "database-cleanup",
|
||||
});
|
||||
expect(openReplication).toHaveBeenCalledOnce();
|
||||
expect(openReplication.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
|
||||
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,15 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
|
||||
fireAndForget(async () => {
|
||||
const canReplicate = await this.services.replication.isReplicationReady(false);
|
||||
if (!canReplicate) return;
|
||||
void this.core.replicator.openReplication(this.settings, continuous, false, false);
|
||||
const openReplication = () =>
|
||||
this.core.replicator.openReplication(this.settings, continuous, false, false);
|
||||
if (continuous) {
|
||||
void openReplication();
|
||||
} else {
|
||||
await this.services.replicator.runBoundedRemoteActivity(openReplication, {
|
||||
label: "replication",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ModuleReplicatorCouchDB } from "./ModuleReplicatorCouchDB.ts";
|
||||
|
||||
function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isReplicationReady = true) {
|
||||
const openReplication = vi.fn(async () => true);
|
||||
const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => await task());
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
appLifecycle: {
|
||||
isSuspended: vi.fn(() => false),
|
||||
isReady: vi.fn(() => true),
|
||||
},
|
||||
replication: {
|
||||
isReplicationReady: vi.fn(async () => isReplicationReady),
|
||||
},
|
||||
replicator: {
|
||||
runBoundedRemoteActivity,
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: {
|
||||
remoteType: "",
|
||||
...settings,
|
||||
},
|
||||
replicator: { openReplication },
|
||||
} as any;
|
||||
return {
|
||||
module: new ModuleReplicatorCouchDB(core),
|
||||
openReplication,
|
||||
runBoundedRemoteActivity,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModuleReplicatorCouchDB resume replication activity", () => {
|
||||
it("runs start-up one-shot replication through bounded remote activity", async () => {
|
||||
const { module, openReplication, runBoundedRemoteActivity } = createModule({
|
||||
liveSync: false,
|
||||
syncOnStart: true,
|
||||
});
|
||||
|
||||
await module._everyAfterResumeProcess();
|
||||
|
||||
await vi.waitFor(() => expect(openReplication).toHaveBeenCalledOnce());
|
||||
expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "replication",
|
||||
});
|
||||
expect(openReplication).toHaveBeenCalledWith(expect.any(Object), false, false, false);
|
||||
});
|
||||
|
||||
it("does not treat continuous replication as bounded activity", async () => {
|
||||
const { module, openReplication, runBoundedRemoteActivity } = createModule({
|
||||
liveSync: true,
|
||||
syncOnStart: false,
|
||||
});
|
||||
|
||||
await module._everyAfterResumeProcess();
|
||||
|
||||
await vi.waitFor(() => expect(openReplication).toHaveBeenCalledOnce());
|
||||
expect(runBoundedRemoteActivity).not.toHaveBeenCalled();
|
||||
expect(openReplication).toHaveBeenCalledWith(expect.any(Object), true, false, false);
|
||||
});
|
||||
|
||||
it("does not start a one-shot activity when start-up readiness fails", async () => {
|
||||
const { module, openReplication, runBoundedRemoteActivity } = createModule(
|
||||
{
|
||||
liveSync: false,
|
||||
syncOnStart: true,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
await module._everyAfterResumeProcess();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(runBoundedRemoteActivity).not.toHaveBeenCalled();
|
||||
expect(openReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,54 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
|
||||
hasFocus = true;
|
||||
isLastHidden = false;
|
||||
private boundedRemoteActivityEndHandler?: (value: { readonly value: number }) => unknown;
|
||||
private deferredBoundedLifecycle?: "suspend-if-hidden" | "restart-continuous-if-visible";
|
||||
|
||||
private keepReplicationActiveInBackground() {
|
||||
return (
|
||||
this.settings.keepReplicationActiveInBackground &&
|
||||
(this.settings.liveSync || this.settings.periodicReplication) &&
|
||||
!this.services.API.isMobile()
|
||||
);
|
||||
}
|
||||
|
||||
private async applyDeferredBoundedActivityLifecycle() {
|
||||
const count = this.services.replicator.boundedRemoteActivityCount;
|
||||
if (count.value !== 0) {
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
return;
|
||||
}
|
||||
const deferredLifecycle = this.deferredBoundedLifecycle;
|
||||
this.deferredBoundedLifecycle = undefined;
|
||||
const keepActiveInBackground = this.keepReplicationActiveInBackground();
|
||||
if (deferredLifecycle === "suspend-if-hidden" && activeWindow.document.hidden) {
|
||||
if (!keepActiveInBackground) await this.services.appLifecycle.onSuspending();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
deferredLifecycle === "restart-continuous-if-visible" &&
|
||||
!activeWindow.document.hidden &&
|
||||
keepActiveInBackground &&
|
||||
this.settings.liveSync
|
||||
) {
|
||||
await this.services.appLifecycle.onSuspending();
|
||||
await this.services.appLifecycle.onResuming();
|
||||
await this.services.appLifecycle.onResumed();
|
||||
}
|
||||
}
|
||||
|
||||
private deferLifecycleUntilBoundedRemoteActivityEnds() {
|
||||
if (this.boundedRemoteActivityEndHandler) return;
|
||||
const count = this.services.replicator.boundedRemoteActivityCount;
|
||||
const handler = (value: { readonly value: number }) => {
|
||||
if (value.value !== 0) return;
|
||||
count.offChanged(handler);
|
||||
this.boundedRemoteActivityEndHandler = undefined;
|
||||
fireAndForget(() => this.applyDeferredBoundedActivityLifecycle());
|
||||
};
|
||||
this.boundedRemoteActivityEndHandler = handler;
|
||||
count.onChanged(handler);
|
||||
}
|
||||
|
||||
setHasFocus(hasFocus: boolean) {
|
||||
this.hasFocus = hasFocus;
|
||||
@@ -122,7 +170,19 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
}
|
||||
|
||||
async watchWindowVisibilityAsync() {
|
||||
if (this.settings.suspendFileWatching) return;
|
||||
if (this.settings.suspendFileWatching) {
|
||||
if (
|
||||
this.settings.isConfigured &&
|
||||
this.services.appLifecycle.isReady() &&
|
||||
this.services.replicator.boundedRemoteActivityCount.value > 0
|
||||
) {
|
||||
const isHidden = activeWindow.document.hidden;
|
||||
this.isLastHidden = isHidden;
|
||||
this.deferredBoundedLifecycle = isHidden ? "suspend-if-hidden" : undefined;
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.settings.isConfigured) return;
|
||||
if (!this.services.appLifecycle.isReady()) return;
|
||||
|
||||
@@ -135,6 +195,13 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
if (this.isLastHidden === isHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedRemoteActivityInProgress = this.services.replicator.boundedRemoteActivityCount.value > 0;
|
||||
if (!isHidden && boundedRemoteActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
|
||||
this.isLastHidden = false;
|
||||
this.deferredBoundedLifecycle = undefined;
|
||||
return;
|
||||
}
|
||||
this.isLastHidden = isHidden;
|
||||
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
@@ -144,16 +211,23 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
// modes (LiveSync's continuous replication and Periodic's timer both stall otherwise);
|
||||
// becoming visible reopens normally, and for LiveSync additionally forces a teardown first
|
||||
// (see the resume branch) so a stalled continuous channel is always replaced.
|
||||
const keepActiveInBackground =
|
||||
this.settings.keepReplicationActiveInBackground &&
|
||||
(this.settings.liveSync || this.settings.periodicReplication) &&
|
||||
!this.services.API.isMobile();
|
||||
const keepActiveInBackground = this.keepReplicationActiveInBackground();
|
||||
|
||||
if (isHidden) {
|
||||
if (!keepActiveInBackground) await this.services.appLifecycle.onSuspending();
|
||||
if (boundedRemoteActivityInProgress && !keepActiveInBackground) {
|
||||
this.deferredBoundedLifecycle = "suspend-if-hidden";
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
} else if (!keepActiveInBackground) {
|
||||
await this.services.appLifecycle.onSuspending();
|
||||
}
|
||||
} else {
|
||||
// suspend all temporary.
|
||||
if (this.services.appLifecycle.isSuspended()) return;
|
||||
if (boundedRemoteActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
|
||||
this.deferredBoundedLifecycle = "restart-continuous-if-visible";
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
return;
|
||||
}
|
||||
// Only the continuous (LiveSync) channel can go stalled-but-not-terminated: PouchDB
|
||||
// emits paused/retry while the replicator keeps its AbortController set, so the reopen
|
||||
// below would no-op on exactly the channel that needs replacing. Force a teardown first
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@lib/common/types";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
|
||||
type SetupOptions = {
|
||||
settings?: Partial<typeof DEFAULT_SETTINGS>;
|
||||
@@ -22,6 +23,7 @@ function setup(opts: SetupOptions) {
|
||||
onResumed: vi.fn(async () => true),
|
||||
};
|
||||
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
|
||||
const boundedRemoteActivityCount = reactiveSource(0);
|
||||
|
||||
const core = {
|
||||
_services: {
|
||||
@@ -36,6 +38,7 @@ function setup(opts: SetupOptions) {
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
replicator: { boundedRemoteActivityCount },
|
||||
},
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
@@ -53,7 +56,7 @@ function setup(opts: SetupOptions) {
|
||||
// The handler reads `activeWindow.document.hidden`.
|
||||
(globalThis as any).activeWindow = { document: { hidden: opts.hidden } };
|
||||
|
||||
return { module, appLifecycle, fileProcessing };
|
||||
return { module, appLifecycle, fileProcessing, boundedRemoteActivityCount };
|
||||
}
|
||||
|
||||
describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
|
||||
@@ -81,6 +84,106 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("defers desktop suspension while bounded remote activity is running", async () => {
|
||||
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: false },
|
||||
hidden: true,
|
||||
});
|
||||
boundedRemoteActivityCount.value = 1;
|
||||
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suspends a hidden desktop window after the final bounded remote activity ends", async () => {
|
||||
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: false },
|
||||
hidden: true,
|
||||
});
|
||||
boundedRemoteActivityCount.value = 1;
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
boundedRemoteActivityCount.value = 0;
|
||||
|
||||
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("defers mobile suspension while bounded remote activity is running", async () => {
|
||||
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: false },
|
||||
hidden: true,
|
||||
isMobile: true,
|
||||
});
|
||||
boundedRemoteActivityCount.value = 1;
|
||||
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
|
||||
boundedRemoteActivityCount.value = 0;
|
||||
|
||||
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("records deferred suspension while rebuild file watching is suspended", async () => {
|
||||
const { module, appLifecycle, boundedRemoteActivityCount, fileProcessing } = setup({
|
||||
settings: { suspendFileWatching: true },
|
||||
hidden: true,
|
||||
});
|
||||
boundedRemoteActivityCount.value = 1;
|
||||
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
expect(fileProcessing.commitPendingFileEvents).not.toHaveBeenCalled();
|
||||
|
||||
boundedRemoteActivityCount.value = 0;
|
||||
|
||||
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("resumes after a hidden rebuild finishes and the window becomes visible", async () => {
|
||||
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
|
||||
settings: { suspendFileWatching: true },
|
||||
hidden: true,
|
||||
});
|
||||
boundedRemoteActivityCount.value = 1;
|
||||
|
||||
await module.watchWindowVisibilityAsync();
|
||||
boundedRemoteActivityCount.value = 0;
|
||||
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
|
||||
|
||||
(module.settings as typeof DEFAULT_SETTINGS).suspendFileWatching = false;
|
||||
(globalThis as any).activeWindow.document.hidden = false;
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
|
||||
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not resume when the window becomes visible before deferred suspension runs", async () => {
|
||||
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: false },
|
||||
hidden: true,
|
||||
});
|
||||
boundedRemoteActivityCount.value = 1;
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
(globalThis as any).activeWindow.document.hidden = false;
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
expect(appLifecycle.onResuming).not.toHaveBeenCalled();
|
||||
expect(appLifecycle.onResumed).not.toHaveBeenCalled();
|
||||
|
||||
boundedRemoteActivityCount.value = 0;
|
||||
await Promise.resolve();
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
expect(appLifecycle.onResuming).not.toHaveBeenCalled();
|
||||
expect(appLifecycle.onResumed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forces onSuspending before the resume on becoming visible when enabled (LiveSync teardown)", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: { keepReplicationActiveInBackground: true, liveSync: true },
|
||||
@@ -99,6 +202,30 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
|
||||
);
|
||||
});
|
||||
|
||||
it("defers the LiveSync teardown on becoming visible until bounded remote activity ends", async () => {
|
||||
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
|
||||
settings: { keepReplicationActiveInBackground: true, liveSync: true },
|
||||
hidden: false,
|
||||
isLastHidden: true,
|
||||
});
|
||||
boundedRemoteActivityCount.value = 1;
|
||||
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
expect(appLifecycle.onResuming).not.toHaveBeenCalled();
|
||||
expect(appLifecycle.onResumed).not.toHaveBeenCalled();
|
||||
|
||||
boundedRemoteActivityCount.value = 0;
|
||||
|
||||
await vi.waitFor(() => expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1));
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
|
||||
expect(appLifecycle.onSuspending.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
appLifecycle.onResuming.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it("does not force a teardown on becoming visible by default (setting off)", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: true },
|
||||
|
||||
@@ -31,6 +31,7 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector.ts";
|
||||
import { hasRemoteActivity } from "./RemoteActivityStatus.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { LiveSyncError } from "@lib/common/LSError.ts";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
@@ -152,8 +153,13 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const queueCountLabel = () => queueCountLabelX.value;
|
||||
|
||||
const requestingStatLabel = computed(() => {
|
||||
const diff = this.services.API.requestCount.value - this.services.API.responseCount.value;
|
||||
return diff != 0 ? "📲 " : "";
|
||||
return hasRemoteActivity(
|
||||
this.services.API.requestCount.value,
|
||||
this.services.API.responseCount.value,
|
||||
this.services.replicator.boundedRemoteActivityCount.value
|
||||
)
|
||||
? "📲 "
|
||||
: "";
|
||||
});
|
||||
|
||||
const replicationStatLabel = computed(() => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasRemoteActivity } from "./RemoteActivityStatus.ts";
|
||||
|
||||
describe("hasRemoteActivity", () => {
|
||||
it("preserves the existing HTTP request balance signal", () => {
|
||||
expect(hasRemoteActivity(2, 1, 0)).toBe(true);
|
||||
expect(hasRemoteActivity(2, 2, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports bounded remote activity without an HTTP request imbalance", () => {
|
||||
expect(hasRemoteActivity(2, 2, 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Returns whether the status UI should report HTTP traffic or a finite remote operation in progress. */
|
||||
export function hasRemoteActivity(requestCount: number, responseCount: number, boundedRemoteActivityCount: number) {
|
||||
return requestCount - responseCount !== 0 || boundedRemoteActivityCount !== 0;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { ObsidianAppLifecycleService } from "./ObsidianAppLifecycleService";
|
||||
import { ObsidianPathService } from "./ObsidianPathService";
|
||||
import { ObsidianVaultService } from "./ObsidianVaultService";
|
||||
import { ObsidianUIService } from "./ObsidianUIService";
|
||||
import { createScreenWakeLockManager } from "octagonal-wheels/browser/wakeLock";
|
||||
|
||||
// InjectableServiceHub
|
||||
|
||||
@@ -55,6 +56,11 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
const path = new ObsidianPathService(context, {
|
||||
settingService: setting,
|
||||
});
|
||||
const screenWakeLock = createScreenWakeLockManager();
|
||||
appLifecycle.onUnload.addHandler(async () => {
|
||||
await screenWakeLock.dispose();
|
||||
return true;
|
||||
});
|
||||
const database = new ObsidianDatabaseService(context, {
|
||||
path: path,
|
||||
vault: vault,
|
||||
@@ -74,6 +80,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
settingService: setting,
|
||||
appLifecycleService: appLifecycle,
|
||||
databaseEventService: databaseEvents,
|
||||
activityRunner: screenWakeLock,
|
||||
});
|
||||
const replication = new ObsidianReplicationService(context, {
|
||||
APIService: API,
|
||||
|
||||
@@ -83,6 +83,15 @@ export function useP2PReplicatorUI(
|
||||
}
|
||||
return api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS);
|
||||
};
|
||||
const runOpenReplication = () => {
|
||||
const activeReplicator = replicator.replicator;
|
||||
if (!activeReplicator) return;
|
||||
const settings = host.services.setting.currentSettings();
|
||||
void host.services.replicator.runBoundedRemoteActivity(
|
||||
() => activeReplicator.openReplication(settings, false, true, false),
|
||||
{ label: "replication" }
|
||||
);
|
||||
};
|
||||
api.registerWindow(viewType, factory);
|
||||
api.registerWindow(VIEW_TYPE_P2P_SERVER_STATUS, statusFactory);
|
||||
|
||||
@@ -115,7 +124,7 @@ export function useP2PReplicatorUI(
|
||||
if (settings.remoteType == REMOTE_P2P) return false;
|
||||
return replicator.replicator?.server?.isServing ?? false;
|
||||
}
|
||||
void replicator.replicator?.openReplication(settings, false, true, false);
|
||||
runOpenReplication();
|
||||
},
|
||||
});
|
||||
host.services.API.addCommand({
|
||||
@@ -127,7 +136,7 @@ export function useP2PReplicatorUI(
|
||||
if (settings.remoteType == REMOTE_P2P) return false;
|
||||
return replicator.replicator?.server?.isServing ?? false;
|
||||
}
|
||||
void replicator.replicator?.openReplication(settings, false, true, false);
|
||||
runOpenReplication();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView", () => ({
|
||||
P2PReplicatorPaneView: class {},
|
||||
VIEW_TYPE_P2P: "p2p",
|
||||
}));
|
||||
vi.mock("@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView", () => ({
|
||||
P2PServerStatusPaneView: class {},
|
||||
VIEW_TYPE_P2P_SERVER_STATUS: "p2p-status",
|
||||
}));
|
||||
|
||||
import { useP2PReplicatorUI } from "./useP2PReplicatorUI";
|
||||
|
||||
describe("useP2PReplicatorUI commands", () => {
|
||||
it("runs a direct modal P2P replication command through the shared activity boundary", async () => {
|
||||
const commands: Array<{ id: string; checkCallback?: (isChecking: boolean) => unknown }> = [];
|
||||
let initialise: (() => Promise<unknown>) | undefined;
|
||||
const openReplication = vi.fn(async () => true);
|
||||
const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => await task());
|
||||
const host = {
|
||||
services: {
|
||||
API: {
|
||||
showWindow: vi.fn(async () => undefined),
|
||||
registerWindow: vi.fn(),
|
||||
addCommand: vi.fn((command) => commands.push(command)),
|
||||
addRibbonIcon: vi.fn(),
|
||||
getPlatform: vi.fn(() => "obsidian"),
|
||||
},
|
||||
appLifecycle: {
|
||||
onInitialise: {
|
||||
addHandler: vi.fn((handler) => {
|
||||
initialise = handler;
|
||||
}),
|
||||
},
|
||||
onLayoutReady: { addHandler: vi.fn() },
|
||||
},
|
||||
setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) },
|
||||
replicator: { runBoundedRemoteActivity },
|
||||
},
|
||||
} as any;
|
||||
const p2p = {
|
||||
replicator: {
|
||||
server: { isServing: true },
|
||||
openReplication,
|
||||
replicateFromCommand: vi.fn(),
|
||||
},
|
||||
} as any;
|
||||
|
||||
useP2PReplicatorUI(host, {} as any, p2p);
|
||||
await initialise?.();
|
||||
commands.find((command) => command.id === "replicate-now-by-p2p")?.checkCallback?.(false);
|
||||
|
||||
await vi.waitFor(() => expect(openReplication).toHaveBeenCalledOnce());
|
||||
expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "replication",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user