fix(settings): keep stored preferences when the file is shared or unreadable #47

Merged
james.maes merged 6 commits from fix/settings-store into main 2026-11-11 01:12:57 +00:00
Owner

Closes #46.

Problem

settings.json is shared by every window and by every process using one data folder. On main:

  • A load that hit a sharing violation returned defaults, and the next save wrote them back. Reproduced with a real exclusive handle: stored shell theme, document theme, global CSS and recent files were replaced by defaults.
  • Two processes erased each other's changes (no lock, one shared temporary file name). Reproduced with two real processes recording 8 recent files each: 11 of 16 saves failed and 5 of 16 entries survived.

Change

  • AppSettingsStore (Core) applies one change to the latest stored settings under a lock file beside settings.json and replaces the file by rename.
  • A file that cannot be read after bounded retries is never overwritten. Only invalid content is corruption; it is kept as settings.json.corrupt.
  • A file over 16 MiB is not loaded. Abandoned temporary files are removed by the next save.
  • Faults are logged by kind, exception type and HRESULT only; no path or operating-system message text.
  • Every save in the app goes through Update; the three-way merge per window is unchanged.

User-visible: a save that cannot complete reports failure instead of silently replacing the profile. User guide and threat model updated.

Verification (run, not only read)

  • Local gate script reproducing the Gitea workflow plus adapter and fuzz tests: all pass. Core tests 698 passed, line coverage 100%.
  • 21 new tests use real exclusive handles and four concurrent store instances; attempts are counted, not timed.
  • Mutation check on the new store: 8 mutants (unreadable no longer blocks the write, shared lock, corrupt not kept aside, sharing violation not retried, no size bound, stale temporary files kept, temporary file left on failed replace, change applied to defaults), all killed.
  • Real-process experiment after the change: the locked-file case keeps the profile; three processes recording 6 entries each keep 18 of 18.

Not run: the FlaUI desktop suite (it takes the interactive desktop; the Gitea workflow does not run it). The maintainer will run it locally.

AI assistance

Written with Claude Fable 5.2 (analysis, implementation, tests). An independent read-only review of this diff is recorded below before merge.

🤖 Generated with Claude Code

Closes #46. ## Problem `settings.json` is shared by every window and by every process using one data folder. On `main`: - A load that hit a sharing violation returned defaults, and the next save wrote them back. Reproduced with a real exclusive handle: stored shell theme, document theme, global CSS and recent files were replaced by defaults. - Two processes erased each other's changes (no lock, one shared temporary file name). Reproduced with two real processes recording 8 recent files each: 11 of 16 saves failed and 5 of 16 entries survived. ## Change - `AppSettingsStore` (Core) applies one change to the latest stored settings under a lock file beside `settings.json` and replaces the file by rename. - A file that cannot be read after bounded retries is never overwritten. Only invalid content is corruption; it is kept as `settings.json.corrupt`. - A file over 16 MiB is not loaded. Abandoned temporary files are removed by the next save. - Faults are logged by kind, exception type and HRESULT only; no path or operating-system message text. - Every save in the app goes through `Update`; the three-way merge per window is unchanged. User-visible: a save that cannot complete reports failure instead of silently replacing the profile. User guide and threat model updated. ## Verification (run, not only read) - Local gate script reproducing the Gitea workflow plus adapter and fuzz tests: all pass. Core tests 698 passed, line coverage 100%. - 21 new tests use real exclusive handles and four concurrent store instances; attempts are counted, not timed. - Mutation check on the new store: 8 mutants (unreadable no longer blocks the write, shared lock, corrupt not kept aside, sharing violation not retried, no size bound, stale temporary files kept, temporary file left on failed replace, change applied to defaults), all killed. - Real-process experiment after the change: the locked-file case keeps the profile; three processes recording 6 entries each keep 18 of 18. Not run: the FlaUI desktop suite (it takes the interactive desktop; the Gitea workflow does not run it). The maintainer will run it locally. ## AI assistance Written with Claude Fable 5.2 (analysis, implementation, tests). An independent read-only review of this diff is recorded below before merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes added 1 commit 2026-11-10 22:12:10 +00:00
fix(settings): keep stored preferences when the file is shared or unreadable
node / node (push) Successful in 14s
build / scripts (push) Successful in 14s
windows / windows (push) Successful in 1m58s
build / windows (push) Successful in 1m58s
6919d3ce4a
A load that hit a sharing violation returned defaults, and the next save
wrote them back over the stored themes, CSS, editors and recent files. Two
processes over one data folder also erased each other's changes: each did
load, modify, save with no lock and one shared temporary file name.

