Protect settings files from overwrite, and fix three save bugs - #599
Merged
Merged
Conversation
…hem, and flush AtomicFile writes to disk Three stores (AppSettingsService, ConnectionStore, SettingsFile) returned defaults whenever their file could not be read or parsed, and the next save silently wrote those defaults over the user's real file. A shared helper, SettingsFileStore, now tells the two failure modes apart: a file that fails to parse (or parses to the wrong shape, like SettingsFile's array-vs-object case) is moved aside to a .bad-<UTC timestamp> sibling and saves resume normally; a file that fails to read at all is left untouched and saves are refused until a later read of it succeeds. AppSettingsService.Save already swallowed write failures and now swallows this one the same way; ConnectionStore.Save and SettingsFile.Update throw an IOException naming the file instead, since their callers need to know why nothing was written. ConnectionStore gained a RedirectForTestHost seam, matching the other two stores, so tests never touch the real ~/.planview files. AtomicFile.WriteAllText wrote its .tmp with File.WriteAllText and renamed it without ever flushing to disk, so a power loss right after a "successful" save could still leave the renamed file empty. It now writes through a FileStream and calls Flush(flushToDisk: true) before the rename, with the same preamble/no-preamble handling File.WriteAllText always had, so the on-disk bytes are unchanged for a null encoding, an explicit BOM-emitting one, and a legacy single-byte code page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR
…lter panel state The connection dialog and the Settings window both let the IOException that ConnectionStore.Save and SettingsFile.Update now throw escape their click handlers, which crashes the app. Both now catch IOException and UnauthorizedAccessException, show the message, and stay open. The connection dialog reports into its existing StatusText through a small TrySaveConnection helper, which can be tested without a live SQL connection. The Settings window had no error display, so it gets a text block in the button bar. ServerFilterExpander_StateChanged cloned the cached AppSettings, changed the clone and saved it. AppSettingsService cached the clone, but MainWindow kept its own older _appSettings, and MainWindow's next save (opening a plan, closing a tab) wrote the older object back over the clone, reverting the panel's expanded state. It now changes the shared cached instance in place, as every other AppSettings save in MainWindow does. The new test drives the real grid control and MainWindow, and fails against the old code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
The hand-written default encoding replaced a lone surrogate with U+FFFD, where File.WriteAllText's default throws EncoderFallbackException. A save-in-place could then succeed and change a character in the user's file without telling them. The default now throws on invalid text, and a test compares the two behaviours directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
The three unreadable-file tests checked the refused save while the file was still locked. The rename onto a locked file fails on its own, so they passed even with the blocking flags removed. The AppSettingsService and ConnectionStore tests now release the lock first and check the refusal before any successful read, which only the flag can cause. SettingsFile.Update reads first, so its refusal can only be seen under the lock; its test now asserts the refusal message instead of just the path. With the flags disabled, all three tests fail. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
MainWindow keeps the settings from its first load and hands them to the Settings window, which clones and saves them. When that first read failed, those are defaults, so a later successful read by another caller must not lift the block: saving MainWindow's copy would still replace the user's file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
AddOrUpdate and SettingsFile.Update read and then write. They checked a shared flag that any read sets, so a read on another thread (the MCP server's) could clear it between their failed read and their write. Each now refuses when its own read found the file unreadable. SettingsFile had no other use for its flag, so the flag is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
erikdarlingdata
marked this pull request as ready for review
September 28, 2026 21:09
|
Reviewed the diff (settings/connection file handling, AtomicFile fsync, dialog error surfacing). No correctness or security blockers. Untrusted plan XML and generated T-SQL aren't touched, and no version files changed, so no Ssms bump is needed. Minor notes, none blocking:
|
…s race The Settings window closed as if it had saved when AppSettingsService was blocking saves after a failed read. It now shows why, and stays open and dirty; Integrations still save. Quarantine names now carry milliseconds and a counter, and a reader that finds the file already moved aside by another reader counts it as moved aside instead of unreadable, which for app settings blocked saves until restart. The settings-file test collection now runs on its own: its save blocks are process-wide, and a test in another class saving settings at that moment would fail for no reason of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
|
Reviewed the diff. I found no correctness, untrusted-input, or security problems, and the change adds no SQL. I did not build the branch or run the tests. The new failure handling looks consistent:
Minor, non-blocking points:
|
Owner
Author
|
Review notes, taken in 6e7d553 unless stated:
The settings-file test collection now also runs on its own. Its save blocks apply to the whole process. A test in another class that saved settings at that moment failed for no reason of its own. |
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.
What does this PR do?
It fixes four settings-persistence bugs. Each one matched the code as described, so none was skipped.
F1: unreadable or corrupt settings files were overwritten with defaults
Three stores returned defaults when their file failed to load:
AppSettingsService,ConnectionStore, andSettingsFile(the MCP port and proxy settings). The next save then wrote those defaults over the user's real file. A new shared helper,SettingsFileStore, gives all three stores one rule:<file name>.bad-<UTC yyyyMMddHHmmssfff>in the same folder, with a counter added if that name is taken. A file is malformed when its text does not parse, or when it parses to the wrong shape. The store then returns defaults, and saves work normally. The user's text survives in the.badfile. Two readers can find the same broken file at once, for example the UI and the MCP server. The second finds it already moved aside, and that counts as moved aside, not unreadable.SettingsFilealso treated valid JSON of the wrong shape, such as an array, as an empty file. That case is now malformed.A refused save works differently in each store:
AppSettingsService.Savealready ignored write failures, so it skips quietly. Its block lasts until the app closes.MainWindowkeeps the settings from its first load and passes them to the Settings window, which saves a copy. After a failed read, those settings are defaults, so a later successful read must not let them be saved. The Settings window checks the block after its save. It shows why nothing was saved and stays open, instead of closing as if it had saved.ConnectionStoreandSettingsFile.Updatethrow anIOExceptionthat names the file. Callers must handle it, and F7 does that.ConnectionStore.AddOrUpdateandSettingsFile.Updateread the file first, and refuse when their own read fails, so a read on another thread cannot let them write.ConnectionStore.Saverefuses until a later read of the file succeeds.ConnectionStorehad no test redirect. Its path pointed at the real~/.planviewfolder, so a test that saved a connection wrote the developer's own list. It now hasRedirectForTestHost, called from the same test-host setup as the other two stores.The task text mentioned
ConnectionStore.Remove. That method does not exist here.ConnectionStorehasLoad,Save, andAddOrUpdate, andConnectionDialogis the only caller that saves.F10:
AtomicFiledid not flush before the renameAtomicFile.WriteAllTextwrote its.tmpfile and renamed it without flushing to disk. After a power loss, the renamed file can be empty. It now writes through aFileStreamand callsFlush(flushToDisk: true)before the rename.The bytes stay the same. A null encoding writes UTF-8 with no BOM. A given encoding writes its own preamble, as
File.WriteAllTextdoes. A separate commit fixes one edge that differed: the new default replaced a lone surrogate with U+FFFD, whereFile.WriteAllTextthrows. The default now throws too, and a test compares the two.F7: a refused save crashed the app
ConnectionDialog.Connect_ClickandSettingsWindow.Save_Clickboth let theIOExceptionfrom F1 escape. Both now catchIOExceptionandUnauthorizedAccessException, show the message, and stay open.StatusText. The save goes through a smallTrySaveConnectionhelper, so a test can call it without a live SQL connection.SaveIntegrationshas two write points, the MCP write andProxySettings.Save, and onetryblock covers both.F8: the server-filter panel state was lost on the next
MainWindowsaveServerFilterExpander_StateChangedcloned the cached settings, changed the clone, and saved it.AppSettingsServicecached the clone, butMainWindowkept its own older reference. The next unrelatedMainWindowsave, such as opening a plan, wrote the older object back over the change. The handler now changes the shared cached instance in place, as every other settings save inMainWindowdoes. The new test drives the real grid control andMainWindow. I checked that it fails against the old code.Which component(s) does this affect?
How was this tested?
There are 21 new tests:
SettingsFileStoreTests(12): the malformed, missing, and unreadable cases for all three stores, and the array-shapedSettingsFilecase. Two more cover a file another reader already moved aside, and two quarantines of the same path.AtomicFileTests(5): byte-for-byte comparison withFile.WriteAllTextfor a null encoding, UTF-8 with a BOM, andEncoding.Latin1.Latin1is a legacy single-byte code page that needs no extra package. One test covers the lone-surrogate edge, and one covers overwriting.SaveFailureDisplayTests(3): each dialog shows the refusal message and stays open, including the Settings window when the app settings save is blocked.ServerFilterPanelPersistenceTests(1): the F8 regression test.The unreadable tests lock the file with
FileShare.None. A rename onto a locked file fails by itself, so a refusal seen under the lock proves little. TheAppSettingsServiceandConnectionStoretests therefore release the lock first. They then try the save before any successful read, and the file must stay untouched.SettingsFile.Updatereads first, so its refusal is only visible under the lock. Its test checks the refusal message instead. TheConnectionStoreandSettingsFiletests then read again and check that saving works. TheAppSettingsServicetest reads again and checks that saves stay refused, for the old defaults and for the newly read settings. With the blocking flags disabled, all three tests fail. I ran that check.Five of these tests hold a file open, and they use
Assert.SkipUnless(OperatingSystem.IsWindows(), ...). Unix file permissions do not reliably block a same-user read. Ubuntu CI skips those five tests. They ran here on Windows.The new settings tests,
SettingsIntegrationsTestsandServerFilterPanelPersistenceTestsshare one xunit collection,SettingsFileStore serial, and that collection runs on its own. The tests lock the same redirected files and turn on save blocks that apply to the whole process. While a block is on, a test in another class that saves settings fails for no reason of its own.Full suite on Windows at 6e7d553: 1059 total, 0 failed, 1057 succeeded, 2 skipped. Both skipped tests were already skipped before this change. They are off-Windows contract tests. The existing save-in-place encoding tests in
OpenSaveQueryTestsand all ofSettingsIntegrationsTestspass. The build has 0 warnings and 0 errors.I copied the real
.planviewandPerformanceStudiosettings folders aside as a backup. I took it after a few targeted test runs, not before the first one. All test settings files are redirected to a temp folder, andconnections.json,settings.json, andappsettings.jsonhad timestamps from before those runs. After the final full run,diff -rqagainst the backup reported no differences.Checklist
dotnet build -c Release, and the Debug build insidedotnet test)dotnet test)🤖 Generated with Claude Code
https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza