Protect bounded remote activity across app lifecycle

This commit is contained in:
vorotamoroz
2026-07-15 07:53:16 +00:00
parent 4cbccf85b4
commit 31e9186930
373 changed files with 1275 additions and 466 deletions
+34 -26
View File
@@ -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();
});
});
+9 -1
View File
@@ -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 },
+8 -2
View File
@@ -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,