feat(sync): add one background full-sync scheduler (#9078)

* feat(sync): add one observable busy definition for sync work

Task 3 needs a single busy/idle definition so callers stop polling three
signals independently and disagreeing about what "busy" means. Two of the
three were only readable point-in-time: SyncCycleGuard.isActive is a plain
field and isEncryptionOperationInProgress is a getter, so a consumer could
ask "busy now?" but never be told when that stopped being true. No
behaviour change; nothing consumes SyncBusyService yet.

SyncCycleGuard now backs its state with a subject and exposes isActive$,
emitting on BOTH transitions. Claim-visibility is the load-bearing part:
the side channels (immediate upload, WS download) claim a cycle without
touching any other sync signal, so a release-only edge would report those
cycles as idle for their entire duration — exactly the coverage the guard
is in the union for. A spec pins that case.

released$ remains as sugar for the release edge, derived via pairwise() so
an idle guard cannot fire a phantom release at subscribe time. Both it and
isActive$ are re-check HINTS, not lock hand-offs: the guard's no-deadlock
property rests on callers never waiting on it, so the docblock states the
emission carries no claim and subscribers must still win tryBegin(). A
spec asserts two subscribers racing one edge get [true, false].

SyncBusyService merges the guard's activity with the wrapper's sync and
encryption signals and recomputes the whole union on every edge, so no
interleaving can desynchronise it. That ordering is load-bearing: sync()'s
finally clears isSyncInProgress$ while the guard is still held, and only
the guard's release resolves the union to idle.

Provider SYNCING is deliberately excluded — it is presentation state set
only by sync(), strictly narrower than the guard, and would be a fourth
lock disagreeing with the other three.

* feat(sync): add a config epoch for revalidating deferred sync work

Task 3 defers background sync work while other work is in flight, so a
request can outlive the target it was made against: a queued sync must not
run after the user switches provider, moves the folder, or signs out.
Add the monotonic in-tab counter that deferred work will capture and
revalidate before I/O. No behaviour change; nothing reads it yet.

Bumped on all four authoritative transitions: any provider-config write,
a target move asserted by a bypass ingress, an active provider switch, and
a credential revoke.

The last two are the reason this is a counter on the manager rather than a
projection of providerConfigChanged$: NEITHER emits on that stream. A
provider switch deliberately does not (switches are handled via
getLastSyncedProviderId -> forceFromSeq0) and clearAuthCredentials never
did, so an epoch derived from that stream alone would silently miss both —
including provider switch, which Task 3's acceptance criteria require to
invalidate a queued request. Specs pin both, asserting the switch case
bumps while providerConfigChanged$ stays silent.

Machine-only OAuth token refresh for an unchanged account still does NOT
bump: it goes through the credential store and moves no target, so
invalidating healthy queued work there would be a regression.

Follows the existing _activeProviderSetupId shape (in-tab, not persisted,
not cross-tab). Documented as a staleness heuristic, never a security
input — callers compare epochs, never configuration.

* feat(sync): add the background full-sync scheduler

Background triggers (interval, resume, visibility, settle) call sync()
behind an exhaustMap, which DROPS any trigger arriving while a sync runs —
the work it asked for is lost until something else happens to trigger
again. Add the single owner of generic pending background work: a burst
collapses into at most one pending rerun, which drains once the current
work settles. Dormant; nothing routes through it yet (that is the next
tranche, where the behaviour actually changes).

request() is fire-and-forget with no result and no failure taxonomy.
sync() resolves with the truthy string 'HANDLED_ERROR' on handled failure,
so its result cannot be truth-tested; the scheduler reads it not at all —
settled success and settled failure release identical state, and
source-specific retry stays with the source.

Deferral is the whole point, and a deferral is exactly the window in which
the user switches provider, moves the folder, or signs out. So a request
captures {configEpoch, providerId} and revalidates before EVERY leading or
trailing run, not only at request() time. A stale request is DROPPED, not
retargeted: the trigger that wanted target A has no opinion about target B,
and a live trigger will ask again.

It never calls sync() while anything else is active — that call would only
bounce off the cycle guard and return HANDLED_ERROR, silently burning the
request — and never before the awaited initial path opens the gate, so it
cannot start a shadow initial sync.

TWO wake-ups are required, and this is the subtle part. sync()'s finally
releases the busy signals BEFORE SyncEffects flips the gate in its .then().
A scheduler waking only on the busy edge therefore finds the gate still
shut, returns, and strands the request forever — the session's first
background sync would silently never run. Verified by removing the gate
wake-up: exactly that scenario fails.

Also exposes SyncTriggerService.initialSyncGateOpen$, the observable mirror
of isInitialSyncDoneSync() (setInitialSyncDone is the sole writer of both).
It deliberately excludes the MAX_WAIT_FOR_INITIAL_SYNC failsafe that the
existing public observables merge in: that timer keeps the UI from hanging
on a sync that never lands, which is right for rendering and wrong here —
it would open the gate on a schedule rather than on the initial sync having
actually completed.

* feat(sync): route background sync triggers through the scheduler

Split the dynamic background branch out of triggerSync$ into
scheduleBackgroundSync$, which calls scheduler.request() instead of
sync(). Interval, resume, visibility, idle/activity, online-regained and
the trailing settle timer now go through the scheduler; initial,
after-enable and before-close stay directly awaited, as do the ~14
explicit user/manual sync callers.

This is the behaviour change the previous three commits were seams for: a
background trigger arriving while a sync is in flight used to be DROPPED by
the shared exhaustMap, losing the work it asked for until something else
happened to trigger again. It is now deferred and drained.

exhaustMap's cross-source protection is not lost but upgraded. It stays on
triggerSync$, where it now guards only initial vs after-enable. Background
exclusion becomes the scheduler's busy check plus SyncCycleGuard.tryBegin()
— which was always the real authority; exhaustMap was a coarse
approximation that could only drop, never defer.

Kept deliberately unchanged so the split does not quietly alter behaviour
as a side effect: the 2s throttle (the scheduler collapses bursts anyway,
but this preserves the pre-existing rate ceiling), the E2E auto-sync kill
switch, and the offline skip.

Incidental correctness win: the ungated setInitialSyncDone(true) in the
catch used to mean ANY failing background sync — hours into a session —
flipped the initial gate. Only initial/after-enable reach that catch now,
so the gate is opened solely by the paths that own it.

Also drops SyncCycleGuard.released$, which had zero consumers: it was built
for Task 4 and is speculative until something needs it. isActive$ is what
the busy union actually consumes, and its edge tests move with it.

* test(sync): make SyncEffects testable and cover the trigger routing

The routing split was the whole behaviour change of this branch and had
NO test coverage: sync.effects.spec.ts is 61 lines testing constants, so
the split broke no test because no test was watching. Two things blocked
covering it, both fixed here.

1. createEffect stamps a non-configurable marker onto whatever object the
   factory returns, and syncBeforeQuit$ returned the shared EMPTY singleton
   when not on Electron. The first construction branded EMPTY process-wide,
   so every later one died with "Cannot redefine property
   __@ngrx/effects_create__" — the class could not be built twice in one
   suite. This, not the Dropbox SDK the spec header blames, is why it never
   had behavioural tests. defer(() => EMPTY) hands over a fresh instance per
   construction; subscribed behaviour is unchanged.

2. isOnline$ is a module-level shareReplay(1) whose startWith(navigator.onLine)
   captures its initial value at import time, and headless Chrome reports
   navigator.onLine === false. Anything gated on it is permanently offline
   under test with no seam to override, so an online-gated assertion would
   pass while proving nothing. Add an IS_ONLINE$ token whose default factory
   returns that same observable — no runtime change, just somewhere for a
   spec to provide a fake.

The 8 new tests pin the acceptance criterion: a background trigger reaches
the scheduler and does NOT call sync(); the settle branch routes even though
it emits null rather than a trigger name (so routing cannot depend on the
emitted value); offline and the E2E kill switch suppress it; and the initial
sync still calls sync() directly, bypassing the scheduler, with only that
path flipping the initial gate.

* fix(sync): stop the scheduler running syncs back-to-back

Two real bugs, both found after the branch looked finished, neither by
reasoning: the first by wiring the real guard and busy service to the real
scheduler, the second by adversarial review. The full unit suite was green
throughout — it was never evidence for either.

1. Re-entrancy. The scheduler drained SYNCHRONOUSLY on the busy edge, and
   that edge fires from SyncCycleGuard.end(), which sync() calls inside its
   own finally. So the next sync started part-way through the previous
   one's teardown, and the wrapper's SYNCING safeguard — which runs two
   lines later — then saw the NEW sync's status and reset it to
   UNKNOWN_OR_CHANGED. Provider status is still a live exclusion gate for
   the immediate-upload side channel, so that is not cosmetic. Wake-ups now
   drain on a microtask, letting the finishing cycle unwind first.

2. A permanent back-to-back sync loop. exhaustMap was the ONLY thing
   bounding the sync RATE; throttleTime(2000) bounds the TRIGGER rate and
   was never the binding constraint, so the previous commit's claim that
   keeping it "preserves the pre-existing rate ceiling" was simply wrong.
   I_INTERVAL_TIMER is self-sustaining and independent of sync activity and
   is not clamped by SYNC_MIN_INTERVAL, so whenever a sync outlasts
   syncInterval — a 90s WebDAV sync on a 60s interval is ordinary, since
   getFileRev is a full GET rather than a cheap ETag — every tick lands
   mid-sync and, once triggers are deferred instead of dropped, drains the
   instant the previous run settles. Idle time goes from ~25% to zero,
   permanently.

   The wasted I/O is the lesser harm. sync() opens the hydration window and
   closes it in its finally, so with no gap between runs isInSyncWindow is
   effectively always true and skipDuringSyncWindow() would suppress
   TODAY_TAG repair and day-change effects INDEFINITELY.

   Add a duty-cycle floor: record the settle time and re-arm a timer rather
   than run when less than SYNC_MIN_INTERVAL has passed. It lives in the
   scheduler because only the scheduler can see run duration. The
   defer-don't-drop win is unaffected — the trailing run still happens, just
   not immediately.

Both fixes are pinned by specs that fail without them.

Also corrects initialSyncGateOpen$'s docblock, which claimed to exclude the
MAX_WAIT_FOR_INITIAL_SYNC failsafe. It does not: the 8s failsafe timers call
setInitialSyncDone(true) from a tap, writing the very subject the gate reads.
I had verified setInitialSyncDone was the sole writer of the subject and
never checked who calls it. The gate therefore means "the gate is open", not
"the initial sync landed" — harm is low since both paths call the same
sync(), but Task 4+ must not build a stronger invariant on it.

* fix(sync): make the duty-cycle floor monotonic and cross-source

Three more real bugs, all found by a second adversarial review of the
already-"finished" branch. None were found by reasoning about the code.

1. A backward wall-clock jump stalled ALL background sync. _lastSettleAt
   used Date.now(), so an NTP correction after Android Doze / Electron
   resume / a VM restore made the elapsed calculation negative and armed a
   timer of `floor + jump` — a one-hour correction armed a one-hour timer,
   and _armSpacingTimer early-returns while one exists, so nothing shortened
   it. Background sync is now the only automatic sync path, so that is a
   total stall. Clamping the delay would NOT have fixed it: each retry
   recomputes the same negative elapsed and re-arms, stalling just as hard
   in a loop. Use performance.now(), which cannot go backwards.

2. The floor only spaced the scheduler against ITSELF. _lastSettleAt was
   written solely inside our own run, so initial, manual, after-enable, quit
   and forceUpload syncs settled without touching it. Consequences: the
   session's first background sync ran back-to-back with the initial sync
   with zero gap — exactly the "blur right after initial sync" case the old
   shared throttleTime guarded, which the effect split dissolved — and a
   trigger deferred during the before-close sync started fresh I/O at the
   instant the window closed, turning a rare crash-mid-sync into a routine
   one. Stamp on any busy->idle transition, so the floor means "5s idle
   after ANY sync work".

3. Fixing (2) naively introduced a third bug, caught by our own spec:
   isBusy$ replays `false` to every subscriber, so filtering on the value
   stamped _lastSettleAt at construction and delayed the session's first
   background sync by the whole floor. pairwise() makes it a real
   transition.

The specs drive performance.now() explicitly — jasmine's mockDate fakes
Date, not the monotonic clock, so the floor would otherwise read real time
and the coverage would be silently meaningless.

Known and deliberate: the floor narrows the skipDuringSyncWindow idle
window versus master (~25% -> ~5% in the slow-provider case), since
SYNC_MIN_INTERVAL is 5s. skipDuringSyncWindow is a drop-filter, so the idle
FRACTION is what matters to TODAY_TAG repair and day-change effects. Still
strictly better than the unbounded loop it replaces, but it is a tradeoff,
not a win, and is worth revisiting with a provider-aware floor.
This commit is contained in:
Johannes Millan 2026-07-16 21:32:18 +02:00 committed by GitHub
parent 6da578aa8d
commit aa94abd890
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1751 additions and 26 deletions

View file

@ -0,0 +1,197 @@
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject, ReplaySubject } from 'rxjs';
import { BackgroundSyncSchedulerService } from './background-sync-scheduler.service';
import { SyncBusyService } from './sync-busy.service';
import { SyncTriggerService } from './sync-trigger.service';
import { SyncWrapperService } from './sync-wrapper.service';
import { SyncCycleGuardService } from '../../op-log/sync/sync-cycle-guard.service';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import { SYNC_MIN_INTERVAL } from './sync.const';
/**
* The scheduler's own spec fakes SyncBusyService, so it proves the state machine
* against a fake's emission behaviour rather than the real one. This wires the
* REAL SyncCycleGuardService and REAL SyncBusyService to the REAL scheduler and
* fakes only SyncWrapperService (whose sync() would otherwise drag in the whole
* stack).
*
* The seam under test is the one that would fail silently: guard release
* busy union recompute scheduler drain. If any link does not emit the way the
* scheduler assumes, deferred work is never picked up the background sync
* simply stops happening, with nothing throwing and no unit test failing.
*/
describe('BackgroundSyncScheduler + SyncBusyService + SyncCycleGuard (integration)', () => {
let scheduler: BackgroundSyncSchedulerService;
let guard: SyncCycleGuardService;
let sync: jasmine.Spy<() => Promise<string>>;
let isSyncInProgress$: BehaviorSubject<boolean>;
let isEncryption$: BehaviorSubject<boolean>;
const flush = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
};
/**
* The duty-cycle floor spaces a background sync against ANY sync work, so
* every drain behind a real cycle release must clear it. It reads
* `performance.now()` (monotonic), which jasmine's clock does not fake.
*/
let fakeNow: number;
const passFloor = async (): Promise<void> => {
fakeNow += SYNC_MIN_INTERVAL + 1;
jasmine.clock().tick(SYNC_MIN_INTERVAL + 1);
await flush();
};
beforeEach(() => {
jasmine.clock().install();
fakeNow = 10_000;
spyOn(performance, 'now').and.callFake(() => fakeNow);
isSyncInProgress$ = new BehaviorSubject(false);
isEncryption$ = new BehaviorSubject(false);
sync = jasmine.createSpy('sync').and.resolveTo('InSync');
const gate$ = new ReplaySubject<boolean>(1);
gate$.next(true);
TestBed.configureTestingModule({
providers: [
BackgroundSyncSchedulerService,
// The real collaborators — this is the point of the test.
SyncBusyService,
SyncCycleGuardService,
{
provide: SyncWrapperService,
useValue: {
sync,
isSyncInProgress$: isSyncInProgress$.asObservable(),
isEncryptionOperationInProgress$: isEncryption$.asObservable(),
get isEncryptionOperationInProgress(): boolean {
return isEncryption$.getValue();
},
isSyncInProgressSync: () => isSyncInProgress$.getValue(),
},
},
{
provide: SyncTriggerService,
useValue: {
initialSyncGateOpen$: gate$.asObservable(),
isInitialSyncDoneSync: () => true,
},
},
{
provide: SyncProviderManager,
useValue: {
configEpoch: 1,
getActiveProvider: () => ({ id: SyncProviderId.WebDAV }),
},
},
],
});
guard = TestBed.inject(SyncCycleGuardService);
scheduler = TestBed.inject(BackgroundSyncSchedulerService);
});
afterEach(() => jasmine.clock().uninstall());
it('defers while a real cycle is held, and drains on the real release', async () => {
// The central claim of the whole branch, end to end through the real busy
// union: a trigger arriving during other sync work is deferred, not dropped.
expect(guard.tryBegin()).toBeTrue();
scheduler.request();
await flush();
expect(sync).not.toHaveBeenCalled();
guard.end();
await passFloor();
expect(sync).toHaveBeenCalledTimes(1);
});
it('drains a request deferred behind a side-channel cycle that touches no other signal', async () => {
// Immediate upload / WS download claim the guard WITHOUT setting
// isSyncInProgress$. This is the case a release-only busy edge got wrong:
// the union must see the cycle at all, then see it end.
guard.tryBegin();
scheduler.request();
await flush();
expect(sync).not.toHaveBeenCalled();
guard.end();
await passFloor();
expect(sync).toHaveBeenCalledTimes(1);
});
it("mirrors sync()'s finally ordering: idle only after the guard releases", async () => {
// sync() clears isSyncInProgress$ BEFORE calling guard.end(). If the union
// resolved to idle on the first of those, the scheduler would start a second
// sync while the cycle was still held and tryBegin() would refuse it.
guard.tryBegin();
isSyncInProgress$.next(true);
scheduler.request();
await flush();
expect(sync).not.toHaveBeenCalled();
isSyncInProgress$.next(false);
await flush();
expect(sync).not.toHaveBeenCalled();
guard.end();
await passFloor();
expect(sync).toHaveBeenCalledTimes(1);
});
it('defers behind a standalone encryption operation (forceUpload holds no cycle here)', async () => {
// isSyncInProgress$ does not span forceUpload; only the encryption flag does.
isEncryption$.next(true);
scheduler.request();
await flush();
expect(sync).not.toHaveBeenCalled();
isEncryption$.next(false);
await passFloor();
expect(sync).toHaveBeenCalledTimes(1);
});
it('collapses a burst arriving during a real cycle into one drain', async () => {
guard.tryBegin();
scheduler.request();
scheduler.request();
scheduler.request();
await flush();
guard.end();
await passFloor();
expect(sync).toHaveBeenCalledTimes(1);
});
it('does not run when the real cycle is re-claimed before the drain', async () => {
// Another flow wins the cycle in the same turn as the release. The
// scheduler must not sync — it would only bounce off tryBegin().
guard.tryBegin();
scheduler.request();
await flush();
guard.end();
guard.tryBegin();
await passFloor();
expect(sync).not.toHaveBeenCalled();
guard.end();
await passFloor();
expect(sync).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,444 @@
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject, ReplaySubject } from 'rxjs';
import { BackgroundSyncSchedulerService } from './background-sync-scheduler.service';
import { SyncBusyService } from './sync-busy.service';
import { SyncTriggerService } from './sync-trigger.service';
import { SyncWrapperService } from './sync-wrapper.service';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import { SYNC_MIN_INTERVAL } from './sync.const';
class FakeBusy {
private _isBusy$ = new BehaviorSubject(false);
isBusy$ = this._isBusy$.asObservable();
get isBusy(): boolean {
return this._isBusy$.getValue();
}
set(v: boolean): void {
this._isBusy$.next(v);
}
}
class FakeTrigger {
private _gate$ = new ReplaySubject<boolean>(1);
private _isDone = false;
initialSyncGateOpen$ = this._gate$.asObservable();
isInitialSyncDoneSync(): boolean {
return this._isDone;
}
setInitialSyncDone(v: boolean): void {
this._isDone = v;
this._gate$.next(v);
}
}
class FakeProviderManager {
configEpoch = 1;
private _active: { id: SyncProviderId } | null = { id: SyncProviderId.WebDAV };
getActiveProvider(): { id: SyncProviderId } | null {
return this._active;
}
setActive(id: SyncProviderId | null): void {
this._active = id ? { id } : null;
}
}
describe('BackgroundSyncSchedulerService', () => {
let scheduler: BackgroundSyncSchedulerService;
let busy: FakeBusy;
let trigger: FakeTrigger;
let providerManager: FakeProviderManager;
let sync: jasmine.Spy<() => Promise<string>>;
/** Lets queued microtasks (the drain chain) run to completion. */
const flush = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
};
/**
* The duty-cycle floor reads `performance.now()` (monotonic a wall-clock
* jump must not strand it), which jasmine's mockDate does NOT fake. Drive it
* explicitly and keep the timer clock in step.
*/
let fakeNow: number;
const advance = (ms: number): void => {
fakeNow += ms;
jasmine.clock().tick(ms);
};
/** Advances past the duty-cycle floor so a deferred trailing run may proceed. */
const passFloor = async (): Promise<void> => {
advance(SYNC_MIN_INTERVAL + 1);
await flush();
};
beforeEach(() => {
jasmine.clock().install();
fakeNow = 10_000;
spyOn(performance, 'now').and.callFake(() => fakeNow);
busy = new FakeBusy();
trigger = new FakeTrigger();
providerManager = new FakeProviderManager();
sync = jasmine.createSpy('sync').and.resolveTo('InSync');
TestBed.configureTestingModule({
providers: [
BackgroundSyncSchedulerService,
{ provide: SyncBusyService, useValue: busy },
{ provide: SyncTriggerService, useValue: trigger },
{ provide: SyncProviderManager, useValue: providerManager },
{ provide: SyncWrapperService, useValue: { sync } },
],
});
scheduler = TestBed.inject(BackgroundSyncSchedulerService);
// Default: past the initial gate, nothing running.
trigger.setInitialSyncDone(true);
});
afterEach(() => jasmine.clock().uninstall());
describe('idle', () => {
it('runs one sync for one request', async () => {
scheduler.request();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
});
it('does not sync without a request', async () => {
await flush();
expect(sync).not.toHaveBeenCalled();
});
});
describe('bursts', () => {
it('collapses a burst arriving while idle into a single leading run', async () => {
// The drain is synchronous up to the sync() call, so the first request
// starts running and the rest collapse into the one pending slot.
scheduler.request();
scheduler.request();
scheduler.request();
scheduler.request();
await flush();
await passFloor();
// One leading run + exactly one trailing rerun for the collapsed burst.
expect(sync).toHaveBeenCalledTimes(2);
});
it('has at most one pending rerun for fifty triggers during a run', async () => {
let release!: () => void;
sync.and.returnValue(
new Promise<string>((resolve) => {
release = () => resolve('InSync');
}),
);
scheduler.request();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
for (let i = 0; i < 50; i++) {
scheduler.request();
}
sync.and.resolveTo('InSync');
release();
await flush();
await passFloor();
expect(sync).toHaveBeenCalledTimes(2);
});
});
describe('duty-cycle floor', () => {
it('does not run a trailing sync back-to-back with the one that just settled', async () => {
// Deferring instead of dropping removed the only bound on the SYNC rate
// (exhaustMap). When a sync outlasts syncInterval, every tick lands
// mid-sync and would drain the instant the previous settled — a permanent
// loop with no idle gap, in which skipDuringSyncWindow() would suppress
// TODAY_TAG repair and day-change effects indefinitely.
scheduler.request();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
// A tick that arrived during the run must not drain immediately.
scheduler.request();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
});
it('runs the deferred trailing sync once the floor has elapsed', async () => {
scheduler.request();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
scheduler.request();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
await passFloor();
expect(sync).toHaveBeenCalledTimes(2);
});
it('never spaces the first request of the session', async () => {
scheduler.request();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
});
});
describe('external busy', () => {
it('does not call sync() while other work is active', async () => {
// A sync() call here would only bounce off the cycle guard and return
// HANDLED_ERROR, silently burning the request.
busy.set(true);
scheduler.request();
await flush();
expect(sync).not.toHaveBeenCalled();
});
it('drains once the external work settles', async () => {
busy.set(true);
scheduler.request();
await flush();
expect(sync).not.toHaveBeenCalled();
busy.set(false);
await flush();
// The floor spaces us against the foreign work that just settled, not only
// against our own runs.
expect(sync).not.toHaveBeenCalled();
await passFloor();
expect(sync).toHaveBeenCalledTimes(1);
});
it('collapses requests made while busy into one drain', async () => {
busy.set(true);
scheduler.request();
scheduler.request();
scheduler.request();
await flush();
busy.set(false);
await passFloor();
expect(sync).toHaveBeenCalledTimes(1);
});
});
describe('initial gate', () => {
it('does not start a shadow sync before the gate opens', async () => {
trigger.setInitialSyncDone(false);
scheduler.request();
await flush();
expect(sync).not.toHaveBeenCalled();
});
it('drains once the awaited initial path opens the gate', async () => {
trigger.setInitialSyncDone(false);
scheduler.request();
await flush();
trigger.setInitialSyncDone(true);
await flush();
expect(sync).toHaveBeenCalledTimes(1);
});
it('drains a pre-gate request even though busy fell BEFORE the gate opened', async () => {
// The real ordering: sync()'s finally releases the busy signals, and only
// then does SyncEffects flip the gate in its .then(). A scheduler waking
// only on the busy edge finds the gate shut, returns, and strands the
// request forever — the first background sync of the session never runs.
trigger.setInitialSyncDone(false);
busy.set(true);
scheduler.request();
await flush();
busy.set(false);
await flush();
expect(sync).not.toHaveBeenCalled();
trigger.setInitialSyncDone(true);
await passFloor();
expect(sync).toHaveBeenCalledTimes(1);
});
});
describe('staleness', () => {
it('drops a request whose config epoch moved before it drained', async () => {
busy.set(true);
scheduler.request();
await flush();
providerManager.configEpoch++;
busy.set(false);
await flush();
expect(sync).not.toHaveBeenCalled();
});
it('drops a request whose provider was switched before it drained', async () => {
busy.set(true);
scheduler.request();
await flush();
providerManager.setActive(SyncProviderId.Dropbox);
busy.set(false);
await flush();
expect(sync).not.toHaveBeenCalled();
});
it('drops rather than retargets — a later current request still runs', async () => {
busy.set(true);
scheduler.request();
await flush();
providerManager.configEpoch++;
busy.set(false);
await flush();
expect(sync).not.toHaveBeenCalled();
// A live trigger asks again against the new target.
scheduler.request();
await passFloor();
expect(sync).toHaveBeenCalledTimes(1);
});
it('revalidates before the TRAILING run, not only at request() time', async () => {
let release!: () => void;
sync.and.returnValue(
new Promise<string>((resolve) => {
release = () => resolve('InSync');
}),
);
scheduler.request();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
// Queued while current, invalidated mid-run.
scheduler.request();
providerManager.configEpoch++;
sync.and.resolveTo('InSync');
release();
await flush();
// The trailing run must not proceed against the new target.
expect(sync).toHaveBeenCalledTimes(1);
});
});
describe('failure', () => {
it('releases state after a rejected sync and can run again', async () => {
sync.and.rejectWith(new Error('network gone'));
scheduler.request();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
sync.and.resolveTo('InSync');
scheduler.request();
await flush();
await passFloor();
expect(sync).toHaveBeenCalledTimes(2);
});
it('honours dirty once after a failure', async () => {
let reject!: (e: Error) => void;
sync.and.returnValue(
new Promise<string>((_resolve, rej) => {
reject = rej;
}),
);
scheduler.request();
await flush();
scheduler.request();
sync.and.resolveTo('InSync');
reject(new Error('boom'));
await flush();
await passFloor();
expect(sync).toHaveBeenCalledTimes(2);
});
it('treats HANDLED_ERROR as a settled attempt, not a success to retry', async () => {
// 'HANDLED_ERROR' is a truthy string; a naive truthiness check reads it as
// success. Either way the scheduler releases state and does not retry on
// its own — retry policy belongs to the source.
sync.and.resolveTo('HANDLED_ERROR');
scheduler.request();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
});
it('does not leak an unhandled rejection out of request()', async () => {
sync.and.rejectWith(new Error('boom'));
expect(() => scheduler.request()).not.toThrow();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
});
});
describe('settled$', () => {
it('emits after a successful run', async () => {
const seen: number[] = [];
scheduler.settled$.subscribe(() => seen.push(1));
scheduler.request();
await flush();
expect(seen.length).toBe(1);
});
it('emits after a failed run too', async () => {
sync.and.rejectWith(new Error('boom'));
const seen: number[] = [];
scheduler.settled$.subscribe(() => seen.push(1));
scheduler.request();
await flush();
expect(seen.length).toBe(1);
});
it('does not emit for a dropped stale request (no attempt was made)', async () => {
const seen: number[] = [];
scheduler.settled$.subscribe(() => seen.push(1));
busy.set(true);
scheduler.request();
await flush();
providerManager.configEpoch++;
busy.set(false);
await flush();
expect(seen).toEqual([]);
});
});
});

View file

@ -0,0 +1,263 @@
import { DestroyRef, inject, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter, Observable, pairwise, Subject } from 'rxjs';
import { SyncWrapperService } from './sync-wrapper.service';
import { SyncBusyService } from './sync-busy.service';
import { SyncTriggerService } from './sync-trigger.service';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import { SYNC_MIN_INTERVAL } from './sync.const';
import { SyncLog } from '../../core/log';
/** What a request was made against, revalidated before every run. */
interface PendingRequest {
configEpoch: number;
providerId: SyncProviderId | null;
}
/**
* The single owner of generic pending background sync work.
*
* ## Why
*
* Background triggers (interval, resume, visibility, settle) previously called
* `sync()` directly behind an `exhaustMap`, which DROPS a trigger that arrives
* while a sync is running. The work it asked for is simply lost until something
* else happens to trigger again. This service collapses a burst into at most one
* pending rerun and drains it once the current work settles.
*
* ## Contract
*
* `request()` is fire-and-forget: it never throws, never returns a result, and
* carries no failure taxonomy. Callers that need a result or an error must keep
* awaiting `sync()` directly initial, after-enable, before-close and explicit
* user syncs all deliberately stay on that path.
*
* ## State
*
* `idle | running`, plus one dirty slot. A new request overwrites the slot with
* a freshly captured epoch while remaining a single dirty bit, so a burst of
* fifty triggers is one rerun, not fifty.
*
* ## Staleness
*
* A request captures the config epoch and active provider. Both are revalidated
* immediately before I/O before EVERY leading or trailing run, not only at
* `request()` time because the whole point is that requests get deferred, and
* a deferral is exactly the window in which the user switches provider, moves
* the folder, or signs out. A stale request is DROPPED, never retargeted at
* whatever the current target happens to be: the trigger that wanted a sync of
* target A has no opinion about target B, and a live trigger will ask again.
*
* ## What it must never do
*
* Start a shadow initial sync. A request arriving before the awaited
* initial/after-enable path may mark dirty, but may not run: that path owns
* opening the gate, and the scheduler drains afterwards.
*/
@Injectable({ providedIn: 'root' })
export class BackgroundSyncSchedulerService {
private _syncWrapper = inject(SyncWrapperService);
private _busy = inject(SyncBusyService);
private _syncTrigger = inject(SyncTriggerService);
private _providerManager = inject(SyncProviderManager);
private _destroyRef = inject(DestroyRef);
private _isRunning = false;
private _pending: PendingRequest | null = null;
private _settled$ = new Subject<void>();
/**
* When sync work last settled, on a MONOTONIC clock. `null` = nothing has
* settled yet, so the first request is never spaced.
*
* Deliberately not `Date.now()`. A backward wall-clock correction NTP after
* Android Doze, Electron resume, a VM restore, a user clock change makes the
* elapsed calculation negative, which arms a timer of `floor + jump`. A
* one-hour correction would arm a one-hour timer, and since this is now the
* only automatic sync path, background sync would stall completely until it
* fired. Clamping the delay does not fix it: each 5s retry recomputes the same
* negative elapsed and re-arms, so it stalls just as hard, in a loop.
* `performance.now()` cannot go backwards.
*/
private _lastSettleAt: number | null = null;
private _spacingTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Emits after every run settles, successfully or not. Deliberately narrow: it
* carries no outcome, so a source cannot mistake it for "your work succeeded".
* It exists so a high-watermark owner can re-check its OWN durable progress
* condition without this service having to model per-source state.
*/
readonly settled$: Observable<void> = this._settled$.asObservable();
constructor() {
// Two independent wake-ups, because either alone strands a request.
//
// Busy falling: work we deferred can now run.
// `pairwise` so this is a real busy→idle TRANSITION, not the seeded `false`
// every subscriber receives. Filtering on the value alone would stamp
// _lastSettleAt at construction and needlessly delay the session's first
// background sync by the whole floor.
this._busy.isBusy$
.pipe(
pairwise(),
filter(([wasBusy, isBusy]) => wasBusy && !isBusy),
takeUntilDestroyed(this._destroyRef),
)
.subscribe(() => {
// Stamp on ANY sync work settling, not just our own runs. The floor must
// space a background sync against every other sync — otherwise the first
// one of the session fires back-to-back with the initial sync (the exact
// "blur right after initial sync" case the old shared throttle guarded,
// which the effect split dissolved), and a trigger deferred during the
// before-close sync starts fresh I/O at the instant the window closes.
this._lastSettleAt = performance.now();
this._scheduleDrain();
});
// Gate opening: the initial sync's own `finally` releases the busy signals
// BEFORE SyncEffects flips the gate in its `.then()`. So the busy-falling
// wake-up above fires while the gate is still shut, finds the request
// ineligible, and returns — and without this second wake-up nothing would
// ever come back for it. The first background sync of the session would
// silently never happen.
this._syncTrigger.initialSyncGateOpen$
.pipe(
filter((isOpen) => isOpen),
takeUntilDestroyed(this._destroyRef),
)
.subscribe(() => this._scheduleDrain());
this._destroyRef.onDestroy(() => {
if (this._spacingTimer !== null) {
clearTimeout(this._spacingTimer);
this._spacingTimer = null;
}
});
}
/**
* Wake-ups must NOT drain on the emitting call stack.
*
* The busy edge fires from `SyncCycleGuard.end()`, which `sync()` calls from
* inside its own `finally`. Draining synchronously there starts the next sync
* re-entrantly, part-way through the previous one's teardown before the
* wrapper's SYNCING safeguard runs, which would then see the NEW sync's status
* and reset it to UNKNOWN_OR_CHANGED. Provider status is still a live
* exclusion gate for the immediate-upload side channel, so corrupting it is
* not merely cosmetic.
*
* Yielding a microtask lets the finishing cycle unwind completely, so the next
* sync starts from a settled state. Found by wiring the real guard and busy
* service together the fakes could not surface it.
*/
private _scheduleDrain(): void {
queueMicrotask(() => void this._drain());
}
/**
* Re-check once the duty-cycle floor has elapsed. A single timer, because the
* dirty bit is single: re-arming per request would stack timers that all drain
* the same one slot.
*/
private _armSpacingTimer(delayMs: number): void {
if (this._spacingTimer !== null) {
return;
}
this._spacingTimer = setTimeout(() => {
this._spacingTimer = null;
void this._drain();
}, delayMs);
}
/**
* Ask for a background full sync. Collapses into the single pending slot if
* one is already queued, and re-captures the epoch so the newest request wins.
*/
request(): void {
this._pending = {
configEpoch: this._providerManager.configEpoch,
providerId: this._providerManager.getActiveProvider()?.id ?? null,
};
void this._drain();
}
private async _drain(): Promise<void> {
if (this._isRunning || !this._pending) {
return;
}
// Someone else's sync/maintenance is running. Stay dirty and make no sync()
// call: it would only bounce off the guard and return HANDLED_ERROR, burning
// the request. The busy-falling wake-up brings us back.
if (this._busy.isBusy) {
return;
}
// The awaited initial/after-enable path owns the gate. The gate wake-up
// brings us back.
if (!this._syncTrigger.isInitialSyncDoneSync()) {
return;
}
// Duty-cycle floor. Deferring instead of dropping removed the only thing
// that bounded the SYNC rate: exhaustMap. The trigger-side throttle never
// bounded it — the interval timer is self-sustaining and independent of sync
// activity, so whenever a sync outlasts syncInterval (a 90s WebDAV sync on a
// 60s interval is ordinary — getFileRev is a full GET, not a cheap ETag),
// every tick lands mid-sync and drains the instant the previous one settles.
// That is a permanent back-to-back sync loop with no idle gap.
//
// The wasted I/O is the lesser harm. sync() opens the hydration window and
// closes it in its finally, so with no gap `isInSyncWindow` is effectively
// always true and skipDuringSyncWindow() would suppress TODAY_TAG repair and
// day-change effects INDEFINITELY. The floor guarantees a real idle window
// between runs, in which those effects can fire.
if (this._lastSettleAt !== null) {
const sinceLastSettle = performance.now() - this._lastSettleAt;
if (sinceLastSettle < SYNC_MIN_INTERVAL) {
this._armSpacingTimer(SYNC_MIN_INTERVAL - sinceLastSettle);
return;
}
}
const request = this._pending;
this._pending = null;
if (!this._isStillCurrent(request)) {
SyncLog.log('BackgroundSyncScheduler: dropping stale request');
return;
}
this._isRunning = true;
try {
// `sync()` resolves with the truthy string 'HANDLED_ERROR' on a handled
// failure, so its result cannot be truth-tested. Nothing here reads it:
// a settled failure and a settled success release identical state, and
// source-specific retry policy lives with the source, not here.
await this._syncWrapper.sync();
} catch (err) {
// Fire-and-forget: an unhandled throw must not escape into an unhandled
// rejection, and must not prevent the trailing drain below.
SyncLog.err('BackgroundSyncScheduler: background sync threw', err);
} finally {
this._isRunning = false;
this._lastSettleAt = performance.now();
this._settled$.next();
}
// Honour dirty once. Any request that arrived during the run drains now;
// requests arriving during THIS trailing run collapse into the slot again,
// so there is never more than one pending rerun.
void this._drain();
}
/**
* A deferred request is only allowed to perform I/O against the same target it
* was made against.
*/
private _isStillCurrent(request: PendingRequest): boolean {
const currentProviderId = this._providerManager.getActiveProvider()?.id ?? null;
return (
request.configEpoch === this._providerManager.configEpoch &&
request.providerId === currentProviderId
);
}
}

View file

@ -0,0 +1,162 @@
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject } from 'rxjs';
import { SyncBusyService } from './sync-busy.service';
import { SyncWrapperService } from './sync-wrapper.service';
import { SyncCycleGuardService } from '../../op-log/sync/sync-cycle-guard.service';
/**
* A stand-in for the parts of SyncWrapperService the busy definition reads.
* The real wrapper drags in the whole sync stack; the contract under test is
* only these three members.
*/
class FakeSyncWrapper {
private _isSyncInProgress$ = new BehaviorSubject(false);
private _isEncryption$ = new BehaviorSubject(false);
isSyncInProgress$ = this._isSyncInProgress$.asObservable();
isEncryptionOperationInProgress$ = this._isEncryption$.asObservable();
get isEncryptionOperationInProgress(): boolean {
return this._isEncryption$.getValue();
}
isSyncInProgressSync(): boolean {
return this._isSyncInProgress$.getValue();
}
setSyncInProgress(v: boolean): void {
this._isSyncInProgress$.next(v);
}
setEncryption(v: boolean): void {
this._isEncryption$.next(v);
}
}
describe('SyncBusyService', () => {
let service: SyncBusyService;
let wrapper: FakeSyncWrapper;
let guard: SyncCycleGuardService;
beforeEach(() => {
wrapper = new FakeSyncWrapper();
TestBed.configureTestingModule({
providers: [
SyncBusyService,
SyncCycleGuardService,
{ provide: SyncWrapperService, useValue: wrapper },
],
});
service = TestBed.inject(SyncBusyService);
guard = TestBed.inject(SyncCycleGuardService);
});
describe('isBusy (synchronous)', () => {
it('is false when nothing is running', () => {
expect(service.isBusy).toBe(false);
});
it('is true while the cycle guard is held', () => {
guard.tryBegin();
expect(service.isBusy).toBe(true);
});
it('is true during an encryption operation with no cycle claimed', () => {
// runWithSyncBlocked() holds this flag across the pre-guard drain window,
// so the guard alone would report idle here.
wrapper.setEncryption(true);
expect(service.isBusy).toBe(true);
});
it('is true while a sync is in progress', () => {
wrapper.setSyncInProgress(true);
expect(service.isBusy).toBe(true);
});
it('stays busy until the last signal clears', () => {
guard.tryBegin();
wrapper.setEncryption(true);
wrapper.setEncryption(false);
expect(service.isBusy).toBe(true);
guard.end();
expect(service.isBusy).toBe(false);
});
});
describe('isBusy$', () => {
let seen: boolean[];
let sub: { unsubscribe: () => void };
beforeEach(() => {
seen = [];
sub = service.isBusy$.subscribe((v) => seen.push(v));
});
afterEach(() => sub.unsubscribe());
it('emits the current state on subscribe', () => {
expect(seen).toEqual([false]);
});
it('emits when a cycle is claimed and released', () => {
guard.tryBegin();
wrapper.setSyncInProgress(true);
guard.end();
wrapper.setSyncInProgress(false);
expect(seen).toEqual([false, true, false]);
});
it('resolves to idle only on the guard release, not on the earlier signal clear', () => {
// Mirrors sync()'s finally: isSyncInProgress$ clears while the guard is
// still held, so only the guard's release may flip the union to idle.
// Without SyncCycleGuard.released$ there would be no edge to recompute on
// and this would emit `true` forever.
guard.tryBegin();
wrapper.setSyncInProgress(true);
expect(seen).toEqual([false, true]);
wrapper.setSyncInProgress(false);
expect(seen).toEqual([false, true]);
guard.end();
expect(seen).toEqual([false, true, false]);
});
it('does not emit a duplicate when a second signal goes busy', () => {
guard.tryBegin();
wrapper.setSyncInProgress(true);
wrapper.setEncryption(true);
expect(seen).toEqual([false, true]);
});
it('does not emit an idle edge for an end() that released nothing', () => {
guard.end();
expect(seen).toEqual([false]);
});
it('emits for a guard-only cycle that touches no other signal', () => {
// The side channels (immediate upload, WS download) claim the cycle
// without ever setting isSyncInProgress$. If the union listened only to
// the guard's RELEASE edge, those cycles would read as idle for their
// whole duration — which is the coverage the guard is in the union for.
guard.tryBegin();
expect(seen).toEqual([false, true]);
guard.end();
expect(seen).toEqual([false, true, false]);
});
it('emits across repeated busy/idle cycles', () => {
guard.tryBegin();
guard.end();
guard.tryBegin();
guard.end();
expect(seen).toEqual([false, true, false, true, false]);
});
it('tracks a standalone encryption operation (forceUpload has no cycle of its own here)', () => {
wrapper.setEncryption(true);
wrapper.setEncryption(false);
expect(seen).toEqual([false, true, false]);
});
});
});

