Merge pull request #949 from AutoraLabs/feat/keep-livesync-active-in-background

feat: opt-in desktop setting to keep replication active in the background
This commit is contained in:
vorotamoroz
2026-06-13 11:14:04 +09:00
committed by GitHub
4 changed files with 210 additions and 3 deletions
+5
View File
@@ -488,6 +488,11 @@ Automatically Sync all files when opening Obsidian.
Setting key: syncAfterMerge
Sync automatically after merging files
#### Keep replication active in the background
Setting key: keepReplicationActiveInBackground
Desktop only; uses more battery and network.
### 3. Update thinning
#### Batch database update
@@ -2,7 +2,7 @@ import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
import { EVENT_FILE_RENAMED, EVENT_LEAF_ACTIVE_CHANGED, eventHub } from "../../common/events.js";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import { scheduleTask } from "octagonal-wheels/concurrency/task";
import { type TFile } from "../../deps.ts";
import type { TFile } from "../../deps.ts";
import { fireAndForget } from "octagonal-wheels/promises";
import { type FilePathWithPrefix } from "../../lib/src/common/types.ts";
import { reactive, reactiveSource, type ReactiveSource } from "octagonal-wheels/dataobject/reactive";
@@ -138,12 +138,38 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
await this.services.fileProcessing.commitPendingFileEvents();
// Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the
// window is hidden, instead of suspending on hide. On hide we skip the suspend for both
// 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();
if (isHidden) {
await this.services.appLifecycle.onSuspending();
if (!keepActiveInBackground) await this.services.appLifecycle.onSuspending();
} else {
// suspend all temporary.
if (this.services.appLifecycle.isSuspended()) return;
// Do not block resume by focus state here; visibility recovery should be enough.
// 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
// so becoming visible always re-establishes a fresh channel (restoring the default's
// reset-on-visibility). Periodic mode has no such channel — its timer just resumes via
// the normal path below — so this teardown is gated on liveSync to avoid needlessly
// bouncing it. The teardown's closeReplication() aborts synchronously while the reopen is
// deferred (fireAndForget + awaited isReplicationReady/initializeDatabaseForReplication),
// so the aborted continuousReplication run (and its shareRunningResult lock) unwinds in
// microtasks before the reopen runs: it neither double-opens nor gets swallowed by the
// still-registered shared run.
if (keepActiveInBackground && this.settings.liveSync) {
await this.services.appLifecycle.onSuspending();
}
// Resume is not gated on focus in this branch, but note the top-of-handler check
// (isLastHidden && !hasFocus) still defers the whole handler when the window becomes
// visible again while unfocused; in that case recovery happens on the next focus.
await this.services.appLifecycle.onResuming();
await this.services.appLifecycle.onResumed();
}
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@lib/common/types";
type SetupOptions = {
settings?: Partial<typeof DEFAULT_SETTINGS>;
hidden: boolean;
isLastHidden?: boolean;
hasFocus?: boolean;
isSuspended?: boolean;
// Platform is read via services.API.isMobile(); default desktop (false) so the feature applies.
isMobile?: boolean;
};
function setup(opts: SetupOptions) {
const appLifecycle = {
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => opts.isSuspended ?? false),
onSuspending: vi.fn(async () => true),
onResuming: vi.fn(async () => true),
onResumed: vi.fn(async () => true),
};
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
const core = {
_services: {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
isMobile: vi.fn(() => opts.isMobile ?? false),
},
setting: { saveSettingData: vi.fn(async () => undefined) },
appLifecycle,
fileProcessing,
},
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
isConfigured: true,
...opts.settings,
},
} as any;
Object.defineProperty(core, "services", { get: () => core._services });
const module = new ModuleObsidianEvents({} as any, core);
module.isLastHidden = opts.isLastHidden ?? false;
module.hasFocus = opts.hasFocus ?? true;
// The handler reads `activeWindow.document.hidden`.
(globalThis as any).activeWindow = { document: { hidden: opts.hidden } };
return { module, appLifecycle, fileProcessing };
}
describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
afterEach(() => {
// The handler reads a global `activeWindow`; clear it so it doesn't leak into sibling spec
// files running in the same worker.
delete (globalThis as any).activeWindow;
});
it("does NOT suspend on hide when enabled in LiveSync mode on the desktop app", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
});
it("suspends on hide by default (setting off)", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: true },
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
it("forces onSuspending before the resume on becoming visible when enabled (LiveSync teardown)", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
hidden: false,
isLastHidden: true, // hidden -> visible transition
});
await module.watchWindowVisibilityAsync();
// Decision-logic only: on visible + enabled + LiveSync the handler calls onSuspending (the
// forced teardown) before onResuming. The actual stalled-channel replacement is exercised by
// the manual integration test, not here.
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
expect(appLifecycle.onResumed).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 },
hidden: false,
isLastHidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
});
it("does not apply in On-Events mode even if the flag is set (no scope leak)", async () => {
const { module, appLifecycle } = setup({
settings: {
keepReplicationActiveInBackground: true,
liveSync: false,
periodicReplication: false,
},
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
it("does NOT suspend on hide when enabled in Periodic mode (the periodic timer also stalls otherwise)", async () => {
const { module, appLifecycle } = setup({
settings: {
keepReplicationActiveInBackground: true,
liveSync: false,
periodicReplication: true,
},
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
});
it("does NOT force a teardown on becoming visible in Periodic mode (only the continuous channel can stall)", async () => {
const { module, appLifecycle } = setup({
settings: {
keepReplicationActiveInBackground: true,
liveSync: false,
periodicReplication: true,
},
hidden: false,
isLastHidden: true,
});
await module.watchWindowVisibilityAsync();
// The teardown is gated on liveSync: a periodic timer doesn't go half-open, so bouncing it
// on every restore would be needless churn. Resume still runs normally.
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
});
it("does not apply on mobile even if the flag is set", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
hidden: true,
isMobile: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
});
@@ -189,6 +189,16 @@ export function paneSyncSettings(
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncOnFileOpen", { onUpdate: onlyOnNonLiveSync });
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncOnStart", { onUpdate: onlyOnNonLiveSync });
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncAfterMerge", { onUpdate: onlyOnNonLiveSync });
// Desktop app only, and only for the sync modes that keep a background replication channel
// (LiveSync and Periodic). Ignored on mobile, where suspending preserves battery. The
// visibility predicate mirrors the runtime guard in ModuleObsidianEvents.
if (!this.services.API.isMobile()) {
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("keepReplicationActiveInBackground", {
onUpdate: visibleOnly(
() => this.isConfiguredAs("syncMode", "LIVESYNC") || this.isConfiguredAs("syncMode", "PERIODIC")
),
});
}
});
void addPanel(