AppSettingsStore applies one change to the latest stored settings under a
lock file beside settings.json and replaces the file by rename. A file that
cannot be read after bounded retries is never overwritten; only invalid
content counts as corruption and is kept as settings.json.corrupt. A file
over 16 MiB is not loaded, abandoned temporary files are removed, and
faults are logged by kind, exception type and HRESULT only.

Every save in the app now goes through Update. Tests use real exclusive
handles and concurrent instances and count attempts instead of time.

Closes #46

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
james.maes added 1 commit 2026-11-10 22:57:43 +00:00
fix(settings): never set aside a file another program is writing
node / node (push) Successful in 23s
build / scripts (push) Successful in 23s
windows / windows (push) Successful in 1m53s
build / windows (push) Successful in 1m52s
600825030a
Reading while denying writers makes a file that is being rewritten in
place read as busy instead of as invalid content, and the read handle is
held until the swap, so the content judged is the content replaced. The
swap uses a replace operation: an invalid original moves to its kept-aside
name in the same step, a failed save leaves it in place, and a reader that
allows replacement no longer blocks a save.

Access denied is permanent and reported at once; only a sharing violation
is waited out, so a read-only profile no longer stalls a window. One
invalid value costs only that value, a trailing comma, a comment and a
quoted number are accepted, and a saved theme the JSON reader cannot
decode is discarded instead of stopping startup. Kept-aside files use the
first free of nine names. Only this store's own temporary files are
removed. ADR-0006 records the on-disk protocol.

Tests cover each of these with real handles held the way other programs
hold them, a deterministic interleaving of two instances, the on-disk
format against the previous store, and bounded retries in the concurrent
test.

Refs #46

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
james.maes added 1 commit 2026-11-10 23:59:48 +00:00
fix(settings): replace the settings file in one rename
node / node (push) Successful in 27s
build / scripts (push) Successful in 27s
windows / windows (push) Successful in 2m48s
build / windows (push) Successful in 2m48s
80e00b3ed1
The stored file is now replaced with a single rename that works while
other handles are open on it, so there is never a moment without a
settings file and a process killed mid-save leaves the old file or the
new one. Volumes without that rename fall back to a move.

Content the JSON reader cannot decode is treated as invalid content
rather than an error, the read handle is released on every path, and a
kept-aside copy that cannot be made no longer blocks the save. Clearing
recent files also deletes kept-aside copies.

ADR-0006, the user guide and the threat model describe the protocol as
implemented.

Refs #46

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
james.maes added 1 commit 2026-11-11 00:43:51 +00:00
fix(settings): save to long paths and place kept copies after the save
node / node (push) Successful in 31s
build / scripts (push) Successful in 31s
windows / windows (push) Successful in 2m24s
build / windows (push) Successful in 2m24s
34b6aba0ff
The rename that replaces the settings file now takes the path in the
form Windows reads as written, so a data folder past 260 characters,
or one whose name is not well-formed UTF-16, saves like any other.
Before, every save after the first was refused there.

The copy of an invalid file is written to a temporary name and given
its kept-aside name only after the save succeeds, so a failed save can
no longer overwrite and then delete an earlier copy, and a killed save
leaves no duplicate copies. A file over 16 MiB is replaced without a
copy.

On a volume that needs the fallback move, a reader's open handle is
waited out instead of failing the save at once.

Refs #46

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
james.maes added 2 commits 2026-11-11 01:09:52 +00:00
A copy of an invalid settings file that cannot be written, for example
on a full disk, is now reported and the save goes ahead, as it does
when the copy cannot be given its name. Before, the save was refused.

