Repository navigation
bug(syncengine): additional-root conflict resolution can overwrite saves or discard conflict state #83
Description
Summary
ResolveRootConflict, the explicit conflict-resolution API for an additional named save location, logs failure of its attempted pre-resolution safety snapshot but continues executing the requested resolution.
keep-remotecan overwrite locally unique content without a successful local recovery copy.keep-local(secondary policy/consistency finding) can report a resolved conflict and advance reconciliation metadata after that safety-capture failure. The test did not demonstrate immediate loss of local bytes in this mode.
A separate reproduced failure occurs when keep-remote clears agreed-base/lineage/conflict state before attempting to fetch the peer manifest. A cancelled peer fetch returns an error but leaves the conflict removed and the agreed hash and recorded lineage cleared.
Code path analysis and current status
- Source reviewed:
dev-publicat0b8d8bfec803, 16 October 2026. - Common safety step:
ResolveRootConflictcallsCreateBeforeReplacing, but converts any error into a warning and continues into the selected resolution. keep-localpath: the local-resolution branch stores the local root hash as agreed before fetching the peer manifest. A failed fetch is ignored,SetSyncStateForRootis called with whatever lineage was obtained and its error is ignored, then the conflict is cleared and a peer pull is triggered.keep-remotepath: the remote-resolution branch empties the agreed hash, resets root lineage and clears the conflict beforeFetchManifestverifies that the peer and named location are available.- Downstream apply: only after fetch and location lookup succeed does
keep-remotecallsyncOneRoot. That additional-root path has no independentCreateBeforeReplacingadmission, so it cannot recover the protection lost when the common safety step continued after failure. - Related work: the open snapshot/watcher PR #54 does not change this conflict-resolution path.
The backup-failure reproduction follows the common safety step into a completed resolution. The cancelled-fetch reproduction returns before file mutation, but only after the prior agreed hash, lineage and conflict record have already been discarded.
Reproduction A — backup failure during two-sided conflict resolution
The issue was reproduced with two synthetic Go testutil.TestDaemon instances over loopback HTTP. Temporary directories and synthetic data were used; no real game saves or credentials were involved.
- Pair two peers with one synthetic game and a named additional location
config. Establish a commonsettings.ini = BASEstate and agreed lineage. - Pause automatic syncing and create two different edits:
LOCAL UNIQUEon the resolving peer andPEER UNIQUEon its counterpart. - Trigger a comparison and assert that
ActiveRootConflicts()contains a genuine conflict forconfig; do not proceed without that precondition. - Make the resolving peer's
BackupsDirunusable by pointing it at an existing regular file. - Invoke
ResolveRootConflict(ctx, gameID, peerID, "config", "keep-remote"); inspect the return error, local bytes, conflict status and lineage. - Independently recreate the two-sided conflict and repeat with
"keep-local".
| Resolution | Expected if its safety snapshot fails | Observed on dev-public@0b8d8bfec803 |
|---|---|---|
keep-remote |
Error; retain conflict and prior local bytes | Returned success; LOCAL UNIQUE replaced with PEER UNIQUE |
keep-local |
Do not claim a safely completed resolution or advance safety-dependent state | Returned success; conflict cleared and reconciliation state advanced, without immediate local-byte loss |
Reproduction B — peer fetch failure after premature state mutation
- Recreate a genuine two-sided
configconflict withsettings.ini = LOCAL UNIQUEon the resolving peer and a different version on its peer. - Record the active conflict, existing agreed root hash,
settings.inilineage and local bytes. - Call
ResolveRootConflictusingkeep-remotewith a cancelled context so the peer-manifest fetch fails. - Inspect the error and compare conflict, lineage, agreed-hash state and file bytes with the recorded values.
In keep-remote, SetAgreedHashForRoot("", ...), SetSyncStateForRoot(..., nil, nil), and clearRootConflict happen before FetchManifest. If the peer becomes unavailable or its named location disappears, the method can return an error after discarding the conflict record and prior lineage information.
Confirmed with a synthetic paired-daemon test on current dev-public: a cancelled transport context makes the remote manifest fetch fail. The method returns an error, the local LOCAL UNIQUE file remains intact, but the conflict is cleared, the agreed root hash changes from its prior value to an empty string, and the stored settings.ini lineage is erased. This is reconciliation-state loss, not immediate file-byte loss in this case.
The keep-local path also treats failed peer-manifest and lineage-persistence operations as nonfatal. Audit whether the resulting agreed-base/conflict state remains truthful when those steps fail.
Evidence and limits
- Test baseline:
dev-public@0b8d8bfec803, 16 October 2026. Synthetic paired-daemon regressions were executed using Go 1.26.4 in an isolated sandbox and Go 1.27.2 in a separate Linux amd64 environment. All files were created in temporary test directories; no real save data was used. The tests were overlaid onto the pinned source without production-code changes. - Regression results (RED):
TestAuditLive_KeepRemoteConflictMustStopWhenBackupFails,TestAuditLive_KeepLocalConflictRequiresSafetySnapshot, andTestAuditLive_KeepRemoteFetchFailureMustRetainConflictAndLineageall reproduce on the pinned upstream revision. - GREEN upstream control:
TestMultiRootScenario_ResolveALocationConflictKeepingMinepasses. - This shows unsafe loss of one local conflict version (
keep-remote) and false completion/state advancement (keep-local), not proof of universal irreversible loss or an observed production incident.
Root-cause analysis
The conflict-resolution operation is not fail-closed as a single state transition. Its safety capture is advisory in executable behavior even though the function contract says a snapshot is taken first, and consequential metadata mutations are committed before later fallible prerequisites have succeeded.
For keep-remote, clearing the base, lineage and conflict is used to prevent the subsequent pull from immediately rediscovering the same divergence. That sequencing solves the successful-path conflict loop, but it exposes partially committed state when peer fetch, named-location validation or later application fails. There is no rollback to the previously recorded conflict state.
For keep-local, local bytes are not replaced, so whether a snapshot must be mandatory is a policy decision. The consistency problem remains: fetch and lineage-persistence failures are treated as nonfatal while the conflict is cleared and success is returned, so the recorded agreed state can claim a completed resolution without all supporting state being established.
The narrow correction is to complete all required preflight and safety work before clearing conflict or lineage, propagate consequential persistence failures, and either commit the resolution state only after successful application or restore the coherent prior state on failure.
Suggested minimal fix and acceptance criteria
- Define a clear safety requirement for each resolution. Where a recoverable pre-resolution copy is promised or required, return on capture error before mutating bytes or reconciliation state; verify that endangered files are actually present in the safety archive, not merely that a ZIP was created.
- If
keep-localis intentionally allowed without a safety copy because it does not itself modify local files, make that policy explicit and ensure the method does not report a safety guarantee it could not satisfy. - For
keep-remote, fetch and validate the required peer manifest/location before clearing conflict or lineage; retain the conflict on fetch, validation, snapshot or subsequent apply failure, or restore coherent prior state. - Check and propagate consequential metadata-persistence errors; do not report success when the recorded resolution state is inconsistent.
- Make these regressions GREEN for backup failure in both resolution modes and peer fetch failure before applying
keep-remote; retain GREEN ordinary successful conflict resolution/convergence controls. - Keep the correction local to additional-root conflict admission/state transitions; avoid unrelated protocol or UI changes.
Related: #82 covers routine additional-root pulls and deletions that bypass primary-root-style safety admission without a conflict. The two call sites may share a narrow safety helper but should retain separate regression coverage.
We would welcome the maintainer's preferred next step. If helpful, we can submit a draft PR with the regression coverage and a narrowly scoped fix, or help review an implementation.
Activity
Seagulls commentedon Oct 16, 2026
A self-contained negative-witness fixture is attached so these cases can be reproduced with synthetic loopback peers and no real save data:
https://gist.github.com/Seagulls/219f66e5381d14906cd41082a57c7891
Drop
additional_root_conflict_repro_test.gointoe2e/on0b8d8bfec80329962f78df3f04bd1f0cb53b59ddand run:Confirmed on that commit with Go 1.26.4 and Go 1.27.2: all three tests fail. They fail because their safety assertions reproduce the current conflict-resolution and reconciliation-state behavior; after a fix, all three should pass.
Omar-VF commentedon Oct 17, 2026
Thanks for the careful analysis and the reproduction fixture. Yes, please open the draft PR with your regression tests and a narrow fix, for both #82 and #83 (one PR or two, whichever is easier to review). Against
dev-public. A shared safety-admission helper for extra locations sounds right.Seagulls commentedon Oct 25, 2026
Rebased the open fixes onto current
dev-public(7e4310b).f4806ff. The only conflict was A save folder that is a link is synced as the folder it points at #110'skeepHeldFilesinsyncOneRoot. Both are kept: the hold filter runs first, then the verified safety admission, thenhandOverEmptying. Conflict and lineage state are still left intact when safety or peer preflight fails.541ee3c.keepHeldFilesandhandOverEmptyingonly adjust peer deletions, so they do not change the local at-risk set. Targeted-raceconflict-resolution and adoption tests passed locally on Go 1.27.2; upstream CI is the remaining gate.Omar-VF commentedon Oct 27, 2026
Fixed by #92 and #93, now in
dev-public. Resolving a conflict in an additional save location keeps the conflict and its state if the safety snapshot or the peer check fails, and "keep theirs" now applies exactly the other device's copy. It'll ship in the next release.