View file

@ -0,0 +1,89 @@
import { inject, Injectable } from '@angular/core';
import { distinctUntilChanged, map, merge, Observable, shareReplay } from 'rxjs';
import { SyncWrapperService } from './sync-wrapper.service';
import { SyncCycleGuardService } from '../../op-log/sync/sync-cycle-guard.service';
/**
* The single busy/idle definition for sync work, so callers stop polling three
* signals independently and disagreeing about what "busy" means.
*
* ## What counts as busy
*
* The union of the three authorities, because none of them covers every flow:
*
* | signal | sync() | conflict dialog | forceUpload | immediate upload | WS download |
* |------------------------------------|--------|-----------------|-------------|------------------|-------------|
* | `SyncCycleGuard.isActive` | yes | yes | yes | yes | yes |
* | `isEncryptionOperationInProgress` | no | no | yes | no | no |
* | `isSyncInProgress$` | yes | yes | no | no | no |
*
* The cycle guard is the widest it is claimed by all four entry points so
* the union is in practice `isActive || isEncryptionOperationInProgress`. The
* encryption flag is still required: `runWithSyncBlocked()` holds it across the
* pre-guard drain window, before any cycle is claimed. `isSyncInProgress$` is
* folded in for defence in depth; it is not known to add coverage today, and is
* cheap.
*
* Provider `SYNCING` status is deliberately NOT an input. It is presentation
* state set only by `sync()`, so it is strictly narrower than the guard, and
* treating it as a fourth authority would be a lock that disagrees with the
* other three. (`SyncProviderManager.isSyncInProgress$` is also dead and
* semantically broken it filters out the very status that ends a cycle.)
*
* ## Why an edge is needed at all
*
* `isActive` is a plain field and `isEncryptionOperationInProgress` is a getter,
* so neither can tell a subscriber when it stops being true.
* {@link SyncCycleGuardService.released$} supplies the missing edge. Recomputing
* the whole union on every edge also makes emission order self-correcting:
* `sync()`'s `finally` clears `isSyncInProgress$` while the guard is still held,
* and only the guard's release resolves the union to idle.
*
* ## What this is not
*
* Not an exclusion authority and not a lock. `SyncCycleGuard.tryBegin()` remains
* the only thing that may grant a cycle; this observable answers "is anything
* running?", which is inherently stale the moment it is read. Gate on it to
* avoid starting pointless work, then still claim the guard and handle refusal.
*/
@Injectable({ providedIn: 'root' })
export class SyncBusyService {
private _syncWrapper = inject(SyncWrapperService);
private _cycleGuard = inject(SyncCycleGuardService);
/**
* Emits the current busy state, and again on every transition. Recomputed
* from all three signals on each edge rather than tracked incrementally, so
* no interleaving of the underlying sets can desynchronise it.
*
* Emits on subscribe: all three inputs replay a current value, so the merge is
* seeded without a `startWith`.
*
* Uses the guard's full activity edge rather than only its release: the side
* channels claim a cycle without touching either wrapper signal, so a
* release-only input would leave those cycles reported as idle for their
* entire duration.
*/
readonly isBusy$: Observable<boolean> = merge(
this._syncWrapper.isSyncInProgress$,
this._syncWrapper.isEncryptionOperationInProgress$,
this._cycleGuard.isActive$,
).pipe(
map(() => this.isBusy),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
/**
* Point-in-time read, for the synchronous check-before-first-await pattern the
* sync entry points use. Inherently stale once returned see the class
* docblock; it never substitutes for `tryBegin()`.
*/
get isBusy(): boolean {
return (
this._cycleGuard.isActive ||
this._syncWrapper.isEncryptionOperationInProgress ||
this._syncWrapper.isSyncInProgressSync()
);
}
}

View file

@ -0,0 +1,206 @@
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { SyncEffects } from './sync.effects';
import { SyncWrapperService } from './sync-wrapper.service';
import { SyncTriggerService } from './sync-trigger.service';
import { BackgroundSyncSchedulerService } from './background-sync-scheduler.service';
import { IS_ONLINE$ } from '../../util/is-online.token';
import { DataInitStateService } from '../../core/data-init/data-init-state.service';
import { SnackService } from '../../core/snack/snack.service';
import { TaskService } from '../../features/tasks/task.service';
import { SimpleCounterService } from '../../features/simple-counter/simple-counter.service';
import { ExecBeforeCloseService } from '../../core/electron/exec-before-close.service';
import { InitialPwaUpdateCheckService } from '../../core/initial-pwa-update-check.service';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import { SYNC_INITIAL_SYNC_TRIGGER } from './sync.const';
/**
* Task 3 routing. The split is the whole behaviour change: background triggers
* must reach the scheduler (which defers rather than drops them), while the
* gate-opening paths must keep calling sync() directly.
*
* These assertions are the reason IS_ONLINE$ exists as a token: the module-level
* isOnline$ freezes its initial value at import time from `navigator.onLine`,
* which headless Chrome reports as false so without the seam the online filter
* would block every case below and the suite would pass while proving nothing.
*/
describe('SyncEffects routing (Task 3)', () => {
let effects: SyncEffects;
let backgroundTrigger$: Subject<string | null>;
let initialUpdateCheck$: Subject<void>;
let isOnline$: BehaviorSubject<boolean>;
let schedulerRequest: jasmine.Spy;
let sync: jasmine.Spy;
let subs: { unsubscribe: () => void }[];
const flush = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};
beforeEach(() => {
backgroundTrigger$ = new Subject<string | null>();
initialUpdateCheck$ = new Subject<void>();
isOnline$ = new BehaviorSubject(true);
schedulerRequest = jasmine.createSpy('request');
sync = jasmine.createSpy('sync').and.resolveTo('InSync');
TestBed.configureTestingModule({
providers: [
SyncEffects,
{ provide: IS_ONLINE$, useValue: isOnline$ },
{
provide: BackgroundSyncSchedulerService,
useValue: { request: schedulerRequest },
},
{
provide: SyncWrapperService,
useValue: {
sync,
isEnabledAndReady$: new BehaviorSubject(true),
syncInterval$: new BehaviorSubject(10000),
syncProviderId$: new BehaviorSubject(SyncProviderId.WebDAV),
},
},
{
provide: SyncTriggerService,
useValue: {
getSyncTrigger$: (): Observable<string | null> =>
backgroundTrigger$.asObservable(),
setInitialSyncDone: jasmine.createSpy('setInitialSyncDone'),
},
},
{
provide: DataInitStateService,
useValue: { isAllDataLoadedInitially$: new BehaviorSubject(true) },
},
{
provide: InitialPwaUpdateCheckService,
useValue: { afterInitialUpdateCheck$: initialUpdateCheck$.asObservable() },
},
{ provide: SnackService, useValue: jasmine.createSpyObj('Snack', ['open']) },
{
provide: TaskService,
useValue: jasmine.createSpyObj('TaskService', ['setCurrentId']),
},
{
provide: SimpleCounterService,
useValue: jasmine.createSpyObj('SimpleCounterService', [
'flushAccumulatedTime',
'turnOffAll',
]),
},
{
provide: ExecBeforeCloseService,
useValue: jasmine.createSpyObj(
'ExecBeforeCloseService',
['schedule', 'unschedule', 'setDone'],
{ onBeforeClose$: new Subject() },
),
},
],
});
effects = TestBed.inject(SyncEffects);
subs = [];
});
afterEach(() => {
subs.forEach((s) => s.unsubscribe());
delete (globalThis as unknown as Record<string, unknown>).__SP_E2E_BLOCK_AUTO_SYNC;
});
const subscribeBackground = (): void => {
subs.push(effects.scheduleBackgroundSync$.subscribe());
};
describe('background triggers', () => {
it('routes a background trigger to the scheduler', async () => {
subscribeBackground();
backgroundTrigger$.next('I_INTERVAL_TIMER');
await flush();
expect(schedulerRequest).toHaveBeenCalledTimes(1);
});
it('does NOT call sync() directly — that is the whole point of the split', async () => {
// Previously this trigger went into the shared exhaustMap and called
// sync(), which dropped it whenever a sync was already running.
subscribeBackground();
backgroundTrigger$.next('I_INTERVAL_TIMER');
await flush();
expect(sync).not.toHaveBeenCalled();
});
it('routes the settle branch, which emits null rather than a trigger name', async () => {
// The dynamic branch emits `null` for the trailing settle timer, so
// routing must not depend on the emitted value being a trigger string.
// For SuperSync this is the only timer-ish trigger that survives.
subscribeBackground();
backgroundTrigger$.next(null);
await flush();
expect(schedulerRequest).toHaveBeenCalledTimes(1);
});
it('does not request while offline', async () => {
isOnline$.next(false);
subscribeBackground();
backgroundTrigger$.next('I_INTERVAL_TIMER');
await flush();
expect(schedulerRequest).not.toHaveBeenCalled();
});
it('honours the E2E auto-sync kill switch', async () => {
(globalThis as unknown as Record<string, unknown>).__SP_E2E_BLOCK_AUTO_SYNC = true;
subscribeBackground();
backgroundTrigger$.next('I_INTERVAL_TIMER');
await flush();
expect(schedulerRequest).not.toHaveBeenCalled();
});
});
describe('gate-opening triggers stay directly awaited', () => {
it('calls sync() directly for the initial sync, bypassing the scheduler', async () => {
subs.push(effects.triggerSync$.subscribe());
initialUpdateCheck$.next();
await flush();
expect(sync).toHaveBeenCalledTimes(1);
expect(schedulerRequest).not.toHaveBeenCalled();
});
it('opens the initial gate from the initial sync, not from a background one', async () => {
const trigger = TestBed.inject(SyncTriggerService);
subscribeBackground();
backgroundTrigger$.next('I_INTERVAL_TIMER');
await flush();
// A background trigger must never flip the gate: the scheduler refuses to
// run until the awaited initial path has opened it.
expect(trigger.setInitialSyncDone).not.toHaveBeenCalled();
subs.push(effects.triggerSync$.subscribe());
initialUpdateCheck$.next();
await flush();
expect(trigger.setInitialSyncDone).toHaveBeenCalledWith(true);
});
});
describe('the SYNC_INITIAL_SYNC_TRIGGER constant', () => {
it('is still the value the awaited path keys its gate flip on', () => {
expect(SYNC_INITIAL_SYNC_TRIGGER).toBeDefined();
});
});
});