On a volume that needs the fallback move, a read-only settings file is
recognized by its attribute and refused at once instead of after the
full wait.

Refs #46

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
chore: merge main into fix/settings-store
node / node (push) Successful in 14s
build / scripts (push) Successful in 13s
windows / windows (push) Successful in 1m52s
build / windows (push) Successful in 1m52s
af81ba050a
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
Author
Owner

Adversarial review record

An independent read-only reviewer tried to break this branch over five rounds. It ran its own kill loops, multi-process runs and mutation campaigns in scratch copies and never edited the branch.

Round Head Verdict Outcome
1 6919d3c BLOCK A valid file was set aside as corrupt while another program rewrote it in place; access denied was waited on for a second on the window thread; tests and docs were not exact. Fixed in 6008250.
2 6008250 BLOCK The two-rename replace left a moment with no settings file, so a kill there lost the profile; undecodable text made load and save throw. Fixed in 80e00b3 with a single rename.
3 80e00b3 PASS 0 missing files in 150 + 150 kills and 28,848 concurrent reads. One Major edge case: a data folder path over 260 characters refused every save after the first. Fixed in 34b6aba, with the kept-copy ordering and the fallback wait.
4 34b6aba PASS Long, UNC, 8.3, junction and ill-formed paths all save; no duplicate or lost earlier copies in 250 kills. Two Minor findings, fixed in 181950b: a copy that cannot be written no longer refuses the save, and a read-only file on a fallback volume is refused at once.

Run by me on real volumes: three saves each on NTFS with a 300-character path, on a loopback network share (the fallback path) and on ReFS, no faults.

Left as is, on purpose:

  • A process killed between the save and the naming of the kept copy loses that copy (ADR-0006 states it).
  • File attributes on settings.json are not preserved across a save, as before this change.
  • Mutants that need fault injection to observe (flush to disk; the default pause between attempts).

Gates on af81ba0 (branch merged with main): locked restore, zero-warning build, format, 953 core tests at 100% line coverage, adapter tests, fuzz, script analysis, Pester.

This is an automated review, not a human one.

## Adversarial review record An independent read-only reviewer tried to break this branch over five rounds. It ran its own kill loops, multi-process runs and mutation campaigns in scratch copies and never edited the branch. | Round | Head | Verdict | Outcome | | --- | --- | --- | --- | | 1 | 6919d3c | BLOCK | A valid file was set aside as corrupt while another program rewrote it in place; access denied was waited on for a second on the window thread; tests and docs were not exact. Fixed in 6008250. | | 2 | 6008250 | BLOCK | The two-rename replace left a moment with no settings file, so a kill there lost the profile; undecodable text made load and save throw. Fixed in 80e00b3 with a single rename. | | 3 | 80e00b3 | PASS | 0 missing files in 150 + 150 kills and 28,848 concurrent reads. One Major edge case: a data folder path over 260 characters refused every save after the first. Fixed in 34b6aba, with the kept-copy ordering and the fallback wait. | | 4 | 34b6aba | PASS | Long, UNC, 8.3, junction and ill-formed paths all save; no duplicate or lost earlier copies in 250 kills. Two Minor findings, fixed in 181950b: a copy that cannot be written no longer refuses the save, and a read-only file on a fallback volume is refused at once. | Run by me on real volumes: three saves each on NTFS with a 300-character path, on a loopback network share (the fallback path) and on ReFS, no faults. Left as is, on purpose: - A process killed between the save and the naming of the kept copy loses that copy (ADR-0006 states it). - File attributes on `settings.json` are not preserved across a save, as before this change. - Mutants that need fault injection to observe (flush to disk; the default pause between attempts). Gates on af81ba0 (branch merged with main): locked restore, zero-warning build, format, 953 core tests at 100% line coverage, adapter tests, fuzz, script analysis, Pester. This is an automated review, not a human one.
james.maes merged commit 1bf7c8132d into main 2026-11-11 01:12:57 +00:00
Sign in to join this conversation.