Skip to content

fix(sync): show a synced library that Obsidian does not report as changed - #480

Merged
chhoumann merged 2 commits into
masterfrom
fix/sync-adoption
Oct 14, 2026
Merged

chhoumann merged 2 commits into
masterfrom
fix/sync-adoption

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Fixes #477

What users get

A phone or laptop that starts PodNotes before sync finishes now shows the synced library as soon as sync replaces data.json, without a restart. Before this, Obsidian never told PodNotes about some of those replacements, so the new device kept showing "Add your first podcast" until something else saved or the app restarted. The merge that keeps the library safe is unchanged: the new device still never writes its empty first-run state over the synced library.

Root cause

PodNotes learned that data.json changed elsewhere only through Plugin#onExternalSettingsChange. Obsidian 1.14.4 calls it from a 50 ms debounced check that compares the file's modified time, rounded to the millisecond, with the Date.now() it recorded at the plugin's last saveData, and calls back only when the file is strictly newer. Two cases fail that check:

  • A sync client that keeps modified times (it writes a copy, gives it the other device's time, and moves it over) delivers a library older than the new device's first save. That is never reported.
  • A write that lands about a millisecond after a PodNotes save rounds to the same millisecond. This is the Investigate: a synced library replacing data.json once didn't reach the view (e2e timeout) #477 timeout. An instrumented loop caught it naturally: the test wrote 1.7 ms after the save started, the kernel stamped the file t1 + 0.49 ms, and Obsidian rounded that to t1. There was no callback, no notice and no error, disk held the library, and the view stayed on the first-run screen, the same signature as the V-475 diagnostics.

So the verdict is a product race, not test timing. The journey waits on the right signal: the device must merge the library and keep its own change.

Fix

  • main.ts listens to the vault's raw event for data.json. Obsidian raises it for every change to the file, PodNotes' own writes included, and uses it to drive onExternalSettingsChange itself. One check also runs right after load, for a change that landed before the listener existed.
  • DataFileWriter.report() checks the file 50 ms after the changes stop, so a file written in place is read whole. The writer's existing comparison with what it last loaded or wrote decides whether anything changed. An own save changes nothing.
  • dataFileAccess.read returns early when the file holds exactly what PodNotes last loaded or wrote, so the extra check after each save costs a JSON parse and a comparison, not a decode.
  • onExternalSettingsChange stays, because Obsidian refreshes an open settings tab once it resolves.
  • global.d.ts types the undocumented raw event.

How I verified it

Loop statistics. The #477 journey was instrumented (raw events, Obsidian's stats and _lastDataModifiedTime, onExternalSettingsChange, loadData/saveData, utimes, writer state and notices) and repeated on this worktree's isolated instance under CPU contention (16 busy loops on 12 cores). Obsidian 1.14.4, Linux, XFS, CONFIG_HZ=1000.

Build Runs Failed Obsidian callback suppressed Merged write after the sync write
master, no load 30 0
master, 24 busy loops 60 0 1
master, 16 busy loops 300 1 15 median 81 ms, max 10.0 s; 14 runs waited for the 10 s retry
first fix attempt (unsettled check) 193, stopped 2 192 median 32 ms
this fix (settle timer, before the read short-cut) 300 0 11 median 87 ms, p95 132 ms, max 209 ms; none over 1 s
this fix, final 150 0 2 median 85 ms, p95 121 ms, max 228 ms; none over 1 s

The first attempt read the file on every raw event. It read data.json mid-write twice, raised the "could not read" refusal, and the refusal made an unloading instance flush its old library back. That is why the check now waits 50 ms after the changes stop, the same wait Obsidian uses.

Deterministic reproduction, the earlier-mtime sync written by the new e2e test:

  • master: 3 of 3 failed, plus 2 of 2 in the scratch loop, all "Timed out waiting for condition after 15000ms".
  • this fix: passes in about 1.1 s, plus 5 of 5 in the scratch loop, with the merged write 5 to 20 ms after the sync write.

Real surface (verify skill, .obsidian-e2e-artifacts/sync-adoption/). The journey is a new install, a screenshot, then a sync-style move of the library with a time one minute earlier, then a second screenshot.

  • master: the view stays on "Add your first podcast" and PodNotes' memory holds no feeds.
  • this fix: the view shows the Library with the synced podcast within 2 s, and dev:errors is empty.
master, after sync this fix, after sync
master: Add your first podcast after the sync this fix: the Library with the synced podcast

Writes. npm run count:writes on the normal session from V-475 (play 12 s, pause, seek to 30, play 2 s, pause), two sessions per build: master 3 attempted, 3 completed, 0 failed, twice; this fix 3, 3, 0, twice.

Mutations

Each plausible wrong version of the fix, and the test that fails on it:

Mutation Failing test
No raw listener (master) e2e "shows a library synced in with the earlier modified time it had on the other device"; unit "takes a data.json change Obsidian reports only as a raw vault event"
Public vault.on("modify") instead of raw (config files are not vault files) e2e earlier-mtime test
raw reloads the file into memory without the merge e2e earlier-mtime test and e2e "keeps a new device's change when the library replaces its data.json"
raw gated by Obsidian's own mtime comparison e2e earlier-mtime test
Check on every report, without the settle wait unit "reads the file once the changes reported to it settle" and "reads nothing for a change reported before unload"
Settle timer left running at unload unit "reads nothing for a change reported before unload"
Path filter that matches any plugin's data.json unit "takes a data.json change Obsidian reports only as a raw vault event"
No check after load unit "takes a data.json change that landed while it loaded"

One mutant survives. A timer that fires 50 ms after the first report instead of the last, which is Obsidian's own debounce, passes every test. It differs only for a writer that pauses more than 50 ms mid-write.

Checks

Node v22.23.2, on head a58c9c6c:

  • npm run lint: 0 errors, 0 warnings; Svelte policies 0 errors, 0 warnings.
  • npm run format:check: all matched files use the correct format.
  • npm run typecheck: passes.
  • npm run build: passes.
  • npm run test: 2,579 tests passed in 122 files.
  • npm run docs:build: 18 pages; all internal links valid; all 112 legacy fragments resolve.
  • npm run test:tooling: 13 tests passed in 2 files.
  • npm run test:e2e on a fresh vault: 193 tests passed in 26 files (767.5 s).
  • npm run size: raw 312,730 bytes (budget 330,000), gzip 99,143 bytes (budget 100,000). Against a master build in the same environment, the bundle grows by 479 raw bytes and 89 gzip bytes (gzip -9).
  • sync-persistence.test.ts on its own: 11 of 11 on a fresh vault.

The test commit lands first and fails on master; the fix commit follows.

Docs at release

docs/src/content/docs/sync.md, wherever the release describes PodNotes picking up a data.json that sync replaces without a restart: say it holds for sync services that keep the file's original modified time too.

Follow-ups

  • A PodNotes save that an external write replaces between Obsidian's writeFile and utimes fails its read-back and shows "PodNotes could not save your changes." although nothing was lost. The file is now adopted at once rather than after the 10 s retry. The false notice remains, in 17 of the 300 loop runs, where the test writes right after PodNotes does. Real sync rarely lands that close.
  • When Obsidian does not call onExternalSettingsChange (the cases above), it also does not refresh an open PodNotes settings tab, so the tab shows the old values until it is reopened. That predates this PR.
  • Obsidian's mobile adapter was not exercised. If it raises no raw event for data.json, PodNotes behaves there as before.
  • Worth reporting upstream: Obsidian's saveData records Date.now() before the write, and its check needs a strictly newer modified time, so synced files that keep their time are never reported to any plugin.

Note

Fix external data.json changes that Obsidian only reports as raw vault events

Fixes a bug where a synced library written by another device was not loaded because Obsidian did not report the data.json change through its normal settings channel.

  • Adds REPORT_SETTLE_MS (50 ms) to DataFileWriter in writer.ts. Reported file changes are coalesced and the file is read once, 50 ms after the last report. Unload cancels pending reports.
  • main.ts registers a raw vault listener for the plugin's exact data.json path and calls the writer's report once at startup, so changes present while loading are also picked up.
  • PodNotes.dataFileAccess gains a fast path: when loaded data is JSON-value-equal to the writer's base document, it returns a clone of the base instead of falling through to the unreadable/missing/document validation paths.
  • Adds unit and end-to-end coverage, including a scenario where data.json is replaced with an earlier modified time and the synced feed is loaded and displayed.

Macroscope summarized a58c9c6.

… changed

Obsidian calls onExternalSettingsChange only when data.json's modified
time is later than the plugin's last saveData. A sync client that keeps
the time the other device saved the file, or a write that rounds to the
same millisecond as a save, is never reported, so a new device keeps
its empty first-run library (#477).

The e2e test moves a library over data.json with an earlier modified
time, the way such a sync client does, and expects the view to show it.
The unit tests take a change reported as a raw vault event once the
reports settle, take a change that landed while PodNotes loaded, and
read nothing after unload. The own-save e2e test now also counts
writes, so a check of PodNotes' own save that writes again fails it.
PodNotes learned of a data.json replaced by sync only through
onExternalSettingsChange, which Obsidian skips unless the file's
modified time is later than the plugin's last save. A new device that
saved its first-run library before sync finished never showed the
synced library: with a sync client that keeps the other device's
earlier modified time always, and in the e2e journey once in about 300
runs, when the test's write rounded to the save's millisecond (#477).

PodNotes now listens to the vault's raw event for data.json, which
Obsidian raises for every change, PodNotes' own writes included, and
checks the file 50 ms after the changes stop, so a file written in
place is read whole. The writer's existing comparison with what was
last loaded or written decides whether anything changed, so an own
save changes nothing. One check also runs after load, for a change
that landed before the listener existed. A read that finds the file
unchanged skips decoding it, so the extra check costs a parse and a
comparison. onExternalSettingsChange stays: Obsidian refreshes an open
settings tab once it resolves.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: a58c9c6
Status: ✅  Deploy successful!
Preview URL: https://917f50c4.podnotes.pages.dev
Branch Preview URL: https://fix-sync-adoption.podnotes.pages.dev

View logs

@chhoumann
chhoumann merged commit 00d4070 into master Oct 14, 2026
18 checks passed
@chhoumann
chhoumann deleted the fix/sync-adoption branch October 14, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investigate: a synced library replacing data.json once didn't reach the view (e2e timeout)

1 participant