Fix data loss in global config writer (updateGlobalConfig)#81
Merged
Conversation
The global config file (~/.treemon/config.json) intermittently collapsed down to only the last-written key (usually lastViewedHashes), silently dropping editor settings and collapsedRepos. Root cause in updateGlobalConfig (src/Server/WorktreeApi.fs): config writes were unsynchronized and non-atomic, and on any read/parse failure the read-modify-write fell back to an empty JsonObject and wrote it back with only the single key being updated. A concurrent partial write (the high-frequency lastViewedHashes writer) could therefore turn a transient race into permanent data loss. Fix: - Serialize the whole read-modify-write with a module-level lock. - Write atomically: temp file in the same directory, then File.Move with overwrite (atomic on the same volume), mirroring SessionManager / CanvasDocOwnership. - On a genuine parse failure, back up the unparseable file to a timestamped sibling (config.json.corrupt-<yyyyMMddHHmmss>) and start fresh instead of silently discarding existing data. The core read-modify-write is extracted into internal updateConfigAtPath (takes the path) so it can be unit-tested against a temp file. No change to the on-disk JSON format or keys for the valid-file case. Adds ConfigWriterTests covering key preservation across sequential writes, backup-on-corruption, and temp-file cleanup.
There was a problem hiding this comment.
Pull request overview
This PR fixes intermittent data loss in the Treemon global config file (~/.treemon/config.json), where unrelated keys (e.g. editor/editorName, collapsedRepos) would silently revert and the file could collapse to just lastViewedHashes. The root cause was an unsynchronized, non-atomic read-modify-write in updateGlobalConfig that reset the config to an empty object whenever a concurrent writer left a partially-written file that failed to parse. The fix hardens the write path and adds unit coverage, fitting into the Server project's existing safe-persistence conventions.
Changes:
- Extract a reusable, testable
internal updateConfigAtPaththat serializes writes with a module-level lock, writes atomically via a same-directory temp file +File.Move(overwrite = true), and backs up an unparseable file to a timestampedconfig.json.corrupt-*sibling instead of discarding existing data. - Reduce
updateGlobalConfigto a thin wrapper that supplies the global path and preserves per-writer failure logging. - Add
ConfigWriterTests.fs(Fast/Unit) covering key preservation across sequential writes, corrupt-file backup/recovery, and no leftover temp file.
Show a summary per file
| File | Description |
|---|---|
| src/Server/WorktreeApi.fs | Adds globalConfigLock, tryParseJsonObject, and updateConfigAtPath (locked + atomic + corrupt-file backup); rewrites updateGlobalConfig as a thin wrapper. |
| src/Tests/ConfigWriterTests.fs | New unit tests exercising updateConfigAtPath against a temp file for the regression, corrupt-backup, and temp-file-cleanup scenarios. |
| src/Tests/Tests.fsproj | Registers the new test file in compile order (after TestUtils.fs, so its helpers resolve). |
Copilot's findings
- Files reviewed: 3/3 changed files
- Comments generated: 0
Follow-up to the config data-loss fix. Replace the `JsonObject -> unit` updater contract with `(string * JsonNode) list`: writers now describe their change as data, and the single `root[key] <- value` mutation is confined to one List.iter inside updateConfigAtPath rather than handing each caller a node tree to mutate (the shape AGENTS.md warns against). Behavior is unchanged.
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.
Problem
The Treemon global config file (
%USERPROFILE%\.treemon\config.json) intermittently lost most of its keys. A user'seditor/editorNamesettings and theircollapsedReposwould silently revert, and the file would end up containing onlylastViewedHashes. This is real data loss in the server's config write path.Root cause
All global-config writers (
writeCollapsedRepos,writeCanvasPaneOpen,writeCanvasPosition,writeLastViewedHashes) funnel throughupdateGlobalConfiginsrc/Server/WorktreeApi.fs. The old read-modify-write had three compounding problems:writeLastViewedHashesfires very frequently (every canvas-doc view).File.WriteAllTextcould be observed mid-write by a concurrent reader, yielding a truncated/partial file.with _ -> JsonObject()branch discarded the entire existing config and wrote back only the single key currently being updated.Together these turn a transient, recoverable race into permanent data loss — and explain why the file collapses down to just
lastViewedHashes, the highest-frequency (usually last) writer.Fix
Hardened the config write path in
src/Server/WorktreeApi.fs:globalConfigLockwrapping the whole read-modify-write (mirrors theconfigLockpattern inTreemonConfig.fs).File.Move(tempPath, configPath, overwrite = true)(atomic on the same volume), mirroringSessionManager.fsandCanvasDocOwnership.fs. Readers can no longer observe a partial file.config.json.corrupt-<yyyyMMddHHmmss>), then start fresh. Existing data is never silently overwritten with a near-empty object.(string * JsonNode) listinstead of aJsonObject -> unitcallback that mutates a passed-in object. The singleroot[key] <- valuemutation is confined to oneList.iterinsideupdateConfigAtPath, keeping the writers pure and honouring the repo rule against passing a collection in to be mutated.The core read-modify-write is
internal updateConfigAtPath (configPath) (updates)returningResult<unit, string>, so it can be unit-tested against a temp file.updateGlobalConfigis a thin wrapper that supplies the global path and preserves the existing per-writer failure logging. No change to the on-disk JSON format or keys for the valid-file case — unrelated keys are now always preserved.Tests
Added
src/Tests/ConfigWriterTests.fs(CategoryFast+Unit), drivingupdateConfigAtPathagainst a temp file:config.json.corrupt-*sibling (original bytes intact) and the rewritten file is valid and contains the new key..tmpfile behind.Results:
dotnet build src/Tests/Tests.fsproj— succeeded, 0 warnings / 0 errors.dotnet test --filter "Category=Fast"— 872 passed, 0 failed (includes the 3 new tests).