# Pending saves after read failures Head: `cb8d43ea94cc47c21f5a9a615e0e5e09f55ba2a4`. Base: `48566abb37a0a69a11acee80b0659205e902a186` (origin/master, merged #425). Branch: `fix/retry-after-read-failure`. Runtime: Obsidian 1.14.4 on Linux. Isolated vault `podnotes-fix-surface-write-failures`. Node v22.23.2. ## Result and scope F1 retries a failed pre-write read through the existing 10-second checkpoint when a failed write or unsaved edit remains. A successful read lifts the unreadable refusal and saves the pending settings. N1 adds no-Notice and no-timer assertions to the existing window-unload test. N2 lets strict import and API-key callers show their specific write-failure Notice without a generic duplicate, including on subsequent retries. Production changes are confined to `writer.ts`. Detection and retention already existed in `f7400ba6`; the 75 baseline persistence tests pass on master. There is no schema, settings-shape, encode-boundary, migration, or public API change. F2, the stale failed-write flag after an external merge, is excluded as requested. ## Live before and after The probes drive actual PodNotes Settings controls with trusted input. No podcast feed or audio fixture is needed for these settings cases. Only the adapter methods for PodNotes' exact `.obsidian/plugins/podnotes/data.json` path reject. The probes restore the adapter and original file in `finally` and reload PodNotes. For F1, reject writes, set Skip backward to 29, then reject reads across two retry intervals. Set Skip forward to 25 during the refusal. Restore reads, then writes, waiting more than 10 seconds at each stage. | Observation | Master | Final branch | | --- | --- | --- | | Memory after recovery | 29/25 | 29/25 | | Disk after recovery | 15/15 | 29/25 | | Automatic recovery save | None | Completed | | Generic write-failure Notice | One | One | | Sticky unreadable Notice | One, remains visible | One, hides after read recovery | | Idle writes after recovery | Zero | Zero | Disk stays unchanged throughout the outage. Its SHA-256 is `fceab877035127efbabc2cba359b825f487a63d739f75bf28ef2bfed64610b81`. On the final branch recovery changes it to `2d98d000180b902984145d69bdc9ed0da0bb3721e887d39583283987443654d9`. The final retry intervals are 10000, 10004, 10007, and 10000 ms. The probe asserts the persisted values, Notice counts, refusal removal, cadence, and no idle writes. See `master.json`, `branch.json`, `branch-summary.json`, both probe logs, and the Settings screenshots. For N2, the real file picker selects `import.json` and the user-facing confirmation starts the import. Master displays a generic Notice plus the specific rollback failure Notice, then another generic Notice after the retry interval. The final branch displays only the specific Notice through the same failures and recovery. Recovery still writes the rollback because a decoded read can contain repaired entries that are absent from raw disk. See both import probe logs, JSON files, and screenshots. The write-count probe records 11 seconds idle, a Settings edit, another 11 seconds idle, and restoration of the setting. It uses the repository's `count:writes` recorder. Master records zero idle writes and two completed writes, 3966 total bytes. The final branch reports are identical at every phase, including both completed writes and total bytes. The recorder remained intact with no in-flight writes. See `branch-counts.json` and `master-counts.json`. Fault injection rejects with `Injected write outage`, `Injected read outage`, or `Injected import write outage`. These are deliberate adapter failures. The probes inspect the resulting Notices and persisted file, and restore both adapter methods and file even if an assertion fails. ## Commands and gates All commands run from this worktree with `TMPDIR="$PWD/.tmp"` and Node 22 through `fnm exec --using 22 --`: ```sh npm run build npm run start:e2e-obsidian node .obsidian-e2e-artifacts/read-retry/probe.mjs master node .obsidian-e2e-artifacts/read-retry/import-probe.mjs master node .obsidian-e2e-artifacts/read-retry/count-probe.mjs master ``` After building the branch, start its isolated instance and repeat the three probes with `branch` instead of `master`. Stop the instance before the full E2E run so the suite owns a fresh process. The final head passes `npm run lint`, `npm run format:check`, `npm run typecheck`, `npm run build`, `npm run test`, `npm run test:tooling`, `npm run docs:build`, and `npm run size`. There are 1736 passing application tests in 99 files and 10 passing tooling tests. Both Svelte checks report zero errors and warnings. Docs build 18 pages with valid internal links and all 112 legacy fragments intact. See `gates.json` and `gate-*.log`. The production bundle is 285447 raw bytes and 90856 gzip bytes at level 9, +109 and +40 versus master. Both budgets pass. Its SHA-256 is `99113210859e52bf3ee81df962d9594db166d1d6e18cfbca9be11a8a006e3486`. `npm run test:e2e` completed successfully on the final head: 77 tests in 12 files, 273.81 seconds. This includes the raw-read rejection, unload, external merge, and quit-save runtime cases. See `e2e.log`. The suite stopped the instance it launched. ## Regression proof and independent review Tests were committed before the fix. On master, the initial regression set has five expected failures: both F1 cases retain old disk values, and the API-key and import cases include unwanted generic Notices. See `red-tests.log`. The independent GPT review found a real bug in an attempted write-skipping optimization: `file.read(base)` can repair a damaged entry from base, so equality with the returned document cannot prove raw disk durability. We removed that optimization entirely. `settleFailedWrite` and the subsequent write retain master's behavior. Capturing notification eligibility before readback preserves the same failure streak without skipping the write. The new damaged-entry test fails on the rejected implementation with `Before: null` on disk. The final version saves valid `Before` and `Remote` entries, keeps the specific Notice alone, and clears the timer after recovery. All 188 targeted tests pass. Moving the notification check back after settlement fails both rollback cases on extra Notices. Removing the WriteRefusedError rethrow fails the unload test with one timer instead of zero. Removing the read retry fails both F1 tests; removing its pending-work guard fails the null-base writer test. See `review-fix-*.log`, `mutation-tests.log`, and `review-fix.md`. Claude's final review reports no blockers, and a fresh GPT follow-up reports no findings. The redundant comments identified by comment review were deleted. See `review-claude.md`, `review-gpt.md`, `review-gpt-final.md`, and `comment-review.md`. An earlier E2E attempt was deliberately interrupted when the critical review finding arrived. Final gates and live probes were rerun after correction. `e2e-interrupted.log`, `superseded/`, and `implementation.md` describe the rejected intermediate version, not the final result. ## Known scope limits An older-schema refusal can continue read-only retries while work remains pending. It still cannot overwrite the file. The pre-existing case where all pending edits revert to base before a retry can leave the unreadable refusal until an external change or restart. No unsaved changes remain in that case. F2 is unchanged. Fix Root Causes placed recovery at the failed pre-write read. Boundary Discipline kept decoding and refusal rules in the existing boundary. Prove It Works required matching live before/after probes, actual disk reads, master regression failures, mutation checks, and independent review. ## Final cleanup The final bundle digest still matches the live-tested bundle above. An explicit `npm run stop:e2e-obsidian` stopped no additional processes, and `npm run stop:e2e-obsidian -- --dry-run` reports `pids: none`. Owned `.tmp` scratch and the interrupted-run vault snapshot were removed. The final source tree is clean at the head recorded above. See `final-stop.log` and `final-stop-dry-run.log`.