fix(settings): stop an interrupted write destroying the user's settings - #524
Merged
Conversation
_save() called write_text, which truncates first and writes second. A process
that died in between -- app quit, the parent watchdog's SIGTERM, power loss --
left settings.json present and unparsable.
_load() then could not tell that apart from a first run: it caught the parse
error, returned {}, and the next set_*() persisted a single key over both
settings.json and the per-user mirror that exists to protect it. A real user
lost port and allow_network from both copies, with only a warning in the log.
jobs_dir is the worse case, since losing it makes a relocated library look
empty -- precisely what the mirror was added to prevent.
Three changes:
- _atomic_write_json() writes through a uniquely-named same-directory temp and
replaces, so an interrupted write cannot truncate what was already there.
_save() and _mirror_settings() both use it; the mirror previously used a
fixed ".json.tmp" name that two writers could interleave on.
- _load() distinguishes absent from unusable. Absent stays a first run. A file
that exists but does not parse -- or parses to something other than an
object -- is moved aside as settings.json.corrupt-<timestamp>, so the bytes
survive for diagnosis instead of being overwritten by the next save, and the
mirror is consulted before falling back to defaults.
- Settings recovered from the mirror are written straight back to the primary.
Recovering only into memory would last until the next start, which would
read a now-absent primary and quietly return to defaults again.
The tests cover the exact sequence that lost data: a torn write, then a
restart. Four of them fail against the old _load and pass against this one.
Refs #509
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #509. This is the bug that lost real settings: a user's
settings.jsonwent from five keys to one, droppingportandallow_networkfrom both the file and its backup.Cause
_save()usedwrite_text, which truncates first and writes second:Every other persistence path in the codebase already used temp+rename -- including
_mirror_settings()eleven lines below, whose comment says "a torn write here would be restored verbatim into the user's next install", andregistry.persist(). The primary file was the only unprotected writer.The sequence:
_save()truncates; the process dies mid-write. The file now exists and does not parse._load()caught the parse error and returned{}-- indistinguishable from a first run.set_*()persisted a one-key file, then mirrored it over the good backup.jobs_diris the worst case:config._stored_jobs_dir()falls back to the default and a relocated library looks empty, exactly the failure the mirror was added to prevent.Changes
_atomic_write_json()-- uniquely-named same-directory temp, thenreplace. Used by both_save()and_mirror_settings(). The mirror previously used a fixed.json.tmpname that two concurrent writers could interleave on; that goes away too._load()distinguishes absent from unusable:Renaming rather than overwriting keeps the bytes for diagnosis. Deleting them, or letting the next save clobber them, destroys the only evidence of what the user had configured.
A file that is valid JSON but not an object (
[1,2,3]) is treated as unusable too -- returning it would make every later.get()raise.Recovered settings are written straight back to the primary. Recovering only into memory would last until the next start, which would read a now-absent primary and silently return to defaults.
Verification
New
tests/test_settings_durability.py, 8 tests covering the exact loss sequence.Confirmed not vacuous: reverting
_load()alone makes 4 of them fail:The 2 failures are
test_stems_api.py::test_all_stems_zip_oggand::test_ogg_is_still_streamed. Both fail identically on0.16.1without this change -- verified by checking out the base branch and re-running. They look like a local ffmpeg build without libvorbis, not a code defect, but worth a look separately.Note for reviewers
test_a_failed_write_leaves_the_previous_settings_intactdeliberately avoidsmonkeypatch.undo(). The samemonkeypatchinstance carries conftest's_SETTINGS_PATHisolation, so undoing mid-test points assertions at the developer's real settings file -- which is exactly what happened while writing this, and is worth knowing about for any future test in this area.