fix: keep replicated document application active

Why:
- Large downloaded batches can outlive the finite remote operation and be suspended before every document reaches the Vault.
- The v1.0 activity contract requires local application to remain distinct from remote-operation reporting.

Changes:
- Add an Obsidian-owned local application activity boundary which reuses Wake Lock policy without incrementing remote activity.
- Retain one boundary through queue processing and the final recovery snapshot, releasing and reacquiring it around suspension.
- Log final snapshot failures, preserve processing results, and cover the lifecycle with focused tests and documentation.
This commit is contained in:
Ouyang Xingyuan
2026-07-31 11:06:18 +08:00
parent c385bd7ce7
commit 0df1f57f3b
8 changed files with 305 additions and 31 deletions
@@ -113,9 +113,20 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
hasFocus = true;
isLastHidden = false;
private boundedRemoteActivityEndHandler?: (value: { readonly value: number }) => unknown;
private boundedActivityEndHandler?: (value: { readonly value: number }) => unknown;
private deferredBoundedLifecycle?: "suspend-if-hidden" | "restart-continuous-if-visible";
private get boundedActivityCounts(): ReactiveSource<number>[] {
const replicator = this.services.replicator as typeof this.services.replicator & {
boundedLocalApplicationActivityCount: ReactiveSource<number>;
};
return [replicator.boundedRemoteActivityCount, replicator.boundedLocalApplicationActivityCount];
}
private hasBoundedActivity() {
return this.boundedActivityCounts.some((count) => count.value > 0);
}
private keepReplicationActiveInBackground() {
return (
this.settings.keepReplicationActiveInBackground &&
@@ -125,9 +136,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
}
private async applyDeferredBoundedActivityLifecycle() {
const count = this.services.replicator.boundedRemoteActivityCount;
if (count.value !== 0) {
this.deferLifecycleUntilBoundedRemoteActivityEnds();
if (this.hasBoundedActivity()) {
this.deferLifecycleUntilBoundedActivityEnds();
return;
}
const deferredLifecycle = this.deferredBoundedLifecycle;
@@ -149,17 +159,17 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
}
}
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;
private deferLifecycleUntilBoundedActivityEnds() {
if (this.boundedActivityEndHandler) return;
const counts = this.boundedActivityCounts;
const handler = () => {
if (this.hasBoundedActivity()) return;
for (const count of counts) count.offChanged(handler);
this.boundedActivityEndHandler = undefined;
fireAndForget(() => this.applyDeferredBoundedActivityLifecycle());
};
this.boundedRemoteActivityEndHandler = handler;
count.onChanged(handler);
this.boundedActivityEndHandler = handler;
for (const count of counts) count.onChanged(handler);
}
setHasFocus(hasFocus: boolean) {
@@ -188,12 +198,12 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
if (
this.settings.isConfigured &&
this.services.appLifecycle.isReady() &&
this.services.replicator.boundedRemoteActivityCount.value > 0
this.hasBoundedActivity()
) {
const isHidden = activeWindow.document.hidden;
this.isLastHidden = isHidden;
this.deferredBoundedLifecycle = isHidden ? "suspend-if-hidden" : undefined;
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
}
return;
}
@@ -210,8 +220,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
return;
}
const boundedRemoteActivityInProgress = this.services.replicator.boundedRemoteActivityCount.value > 0;
if (!isHidden && boundedRemoteActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
const boundedActivityInProgress = this.hasBoundedActivity();
if (!isHidden && boundedActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
this.isLastHidden = false;
this.deferredBoundedLifecycle = undefined;
return;
@@ -228,18 +238,18 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
const keepActiveInBackground = this.keepReplicationActiveInBackground();
if (isHidden) {
if (boundedRemoteActivityInProgress && !keepActiveInBackground) {
if (boundedActivityInProgress && !keepActiveInBackground) {
this.deferredBoundedLifecycle = "suspend-if-hidden";
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
} else if (!keepActiveInBackground) {
await this.services.appLifecycle.onSuspending();
}
} else {
// suspend all temporary.
if (this.services.appLifecycle.isSuspended()) return;
if (boundedRemoteActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
if (boundedActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
this.deferredBoundedLifecycle = "restart-continuous-if-visible";
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
return;
}
// Only the continuous (LiveSync) channel can go stalled-but-not-terminated: PouchDB
@@ -24,6 +24,7 @@ function setup(opts: SetupOptions) {
};
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
const boundedRemoteActivityCount = reactiveSource(0);
const boundedLocalApplicationActivityCount = reactiveSource(0);
const core = {
_services: {
@@ -38,7 +39,7 @@ function setup(opts: SetupOptions) {
setting: { saveSettingData: vi.fn(async () => undefined) },
appLifecycle,
fileProcessing,
replicator: { boundedRemoteActivityCount },
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
},
settings: {
...DEFAULT_SETTINGS,
@@ -56,7 +57,13 @@ function setup(opts: SetupOptions) {
// The handler reads `activeWindow.document.hidden`.
(globalThis as any).activeWindow = { document: { hidden: opts.hidden } };
return { module, appLifecycle, fileProcessing, boundedRemoteActivityCount };
return {
module,
appLifecycle,
fileProcessing,
boundedRemoteActivityCount,
boundedLocalApplicationActivityCount,
};
}
describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
@@ -109,6 +116,21 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
});
it("defers suspension while local document application is active", async () => {
const { module, appLifecycle, boundedLocalApplicationActivityCount } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: false },
hidden: true,
});
boundedLocalApplicationActivityCount.value = 1;
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
boundedLocalApplicationActivityCount.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 },