fix(watchlist): stale-read clobber, concurrency guard, leaked timeout, quiet catch-up notices

- SyncDeps.listNotes now returns paths only; new readNote(path) reads
  content immediately before parse/diff/write per note, closing the
  window where an edit made between the initial scan and a later write
  got silently overwritten. resolveMissingIds updated to match.
- WatchlistController gains a syncing flag shared by syncNow and
  resolveMissingIds so overlapping invocations short-circuit instead
  of racing on the same vault notes.
- main.ts registers cleanup for the 30s startup catch-up setTimeout so
  it doesn't fire after unload.
- syncNow takes a quiet flag (used by the scheduled catch-up path) so
  zero-change scheduled syncs don't spam a Notice; manual commands are
  unaffected.
This commit is contained in:
afiqzudinhadi 2026-07-30 13:24:33 +08:00
parent b77a189848
commit a4ba1b8e42
5 changed files with 208 additions and 72 deletions

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { WatchlistController } from 'packages/obsidian/src/watchlist/WatchlistController';
import { WatchlistController, shouldNotifySync } from 'packages/obsidian/src/watchlist/WatchlistController';
import type { SyncDeps } from 'packages/obsidian/src/watchlist/SyncEngine';
function fakePlugin(overrides: Partial<{ enabled: boolean; last: number; hours: number }> = {}) {
return {
@ -38,3 +39,80 @@ describe('maybeCatchUp', () => {
expect(called).toBe(false);
});
});
function deferredDeps(): { deps: SyncDeps; listNotesCalls: () => number; release: () => void } {
let listNotesCalls = 0;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const deps: SyncDeps = {
listNotes: async () => { listNotesCalls++; await gate; return []; },
readNote: async () => '',
writeNote: async () => {},
fetchDetail: async () => ({}),
sleep: async () => {},
log: () => {},
};
return { deps, listNotesCalls: () => listNotesCalls, release };
}
describe('concurrency guard', () => {
test('overlapping syncNow calls: second short-circuits while first is in flight', async () => {
const c = new WatchlistController(fakePlugin());
const { deps, listNotesCalls, release } = deferredDeps();
(c as any).getKey = () => 'fake-key';
(c as any).makeDeps = () => deps;
const first = c.syncNow(false);
const second = await c.syncNow(false);
expect(listNotesCalls()).toBe(1);
expect(second.scanned).toBe(0);
expect(second.written).toBe(0);
release();
const firstResult = await first;
expect(firstResult.scanned).toBe(0);
});
test('syncNow in flight blocks resolveMissingIds (shared guard)', async () => {
const c = new WatchlistController(fakePlugin());
const { deps, release } = deferredDeps();
(c as any).getKey = () => 'fake-key';
(c as any).makeDeps = () => deps;
const first = c.syncNow(false);
const resolveResult = await c.resolveMissingIds();
expect(resolveResult.scanned).toBe(0);
expect(resolveResult.resolved).toBe(0);
release();
await first;
});
test('flag resets after completion → next call runs normally', async () => {
const c = new WatchlistController(fakePlugin());
(c as any).getKey = () => 'fake-key';
let listNotesCalls = 0;
(c as any).makeDeps = () => ({
listNotes: async () => { listNotesCalls++; return []; },
readNote: async () => '',
writeNote: async () => {},
fetchDetail: async () => ({}),
sleep: async () => {},
log: () => {},
});
await c.syncNow(false);
await c.syncNow(false);
expect(listNotesCalls).toBe(2);
});
});
describe('shouldNotifySync', () => {
test('non-quiet → always notifies', () => expect(shouldNotifySync(false, 0, 0)).toBe(true));
test('quiet + no changes + no errors → suppressed', () => expect(shouldNotifySync(true, 0, 0)).toBe(false));
test('quiet + written>0 → notifies', () => expect(shouldNotifySync(true, 3, 0)).toBe(true));
test('quiet + errors>0 → notifies', () => expect(shouldNotifySync(true, 0, 2)).toBe(true));
});

View file

@ -23,16 +23,18 @@ keep me
const AIRING_NOTE = ENDED_NOTE.replace('status: Ended', 'status: Returning Series').replace('last_air_date: 2023-11-09', 'last_air_date: 2023-10-01');
function makeDeps(notes: { path: string; content: string }[], detail: any = tvDetail) {
const contents = new Map(notes.map(n => [n.path, n.content]));
const writes: { path: string; content: string }[] = [];
const fetches: string[] = [];
const deps: SyncDeps = {
listNotes: async () => notes,
listNotes: async () => notes.map(n => ({ path: n.path })),
readNote: async path => contents.get(path)!,
writeNote: async (path, content) => { writes.push({ path, content }); },
fetchDetail: async (id) => { fetches.push(id); return detail; },
sleep: async () => {},
log: () => {},
};
return { deps, writes, fetches };
return { deps, writes, fetches, contents };
}
describe('withRateLimitRetry', () => {
@ -133,7 +135,8 @@ describe('syncFolder', () => {
let calls = 0;
const slept: number[] = [];
const deps: SyncDeps = {
listNotes: async () => [{ path: 'Loki.md', content: AIRING_NOTE }],
listNotes: async () => [{ path: 'Loki.md' }],
readNote: async () => AIRING_NOTE,
writeNote: async () => {},
fetchDetail: async () => {
calls++;
@ -160,4 +163,25 @@ describe('syncFolder', () => {
expect(report.errors[0].path).toBe('Bad.md');
expect(report.synced).toBe(1);
});
test('stale-read guard: content edited mid-sync is re-read fresh, not clobbered by early snapshot', async () => {
const { deps, writes, contents } = makeDeps([
{ path: 'A.md', content: AIRING_NOTE },
{ path: 'B.md', content: AIRING_NOTE },
]);
const originalFetch = deps.fetchDetail;
let calls = 0;
deps.fetchDetail = async (id, isMovie) => {
calls++;
if (calls === 1) {
// simulate the user editing B.md's "My Notes" while A.md is still mid-sync,
// i.e. after listNotes() ran but before B.md is actually processed.
contents.set('B.md', AIRING_NOTE.replace('keep me', 'edited during sync'));
}
return originalFetch(id, isMovie);
};
await syncFolder(deps);
const bWrite = writes.find(w => w.path === 'B.md');
expect(bWrite?.content).toContain('edited during sync');
expect(bWrite?.content).not.toContain('keep me');
});
});