Skip to content

Fix data loss in global config writer (updateGlobalConfig)#81

Merged
0101 merged 2 commits into
mainfrom
fix-config-data-loss
Jun 22, 2026
Merged

Fix data loss in global config writer (updateGlobalConfig)#81
0101 merged 2 commits into
mainfrom
fix-config-data-loss

Conversation

@0101

@0101 0101 commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Problem

The Treemon global config file (%USERPROFILE%\.treemon\config.json) intermittently lost most of its keys. A user's editor / editorName settings and their collapsedRepos would silently revert, and the file would end up containing only lastViewedHashes. This is real data loss in the server's config write path.

Root cause

All global-config writers (writeCollapsedRepos, writeCanvasPaneOpen, writeCanvasPosition, writeLastViewedHashes) funnel through updateGlobalConfig in src/Server/WorktreeApi.fs. The old read-modify-write had three compounding problems:

  1. No synchronization — every mutation called the writer with no lock. writeLastViewedHashes fires very frequently (every canvas-doc view).
  2. Non-atomic writeFile.WriteAllText could be observed mid-write by a concurrent reader, yielding a truncated/partial file.
  3. Silent reset-to-empty on read failure — when the read/parse threw (e.g. because a concurrent writer left a partial file), the 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:

  • Serialize writes with a module-level globalConfigLock wrapping the whole read-modify-write (mirrors the configLock pattern in TreemonConfig.fs).
  • Atomic write — serialize to a temp file in the same directory, then File.Move(tempPath, configPath, overwrite = true) (atomic on the same volume), mirroring SessionManager.fs and CanvasDocOwnership.fs. Readers can no longer observe a partial file.
  • Back up instead of discard — on a genuine parse failure, log a clear error including the path, copy the unparseable file to a timestamped sibling (config.json.corrupt-<yyyyMMddHHmmss>), then start fresh. Existing data is never silently overwritten with a near-empty object.
  • Data-oriented write contract — writers describe their change as (string * JsonNode) list instead of a JsonObject -> unit callback that mutates a passed-in object. The single root[key] <- value mutation is confined to one List.iter inside updateConfigAtPath, 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) returning Result<unit, string>, so it can be unit-tested against a temp file. updateGlobalConfig is 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 (Category Fast + Unit), driving updateConfigAtPath against a temp file:

  • Two sequential writes of different keys preserve both (the regression).
  • An unparseable on-disk file is backed up to a config.json.corrupt-* sibling (original bytes intact) and the rewritten file is valid and contains the new key.
  • A successful write leaves no .tmp file 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).

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.
Copilot AI review requested due to automatic review settings June 22, 2026 13:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 updateConfigAtPath that 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 timestamped config.json.corrupt-* sibling instead of discarding existing data.
  • Reduce updateGlobalConfig to 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.
@0101 0101 merged commit 64bcf73 into main Jun 22, 2026
1 check passed
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.

2 participants