Skip to content

fix(settings): stop an interrupted write destroying the user's settings - #524

Merged
thcp merged 1 commit into
0.16.1from
fix/509-settings-atomic-write
Aug 31, 2026
Merged

fix(settings): stop an interrupted write destroying the user's settings#524
thcp merged 1 commit into
0.16.1from
fix/509-settings-atomic-write

Conversation

@thcp

@thcp thcp commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes #509. This is the bug that lost real settings: a user's settings.json went from five keys to one, dropping port and allow_network from both the file and its backup.

Cause

_save() used write_text, which truncates first and writes second:

_SETTINGS_PATH.write_text(json.dumps(_ensure()), encoding="utf-8")

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", and registry.persist(). The primary file was the only unprotected writer.

The sequence:

  1. _save() truncates; the process dies mid-write. The file now exists and does not parse.
  2. _load() caught the parse error and returned {} -- indistinguishable from a first run.
  3. The next set_*() persisted a one-key file, then mirrored it over the good backup.

jobs_dir is 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, then replace. Used by both _save() and _mirror_settings(). The mirror previously used a fixed .json.tmp name that two concurrent writers could interleave on; that goes away too.

_load() distinguishes absent from unusable:

parses             -> use it
absent             -> defaults (genuine first run)
exists, unparsable -> rename to settings.json.corrupt-<timestamp>
                      seed from mirror if present, else defaults

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:

FAILED test_an_unreadable_file_is_not_mistaken_for_a_first_run
FAILED test_an_unreadable_file_is_kept_for_diagnosis
FAILED test_recovered_settings_are_written_back_immediately
FAILED test_a_non_object_settings_file_is_treated_as_unusable
ruff check       All checks passed
ruff format      already formatted
pytest tests/    901 passed, 2 failed

The 2 failures are test_stems_api.py::test_all_stems_zip_ogg and ::test_ogg_is_still_streamed. Both fail identically on 0.16.1 without 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_intact deliberately avoids monkeypatch.undo(). The same monkeypatch instance carries conftest's _SETTINGS_PATH isolation, 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.

_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
@thcp
thcp marked this pull request as ready for review August 31, 2026 21:02
@thcp
thcp merged commit 646e9e2 into 0.16.1 Aug 31, 2026
8 checks passed
@thcp
thcp deleted the fix/509-settings-atomic-write branch August 31, 2026 21:02
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.

1 participant