View file

@ -317,4 +317,33 @@ export class SyncTriggerService {
isInitialSyncDoneSync(): boolean {
return this._isInitialSyncDoneSync;
}
/**
* The initial/after-enable gate as an edge the exact observable mirror of
* {@link isInitialSyncDoneSync}, since `setInitialSyncDone()` is the only
* writer of both. For deferred work that must not start before the awaited
* initial path has opened the gate, and must be told the moment it does.
*
* NOT the same thing as the private `_isInitialSyncDone$`, which answers a
* different question ("may the UI show data yet?") and short-circuits to true
* for SuperSync and when initial sync is disabled.
*
* Emits on a `setInitialSyncDone()` call: replays the latest value to late
* subscribers, and emits nothing at all before the first flip (the gate reads
* closed via the getter until then).
*
* CAVEAT this does NOT mean "the initial sync completed". The
* `MAX_WAIT_FOR_INITIAL_SYNC` (8s) failsafes inside
* `afterInitialSyncDoneAndDataLoadedInitially$` and `afterInitialSyncDoneStrict$`
* call `setInitialSyncDone(true)` from a `tap`, so a wall-clock timer opens
* this gate too and both are subscribed live across the app, so the timers
* really are armed. The honest reading is "the gate is open", not "the initial
* sync landed": if the initial path hangs (e.g. the PWA update check, also 8s),
* the failsafe can open it first, a deferred background sync drains, and the
* later initial `sync()` bounces off `SyncCycleGuard.tryBegin()` and returns
* HANDLED_ERROR. Both paths call the same `sync()`, so the practical harm is
* low but do not build a stronger invariant on this than it can carry.
*/
readonly initialSyncGateOpen$: Observable<boolean> =
this._isInitialSyncDoneManual$.asObservable();
}

