ShoppingListStore writes the .shopping-list file with std::fs::write, which truncates the target before writing. A failure part-way through leaves the user with a truncated or empty shopping list.
Where
src/server/shopping_list_store.rs:
fs::write(&self.list_path, buf).context("writing .shopping-list")
That is save_list, which sits behind add, add_menu, remove, clear — and the legacy migration.
The same file already does the right thing elsewhere: compact() stages the new .shopping-checked content in a sibling temp file, fsyncs it, and renames it into place. So the atomic pattern is present and understood; it just is not used on the .shopping-list path.
Why the migration case is the worst of them
migrate_if_needed reads the legacy .shopping_list.txt, converts it, and its first write is save_list. If that write fails part-way, the new file is truncated while the legacy file has already been renamed to a backup — so both copies are in a bad state at once.
Impact
Requires a write failure to trigger — a full disk, a revoked permission, a disconnected network mount, or the process being killed at the wrong moment. Uncommon, but the failure mode is silent data loss on a file the user has curated, and the fix is one the codebase already contains.
Suggested direction
Route save_list through the same stage-fsync-rename helper compact() uses (src/server/fs_atomic.rs).
Notes
Found while lifting the store into a library crate, where the same code becomes reachable by the Cooklang editor and other consumers. Both write paths go through the atomic helper on that branch, pinned by a test that asserts the file's inode is replaced rather than rewritten in place. Filing separately because it is a live defect on main.
Related: #429 is the same class of bug in the pantry writer.
ShoppingListStorewrites the.shopping-listfile withstd::fs::write, which truncates the target before writing. A failure part-way through leaves the user with a truncated or empty shopping list.Where
src/server/shopping_list_store.rs:That is
save_list, which sits behindadd,add_menu,remove,clear— and the legacy migration.The same file already does the right thing elsewhere:
compact()stages the new.shopping-checkedcontent in a sibling temp file, fsyncs it, and renames it into place. So the atomic pattern is present and understood; it just is not used on the.shopping-listpath.Why the migration case is the worst of them
migrate_if_neededreads the legacy.shopping_list.txt, converts it, and its first write issave_list. If that write fails part-way, the new file is truncated while the legacy file has already been renamed to a backup — so both copies are in a bad state at once.Impact
Requires a write failure to trigger — a full disk, a revoked permission, a disconnected network mount, or the process being killed at the wrong moment. Uncommon, but the failure mode is silent data loss on a file the user has curated, and the fix is one the codebase already contains.
Suggested direction
Route
save_listthrough the same stage-fsync-rename helpercompact()uses (src/server/fs_atomic.rs).Notes
Found while lifting the store into a library crate, where the same code becomes reachable by the Cooklang editor and other consumers. Both write paths go through the atomic helper on that branch, pinned by a test that asserts the file's inode is replaced rather than rewritten in place. Filing separately because it is a live defect on
main.Related: #429 is the same class of bug in the pantry writer.