mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-31 15:57:06 +00:00
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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user