View file

@ -183,6 +183,16 @@ export class SyncWrapperService {
*/
private _isEncryptionOperationInProgress$ = new BehaviorSubject(false);
/**
* Observable form of {@link isEncryptionOperationInProgress}, for consumers
* that need an edge rather than a point-in-time read (the getter cannot tell
* a waiter when the operation ends). Same coverage as the getter: everything
* routed through `runWithSyncBlocked()` password change, enable/disable
* encryption, and `forceUpload()`, which `isSyncInProgress$` does NOT span.
*/
readonly isEncryptionOperationInProgress$: Observable<boolean> =
this._isEncryptionOperationInProgress$.asObservable();
/**
* When true, encryption-related dialogs (missing password, decrypt error) are suppressed.
* Set after the user cancels a dialog so they can navigate to settings to change the password

View file

@ -17,14 +17,15 @@ import {
withLatestFrom,
} from 'rxjs/operators';
import { SyncTriggerService } from './sync-trigger.service';
import { BackgroundSyncSchedulerService } from './background-sync-scheduler.service';
import {
INITIAL_SYNC_DELAY_MS,
SYNC_BEFORE_CLOSE_ID,
SYNC_INITIAL_SYNC_TRIGGER,
} from '../../imex/sync/sync.const';
import { SyncProviderId } from '../../op-log/sync-exports';
import { asyncScheduler, combineLatest, EMPTY, merge, Observable, of } from 'rxjs';
import { isOnline$ } from '../../util/is-online';
import { asyncScheduler, combineLatest, defer, EMPTY, merge, Observable, of } from 'rxjs';
import { IS_ONLINE$ } from '../../util/is-online.token';
import { SnackService } from '../../core/snack/snack.service';
import { T } from '../../t.const';
import { ExecBeforeCloseService } from '../../core/electron/exec-before-close.service';
@ -43,6 +44,8 @@ import { vectorClockPruned$ } from '../../core/util/vector-clock';
export class SyncEffects {
private _syncWrapperService = inject(SyncWrapperService);
private _syncTriggerService = inject(SyncTriggerService);
private _backgroundSyncScheduler = inject(BackgroundSyncSchedulerService);
private _isOnline$ = inject(IS_ONLINE$);
private _snackService = inject(SnackService);
private _taskService = inject(TaskService);
private _simpleCounterService = inject(SimpleCounterService);
@ -53,7 +56,14 @@ export class SyncEffects {
syncBeforeQuit$ = createEffect(
() =>
!IS_ELECTRON
? EMPTY
? // NOT the bare `EMPTY` singleton: createEffect stamps a
// non-configurable marker onto whatever object it is handed, so
// returning the module-wide instance brands it process-wide and every
// later construction of this class dies with "Cannot redefine property
// __@ngrx/effects_create__" — which is why this class had no
// behavioural tests. `defer` hands over a fresh instance per
// construction; the subscribed behaviour is unchanged.
defer(() => EMPTY)
: this._dataInitStateService.isAllDataLoadedInitially$.pipe(
concatMap(() => this._syncWrapperService.isEnabledAndReady$),
distinctUntilChanged(),
@ -135,27 +145,73 @@ export class SyncEffects {
shareReplay(),
);
/**
* The dynamic background branch interval, resume, visibility, idle/activity,
* online-regained, and the trailing settle timer.
*
* Split out of {@link triggerSync$} and routed through the scheduler, which
* defers a trigger that arrives while other sync work is running instead of
* dropping it. Under the old shared `exhaustMap` these triggers were silently
* discarded whenever a sync was already in flight, so the work they asked for
* was lost until something else happened to trigger again.
*
* The gate is NOT checked here: the scheduler owns it, so a trigger arriving
* before the initial sync completes marks dirty and drains afterwards rather
* than being dropped or starting a shadow initial sync.
*/
scheduleBackgroundSync$ = createEffect(
() =>
this._dataInitStateService.isAllDataLoadedInitially$.pipe(
switchMap(() =>
combineLatest([
this._syncWrapperService.isEnabledAndReady$,
this._syncWrapperService.syncInterval$,
this._syncWrapperService.syncProviderId$,
]).pipe(
switchMap(([isEnabledAndReady, syncInterval, providerId]) =>
isEnabledAndReady && syncInterval
? this._syncTriggerService.getSyncTrigger$(
syncInterval,
providerId !== SyncProviderId.SuperSync,
)
: EMPTY,
),
),
),
tap((x) => SyncLog.log('sync(effect) background trigger.....', x)),
// Unchanged frequency limit. The scheduler collapses a burst into one
// rerun anyway, but this keeps the pre-existing rate ceiling rather than
// quietly raising it as a side effect of the split.
throttleTime(2000, asyncScheduler, { leading: true, trailing: false }),
// E2E tests set this flag after setup to prevent auto-sync from interfering
// with controlled, sequential sync via the sync button click
filter(() => !(globalThis as any).__SP_E2E_BLOCK_AUTO_SYNC),
withLatestFrom(this._isOnline$),
// Offline background triggers were already no-ops; requesting here would
// only queue work that fails. I_IS_ONLINE re-triggers on reconnect.
filter(([, isOnline]) => !!isOnline),
tap(() => this._backgroundSyncScheduler.request()),
),
{ dispatch: false },
);
/**
* The two directly-awaited gate-openers: initial sync and after-enable.
*
* These deliberately do NOT go through the scheduler. They own opening the
* initial gate the scheduler refuses to run until they have and they are
* the only sync callers here whose completion other code waits on.
*
* Both are one-shot, so the retained `exhaustMap` now only guards these two
* against each other. Background triggers used to share it; their exclusion
* against a running sync is now the scheduler's busy check plus
* `SyncCycleGuard.tryBegin()`, which is the authority either way.
*/
triggerSync$ = createEffect(
() =>
this._dataInitStateService.isAllDataLoadedInitially$.pipe(
switchMap(() =>
merge(
// dynamic
combineLatest([
this._syncWrapperService.isEnabledAndReady$,
this._syncWrapperService.syncInterval$,
this._syncWrapperService.syncProviderId$,
]).pipe(
switchMap(([isEnabledAndReady, syncInterval, providerId]) =>
isEnabledAndReady && syncInterval
? this._syncTriggerService.getSyncTrigger$(
syncInterval,
providerId !== SyncProviderId.SuperSync,
)
: EMPTY,
),
),
// initial after starting app — wait for provider to actually be ready
this._initialPwaUpdateCheckService.afterInitialUpdateCheck$.pipe(
concatMap(() =>
@ -188,7 +244,7 @@ export class SyncEffects {
// E2E tests set this flag after setup to prevent auto-sync from interfering
// with controlled, sequential sync via the sync button click
filter(() => !(globalThis as any).__SP_E2E_BLOCK_AUTO_SYNC),
withLatestFrom(isOnline$),
withLatestFrom(this._isOnline$),
// don't run multiple after each other when dialog is open
exhaustMap(([trigger, isOnline]) => {
if (!isOnline) {

View file

@ -108,4 +108,99 @@ describe('SyncProviderManager target-change notification', () => {
expect(provider.privateCfg.load).toHaveBeenCalledBefore(provider.setPrivateCfg);
});
});
/**
* Task 3. Deferred background work captures the epoch and revalidates it
* before I/O, so a request queued against one target cannot execute against
* another. Anything that moves the target or the authority to reach it must
* tick, or stale work runs against the wrong remote.
*/
describe('configEpoch', () => {
/**
* Drives the real switch path. It is private because nothing outside the
* constructor's config subscription may choose the active provider; the
* async tail is inert here because getProviderById is stubbed.
*/
const setActiveProvider = (id: SyncProviderId | null): void =>
(
service as unknown as { _setActiveProvider: (i: SyncProviderId | null) => void }
)._setActiveProvider(id);
it('starts at a stable value and does not drift on its own', () => {
const first = service.configEpoch;
expect(service.configEpoch).toBe(first);
});
it('advances on every provider-config write', async () => {
stubProvider(webdavCfg);
const before = service.configEpoch;
await service.setProviderConfig(SyncProviderId.WebDAV, { ...webdavCfg } as never);
expect(service.configEpoch).toBeGreaterThan(before);
});
it('advances on a target move asserted by a bypass ingress', () => {
const before = service.configEpoch;
service.notifyProviderTargetChanged();
expect(service.configEpoch).toBeGreaterThan(before);
});
it('advances on an active provider switch', () => {
// A switch emits NO providerConfigChanged$ by design, so an epoch derived
// from that stream would miss it and let work captured against the old
// provider survive.
stubProvider(webdavCfg);
const before = service.configEpoch;
setActiveProvider(SyncProviderId.WebDAV);
expect(service.configEpoch).toBeGreaterThan(before);
expect(configSpy).not.toHaveBeenCalled();
});
it('does not advance when the provider is set to what it already was', () => {
stubProvider(webdavCfg);
setActiveProvider(SyncProviderId.WebDAV);
const before = service.configEpoch;
setActiveProvider(SyncProviderId.WebDAV);
expect(service.configEpoch).toBe(before);
});
it('advances on a credential revoke', async () => {
// Also emits no providerConfigChanged$, but revokes the authority a queued
// request captured.
const provider = {
id: SyncProviderId.WebDAV,
clearAuthCredentials: jasmine.createSpy('clear').and.resolveTo(undefined),
isReady: jasmine.createSpy('isReady').and.resolveTo(false),
privateCfg: { load: jasmine.createSpy('load').and.resolveTo(webdavCfg) },
} as unknown as SyncProviderBase<SyncProviderId>;
spyOn(service, 'getProviderById').and.resolveTo(provider);
const before = service.configEpoch;
await service.clearAuthCredentials(SyncProviderId.WebDAV);
expect(service.configEpoch).toBeGreaterThan(before);
});
it('is monotonic across a burst of transitions', () => {
stubProvider(webdavCfg);
const seen: number[] = [service.configEpoch];
service.notifyProviderTargetChanged();
seen.push(service.configEpoch);
setActiveProvider(SyncProviderId.WebDAV);
seen.push(service.configEpoch);
service.notifyProviderTargetChanged();
seen.push(service.configEpoch);
const isStrictlyIncreasing = seen.every((v, i) => i === 0 || v > seen[i - 1]);
expect(isStrictlyIncreasing).toBeTrue();
});
});
});

View file

@ -58,6 +58,12 @@ export class SyncProviderManager {
/** Counter to detect stale provider activations */
private _activeProviderSetupId = 0;
/**
* Monotonic in-tab counter over authoritative sync-target/configuration
* transitions. See {@link configEpoch}.
*/
private _configEpoch = 0;
// Current active provider
private _activeProvider: SyncProviderBase<SyncProviderId> | null = null;
private _activeProviderId$ = new BehaviorSubject<SyncProviderId | null>(null);
@ -98,6 +104,29 @@ export class SyncProviderManager {
public readonly activeProviderId$: Observable<SyncProviderId | null> =
this._activeProviderId$.pipe(distinctUntilChanged(), shareReplay(1));
/**
* Monotonic counter over authoritative sync-target/configuration transitions.
* Deferred work captures it and revalidates before I/O, so a request queued
* against one target cannot be executed against another.
*
* Bumped on: any provider-config write (`setProviderConfig`), a target move
* reported by a bypass ingress (`notifyProviderTargetChanged`), an active
* provider switch, and a credential revoke. The switch and revoke cases are
* why this is not derived from `providerConfigChanged$`: neither emits on that
* stream, so an epoch built on it alone would silently miss both.
*
* Deliberately NOT bumped by machine-only OAuth access-token refresh for an
* unchanged account that goes through the credential store and moves no
* target, so bumping would invalidate healthy queued work.
*
* In-tab, not persisted, not a cross-tab protocol, and not a security input:
* it is a staleness heuristic over local UI/config actions. Never derive it
* from, or let it carry, secrets compare epochs, never configuration.
*/
get configEpoch(): number {
return this._configEpoch;
}
/**
* Observable for sync status
*/
@ -267,6 +296,7 @@ export class SyncProviderManager {
await provider.setPrivateCfg(config);
// Notify subscribers (e.g., WrappedProviderService) that config changed
this._configEpoch++;
this._providerConfigChanged$.next({
isTargetChanged: isSyncTargetChanged(prevCfg, config),
});
@ -297,6 +327,7 @@ export class SyncProviderManager {
* already persisted the new target).
*/
notifyProviderTargetChanged(): void {
this._configEpoch++;
this._providerConfigChanged$.next({ isTargetChanged: true });
}
@ -311,6 +342,10 @@ export class SyncProviderManager {
}
await provider.clearAuthCredentials();
// Revoking credentials invalidates the authority a queued request captured,
// even though this path emits no providerConfigChanged$.
this._configEpoch++;
if (this._activeProvider?.id === providerId) {
const ready = await provider.isReady();
this._isProviderReady$.next(ready);
@ -339,6 +374,11 @@ export class SyncProviderManager {
return;
}
// A provider SWITCH deliberately does not emit providerConfigChanged$ (see
// that observable's doc), so the epoch must be bumped here or work captured
// against the previous provider would survive the switch.
this._configEpoch++;
const setupId = ++this._activeProviderSetupId;
this._activeProviderId$.next(providerId);

View file

@ -39,4 +39,81 @@ describe('SyncCycleGuardService', () => {
expect(guard.isActive).toBe(false);
expect(guard.tryBegin()).toBe(true);
});
describe('isActive$', () => {
let seen: boolean[];
let sub: { unsubscribe: () => void };
beforeEach(() => {
seen = [];
sub = guard.isActive$.subscribe((v) => seen.push(v));
});
afterEach(() => sub.unsubscribe());
it('emits the current state on subscribe', () => {
expect(seen).toEqual([false]);
});
it('emits on claim and on release', () => {
// The claim edge is load-bearing: the side channels claim a cycle without
// touching any other sync signal, so a busy definition watching only the
// release would report those cycles as idle for their whole duration.
guard.tryBegin();
expect(seen).toEqual([false, true]);
guard.end();
expect(seen).toEqual([false, true, false]);
});
it('does not emit for an end() that released nothing', () => {
// `end()` runs from `finally` blocks that may not hold the cycle (e.g. a
// caller whose tryBegin() returned false). A busy definition must not see
// an idle edge that never happened.
guard.end();
expect(seen).toEqual([false]);
});
it('does not emit for a tryBegin() that claimed nothing', () => {
guard.tryBegin();
guard.tryBegin();
expect(seen).toEqual([false, true]);
});
it('emits on the _resetForTest release path', () => {
// Third mutation site: a consumer recomputing busy state on this edge
// would otherwise never see the reset.
guard.tryBegin();
guard._resetForTest();
expect(seen).toEqual([false, true, false]);
});
it('emits across repeated cycles', () => {
guard.tryBegin();
guard.end();
guard.tryBegin();
guard.end();
expect(seen).toEqual([false, true, false, true, false]);
});
it('carries no claim — a subscriber must still win tryBegin()', () => {
// Observing activity is not holding it. Two subscribers racing the same
// release edge: only one can claim.
guard.tryBegin();
const claims: boolean[] = [];
const a = guard.isActive$
.pipe()
.subscribe((isActive) => !isActive && claims.push(guard.tryBegin()));
const b = guard.isActive$
.pipe()
.subscribe((isActive) => !isActive && claims.push(guard.tryBegin()));
guard.end();
// Each subscriber replays the current value on subscribe, so filter to the
// claims made on the release edge itself.
expect(claims.slice(-2)).toEqual([true, false]);
a.unsubscribe();
b.unsubscribe();
});
});
});

View file

@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, distinctUntilChanged, Observable } from 'rxjs';
/**
* In-tab mutual-exclusion guard for the three top-level sync entry points:
@ -26,15 +27,36 @@ import { Injectable } from '@angular/core';
* guard itself: immediate/user-triggered flows skip, while the WebSocket
* high-watermark queue retries later. Therefore the guard cannot deadlock.
*
* {@link isActive$} does NOT weaken that: it reports activity, it does not grant
* it. A subscriber may use it to retry `tryBegin()` at a moment when the claim
* is likelier to succeed, but must still treat a `false` return as "skip/retry
* later" and must never block on the notification. Awaiting an emission as if it
* were a lock hand-off would reintroduce exactly the deadlock this design rules
* out it carries no claim, and any number of subscribers may race for the next
* `tryBegin()`.
*
* Cross-tab apply-phase serialization remains the job of the existing Web
* Locks; cross-tab gate/seq staleness is out of scope for this guard.
*/
@Injectable({ providedIn: 'root' })
export class SyncCycleGuardService {
private _isActive = false;
private _isActive$ = new BehaviorSubject(false);
/**
* Cycle activity as an edge, for busy definitions built on {@link isActive}
* (the getter alone cannot tell a subscriber when it changes). Emits on BOTH
* transitions: the side channels claim a cycle without touching any other
* sync signal, so a claim-only-visible-on-release stream would report those
* cycles as idle for their whole duration.
*
* Observing activity is NOT holding it see the class docblock.
*/
readonly isActive$: Observable<boolean> = this._isActive$
.asObservable()
.pipe(distinctUntilChanged());
get isActive(): boolean {
return this._isActive;
return this._isActive$.getValue();
}
/**
@ -43,20 +65,33 @@ export class SyncCycleGuardService {
* so the check-and-set is atomic within the single-threaded event loop.
*/
tryBegin(): boolean {
if (this._isActive) {
if (this.isActive) {
return false;
}
this._isActive = true;
this._isActive$.next(true);
return true;
}
/** Release the cycle. Always call from a `finally` block. */
end(): void {
this._isActive = false;
this._setInactive();
}
/** @internal Test-only reset for the root singleton between unit tests. */
_resetForTest(): void {
this._isActive = false;
this._setInactive();
}
/**
* Single activeinactive transition point, so every release site notifies.
* `end()` is called unconditionally from `finally` blocks that may not hold
* the cycle, so the guard suppresses no-op releases rather than emitting a
* spurious edge for a cycle that was never active.
*/
private _setInactive(): void {
if (!this.isActive) {
return;
}
this._isActive$.next(false);
}
}

View file

@ -0,0 +1,22 @@
import { InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
import { isOnline$ } from './is-online';
/**
* Injectable form of {@link isOnline$}, for code that must be testable.
*
* The module-level `isOnline$` cannot be substituted in a unit test: it is a
* `shareReplay(1)` whose `startWith(navigator.onLine)` captures the value when
* the module is first evaluated, so by the time a spec runs, its initial value
* is already fixed to whatever the test browser reported at import time and
* headless Chrome commonly reports `navigator.onLine === false`. Any consumer
* that gates on it is then permanently offline under test, with no seam to
* override.
*
* The default factory returns that same observable, so injecting this token
* changes nothing at runtime; it only gives specs a place to provide a fake.
*/
export const IS_ONLINE$ = new InjectionToken<Observable<boolean>>('IS_ONLINE$', {
providedIn: 'root',
factory: () => isOnline$,
});